From 993e9b2948264546a8dd1df8dead2cb5a4d17aeb Mon Sep 17 00:00:00 2001 From: franaudo Date: Sun, 12 Jan 2025 18:41:02 +0100 Subject: [PATCH 01/39] new results structure --- src/compas_fea2/UI/viewer/scene.py | 6 +- src/compas_fea2/UI/viewer/viewer.py | 20 +- src/compas_fea2/model/groups.py | 2 - src/compas_fea2/model/nodes.py | 12 +- src/compas_fea2/problem/problem.py | 34 +-- .../problem/steps/perturbations.py | 18 ++ src/compas_fea2/problem/steps/step.py | 25 +- src/compas_fea2/results/__init__.py | 7 +- src/compas_fea2/results/database.py | 101 ++++++- src/compas_fea2/results/fields.py | 265 ++++++------------ src/compas_fea2/results/modal.py | 195 +++++++++++++ src/compas_fea2/results/results.py | 118 +------- 12 files changed, 464 insertions(+), 339 deletions(-) create mode 100644 src/compas_fea2/results/modal.py diff --git a/src/compas_fea2/UI/viewer/scene.py b/src/compas_fea2/UI/viewer/scene.py index a1851718f..a3d13d69b 100644 --- a/src/compas_fea2/UI/viewer/scene.py +++ b/src/compas_fea2/UI/viewer/scene.py @@ -297,7 +297,7 @@ def __init__(self, step, component=None, show_vectors=1, show_contour=False, **k 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) + vectors, colors = draw_field_vectors([n.point for n in field.locations], list(field.vectors), 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})) @@ -305,8 +305,8 @@ def __init__(self, step, component=None, show_vectors=1, show_contour=False, **k 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 = list(field.component(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) diff --git a/src/compas_fea2/UI/viewer/viewer.py b/src/compas_fea2/UI/viewer/viewer.py index 28c9f25e1..5411f254c 100644 --- a/src/compas_fea2/UI/viewer/viewer.py +++ b/src/compas_fea2/UI/viewer/viewer.py @@ -145,6 +145,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 @@ -154,22 +155,31 @@ 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() + def add_displacement_field( + self, field, step, 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__, FEA2DisplacementFieldResultsObject, 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 + field, + step=step, + 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_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) diff --git a/src/compas_fea2/model/groups.py b/src/compas_fea2/model/groups.py index 405c32542..b8c80d7e9 100644 --- a/src/compas_fea2/model/groups.py +++ b/src/compas_fea2/model/groups.py @@ -6,8 +6,6 @@ from compas_fea2.base import FEAData -# TODO change lists to sets - class _Group(FEAData): """Base class for all groups. diff --git a/src/compas_fea2/model/nodes.py b/src/compas_fea2/model/nodes.py index d03680445..81bfe8119 100644 --- a/src/compas_fea2/model/nodes.py +++ b/src/compas_fea2/model/nodes.py @@ -93,9 +93,7 @@ def __init__(self, xyz, mass=None, temperature=None, **kwargs): self._is_reference = False self._loads = {} - self.total_load = None - self._displacements = {} - self._results = {} + self._total_load = None self._connected_elements = [] @@ -234,3 +232,11 @@ def point(self): @property def connected_elements(self): return self._connected_elements + + def displacement(self, step): + if step.displacement_field: + return step.displacement_field.get_result_at(location=self) + + def reaction(self, step): + if step.reaction_field: + return step.reaction_field.get_result_at(location=self) diff --git a/src/compas_fea2/problem/problem.py b/src/compas_fea2/problem/problem.py index 960e58822..f75af034e 100644 --- a/src/compas_fea2/problem/problem.py +++ b/src/compas_fea2/problem/problem.py @@ -16,10 +16,6 @@ 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 @@ -106,27 +102,7 @@ def path_db(self): @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 modal_shape(self, mode): - return ModalShape(problem=self, mode=mode) + return ResultsDatabase(self) @property def steps_order(self): @@ -635,14 +611,14 @@ def show_displacements(self, step=None, fast=True, show_bcs=1, scale_model=1, sh if not step: step = self.steps_order[-1] - if not step.problem.displacement_field: + if not step.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=False, 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) + 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.displacement_field, fast=fast, step=step, component=component, show_vectors=show_vectors, show_contours=show_contours, **kwargs) if show_loads: - self.add_step(step, show_loads=show_loads) + viewer.add_step(step, show_loads=show_loads) viewer.show() viewer.scene.clear() diff --git a/src/compas_fea2/problem/steps/perturbations.py b/src/compas_fea2/problem/steps/perturbations.py index 48d00e0b2..373dca417 100644 --- a/src/compas_fea2/problem/steps/perturbations.py +++ b/src/compas_fea2/problem/steps/perturbations.py @@ -3,6 +3,8 @@ from __future__ import print_function from .step import Step +from compas_fea2.results import ModalAnalysisResults +from compas_fea2.results import ModalShape class _Perturbation(Step): @@ -37,6 +39,22 @@ def __init__(self, modes=1, **kwargs): super(ModalAnalysis, self).__init__(**kwargs) self.modes = modes + @property + def results(self): + return [ModalAnalysisResults(problem=self) for mode in range(self.modes)] + + def frequencies(self): + return [ModalAnalysisResults(problem=self) for mode in range(self.modes)] + + @property + def shapes(self): + return [ModalShape(step=self, mode=mode) for mode in range(self.modes)] + + def shape(self, mode): + if mode > self.modes: + raise ValueError("Mode number exceeds the number of modes.") + return ModalShape(problem=self, mode=mode) + class ComplexEigenValue(_Perturbation): """""" diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index 356d4e3a4..33a8f52ab 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -7,6 +7,10 @@ from compas_fea2.problem.fields import _PrescribedField from compas_fea2.problem.loads import Load +from compas_fea2.results import DisplacementFieldResults +from compas_fea2.results import ReactionFieldResults +from compas_fea2.results import StressFieldResults + # ============================================================================== # Base Steps # ============================================================================== @@ -106,10 +110,10 @@ def combination(self, combination): factored_load = factor * load node.loads.setdefault(self, {}).setdefault(combination, {})[pattern] = factored_load - if node.total_load: - node.total_load += 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): @@ -169,6 +173,21 @@ 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) # ============================================================================== diff --git a/src/compas_fea2/results/__init__.py b/src/compas_fea2/results/__init__.py index 87b6e7085..4e76c3393 100644 --- a/src/compas_fea2/results/__init__.py +++ b/src/compas_fea2/results/__init__.py @@ -11,7 +11,6 @@ MembraneStressResult, ShellStressResult, SolidStressResult, - ModalAnalysisResult, ) from .fields import ( @@ -20,6 +19,10 @@ VelocityFieldResults, StressFieldResults, ReactionFieldResults, +) + +from .modal import ( + ModalAnalysisResults, ModalShape, ) @@ -38,6 +41,6 @@ "VelocityFieldResults", "ReactionFieldResults", "StressFieldResults", - "ModalAnalysisResult", + "ModalAnalysisResults", "ModalShape", ] diff --git a/src/compas_fea2/results/database.py b/src/compas_fea2/results/database.py index 6d0290f89..b833dab0e 100644 --- a/src/compas_fea2/results/database.py +++ b/src/compas_fea2/results/database.py @@ -1,10 +1,11 @@ import sqlite3 +from compas_fea2.base import FEAData -class ResultsDatabase: +class ResultsDatabase(FEAData): """sqlite3 wrapper class to access the SQLite database.""" - def __init__(self, db_uri): + def __init__(self, problem, **kwargs): """ Initialize DataRetriever with the database URI. @@ -13,10 +14,20 @@ def __init__(self, db_uri): db_uri : str The database URI. """ - self.db_uri = db_uri + super(ResultsDatabase, self).__init__(**kwargs) + self._registration = problem + self.db_uri = problem.path_db self.connection = self.db_connection() self.cursor = self.connection.cursor() + @property + def problem(self): + return self._registration + + @property + def model(self): + return self.problem.model + def db_connection(self): """ Create and return a connection to the SQLite database. @@ -55,14 +66,43 @@ 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()] @@ -74,16 +114,16 @@ def get_table(self, table_name): 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 +135,13 @@ 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 ---------- @@ -131,10 +171,10 @@ def get_rows(self, table_name, columns_names, filters): filters : dict Filtering criteria as {"column_name":[admissible values]} - 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}" @@ -172,3 +212,34 @@ def get_func_row(self, table_name, column_name, func, filters, columns_names): 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) + + # ========================================================================= + # FEA2 Methods + # ========================================================================= + + def to_result(self, results_set, results_class, results_func): + """Convert a set of results in the database to the appropriate + result object. + + Parameters + ---------- + results_set : list of tuples + The set of results retrieved from the database. + results_class : class + The class to instantiate for each result. + + Returns + ------- + dict + Dictionary grouping the results per Step. + """ + 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, results_func)(r[2]) + results[step].append(results_class(m, *r[3:])) + return results diff --git a/src/compas_fea2/results/fields.py b/src/compas_fea2/results/fields.py index 200c845ec..42e2fa395 100644 --- a/src/compas_fea2/results/fields.py +++ b/src/compas_fea2/results/fields.py @@ -54,27 +54,30 @@ 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, field_name, *args, **kwargs): super(FieldResults, self).__init__(*args, **kwargs) - self._registration = problem + self._registration = step self._field_name = field_name - self._table = self.problem.results_db.get_table(field_name) - self._components_names = None + self._table = step.problem.results_db.get_table(field_name) + self._componets_names = None self._invariants_names = None - self._results_class = None - self._results_func = None + self._restuls_func = None @property - def field_name(self): - return self._field_name + def step(self): + return self._registration @property def problem(self): - return self._registration + return self.step.problem + + @property + def field_name(self): + return self._field_name @property def model(self): @@ -85,21 +88,16 @@ def rdb(self): return self.problem.results_db @property - def components_names(self): - return self._components_names - - @property - def invariants_names(self): - return self._invariants_names + def results(self): + return NotImplementedError() - def _get_db_results(self, members, steps, **kwargs): - """Get the results for the given members and steps in the database - format. + def _get_results_from_db(self, members, columns, filters=None, **kwargs): + """Get the results for the given members and steps. Parameters ---------- members : _type_ - The FieldResults object containing the field results data. + _description_ steps : _type_ _description_ @@ -110,64 +108,27 @@ def _get_db_results(self, members, steps, **kwargs): """ 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 + columns = ["step", "part", "input_key"] + self._components_names + if not filters: + filters = {"input_key": members_keys, "part": parts_names, "step": [self.step.name]} + # if kwargs.get("mode", None): + # filters["mode"] = set([kwargs["mode"] for member in members]) - filters = {"input_key": members_keys, "part": parts_names, "step": steps_names} + results_set = self.rdb.get_rows(self._field_name, columns, filters) - if kwargs.get("mode", None): - filters["mode"] = set([kwargs['mode'] for member in members]) + return self.rdb.to_result(results_set, self._results_class, self._restuls_func) - results_set = self.rdb.get_rows(field_name, columns, filters) - return results_set - - def _to_result(self, results_set): - """Convert a set of results in database format to the appropriate - result object. + def get_result_at(self, location): + """Get the result for a given member and step. Parameters ---------- - results_set : _type_ + member : _type_ _description_ - - Returns - ------- - dic - Dictiorany grouping the results per Step. - """ - 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. - - Parameters - ---------- - members : _type_ - _description_ - steps : _type_ + step : _type_ _description_ Returns @@ -175,10 +136,9 @@ def get_results(self, members, steps, **kwargs): _type_ _description_ """ - results_set = self._get_db_results(members, steps, **kwargs) - return self._to_result(results_set) + return self._get_results_from_db(location, self.step)[self.step][0] - def get_max_result(self, component, step, **kwargs): + def get_max_result(self, component): """Get the result where a component is maximum for a given step. Parameters @@ -193,14 +153,14 @@ def get_max_result(self, component, step, **kwargs): :class:`compas_fea2.results.Result` The appriate Result object. """ - 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] + results_set = self.rdb.get_func_row(self.field_name, self.field_name + str(component), "MAX", {"step": [self.step.name]}, self.results_columns) + return self.rdb.to_result(results_set)[self.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] + def get_min_result(self, component): + results_set = self.rdb.get_func_row(self.field_name, self.field_name + str(component), "MIN", {"step": [self.step.name]}, self.results_columns) + return self.rdb.to_result(results_set, self._results_class)[self.step][0] - def get_max_component(self, component, step, **kwargs): + def get_max_component(self, component): """Get the result where a component is maximum for a given step. Parameters @@ -215,9 +175,9 @@ def get_max_component(self, component, step, **kwargs): :class:`compas_fea2.results.Result` The appriate Result object. """ - return self.get_max_result(component, step).vector[component - 1] + return self.get_max_result(component, self.step).vector[component - 1] - def get_min_component(self, component, step, **kwargs): + def get_min_component(self, component): """Get the result where a component is minimum for a given step. Parameters @@ -232,9 +192,9 @@ def get_min_component(self, component, step, **kwargs): :class:`compas_fea2.results.Result` The appropriate Result object. """ - return self.get_min_result(component, step).vector[component - 1] + return self.get_min_result(component, self.step).vector[component - 1] - def get_limits_component(self, component, step, **kwargs): + def get_limits_component(self, component): """Get the result objects with the min and max value of a given component in a step. @@ -250,40 +210,33 @@ def get_limits_component(self, component, step, **kwargs): 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): + def get_limits_absolute(self): 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] + limits.append(self.rdb.get_func_row(self.field_name, "magnitude", func, {"step": [self.step.name]}, self.results_columns)) + return [self.rdb.to_result(limit)[self.step][0] for limit in limits] - def get_results_at_point(self, point, distance, plane=None, steps=None, **kwargs): - """Get the displacement of the model around a location (point). + @property + def locations(self): + """Return the locations where the field is defined. Parameters ---------- - point : [float] - The coordinates of the point. - steps : _type_, optional - _description_, by default None - - Returns - ------- - dict - Dictionary with {step: result} + step : :class:`compas_fea2.problem.steps.Step`, optional + The analysis step, by default None + Yields + ------ + :class:`compas.geometry.Point` + The location where the field is defined. """ - 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 + for r in self.results: + yield r.node - def locations(self, step=None, point=False, **kwargs): + @property + def points(self): """Return the locations where the field is defined. Parameters @@ -296,14 +249,11 @@ def locations(self, step=None, point=False, **kwargs): :class:`compas.geometry.Point` The location 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 + for r in self.results: + yield r.node.point - def vectors(self, step=None, **kwargs): + @property + def vectors(self): """Return the locations where the field is defined. Parameters @@ -316,11 +266,10 @@ def vectors(self, step=None, **kwargs): :class:`compas.geometry.Point` The location where the field is defined. """ - step = step or self.problem.steps_order[-1] - for r in self.results(step): + for r in self.results: yield r.vector - def component(self, step=None, component=None, **kwargs): + def component(self, dof=None): """Return the locations where the field is defined. Parameters @@ -333,12 +282,11 @@ def component(self, step=None, component=None, **kwargs): :class:`compas.geometry.Point` The location where the field is defined. """ - step = step or self.problem.steps_order[-1] - for r in self.results(step): - if component is None: + for r in self.results: + if dof is None: yield r.vector.magnitude else: - yield r.vector[component] + yield r.vector[dof] class DisplacementFieldResults(FieldResults): @@ -361,49 +309,17 @@ 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) + def __init__(self, step, *args, **kwargs): + super(DisplacementFieldResults, self).__init__(step=step, 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] - - -class ModalShape(FieldResults): - """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. - - Attributes - ---------- - components_names : list of str - Names of the displacement components. - invariants_names : list of str - Names of the invariants of the displacement field. - results_class : class - The class used to instantiate the displacement 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 + self._restuls_func = "find_node_by_inputkey" - def results(self, step): + @property + def results(self): nodes = self.model.nodes - return self.get_results(nodes, steps=step, mode=self.mode)[step] + return self._get_results_from_db(nodes, step=self.step, columns=self._components_names)[self.step] class AccelerationFieldResults(FieldResults): @@ -426,16 +342,17 @@ class AccelerationFieldResults(FieldResults): 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) + def __init__(self, step, *args, **kwargs): + super(AccelerationFieldResults, self).__init__(problem=step, 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" + self._restuls_func = "find_node_by_inputkey" - def results(self, step): + @property + def results(self): nodes = self.model.nodes - return self.get_results(nodes, steps=step)[step] + return self._get_results_from_db(nodes, step=self.step, columns=self._components_names)[self.step] class VelocityFieldResults(FieldResults): @@ -458,16 +375,17 @@ class VelocityFieldResults(FieldResults): 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) + def __init__(self, step, *args, **kwargs): + super(VelocityFieldResults, self).__init__(problem=step, 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" + self._restuls_func = "find_node_by_inputkey" - def results(self, step): + @property + def results(self): nodes = self.model.nodes - return self.get_results(nodes, steps=step)[step] + return self._get_results_from_db(nodes, step=self.step, columns=self._components_names)[self.step] class ReactionFieldResults(FieldResults): @@ -479,16 +397,17 @@ class ReactionFieldResults(FieldResults): The Problem where the Step is registered. """ - def __init__(self, problem, *args, **kwargs): - super(ReactionFieldResults, self).__init__(problem=problem, field_name="rf", *args, **kwargs) + def __init__(self, step, *args, **kwargs): + super(ReactionFieldResults, self).__init__(step=step, 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" + self._restuls_func = "find_node_by_inputkey" - def results(self, step): + @property + def results(self): nodes = self.model.nodes - return self.get_results(nodes, steps=step)[step] + return self._get_results_from_db(nodes, step=self.step, columns=self._components_names)[self.step] class StressFieldResults(FEAData): @@ -509,7 +428,7 @@ def __init__(self, problem, *args, **kwargs): self._field_name_3d = "s3d" self._results_class_2d = ShellStressResult self._results_class_3d = SolidStressResult - self._results_func = "find_element_by_key" + self._restuls_func = "find_element_by_inputkey" @property def field_name(self): diff --git a/src/compas_fea2/results/modal.py b/src/compas_fea2/results/modal.py new file mode 100644 index 000000000..7154cf744 --- /dev/null +++ b/src/compas_fea2/results/modal.py @@ -0,0 +1,195 @@ +from compas_fea2.base import FEAData +from .fields import FieldResults +from .fields import DisplacementResult +from .results import Result +import numpy as np + +# from typing import Iterable + + +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)" + + +class ModalAnalysisResults(FEAData): + def __init__(self, step, **kwargs): + super(ModalAnalysisResults, self).__init__(**kwargs) + self._registration = step + self._eigenvalues = None + self._eigenvectors = None + self._eigenvalues_table = step.problem.results_db.get_table("eigenvalues") + self._eigenvalues_table = step.problem.results_db.get_table("eigenvectors") + self._components_names = ["dof_1", "dof_2", "dof_3", "dof_4", "dof_5", "dof_6"] + + @property + def step(self): + return self._registration + + @property + def problem(self): + return self.step.problem + + @property + def model(self): + return self.problem.model + + @property + def rdb(self): + return self.problem.results_db + + @property + def components_names(self): + return self._components_names + + def get_results(self, mode, members, steps, field_name, results_func, results_class, **kwargs): + """Get the results for the given members and steps. + + Parameters + ---------- + members : _type_ + _description_ + steps : _type_ + _description_ + + Returns + ------- + _type_ + _description_ + """ + 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]) + + columns = ["step", "part", "input_key"] + self._components_names + filters = {"input_key": members_keys, "part": parts_names, "step": steps_names, "mode": set([mode for _ in members])} + + results_set = self.rdb.get_rows(field_name, columns, filters) + + 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, results_func)(r[2]) + results[step].append(results_class(m, *r[3:])) + return self._to_result(results_set) + + +class ModalShape(FieldResults): + """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. + + Attributes + ---------- + components_names : list of str + Names of the displacement components. + invariants_names : list of str + Names of the invariants of the displacement field. + results_class : class + The class used to instantiate the displacement results. + results_func : str + The function used to find nodes by key. + """ + + def __init__(self, step, mode, *args, **kwargs): + super(ModalShape, self).__init__(step=step, 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_from_db(nodes, step=step, mode=self.mode)[step] diff --git a/src/compas_fea2/results/results.py b/src/compas_fea2/results/results.py index 69109c939..a1c8f130b 100644 --- a/src/compas_fea2/results/results.py +++ b/src/compas_fea2/results/results.py @@ -122,6 +122,7 @@ def __init__(self, node, title, x=None, y=None, z=None, xx=None, yy=None, zz=Non self._xx = xx self._yy = yy self._zz = zz + self._results_func = "find_node_by_inputkey" @property def node(self): @@ -224,18 +225,26 @@ def __init__(self, node, x, y, z, xx, yy, zz, **kwargs): super(ReactionResult, self).__init__(node, "rf", x, y, z, xx, yy, zz, **kwargs) -class Element1DResult(Result): +class ElementResult(Result): """Element1DResult object.""" def __init__(self, element, **kwargs): super(Element1DResult, self).__init__(**kwargs) self._registration = element + self._results_func = "find_element_by_inputkey" @property def element(self): return self._registration +class Element1DResult(ElementResult): + """Element1DResult object.""" + + def __init__(self, element, **kwargs): + super(Element1DResult, self).__init__(element, **kwargs) + + class SectionForcesResult(Element1DResult): """DisplacementResult object. @@ -292,16 +301,11 @@ def element(self): return self.location -class Element2DResult(Result): +class Element2DResult(ElementResult): """Element1DResult object.""" def __init__(self, element, **kwargs): - super(Element2DResult, self).__init__(**kwargs) - self._registration = element - - @property - def element(self): - return self._registration + super(Element2DResult, self).__init__(element, **kwargs) class StressResult(Element2DResult): @@ -903,16 +907,11 @@ def stress_along_direction(self, direction, side="mid"): return unit_direction.T @ tensors[side] @ unit_direction -class Element3DResult(Result): +class Element3DResult(ElementResult): """Element1DResult object.""" def __init__(self, element, **kwargs): - super(Element2DResult, self).__init__(**kwargs) - self._registration = element - - @property - def element(self): - return self._registration + super(Element2DResult, self).__init__(element, **kwargs) # TODO: double inheritance StressResult and Element3DResult @@ -928,92 +927,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)" From 184de6d7c0008efccd0fd9152275924b158feb47 Mon Sep 17 00:00:00 2001 From: franaudo Date: Sun, 19 Jan 2025 16:53:50 +0100 Subject: [PATCH 02/39] new modal results --- src/compas_fea2/UI/viewer/viewer.py | 47 +- src/compas_fea2/problem/outputs.py | 12 +- src/compas_fea2/problem/problem.py | 10 +- .../problem/steps/perturbations.py | 59 ++- src/compas_fea2/results/__init__.py | 5 +- src/compas_fea2/results/fields.py | 458 ++++++++---------- src/compas_fea2/results/modal.py | 161 +++--- src/compas_fea2/results/results.py | 271 ++++++++--- 8 files changed, 575 insertions(+), 448 deletions(-) diff --git a/src/compas_fea2/UI/viewer/viewer.py b/src/compas_fea2/UI/viewer/viewer.py index 5411f254c..91c5b1859 100644 --- a/src/compas_fea2/UI/viewer/viewer.py +++ b/src/compas_fea2/UI/viewer/viewer.py @@ -8,6 +8,8 @@ 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 FEA2StressFieldResultsObject from compas_fea2.UI.viewer.scene import FEA2StepObject @@ -133,6 +135,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 @@ -156,12 +159,12 @@ def add_model(self, model, fast=True, show_parts=True, opacity=0.5, show_bcs=Tru 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, show_vectors=True, show_contours=False, **kwargs + 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__, FEA2DisplacementFieldResultsObject, context="Viewer") self.displacements = self.scene.add( field, - step=step, + model=model, component=component, fast=fast, show_parts=show_parts, @@ -173,10 +176,46 @@ def add_displacement_field( **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): + 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__, FEA2ReactionFieldResultsObject, context="Viewer") + self.reactions = 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, + **kwargs, + ) + + def add_stress_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__, FEA2StressFieldResultsObject, 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, + **kwargs, + ) + + def add_mode_shape(self, mode_shape, model, component=None, fast=False, show_parts=True, opacity=0.5, show_bcs=True, show_loads=True, **kwargs): 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 + mode_shape, model=model, 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): diff --git a/src/compas_fea2/problem/outputs.py b/src/compas_fea2/problem/outputs.py index d3757738a..5e5110621 100644 --- a/src/compas_fea2/problem/outputs.py +++ b/src/compas_fea2/problem/outputs.py @@ -2,8 +2,6 @@ from __future__ import division from __future__ import print_function -from itertools import chain - from compas_fea2.base import FEAData @@ -144,7 +142,7 @@ def get_sqltable_schema(cls): "table_name": "u", "columns": [ ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), - ("input_key", "INTEGER"), + ("key", "INTEGER"), ("step", "TEXT"), ("part", "TEXT"), ("ux", "REAL"), @@ -174,7 +172,7 @@ def get_sqltable_schema(cls): "table_name": "a", "columns": [ ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), - ("input_key", "INTEGER"), + ("key", "INTEGER"), ("step", "TEXT"), ("part", "TEXT"), ("ax", "REAL"), @@ -204,7 +202,7 @@ def get_sqltable_schema(cls): "table_name": "v", "columns": [ ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), - ("input_key", "INTEGER"), + ("key", "INTEGER"), ("step", "TEXT"), ("part", "TEXT"), ("vx", "REAL"), @@ -234,7 +232,7 @@ def get_sqltable_schema(cls): "table_name": "rf", "columns": [ ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), - ("input_key", "INTEGER"), + ("key", "INTEGER"), ("step", "TEXT"), ("part", "TEXT"), ("rfx", "REAL"), @@ -263,7 +261,7 @@ def get_sqltable_schema(cls): "table_name": "s2d", "columns": [ ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), - ("input_key", "INTEGER"), + ("key", "INTEGER"), ("step", "TEXT"), ("part", "TEXT"), ("s11", "REAL"), diff --git a/src/compas_fea2/problem/problem.py b/src/compas_fea2/problem/problem.py index f75af034e..6776fd320 100644 --- a/src/compas_fea2/problem/problem.py +++ b/src/compas_fea2/problem/problem.py @@ -5,8 +5,6 @@ import os 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 @@ -718,16 +716,16 @@ def show_mode_shape( 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) + shape = step.mode_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) + viewer.add_mode_shape(shape, fast=fast, model=self.model, 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): + 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, step=step, component=None, show_vectors=show_vectors, show_contour=show_contour, **kwargs) + viewer.add_mode_shape(shape, fast=fast, model=self.model, 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() diff --git a/src/compas_fea2/problem/steps/perturbations.py b/src/compas_fea2/problem/steps/perturbations.py index 373dca417..72f2b1ef7 100644 --- a/src/compas_fea2/problem/steps/perturbations.py +++ b/src/compas_fea2/problem/steps/perturbations.py @@ -3,8 +3,8 @@ from __future__ import print_function from .step import Step -from compas_fea2.results import ModalAnalysisResults -from compas_fea2.results import ModalShape +from compas_fea2.results import ModalAnalysisResult +from compas_fea2.results import DisplacementResult class _Perturbation(Step): @@ -39,21 +39,62 @@ def __init__(self, modes=1, **kwargs): super(ModalAnalysis, self).__init__(**kwargs) self.modes = modes + @property + def rdb(self): + return self.problem.results_db + + 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 + results_set = self.rdb.get_rows("eigenvectors", ["step", "part", "key", "dof_1", "dof_2", "dof_3", "dof_4", "dof_5", "dof_6"], filters) + eigenvector = self.rdb.to_result(results_set, DisplacementResult, "find_node_by_key")[self] + + return eigenvalue, eigenvector + @property def results(self): - return [ModalAnalysisResults(problem=self) for mode in range(self.modes)] + for mode in range(self.modes): + yield self.mode_result(mode + 1) + @property def frequencies(self): - return [ModalAnalysisResults(problem=self) for mode in range(self.modes)] + for mode in range(self.modes): + yield self.mode_frequency(mode + 1) @property def shapes(self): - return [ModalShape(step=self, mode=mode) for mode in range(self.modes)] + 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 shape(self, mode): - if mode > self.modes: - raise ValueError("Mode number exceeds the number of modes.") - return ModalShape(problem=self, mode=mode) + def mode_result(self, mode): + eigenvalue, eigenvector = self._get_results_from_db(mode) + return ModalAnalysisResult(step=self, mode=mode, eigenvalue=eigenvalue, eigenvector=eigenvector) class ComplexEigenValue(_Perturbation): diff --git a/src/compas_fea2/results/__init__.py b/src/compas_fea2/results/__init__.py index 4e76c3393..bd01e00ce 100644 --- a/src/compas_fea2/results/__init__.py +++ b/src/compas_fea2/results/__init__.py @@ -22,7 +22,7 @@ ) from .modal import ( - ModalAnalysisResults, + ModalAnalysisResult, ModalShape, ) @@ -41,6 +41,7 @@ "VelocityFieldResults", "ReactionFieldResults", "StressFieldResults", - "ModalAnalysisResults", + "SectionForcesFieldResults", + "ModalAnalysisResult", "ModalShape", ] diff --git a/src/compas_fea2/results/fields.py b/src/compas_fea2/results/fields.py index 42e2fa395..18c31a907 100644 --- a/src/compas_fea2/results/fields.py +++ b/src/compas_fea2/results/fields.py @@ -2,13 +2,10 @@ 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 @@ -16,6 +13,7 @@ from .results import ShellStressResult from .results import SolidStressResult from .results import VelocityResult +from .results import SectionForcesResult class FieldResults(FEAData): @@ -62,8 +60,8 @@ def __init__(self, step, field_name, *args, **kwargs): super(FieldResults, self).__init__(*args, **kwargs) self._registration = step self._field_name = field_name - self._table = step.problem.results_db.get_table(field_name) - self._componets_names = None + # self._table = step.problem.results_db.get_table(field_name) + self._components_names = None self._invariants_names = None self._restuls_func = None @@ -89,9 +87,9 @@ def rdb(self): @property def results(self): - return NotImplementedError() + return self._get_results_from_db(step=self.step, columns=self._components_names)[self.step] - def _get_results_from_db(self, members, columns, filters=None, **kwargs): + def _get_results_from_db(self, members=None, columns=None, filters=None, **kwargs): """Get the results for the given members and steps. Parameters @@ -106,18 +104,18 @@ def _get_results_from_db(self, members, columns, filters=None, **kwargs): _type_ _description_ """ - if not isinstance(members, Iterable): - members = [members] - - members_keys = set([member.input_key for member in members]) - parts_names = set([member.part.name for member in members]) - columns = ["step", "part", "input_key"] + self._components_names if not filters: - filters = {"input_key": members_keys, "part": parts_names, "step": [self.step.name]} - # if kwargs.get("mode", None): - # filters["mode"] = set([kwargs["mode"] for member in members]) + filters = {} + + filters["step"] = [self.step.name] + + 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]) - results_set = self.rdb.get_rows(self._field_name, columns, filters) + results_set = self.rdb.get_rows(self._field_name, ["step", "part", "key"] + self._components_names, filters) return self.rdb.to_result(results_set, self._results_class, self._restuls_func) @@ -233,7 +231,7 @@ def locations(self): The location where the field is defined. """ for r in self.results: - yield r.node + yield r.location @property def points(self): @@ -250,7 +248,7 @@ def points(self): The location where the field is defined. """ for r in self.results: - yield r.node.point + yield r.location @property def vectors(self): @@ -314,12 +312,7 @@ def __init__(self, step, *args, **kwargs): self._components_names = ["ux", "uy", "uz", "uxx", "uyy", "uzz"] self._invariants_names = ["magnitude"] self._results_class = DisplacementResult - self._restuls_func = "find_node_by_inputkey" - - @property - def results(self): - nodes = self.model.nodes - return self._get_results_from_db(nodes, step=self.step, columns=self._components_names)[self.step] + self._restuls_func = "find_node_by_key" class AccelerationFieldResults(FieldResults): @@ -347,12 +340,7 @@ def __init__(self, step, *args, **kwargs): self._components_names = ["ax", "ay", "az", "axx", "ayy", "azz"] self._invariants_names = ["magnitude"] self._results_class = AccelerationResult - self._restuls_func = "find_node_by_inputkey" - - @property - def results(self): - nodes = self.model.nodes - return self._get_results_from_db(nodes, step=self.step, columns=self._components_names)[self.step] + self._restuls_func = "find_node_by_key" class VelocityFieldResults(FieldResults): @@ -380,12 +368,7 @@ def __init__(self, step, *args, **kwargs): self._components_names = ["vx", "vy", "vz", "vxx", "vyy", "vzz"] self._invariants_names = ["magnitude"] self._results_class = VelocityResult - self._restuls_func = "find_node_by_inputkey" - - @property - def results(self): - nodes = self.model.nodes - return self._get_results_from_db(nodes, step=self.step, columns=self._components_names)[self.step] + self._restuls_func = "find_node_by_key" class ReactionFieldResults(FieldResults): @@ -402,15 +385,10 @@ def __init__(self, step, *args, **kwargs): self._components_names = ["rfx", "rfy", "rfz", "rfxx", "rfyy", "rfzz"] self._invariants_names = ["magnitude"] self._results_class = ReactionResult - self._restuls_func = "find_node_by_inputkey" - - @property - def results(self): - nodes = self.model.nodes - return self._get_results_from_db(nodes, step=self.step, columns=self._components_names)[self.step] + self._restuls_func = "find_node_by_key" -class StressFieldResults(FEAData): +class StressFieldResults(FieldResults): """_summary_ Parameters @@ -419,216 +397,20 @@ class StressFieldResults(FEAData): _description_ """ - 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"] + def __init__(self, step, *args, **kwargs): + super(StressFieldResults, self).__init__(step=step, field_name="s2d", *args, **kwargs) + self._components_names = ["s11", "s22", "s12", "m11", "m22", "m12"] + # 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 = ShellStressResult + # self._results_class_2d = ShellStressResult self._results_class_3d = SolidStressResult - self._restuls_func = "find_element_by_inputkey" - - @property - def field_name(self): - return self._field_name - - @property - def problem(self): - return self._registration + self._restuls_func = "find_element_by_key" @property - def model(self): - return self.problem.model - - @property - def rdb(self): - return self.problem.results_db - - @property - def components_names(self): - return self._components_names - - @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. - - Parameters - ---------- - members : _type_ - _description_ - steps : _type_ - _description_ - - Returns - ------- - _type_ - _description_ - """ - 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") - - 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. - - Parameters - ---------- - results_set : _type_ - _description_ - - Returns - ------- - dic - Dictiorany grouping the results per Step. - """ - 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. - - Parameters - ---------- - component : _type_ - _description_ - step : _type_ - _description_ - - Returns - ------- - :class:`compas_fea2.results.Result` - The appriate Result object. - """ - 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] - - 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. - - Parameters - ---------- - component : _type_ - _description_ - step : _type_ - _description_ - - Returns - ------- - list(:class:`compas_fea2.results.StressResults) - _description_ - """ - return [self.get_min_component(component, step), self.get_max_component(component, step)] - - 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). - - Parameters - ---------- - point : [float] - The coordinates of the point. - steps : _type_, optional - _description_, by default None - - Returns - ------- - dict - Dictionary with {'part':..; 'node':..; 'vector':...} - - """ - nodes = self.model.find_nodes_around_point(point, distance, plane) - results = [] - for step in steps: - results.append(self.get_results(nodes, steps)[step]) - - 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. - - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step, optional - The analysis step. By default, the last step is used. - - Returns - ------- - list(:class:`compas_fea2.results.StressResult`) - A list with al the results of the field for the analysis step. - """ - step or self.problem.steps_order[-1] - return self._get_results_from_db(self.model.elements, steps=step)[step] - - def locations(self, step=None, point=False): - """Return the locations where the field is defined. - - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step, by default None - - 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 - - def global_stresses(self, step=None): + def global_stresses(self): """Stress field in global coordinates Parameters @@ -643,9 +425,7 @@ def global_stresses(self, step=None): The stress tensor defined at each location of the field in global coordinates. """ - step = step or self.problem.steps_order[-1] - results = self.results(step) - n_locations = len(results) + n_locations = len(self.results) new_frame = Frame.worldXY() # Initialize tensors and rotation_matrices arrays @@ -655,7 +435,7 @@ def global_stresses(self, step=None): from_change_of_basis = Transformation.from_change_of_basis np_array = np.array - for i, r in enumerate(results): + for i, r in enumerate(self.results): tensors[i] = r.local_stress rotation_matrices[i] = np_array(from_change_of_basis(r.element.frame, new_frame).matrix)[:3, :3] @@ -664,7 +444,8 @@ def global_stresses(self, step=None): return transformed_tensors - def principal_components(self, step=None): + @property + def principal_components(self): """Compute the eigenvalues and eigenvetors of the stress field at each location. Parameters @@ -678,10 +459,10 @@ def principal_components(self, step=None): touple(np.array, np.array) The eigenvalues and the eigenvectors, not ordered. """ - step = step or self.problem.steps_order[-1] - return np.linalg.eig(self.global_stresses(step)) + return np.linalg.eig(self.global_stresses) - def principal_components_vectors(self, step=None): + @property + def principal_components_vectors(self): """Compute the principal components of the stress field at each location as vectors. @@ -697,15 +478,14 @@ def principal_components_vectors(self, step=None): 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] - eigenvalues, eigenvectors = self.principal_components(step) + eigenvalues, eigenvectors = self.principal_components 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): + def vonmieses(self): """Compute the principal components of the stress field at each location as vectors. @@ -721,6 +501,162 @@ def vonmieses(self, step=None): 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): + for r in self.results: yield r.von_mises_stress + + +class SectionForcesFieldResults(FieldResults): + """_summary_ + + Parameters + ---------- + FieldResults : _type_ + _description_ + """ + + def __init__(self, step, *args, **kwargs): + super(SectionForcesFieldResults, self).__init__(step=step, field_name="sf", *args, **kwargs) + self._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"] + self._invariants_names = ["magnitude"] + self._results_class = SectionForcesResult + self._restuls_func = "find_element_by_key" + + def get_element_forces(self, element): + """_summary_ + + Parameters + ---------- + element : _type_ + _description_ + + Returns + ------- + _type_ + _description_ + """ + return self.get_result_at(element) + + def get_elements_forces(self, elements): + """ + Get the section forces for a given element. + + Parameters + ---------- + element : Element + The element for which to retrieve section forces. + + Returns + ------- + SectionForcesResult + The section forces result for the specified element. + """ + for element in elements: + yield self.get_element_forces(element) + + def get_all_section_forces(self): + """ + Retrieve section forces for all elements in the field. + + Returns + ------- + dict + A dictionary mapping elements to their section forces. + """ + return {element: self.get_result_at(element) for element in self.elements} + + def filter_by_component(self, component_name, threshold=None): + """ + Filter results by a specific component, optionally using a threshold. + + Parameters + ---------- + component_name : 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 + A dictionary of filtered elements and their results. + """ + if component_name not in self._components_names: + raise ValueError(f"Component '{component_name}' is not valid. Choose from {self._components_names}.") + + filtered_results = {} + for element, result in self.get_all_section_forces().items(): + component_value = getattr(result, component_name, None) + if component_value is not None and (threshold is None or component_value >= threshold): + filtered_results[element] = result + + return filtered_results + + def export_to_dict(self): + """ + Export all field results to a dictionary. + + Returns + ------- + dict + A dictionary containing all section force results. + """ + results_dict = {} + for element, result in self.get_all_section_forces().items(): + results_dict[element] = { + "forces": { + "Fx_1": result.force_vector_1.x, + "Fy_1": result.force_vector_1.y, + "Fz_1": result.force_vector_1.z, + "Fx_2": result.force_vector_2.x, + "Fy_2": result.force_vector_2.y, + "Fz_2": result.force_vector_2.z, + }, + "moments": { + "Mx_1": result.moment_vector_1.x, + "My_1": result.moment_vector_1.y, + "Mz_1": result.moment_vector_1.z, + "Mx_2": result.moment_vector_2.x, + "My_2": result.moment_vector_2.y, + "Mz_2": result.moment_vector_2.z, + }, + "invariants": { + "magnitude": result.net_force.length, + }, + } + return results_dict + + def export_to_csv(self, file_path): + """ + Export all field results to a CSV file. + + Parameters + ---------- + file_path : str + Path to the CSV file. + """ + import csv + + with open(file_path, mode="w", newline="") as csvfile: + writer = csv.writer(csvfile) + # Write headers + writer.writerow(["Element", "Fx_1", "Fy_1", "Fz_1", "Mx_1", "My_1", "Mz_1", "Fx_2", "Fy_2", "Fz_2", "Mx_2", "My_2", "Mz_2", "Magnitude"]) + # Write results + for element, result in self.get_all_section_forces().items(): + writer.writerow( + [ + element, + result.force_vector_1.x, + result.force_vector_1.y, + result.force_vector_1.z, + result.moment_vector_1.x, + result.moment_vector_1.y, + result.moment_vector_1.z, + result.force_vector_2.x, + result.force_vector_2.y, + result.force_vector_2.z, + result.moment_vector_2.x, + result.moment_vector_2.y, + result.moment_vector_2.z, + result.net_force.length, + ] + ) diff --git a/src/compas_fea2/results/modal.py b/src/compas_fea2/results/modal.py index 7154cf744..4412cc7e8 100644 --- a/src/compas_fea2/results/modal.py +++ b/src/compas_fea2/results/modal.py @@ -1,15 +1,41 @@ from compas_fea2.base import FEAData from .fields import FieldResults -from .fields import DisplacementResult -from .results import Result + +# from .results import Result import numpy as np -# from typing import Iterable +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. + """ -class ModalAnalysisResult(Result): - def __init__(self, mode, eigenvalue, eigenvector, **kwargs): - super(ModalAnalysisResult, self).__init__(mode, **kwargs) + 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 @@ -24,7 +50,7 @@ def eigenvalue(self): @property def frequency(self): - return self._eigenvalue + return self.omega / (2 * np.pi) @property def omega(self): @@ -32,12 +58,16 @@ def omega(self): @property def period(self): - return 2 * np.pi / self.omega + 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. @@ -96,100 +126,45 @@ def __repr__(self): return f"ModalAnalysisResult(mode={self.mode}, eigenvalue={self.eigenvalue:.4f}, " f"frequency={self.frequency:.4f} Hz, period={self.period:.4f} s)" -class ModalAnalysisResults(FEAData): - def __init__(self, step, **kwargs): - super(ModalAnalysisResults, self).__init__(**kwargs) - self._registration = step - self._eigenvalues = None - self._eigenvectors = None - self._eigenvalues_table = step.problem.results_db.get_table("eigenvalues") - self._eigenvalues_table = step.problem.results_db.get_table("eigenvectors") - self._components_names = ["dof_1", "dof_2", "dof_3", "dof_4", "dof_5", "dof_6"] - - @property - def step(self): - return self._registration - - @property - def problem(self): - return self.step.problem +class ModalShape(FieldResults): + """ModalShape result applied as Displacement field. - @property - def model(self): - return self.problem.model + Parameters + ---------- + step : :class:`compas_fea2.problem.Step` + The analysis step + results : list + List of DisplcementResult objects. + """ - @property - def rdb(self): - return self.problem.results_db + def __init__(self, step, results, *args, **kwargs): + super(ModalShape, self).__init__(step=step, field_name=None, *args, **kwargs) + self._results = results @property - def components_names(self): - return self._components_names - - def get_results(self, mode, members, steps, field_name, results_func, results_class, **kwargs): - """Get the results for the given members and steps. - - Parameters - ---------- - members : _type_ - _description_ - steps : _type_ - _description_ - - Returns - ------- - _type_ - _description_ - """ - 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]) - - columns = ["step", "part", "input_key"] + self._components_names - filters = {"input_key": members_keys, "part": parts_names, "step": steps_names, "mode": set([mode for _ in members])} + def results(self): + return self._results - results_set = self.rdb.get_rows(field_name, columns, filters) + def _get_results_from_db(self, members=None, columns=None, filters=None, **kwargs): + raise NotImplementedError("this method is not applicable for ModalShape 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, results_func)(r[2]) - results[step].append(results_class(m, *r[3:])) - return self._to_result(results_set) + 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") -class ModalShape(FieldResults): - """Displacement field results. + def get_min_result(self, component): + raise NotImplementedError("this method is not applicable for ModalShape results") - This class handles the displacement field results from a finite element analysis. + def get_max_component(self, component): + raise NotImplementedError("this method is not applicable for ModalShape results") - problem : :class:`compas_fea2.problem.Problem` - The Problem where the Step is registered. + def get_min_component(self, component): + raise NotImplementedError("this method is not applicable for ModalShape results") - Attributes - ---------- - components_names : list of str - Names of the displacement components. - invariants_names : list of str - Names of the invariants of the displacement field. - results_class : class - The class used to instantiate the displacement results. - results_func : str - The function used to find nodes by key. - """ + def get_limits_component(self, component): + raise NotImplementedError("this method is not applicable for ModalShape results") - def __init__(self, step, mode, *args, **kwargs): - super(ModalShape, self).__init__(step=step, 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_from_db(nodes, step=step, mode=self.mode)[step] + 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 a1c8f130b..7d745c8a6 100644 --- a/src/compas_fea2/results/results.py +++ b/src/compas_fea2/results/results.py @@ -122,7 +122,7 @@ def __init__(self, node, title, x=None, y=None, z=None, xx=None, yy=None, zz=Non self._xx = xx self._yy = yy self._zz = zz - self._results_func = "find_node_by_inputkey" + self._results_func = "find_node_by_key" @property def node(self): @@ -225,90 +225,241 @@ def __init__(self, node, x, y, z, xx, yy, zz, **kwargs): super(ReactionResult, self).__init__(node, "rf", x, y, z, xx, yy, zz, **kwargs) +# --------------------------------------------------------------------------------------------- +# Element Results +# --------------------------------------------------------------------------------------------- + + class ElementResult(Result): """Element1DResult object.""" def __init__(self, element, **kwargs): - super(Element1DResult, self).__init__(**kwargs) + super(ElementResult, self).__init__(**kwargs) self._registration = element - self._results_func = "find_element_by_inputkey" + self._results_func = "find_element_by_key" @property def element(self): return self._registration -class Element1DResult(ElementResult): - """Element1DResult object.""" - - def __init__(self, element, **kwargs): - super(Element1DResult, self).__init__(element, **kwargs) - - -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): + def __init__(self, element, Fx_1, Fy_1, Fz_1, Mx_1, My_1, Mz_1, Fx_2, Fy_2, Fz_2, Mx_2, My_2, Mz_2, **kwargs): super(SectionForcesResult, self).__init__(element, **kwargs) + self._end_1 = element.nodes[0] + self._force_vector_1 = Vector(Fx_1, Fy_1, Fz_1) + self._moment_vector_1 = Vector(Mx_1, My_1, Mz_1) + + self._end_2 = element.nodes[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 end_1(self): + """Returns the first end node of the element.""" + return self._end_1 @property - def moments_vector(self): - pass + def end_2(self): + """Returns the second end node of the element.""" + return self._end_2 @property - def element(self): - return self.location + 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 -class Element2DResult(ElementResult): - """Element1DResult object.""" + @property + def force_vector_2(self): + """Returns the force vector at the second end of the element.""" + return self._force_vector_2 - def __init__(self, element, **kwargs): - super(Element2DResult, self).__init__(element, **kwargs) + @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. + + Parameters + ---------- + file_path : str + Path to the CSV file. + """ + import csv -class StressResult(Element2DResult): + 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 @@ -389,7 +540,6 @@ class StressResult(Element2DResult): def __init__(self, element, *, s11, s12, s13, s22, s23, s33, **kwargs): super(StressResult, self).__init__(element, **kwargs) - self._title = None 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))} @@ -427,10 +577,6 @@ def global_strain(self): return strain_tensor - @property - def element(self): - return self.location - @property # First invariant def I1(self): @@ -717,13 +863,13 @@ def thermal_stress_analysis(self, temperature_change): class MembraneStressResult(StressResult): - def __init__(self, element, *, s11, s12, s22, **kwargs): + 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): + 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]]) @@ -907,16 +1053,9 @@ def stress_along_direction(self, direction, side="mid"): return unit_direction.T @ tensors[side] @ unit_direction -class Element3DResult(ElementResult): - """Element1DResult object.""" - - def __init__(self, element, **kwargs): - super(Element2DResult, self).__init__(element, **kwargs) - - # 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" From 1bae4cee029056d5440211e03763437a10f0f4ad Mon Sep 17 00:00:00 2001 From: franaudo Date: Sun, 19 Jan 2025 16:54:30 +0100 Subject: [PATCH 03/39] section forces --- scripts/shape_transformation.py | 2 +- src/compas_fea2/UI/viewer/scene.py | 30 +- src/compas_fea2/__init__.py | 40 +- src/compas_fea2/model/elements.py | 22 + src/compas_fea2/model/sections.py | 1180 ++++++++++++++++++++----- src/compas_fea2/model/shapes.py | 901 ++++++++++++------- src/compas_fea2/problem/__init__.py | 3 +- src/compas_fea2/problem/outputs.py | 75 +- src/compas_fea2/problem/problem.py | 194 ++-- src/compas_fea2/problem/steps/step.py | 5 + src/compas_fea2/results/__init__.py | 1 + tests/test_bcs.py | 1 + tests/test_sections.py | 5 +- tests/test_shapes.py | 5 +- 14 files changed, 1758 insertions(+), 706 deletions(-) diff --git a/scripts/shape_transformation.py b/scripts/shape_transformation.py index f56514420..028bdd8e6 100644 --- a/scripts/shape_transformation.py +++ b/scripts/shape_transformation.py @@ -14,4 +14,4 @@ print(m1) print("Original rectangle centroid:", r.centroid) -print("Transformed rectangle centroid:", r_transf.centroid) \ No newline at end of file +print("Transformed rectangle centroid:", r_transf.centroid) diff --git a/src/compas_fea2/UI/viewer/scene.py b/src/compas_fea2/UI/viewer/scene.py index a3d13d69b..200600a4e 100644 --- a/src/compas_fea2/UI/viewer/scene.py +++ b/src/compas_fea2/UI/viewer/scene.py @@ -254,10 +254,10 @@ class FEA2StressFieldResultsObject(GroupObject): """ - def __init__(self, step, scale_factor=1, components=None, **kwargs): + def __init__(self, model, scale_factor=1, components=None, **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] @@ -266,7 +266,7 @@ def __init__(self, step, scale_factor=1, components=None, **kwargs): collections = [] for component in components: - field_results = [v[component] for v in field.principal_components_vectors(step)] + field_results = [v[component] for v in field.principal_components_vectors] 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})) @@ -290,7 +290,7 @@ class FEA2DisplacementFieldResultsObject(GroupObject): """ # FIXME: component is not used - def __init__(self, step, component=None, show_vectors=1, show_contour=False, **kwargs): + def __init__(self, model, component=None, show_vectors=1, show_contour=False, **kwargs): field = kwargs.pop("item") cmap = kwargs.get("cmap", ColorMap.from_palette("hawaii")) @@ -309,14 +309,14 @@ def __init__(self, step, component=None, show_vectors=1, show_contour=False, **k field_results = list(field.component(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) + part_vertexcolor = draw_field_contour(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 model.parts: for element in part.elements: vertexcolor = {} if isinstance(element, BeamElement): @@ -346,34 +346,32 @@ class FEA2ReactionFieldResultsObject(GroupObject): """ - def __init__(self, field, step, component, show_vectors=1, show_contour=False, **kwargs): - # FIXME: component is not used - + def __init__(self, model, component, show_vectors=1, show_contour=False, **kwargs): field = kwargs.pop("item") cmap = kwargs.get("cmap", ColorMap.from_palette("hawaii")) - cmap = None 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) + vectors, colors = draw_field_vectors([n.point for n in field.locations], list(field.vectors), 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"REACT-{component}", "linecolor": c, "linewidth": 3})) + group_elements.append((v, {"name": f"DISP-{component}", "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 = list(field.component(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) + part_vertexcolor = draw_field_contour(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 model.parts: for element in part.elements: vertexcolor = {} if isinstance(element, BeamElement): diff --git a/src/compas_fea2/__init__.py b/src/compas_fea2/__init__.py index e4d77dbb7..6297683d5 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. @@ -54,18 +45,6 @@ def init_fea2(verbose=False, point_overlap=True, global_tolerance=1, precision=3 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 @@ -111,4 +90,23 @@ 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")) +PART_NODES_LIMIT = int(os.getenv("PART_NODES_LIMIT")) +BACKEND = None +BACKENDS = defaultdict(dict) + __all__ = ["HOME", "DATA", "DOCS", "TEMP"] diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index 457332773..1bf8576ac 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -281,6 +281,28 @@ def length(self): def volume(self): return self.section.A * self.length + def plot_section(self): + self.section.plot() + + def plot_stress_distribution(self, step, end="end_1", nx=100, ny=100, *args, **kwargs): + 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): + 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): + r = self.section_forces_result(step) + return r.forces + + def moments(self, step): + r = self.section_forces_result(step) + return r.moments + class BeamElement(_Element1D): """A 1D element that resists axial, shear, bending and torsion. diff --git a/src/compas_fea2/model/sections.py b/src/compas_fea2/model/sections.py index 7ceeef819..b5e5c0527 100644 --- a/src/compas_fea2/model/sections.py +++ b/src/compas_fea2/model/sections.py @@ -4,6 +4,10 @@ from math import pi from math import sqrt +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.patches import Polygon as mplPolygon +from matplotlib.path import Path from compas_fea2 import units from compas_fea2.base import FEAData @@ -12,6 +16,7 @@ from .shapes import Circle from .shapes import IShape from .shapes import Rectangle +from .shapes import LShape def from_shape(shape, material, **kwargs): @@ -31,12 +36,15 @@ def from_shape(shape, 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,13 +59,23 @@ 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) self._material = material + def __str__(self): + return """ +Section {} +--------{} +model : {!r} +key : {} +material : {!r} +""".format( + self.name, "-" * len(self.name), self.model, self.key, self.material + ) + @property def model(self): return self._registration @@ -73,17 +91,6 @@ def material(self, value): 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 +98,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,7 +114,6 @@ class MassSection(FEAData): Identifier of the element in the parent part. mass : float Point mass value. - """ def __init__(self, mass, **kwargs): @@ -123,7 +132,8 @@ def __str__(self): class SpringSection(FEAData): - """Section for use with spring elements. + """ + Section for use with spring elements. Parameters ---------- @@ -131,8 +141,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 +152,7 @@ class SpringSection(FEAData): Axial stiffness value. lateral : float Lateral stiffness value. - axial : float + rotational : float Rotational stiffness value. Notes @@ -186,54 +198,58 @@ 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): @@ -290,9 +306,395 @@ def from_shape(cls, shape, material, **kwargs): def shape(self): return self._shape + def plot(self): + self.shape.plot() + + def compute_stress(self, N=0.0, Mx=0.0, My=0.0, Vx=0.0, Vy=0.0, x=0.0, y=0.0): + """ + 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=0.0, Mx=0.0, My=0.0, Vx=0.0, Vy=0.0, nx=50, ny=50): + """ + 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=0.0, Mx=0.0, My=0.0): + """ + 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=0.0, Mx=0.0, My=0.0, Vx=0.0, Vy=0.0, nx=50, ny=50, cmap="coolwarm", show_tau=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=0.0, Mx=0.0, My=0.0, Vx=0.0, Vy=0.0, direction=(1, 0), point=None, nx=50, ny=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) @@ -301,7 +703,8 @@ def __init__(self, A, Ixx, Iyy, Ixy, Avx, Avy, J, g0, gw, material, **kwargs): class AngleSection(BeamSection): - """Uniform thickness angle cross-section for beam elements. + """ + Uniform thickness angle cross-section for beam elements. Parameters ---------- @@ -309,13 +712,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 +727,45 @@ 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 - - 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, - ) + def __init__(self, w, h, t1, t2, material, **kwargs): + self._shape = LShape(w, h, t1, t2) + super().__init__(**from_shape(self._shape, material, **kwargs)) -# 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 +779,8 @@ class BoxSection(BeamSection): Flange thickness. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -417,23 +793,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 +819,7 @@ class BoxSection(BeamSection): Warnings -------- - - Ixy not yet calculated. - + Ixy not yet calculated. """ def __init__(self, w, h, tw, tf, material, **kwargs): @@ -482,7 +857,8 @@ def __init__(self, w, h, tw, tf, material, **kwargs): class CircularSection(BeamSection): - """Solid circular cross-section for beam elements. + """ + Solid circular cross-section for beam elements. Parameters ---------- @@ -490,102 +866,87 @@ 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 - - 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, - ) - self._shape = Circle(radius=r) + self._shape = Circle(r, 360) + super().__init__(**from_shape(self._shape, material, **kwargs)) +# 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): @@ -593,7 +954,8 @@ def __init__(self, r, t, material, **kwargs): class ISection(BeamSection): - """Equal flanged I-section for beam elements. + """ + Equal flanged I-section for beam elements. Parameters ---------- @@ -603,10 +965,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 +982,387 @@ 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)) + @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 +1372,8 @@ class PipeSection(BeamSection): Wall thickness. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -669,26 +1382,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): @@ -722,7 +1434,8 @@ def __init__(self, r, t, material, **kwargs): class RectangularSection(BeamSection): - """Solid rectangular cross-section for beam elements. + """ + Solid rectangular cross-section for beam elements. Parameters ---------- @@ -732,6 +1445,8 @@ class RectangularSection(BeamSection): Height. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -740,26 +1455,25 @@ 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): @@ -768,7 +1482,8 @@ def __init__(self, w, h, material, **kwargs): class TrapezoidalSection(BeamSection): - """Solid trapezoidal cross-section for beam elements. + """ + Solid trapezoidal cross-section for beam elements. Parameters ---------- @@ -780,6 +1495,8 @@ class TrapezoidalSection(BeamSection): Height. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -790,30 +1507,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): @@ -854,7 +1570,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 +1579,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): @@ -914,7 +1632,8 @@ def __init__(self, A, material, **kwargs): class StrutSection(TrussSection): - """For use with strut elements. + """ + For use with strut elements. Parameters ---------- @@ -922,30 +1641,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 +1673,8 @@ def __init__(self, A, material, **kwargs): class TieSection(TrussSection): - """For use with tie elements. + """ + For use with tie elements. Parameters ---------- @@ -961,30 +1682,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 +1719,8 @@ def __init__(self, A, material, **kwargs): class ShellSection(_Section): - """Section for shell elements. + """ + Section for shell elements. Parameters ---------- @@ -1005,6 +1728,8 @@ class ShellSection(_Section): Thickness. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -1012,7 +1737,6 @@ class ShellSection(_Section): Thickness. material : :class:`compas_fea2.model._Material` The section material. - """ def __init__(self, t, material, **kwargs): @@ -1021,7 +1745,8 @@ def __init__(self, t, material, **kwargs): class MembraneSection(_Section): - """Section for membrane elements. + """ + Section for membrane elements. Parameters ---------- @@ -1029,6 +1754,8 @@ class MembraneSection(_Section): Thickness. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -1036,7 +1763,6 @@ class MembraneSection(_Section): Thickness. material : :class:`compas_fea2.model._Material` The section material. - """ def __init__(self, t, material, **kwargs): @@ -1050,18 +1776,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..313476f90 100644 --- a/src/compas_fea2/model/shapes.py +++ b/src/compas_fea2/model/shapes.py @@ -1,231 +1,303 @@ -from math import atan2 -from math import degrees -from math import pi -from math import sqrt +import math +from math import atan2, degrees, pi, sqrt +from functools import cached_property +from typing import List, Optional, Tuple +import matplotlib.pyplot as plt +from matplotlib.patches import Polygon as MplPolygon +from matplotlib.lines import Line2D +from compas.geometry import Point import numpy as np -from compas.datastructures import Mesh -from compas.geometry import Frame -from compas.geometry import Point -from compas.geometry import Polygon -from compas.geometry import Rotation -from compas.geometry import Transformation -from compas.geometry import Translation +from compas.datastructures import Mesh +from compas.geometry import ( + Frame, + Polygon, + Rotation, + Transformation, + Translation, +) 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): + # Optional advanced FEA properties + self._J = None # Torsional constant + self._g0 = None # Shear modulus in the x-y plane + self._gw = None # Shear modulus in the x-z plane + self._Avx = None # Shear area in the x-direction + self._Avy = None # Shear area in the y-direction + + 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) """ - # ========================================================================== + # -------------------------------------------------------------------------- # 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. + + 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 + 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.""" + def Avx(self) -> Optional[float]: + """Shear area in the x-direction (if defined).""" return self._Avx @property - def Avy(self): - """Shear area in the y-direction.""" + def Avy(self) -> Optional[float]: + """Shear area in the y-direction (if defined).""" return self._Avy @property - def g0(self): - """Shear modulus in the x-y plane.""" + def g0(self) -> Optional[float]: + """Shear modulus in the x-y plane (if defined).""" return self._g0 @property - def gw(self): - """Shear modulus in the x-z plane.""" + def gw(self) -> Optional[float]: + """Shear modulus in the x-z plane (if defined).""" return self._gw @property - def J(self): - """Torsional constant.""" + def J(self) -> Optional[float]: + """Torsional constant (if defined).""" return self._J - # ========================================================================== + # -------------------------------------------------------------------------- # 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 +311,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 +335,194 @@ 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}" + ) + + # Place the text to the right of the bounding box. + # For a little margin, we add ~10% of the bounding-box width. + text_x = max_x + 0.1 * (max_x - min_x) + text_y = max_y # near the top of the shape + + 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 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 + super().__init__(pts, frame=frame) + # Example placeholders for shear area, torsional const, etc. + self._Avy = 0.833 * self.area # from approximate formulas + self._Avx = 0.833 * self.area 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 + # example approximate formula for J + self._J = (l1 * (l2**3)) * (0.33333 - 0.21 * (l2 / l1) * (1 - (l2**4) / (l2 * l1**4))) + self._g0 = 0 # placeholders + self._gw = 0 @property - def w(self): + def w(self) -> float: return self._w @property - def h(self): + def h(self) -> float: return self._h 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 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 +532,42 @@ 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 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 +577,107 @@ 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 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 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 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) 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 +685,41 @@ 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 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 +729,24 @@ 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) 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 +754,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 +776,257 @@ 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) 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): + """ + 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 - points = self._set_points() - super().__init__(points, frame=frame) + 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): + def radius(self) -> float: return self._radius @radius.setter - def radius(self, val): + def radius(self, val: float): 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): + """ + 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 - points = self._set_points() - super().__init__(points, frame=frame) + 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): + def radius_a(self) -> float: return self._radius_a @radius_a.setter - def radius_a(self, val): + def radius_a(self, val: float): self._radius_a = val self.points = self._set_points() @property - def radius_b(self): + def radius_b(self) -> float: return self._radius_b @radius_b.setter - def radius_b(self, val): + def radius_b(self, val: float): self._radius_b = val self.points = self._set_points() - 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)] - 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() 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) + ] 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) + ] 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) + ] 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), ] 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), ] diff --git a/src/compas_fea2/problem/__init__.py b/src/compas_fea2/problem/__init__.py index 13c668bea..fa30393ab 100644 --- a/src/compas_fea2/problem/__init__.py +++ b/src/compas_fea2/problem/__init__.py @@ -38,11 +38,11 @@ ) from .outputs import ( - FieldOutput, DisplacementFieldOutput, AccelerationFieldOutput, VelocityFieldOutput, Stress2DFieldOutput, + SectionForcesFieldOutput, # StrainFieldOutput, ReactionFieldOutput, HistoryOutput, @@ -87,4 +87,5 @@ "VelocityFieldOutput", "Stress2DFieldOutput", "ReactionFieldOutput", + "SectionForcesFieldOutput", ] diff --git a/src/compas_fea2/problem/outputs.py b/src/compas_fea2/problem/outputs.py index 5e5110621..61a78d4c0 100644 --- a/src/compas_fea2/problem/outputs.py +++ b/src/compas_fea2/problem/outputs.py @@ -275,50 +275,41 @@ def get_sqltable_schema(cls): } -class FieldOutput(_Output): - """FieldOutput object for specification of the fields (stresses, displacements, - etc..) to output from the analysis. +class SectionForcesFieldOutput(_ElementFieldOutput): + """SectionForcesFieldOutput object for requesting the section forces at the elements 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 + def __init__(self, **kwargs): + super(SectionForcesFieldOutput, self).__init__( + "sf", ["Fx_1", "Fy_1", "Fz_1", "Mx_1", "My_1", "Mz_1", "Fx_2", "Fy_2", "Fz_2", "Mx_2", "My_2", "Mz_2"], ["magnitude"], **kwargs + ) - @property - def outputs(self): - return chain(self.node_outputs, self.element_outputs, self.contact_outputs) + @classmethod + def get_sqltable_schema(cls): + """ + Return a dict describing the table name and each column + (column_name, column_type, constraints). + """ + return { + "table_name": "sf", + "columns": [ + ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), + ("key", "INTEGER"), + ("step", "TEXT"), + ("part", "TEXT"), + ("Fx_1", "REAL"), + ("Fy_1", "REAL"), + ("Fz_1", "REAL"), + ("Mx_1", "REAL"), + ("My_1", "REAL"), + ("Mz_1", "REAL"), + ("Fx_2", "REAL"), + ("Fy_2", "REAL"), + ("Fz_2", "REAL"), + ("Mx_2", "REAL"), + ("My_2", "REAL"), + ("Mz_2", "REAL"), + ], + } class HistoryOutput(_Output): diff --git a/src/compas_fea2/problem/problem.py b/src/compas_fea2/problem/problem.py index 6776fd320..980bbd8ae 100644 --- a/src/compas_fea2/problem/problem.py +++ b/src/compas_fea2/problem/problem.py @@ -515,53 +515,6 @@ def show(self, fast=True, scale_model=1.0, show_parts=True, show_bcs=1.0, show_l 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. @@ -584,12 +537,14 @@ def show_deformed(self, step=None, opacity=1, show_bcs=1, scale_results=1, scale 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): + displacements = step.displacement_field + for displacement in displacements.results: vector = displacement.vector.scaled(scale_results) - displacement.node.xyz = sum_vectors([Vector(*displacement.location.xyz), vector]) + 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) - viewer.viewer.show() + if show_loads: + viewer.add_step(step, show_loads=show_loads) + 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. @@ -614,13 +569,13 @@ def show_displacements(self, step=None, fast=True, show_bcs=1, scale_model=1, sh 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.displacement_field, fast=fast, step=step, component=component, show_vectors=show_vectors, show_contours=show_contours, **kwargs) + viewer.add_displacement_field(step.displacement_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contours=show_contours, **kwargs) if show_loads: viewer.add_step(step, show_loads=show_loads) viewer.show() viewer.scene.clear() - def show_reactions(self, step=None, show_bcs=1, scale_model=1, show_loads=0.1, component=None, show_vectors=1, show_contours=False, **kwargs): + def show_reactions(self, fast=True, step=None, show_bcs=1, scale_model=1, show_loads=0.1, component=None, show_vectors=1, show_contours=False, **kwargs): """Display the reaction field results for a given step. Parameters @@ -638,74 +593,117 @@ def show_reactions(self, step=None, show_bcs=1, scale_model=1, show_loads=0.1, c scale_results : _type_, optional _description_, by default 1 """ - 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 FEA2ReactionFieldResultsObject - from compas_fea2.UI.viewer import FEA2StepObject - from compas_fea2.UI.viewer import FEA2Viewer - if not step: step = self.steps_order[-1] - if not step.problem.reaction_field: + if not step.reaction_field: raise ValueError("No reaction field results available for this step") viewer = FEA2Viewer(center=self.model.center, scale_model=scale_model) - viewer.viewer.config.vectorsize = 0.2 + 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.reaction_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contours=show_contours, **kwargs) - register_scene_objects() # This has to be called before registering the model object + if show_loads: + viewer.add_step(step, show_loads=show_loads) + viewer.show() + viewer.scene.clear() - register(self.model.__class__, FEA2ModelObject, context="Viewer") - viewer.viewer.scene.add(self.model, model=self.model, show_parts=True, opacity=0.5, show_bcs=show_bcs, show_loads=show_loads, **kwargs) + def show_stress_contour(self, fast=True, step=None, show_bcs=1, scale_model=1, show_loads=0.1, component=None, show_vectors=1, show_contours=False, **kwargs): + if not step: + step = self.steps_order[-1] + + if not step.stress_field: + raise ValueError("No reaction field results available for this step") - register(step.problem.reaction_field.__class__, FEA2ReactionFieldResultsObject, context="Viewer") - viewer.viewer.scene.add( - step.problem.reaction_field, field=step.problem.reaction_field, step=step, component=component, show_vectors=show_vectors, show_contour=show_contours, **kwargs - ) + 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_stress_field(step.stress_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contours=show_contours, **kwargs) if show_loads: - register(step.__class__, FEA2StepObject, context="Viewer") - viewer.viewer.scene.add(step, step=step, scale_factor=show_loads) + viewer.add_step(step, show_loads=show_loads) + viewer.show() + viewer.scene.clear() - viewer.viewer.show() + # 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_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 + """ - 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 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) + register_scene_objects() # This has to be called before registering the model object + register(self.model.__class__.__bases__[-1], FEA2ModelObject, context="Viewer") + viewer.scene.add(self.model, model=self.model, opacity=0.5, show_bcs=show_bcs, show_loads=show_loads, **kwargs) - 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) + register(step.stress_field.__class__.__bases__[-1], FEA2StressFieldResultsObject, context="Viewer") + viewer.scene.add(step.stress_field, field=step.stress_field, step=step, scale_factor=scale_results, components=components, **kwargs) - 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() + if show_loads: + register(step.__class__, FEA2StepObject, context="Viewer") + viewer.scene.add(step, step=step, scale_factor=show_loads) + + 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 diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index 33a8f52ab..02773d183 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -10,6 +10,7 @@ from compas_fea2.results import DisplacementFieldResults from compas_fea2.results import ReactionFieldResults from compas_fea2.results import StressFieldResults +from compas_fea2.results import SectionForcesFieldResults # ============================================================================== # Base Steps @@ -189,6 +190,10 @@ def temperature_field(self): def stress_field(self): return StressFieldResults(self) + @property + def section_forces_field(self): + return SectionForcesFieldResults(self) + # ============================================================================== # General Steps diff --git a/src/compas_fea2/results/__init__.py b/src/compas_fea2/results/__init__.py index bd01e00ce..de376a781 100644 --- a/src/compas_fea2/results/__init__.py +++ b/src/compas_fea2/results/__init__.py @@ -19,6 +19,7 @@ VelocityFieldResults, StressFieldResults, ReactionFieldResults, + SectionForcesFieldResults, ) from .modal import ( diff --git a/tests/test_bcs.py b/tests/test_bcs.py index c2c7af368..42f4cf0c0 100644 --- a/tests/test_bcs.py +++ b/tests/test_bcs.py @@ -3,6 +3,7 @@ class TestBCs(unittest.TestCase): + def test_fixed_bc(self): bc = FixedBC() self.assertTrue(bc.x) diff --git a/tests/test_sections.py b/tests/test_sections.py index 0a83008ce..a13edd230 100644 --- a/tests/test_sections.py +++ b/tests/test_sections.py @@ -1,11 +1,12 @@ import unittest from compas_fea2.model.sections import RectangularSection, CircularSection, ISection -from compas_fea2.model.materials.steel import Steel +from compas_fea2.model.materials.material import _Material class TestSections(unittest.TestCase): + def setUp(self): - self.material = Steel.S355() + self.material = _Material(name="Steel") def test_rectangular_section(self): section = RectangularSection(w=100, h=50, material=self.material) diff --git a/tests/test_shapes.py b/tests/test_shapes.py index ed1881efd..f2dd661f9 100644 --- a/tests/test_shapes.py +++ b/tests/test_shapes.py @@ -4,6 +4,7 @@ class TestShapes(unittest.TestCase): + def test_rectangle(self): rect = Rectangle(w=100, h=50) self.assertEqual(rect.w, 100) @@ -12,9 +13,9 @@ def test_rectangle(self): self.assertIsInstance(rect.centroid, Point) 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.assertAlmostEqual(circle.A, 314.159, places=3) self.assertIsInstance(circle.centroid, Point) def test_ishape(self): From 40e09e8bc487677201d8ed754c3b9de5417f67aa Mon Sep 17 00:00:00 2001 From: franaudo Date: Sun, 19 Jan 2025 20:40:46 +0100 Subject: [PATCH 04/39] stress2D field --- src/compas_fea2/UI/viewer/__init__.py | 4 +- src/compas_fea2/UI/viewer/scene.py | 18 +- src/compas_fea2/UI/viewer/viewer.py | 9 +- src/compas_fea2/problem/outputs.py | 11 +- src/compas_fea2/problem/problem.py | 20 +- src/compas_fea2/problem/steps/step.py | 6 +- src/compas_fea2/results/__init__.py | 4 +- src/compas_fea2/results/fields.py | 516 ++++++++++++++------------ src/compas_fea2/results/results.py | 213 ++--------- 9 files changed, 354 insertions(+), 447 deletions(-) diff --git a/src/compas_fea2/UI/viewer/__init__.py b/src/compas_fea2/UI/viewer/__init__.py index ccf7f674f..10cbe71ce 100644 --- a/src/compas_fea2/UI/viewer/__init__.py +++ b/src/compas_fea2/UI/viewer/__init__.py @@ -1,7 +1,7 @@ from .viewer import FEA2Viewer from .scene import FEA2ModelObject from .scene import FEA2StepObject -from .scene import FEA2StressFieldResultsObject +from .scene import FEA2Stress2DFieldResultsObject from .scene import FEA2DisplacementFieldResultsObject from .scene import FEA2ReactionFieldResultsObject @@ -22,7 +22,7 @@ "ArrowShape", "FEA2ModelObject", "FEA2StepObject", - "FEA2StressFieldResultsObject", + "FEA2Stress2DFieldResultsObject", "FEA2DisplacementFieldResultsObject", "FEA2ReactionFieldResultsObject", ] diff --git a/src/compas_fea2/UI/viewer/scene.py b/src/compas_fea2/UI/viewer/scene.py index 200600a4e..52882fa80 100644 --- a/src/compas_fea2/UI/viewer/scene.py +++ b/src/compas_fea2/UI/viewer/scene.py @@ -238,7 +238,7 @@ def __init__(self, scale_factor=1, **kwargs): super().__init__(item=patterns, name=f"STEP-{step.name}", componets=None, **kwargs) -class FEA2StressFieldResultsObject(GroupObject): +class FEA2Stress2DFieldResultsObject(GroupObject): """StressFieldResults object for visualization. Parameters @@ -254,7 +254,7 @@ class FEA2StressFieldResultsObject(GroupObject): """ - def __init__(self, model, 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 = [e.reference_point for e in field.locations] @@ -265,12 +265,16 @@ def __init__(self, model, 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] - 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})) + if show_vectors: + 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: + raise NotImplementedError("Contour visualization not implemented for stress fields.") - super().__init__(item=collections, name=f"RESULTS-{field.name}", **kwargs) + super().__init__(item=collections, name=f"STRESS-{field.name}", **kwargs) class FEA2DisplacementFieldResultsObject(GroupObject): diff --git a/src/compas_fea2/UI/viewer/viewer.py b/src/compas_fea2/UI/viewer/viewer.py index 91c5b1859..c66cd328d 100644 --- a/src/compas_fea2/UI/viewer/viewer.py +++ b/src/compas_fea2/UI/viewer/viewer.py @@ -9,7 +9,7 @@ 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 FEA2StressFieldResultsObject +from compas_fea2.UI.viewer.scene import FEA2Stress2DFieldResultsObject from compas_fea2.UI.viewer.scene import FEA2StepObject @@ -194,10 +194,10 @@ def add_reaction_field( **kwargs, ) - def add_stress_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 + 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__, FEA2StressFieldResultsObject, context="Viewer") + register(field.__class__.__base__, FEA2Stress2DFieldResultsObject, context="Viewer") self.stresses = self.scene.add( field, model=model, @@ -209,6 +209,7 @@ def add_stress_field( show_loads=show_loads, show_vectors=show_vectors, show_contours=show_contours, + plane=plane, **kwargs, ) diff --git a/src/compas_fea2/problem/outputs.py b/src/compas_fea2/problem/outputs.py index 61a78d4c0..1ef2074be 100644 --- a/src/compas_fea2/problem/outputs.py +++ b/src/compas_fea2/problem/outputs.py @@ -249,7 +249,7 @@ 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) + super(Stress2DFieldOutput, self).__init__("s2d", ["s11", "s22", "s12", "sb11", "sb22", "sb12", "tq1", "tq2"], ["von_mises"], **kwargs) @classmethod def get_sqltable_schema(cls): @@ -267,10 +267,11 @@ def get_sqltable_schema(cls): ("s11", "REAL"), ("s22", "REAL"), ("s12", "REAL"), - ("m11", "REAL"), - ("m22", "REAL"), - ("m12", "REAL"), - # ("von_mises", "REAL"), + ("sb11", "REAL"), + ("sb22", "REAL"), + ("sb12", "REAL"), + ("tq1", "REAL"), + ("tq2", "REAL"), ], } diff --git a/src/compas_fea2/problem/problem.py b/src/compas_fea2/problem/problem.py index 980bbd8ae..748859b29 100644 --- a/src/compas_fea2/problem/problem.py +++ b/src/compas_fea2/problem/problem.py @@ -546,7 +546,7 @@ def show_deformed(self, step=None, opacity=1, show_bcs=1, scale_results=1, scale viewer.add_step(step, show_loads=show_loads) 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): + def show_displacements(self, step=None, 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 @@ -569,13 +569,13 @@ def show_displacements(self, step=None, fast=True, show_bcs=1, scale_model=1, sh 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.displacement_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contours=show_contours, **kwargs) + viewer.add_displacement_field(step.displacement_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contour=show_contour, **kwargs) if show_loads: viewer.add_step(step, show_loads=show_loads) viewer.show() viewer.scene.clear() - def show_reactions(self, fast=True, step=None, show_bcs=1, scale_model=1, show_loads=0.1, component=None, show_vectors=1, show_contours=False, **kwargs): + def show_reactions(self, fast=True, step=None, 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 @@ -601,23 +601,25 @@ def show_reactions(self, fast=True, step=None, show_bcs=1, scale_model=1, show_l 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.reaction_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contours=show_contours, **kwargs) + viewer.add_reaction_field(step.reaction_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contour=show_contour, **kwargs) if show_loads: viewer.add_step(step, show_loads=show_loads) viewer.show() viewer.scene.clear() - def show_stress_contour(self, fast=True, step=None, show_bcs=1, scale_model=1, show_loads=0.1, component=None, show_vectors=1, show_contours=False, **kwargs): + def show_stress(self, fast=True, step=None, show_bcs=1, scale_model=1, show_loads=0.1, component=None, show_vectors=1, show_contour=False, plane="mid", **kwargs): if not step: step = self.steps_order[-1] - if not step.stress_field: + if not step.stress2D_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_stress_field(step.stress_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contours=show_contours, **kwargs) + viewer.add_stress2D_field( + step.stress2D_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(step, show_loads=show_loads) @@ -683,7 +685,7 @@ def show_principal_stress_vectors(self, step=None, components=None, scale_model= 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 FEA2Stress2DFieldResultsObject from compas_fea2.UI.viewer import FEA2Viewer if not step: @@ -696,7 +698,7 @@ def show_principal_stress_vectors(self, step=None, components=None, scale_model= register(self.model.__class__.__bases__[-1], FEA2ModelObject, context="Viewer") viewer.scene.add(self.model, model=self.model, opacity=0.5, show_bcs=show_bcs, show_loads=show_loads, **kwargs) - register(step.stress_field.__class__.__bases__[-1], FEA2StressFieldResultsObject, context="Viewer") + register(step.stress_field.__class__.__bases__[-1], FEA2Stress2DFieldResultsObject, context="Viewer") viewer.scene.add(step.stress_field, field=step.stress_field, step=step, scale_factor=scale_results, components=components, **kwargs) if show_loads: diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index 02773d183..c25b15c7d 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -9,7 +9,7 @@ from compas_fea2.results import DisplacementFieldResults from compas_fea2.results import ReactionFieldResults -from compas_fea2.results import StressFieldResults +from compas_fea2.results import Stress2DFieldResults from compas_fea2.results import SectionForcesFieldResults # ============================================================================== @@ -187,8 +187,8 @@ def temperature_field(self): raise NotImplementedError @property - def stress_field(self): - return StressFieldResults(self) + def stress2D_field(self): + return Stress2DFieldResults(self) @property def section_forces_field(self): diff --git a/src/compas_fea2/results/__init__.py b/src/compas_fea2/results/__init__.py index de376a781..515f709a4 100644 --- a/src/compas_fea2/results/__init__.py +++ b/src/compas_fea2/results/__init__.py @@ -17,7 +17,7 @@ DisplacementFieldResults, AccelerationFieldResults, VelocityFieldResults, - StressFieldResults, + Stress2DFieldResults, ReactionFieldResults, SectionForcesFieldResults, ) @@ -41,7 +41,7 @@ "AccelerationFieldResults", "VelocityFieldResults", "ReactionFieldResults", - "StressFieldResults", + "Stress2DFieldResults", "SectionForcesFieldResults", "ModalAnalysisResult", "ModalShape", diff --git a/src/compas_fea2/results/fields.py b/src/compas_fea2/results/fields.py index 18c31a907..bf4e9e778 100644 --- a/src/compas_fea2/results/fields.py +++ b/src/compas_fea2/results/fields.py @@ -1,30 +1,30 @@ from typing import Iterable import numpy as np -from compas.geometry import Frame -from compas.geometry import Transformation -from compas.geometry import Vector +from compas.geometry import Frame, Transformation, Vector from compas_fea2.base import FEAData -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 .results import SectionForcesResult +from .results import ( + AccelerationResult, + DisplacementResult, + ReactionResult, + ShellStressResult, + SolidStressResult, + VelocityResult, + SectionForcesResult, +) 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 ---------- @@ -41,10 +41,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 @@ -53,17 +53,15 @@ class FieldResults(FEAData): Notes ----- FieldResults are registered to a :class:`compas_fea2.problem.Step`. - """ def __init__(self, step, field_name, *args, **kwargs): super(FieldResults, self).__init__(*args, **kwargs) self._registration = step self._field_name = field_name - # self._table = step.problem.results_db.get_table(field_name) self._components_names = None self._invariants_names = None - self._restuls_func = None + self._results_func = None @property def step(self): @@ -94,15 +92,17 @@ def _get_results_from_db(self, members=None, columns=None, filters=None, **kwarg Parameters ---------- - members : _type_ - _description_ - steps : _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 ------- - _type_ - _description_ + dict + Dictionary of results. """ if not filters: filters = {} @@ -117,22 +117,20 @@ def _get_results_from_db(self, members=None, columns=None, filters=None, **kwarg results_set = self.rdb.get_rows(self._field_name, ["step", "part", "key"] + self._components_names, filters) - return self.rdb.to_result(results_set, self._results_class, self._restuls_func) + return self.rdb.to_result(results_set, self._results_class, self._results_func) def get_result_at(self, location): - """Get the result for a given member and step. + """Get the result for a given location. Parameters ---------- - member : _type_ - _description_ - step : _type_ - _description_ + location : object + The location to retrieve the result for. Returns ------- - _type_ - _description_ + object + The result at the given location. """ return self._get_results_from_db(location, self.step)[self.step][0] @@ -141,20 +139,30 @@ def get_max_result(self, component): 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. """ results_set = self.rdb.get_func_row(self.field_name, self.field_name + str(component), "MAX", {"step": [self.step.name]}, self.results_columns) return self.rdb.to_result(results_set)[self.step][0] def get_min_result(self, component): + """Get the result where a component is minimum for a given step. + + Parameters + ---------- + component : str + The component to retrieve the minimum result for. + + Returns + ------- + :class:`compas_fea2.results.Result` + The appropriate Result object. + """ results_set = self.rdb.get_func_row(self.field_name, self.field_name + str(component), "MIN", {"step": [self.step.name]}, self.results_columns) return self.rdb.to_result(results_set, self._results_class)[self.step][0] @@ -163,15 +171,13 @@ def get_max_component(self, component): Parameters ---------- - component : _type_ - _description_ - step : _type_ - _description_ + component : int + The index of the component to retrieve. Returns ------- - :class:`compas_fea2.results.Result` - The appriate Result object. + float + The maximum value of the component. """ return self.get_max_result(component, self.step).vector[component - 1] @@ -180,28 +186,23 @@ def get_min_component(self, component): Parameters ---------- - component : _type_ - _description_ - step : _type_ - _description_ + component : int + The index of the component to retrieve. Returns ------- - :class:`compas_fea2.results.Result` - The appropriate Result object. + float + The minimum value of the component. """ return self.get_min_result(component, self.step).vector[component - 1] def get_limits_component(self, component): - """Get the result objects with the min and max value of a given - component in a step. + """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 ------- @@ -211,6 +212,13 @@ def get_limits_component(self, component): return [self.get_min_result(component, self.step), self.get_max_result(component, self.step)] def get_limits_absolute(self): + """Get the result objects with the absolute min and max value in a step. + + Returns + ------- + list + A list containing the result objects with the absolute minimum and maximum value in the step. + """ limits = [] for func in ["MIN", "MAX"]: limits.append(self.rdb.get_func_row(self.field_name, "magnitude", func, {"step": [self.step.name]}, self.results_columns)) @@ -220,11 +228,6 @@ def get_limits_absolute(self): def locations(self): """Return the locations where the field is defined. - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step, by default None - Yields ------ :class:`compas.geometry.Point` @@ -237,11 +240,6 @@ def locations(self): def points(self): """Return the locations where the field is defined. - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step, by default None - Yields ------ :class:`compas.geometry.Point` @@ -252,33 +250,28 @@ def points(self): @property def vectors(self): - """Return the locations where the field is defined. - - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step, by default None + """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. """ for r in self.results: yield r.vector def component(self, dof=None): - """Return the locations where the field is defined. + """Return the components where the field is defined. Parameters ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step, by default None + dof : int, optional + The degree of freedom to retrieve, by default None. Yields ------ - :class:`compas.geometry.Point` - The location where the field is defined. + float + The component value. """ for r in self.results: if dof is None: @@ -287,13 +280,20 @@ def component(self, dof=None): yield r.vector[dof] +# ------------------------------------------------------------------------------ +# Node Field Results +# ------------------------------------------------------------------------------ + + class DisplacementFieldResults(FieldResults): """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 ---------- @@ -312,72 +312,89 @@ def __init__(self, step, *args, **kwargs): self._components_names = ["ux", "uy", "uz", "uxx", "uyy", "uzz"] self._invariants_names = ["magnitude"] self._results_class = DisplacementResult - self._restuls_func = "find_node_by_key" + self._results_func = "find_node_by_key" class AccelerationFieldResults(FieldResults): - """Displacement field results. + """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, step, *args, **kwargs): - super(AccelerationFieldResults, self).__init__(problem=step, field_name="a", *args, **kwargs) + super(AccelerationFieldResults, self).__init__(step=step, field_name="a", *args, **kwargs) self._components_names = ["ax", "ay", "az", "axx", "ayy", "azz"] self._invariants_names = ["magnitude"] self._results_class = AccelerationResult - self._restuls_func = "find_node_by_key" + self._results_func = "find_node_by_key" class VelocityFieldResults(FieldResults): - """Displacement field results. + """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, step, *args, **kwargs): - super(VelocityFieldResults, self).__init__(problem=step, field_name="v", *args, **kwargs) + super(VelocityFieldResults, self).__init__(step=step, field_name="v", *args, **kwargs) self._components_names = ["vx", "vy", "vz", "vxx", "vyy", "vzz"] self._invariants_names = ["magnitude"] self._results_class = VelocityResult - self._restuls_func = "find_node_by_key" + self._results_func = "find_node_by_key" class ReactionFieldResults(FieldResults): - """Reaction field. + """Reaction field results. + + 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, step, *args, **kwargs): @@ -385,133 +402,34 @@ def __init__(self, step, *args, **kwargs): self._components_names = ["rfx", "rfy", "rfz", "rfxx", "rfyy", "rfzz"] self._invariants_names = ["magnitude"] self._results_class = ReactionResult - self._restuls_func = "find_node_by_key" - - -class StressFieldResults(FieldResults): - """_summary_ - - Parameters - ---------- - FieldResults : _type_ - _description_ - """ - - def __init__(self, step, *args, **kwargs): - super(StressFieldResults, self).__init__(step=step, field_name="s2d", *args, **kwargs) - self._components_names = ["s11", "s22", "s12", "m11", "m22", "m12"] - # 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 = ShellStressResult - # self._results_class_2d = ShellStressResult - self._results_class_3d = SolidStressResult - self._restuls_func = "find_element_by_key" - - @property - def global_stresses(self): - """Stress field in global coordinates - - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step, by default None - - - Returns - ------- - numpy array - The stress tensor defined at each location of the field in - global coordinates. - """ - n_locations = len(self.results) - new_frame = Frame.worldXY() - - # Initialize tensors and rotation_matrices arrays - tensors = np.zeros((n_locations, 3, 3)) - rotation_matrices = np.zeros((n_locations, 3, 3)) - - from_change_of_basis = Transformation.from_change_of_basis - np_array = np.array - - for i, r in enumerate(self.results): - tensors[i] = r.local_stress - rotation_matrices[i] = np_array(from_change_of_basis(r.element.frame, new_frame).matrix)[:3, :3] - - # Perform the tensor transformation using numpy's batch matrix multiplication - transformed_tensors = rotation_matrices @ tensors @ rotation_matrices.transpose(0, 2, 1) - - return transformed_tensors - - @property - def principal_components(self): - """Compute the eigenvalues and eigenvetors of the stress field at each location. - - 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 - ------- - touple(np.array, np.array) - The eigenvalues and the eigenvectors, not ordered. - """ - return np.linalg.eig(self.global_stresses) - - @property - def principal_components_vectors(self): - """Compute the principal components of the stress field at each location - as vectors. - - 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. - - - Yields - ------ - list(:class:`compas.geometry.Vector) - list with the vectors corresponding to max, mid and min principal componets. - """ - eigenvalues, eigenvectors = self.principal_components - 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): - """Compute the principal components of the stress field at each location - as vectors. - - 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. + self._results_func = "find_node_by_key" - Yields - ------ - list(:class:`compas.geometry.Vector) - list with the vectors corresponding to max, mid and min principal componets. - """ - for r in self.results: - yield r.von_mises_stress +# ------------------------------------------------------------------------------ +# Section Forces Field Results +# ------------------------------------------------------------------------------ class SectionForcesFieldResults(FieldResults): - """_summary_ + """Section forces field results. + + 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, step, *args, **kwargs): @@ -519,43 +437,41 @@ def __init__(self, step, *args, **kwargs): self._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"] self._invariants_names = ["magnitude"] self._results_class = SectionForcesResult - self._restuls_func = "find_element_by_key" + self._results_func = "find_element_by_key" def get_element_forces(self, element): - """_summary_ + """Get the section forces for a given element. Parameters ---------- - element : _type_ - _description_ + element : object + The element to retrieve the section forces for. Returns ------- - _type_ - _description_ + object + The section forces result for the specified element. """ return self.get_result_at(element) def get_elements_forces(self, elements): - """ - Get the section forces for a given element. + """Get the section forces for a list of elements. Parameters ---------- - element : Element - The element for which to retrieve section forces. + elements : list + The elements to retrieve the section forces for. - Returns - ------- - SectionForcesResult - The section forces result for the specified element. + Yields + ------ + object + The section forces result for each element. """ for element in elements: yield self.get_element_forces(element) def get_all_section_forces(self): - """ - Retrieve section forces for all elements in the field. + """Retrieve section forces for all elements in the field. Returns ------- @@ -565,8 +481,7 @@ def get_all_section_forces(self): return {element: self.get_result_at(element) for element in self.elements} def filter_by_component(self, component_name, threshold=None): - """ - Filter results by a specific component, optionally using a threshold. + """Filter results by a specific component, optionally using a threshold. Parameters ---------- @@ -592,8 +507,7 @@ def filter_by_component(self, component_name, threshold=None): return filtered_results def export_to_dict(self): - """ - Export all field results to a dictionary. + """Export all field results to a dictionary. Returns ------- @@ -626,8 +540,7 @@ def export_to_dict(self): return results_dict def export_to_csv(self, file_path): - """ - Export all field results to a CSV file. + """Export all field results to a CSV file. Parameters ---------- @@ -660,3 +573,120 @@ def export_to_csv(self, file_path): result.net_force.length, ] ) + + +# ------------------------------------------------------------------------------ +# Stress Field Results +# ------------------------------------------------------------------------------ + + +class Stress2DFieldResults(FieldResults): + """Stress field results for 2D elements. + + This class handles the stress field results for 2D elements 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 stress components. + results_class : class + The class used to instantiate the stress results. + results_func : str + The function used to find elements by key. + """ + + def __init__(self, step, *args, **kwargs): + super(Stress2DFieldResults, self).__init__(step=step, field_name="s2d", *args, **kwargs) + self._components_names = ["s11", "s22", "s12", "sb11", "sb12", "sb22", "tq1", "tq2"] + self._results_class = ShellStressResult + self._results_func = "find_element_by_key" + + def global_stresses(self, plane="mid"): + """Stress field in global coordinates. + + Parameters + ---------- + plane : str, optional + The plane to retrieve the stress field for, by default "mid". + + Returns + ------- + numpy.ndarray + The stress tensor defined at each location of the field in global coordinates. + """ + n_locations = len(self.results) + new_frame = Frame.worldXY() + + # Initialize tensors and rotation_matrices arrays + tensors = np.zeros((n_locations, 3, 3)) + rotation_matrices = np.zeros((n_locations, 3, 3)) + + from_change_of_basis = Transformation.from_change_of_basis + np_array = np.array + + for i, r in enumerate(self.results): + r = r.plane_results(plane) + tensors[i] = r.local_stress + rotation_matrices[i] = np_array(from_change_of_basis(r.element.frame, new_frame).matrix)[:3, :3] + + # Perform the tensor transformation using numpy's batch matrix multiplication + transformed_tensors = rotation_matrices @ tensors @ rotation_matrices.transpose(0, 2, 1) + + return transformed_tensors + + def principal_components(self, plane): + """Compute the eigenvalues and eigenvectors of the stress field at each location. + + Parameters + ---------- + plane : str + The plane to retrieve the principal components for. + + Returns + ------- + tuple(numpy.ndarray, numpy.ndarray) + The eigenvalues and the eigenvectors, not ordered. + """ + return np.linalg.eig(self.global_stresses(plane)) + + def principal_components_vectors(self, plane): + """Compute the principal components of the stress field at each location as vectors. + + Parameters + ---------- + plane : str + The plane to retrieve the principal components for. + + Yields + ------ + list(:class:`compas.geometry.Vector`) + List with the vectors corresponding to max, mid and min principal components. + """ + eigenvalues, eigenvectors = self.principal_components(plane=plane) + 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, plane): + """Compute the von Mises stress field at each location. + + Parameters + ---------- + plane : str + The plane to retrieve the von Mises stress for. + + Yields + ------ + float + The von Mises stress at each location. + """ + for r in self.plane_results: + r = r.plane_results(plane) + yield r.von_mises_stress diff --git a/src/compas_fea2/results/results.py b/src/compas_fea2/results/results.py index 7d745c8a6..c67104c01 100644 --- a/src/compas_fea2/results/results.py +++ b/src/compas_fea2/results/results.py @@ -868,189 +868,58 @@ def __init__(self, element, s11, s12, s22, **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 - - @property - def global_stress_bottom(self): - return self._global_stress_bottom - - @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) - - @property - def deviatoric_stress_top(self): - return self.global_stress_top - np.eye(len(self.global_stress_top)) * self.hydrostatic_stress_top - - @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) - - @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))) - - @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))) - - @property - # Third invariant - def I3_top(self): - return np.linalg.det(self.global_stress_top) - - @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)) - - @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)) - - @property - # Third invariant of the deviatoric stress tensor: J3 - def J3_top(self): - return np.linalg.det(self.deviatoric_stress_top) - - @property - # Third invariant of the deviatoric stress tensor: J3 - def J3_bottom(self): - return np.linalg.det(self.deviatoric_stress_bottom) - - @property - def principal_stresses_values(self): - eigenvalues = np.linalg.eigvalsh(self.global_stress[:2, :2]) - sorted_indices = np.argsort(eigenvalues) - return eigenvalues[sorted_indices] - - @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] +class ShellStressResult(Result): + """ + ShellStressResult object. - @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] + 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 - 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))] + 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 - 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))] + """ - @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 __init__(self, element, s11, s22, s12, sb11, sb22, sb12, tq1, tq2, **kwargs): + super(ShellStressResult, self).__init__(**kwargs) + self._title = "s2d" + self._registration = element + self._components = {} + self._invariants = {} + 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 - 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) + def bottom_plane_stress_result(self): + return self._bottom_plane_stress_result - @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_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 + 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] # TODO: double inheritance StressResult and Element3DResult From f87181baa8e38ec8ea38f750a17125d5eacf9d70 Mon Sep 17 00:00:00 2001 From: franaudo Date: Wed, 22 Jan 2025 17:17:07 +0100 Subject: [PATCH 05/39] spinner decorator --- src/compas_fea2/utilities/_utils.py | 106 ++++++++++++++++++---------- 1 file changed, 70 insertions(+), 36 deletions(-) diff --git a/src/compas_fea2/utilities/_utils.py b/src/compas_fea2/utilities/_utils.py index 9af848fad..9ba2e8346 100644 --- a/src/compas_fea2/utilities/_utils.py +++ b/src/compas_fea2/utilities/_utils.py @@ -7,10 +7,47 @@ import subprocess from functools import wraps from time import perf_counter +from typing import Generator, Optional +import threading +import sys +import time 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""" @@ -34,52 +71,49 @@ 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() - - if p.returncode != 0: - raise subprocess.CalledProcessError(p.returncode, cmd_args) + 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: + if verbose: + print(line.decode().strip()) + yield line + + 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: From e42f01ccf0f097557b07359583a93652f14019b4 Mon Sep 17 00:00:00 2001 From: franaudo Date: Wed, 22 Jan 2025 18:24:41 +0100 Subject: [PATCH 06/39] clean up input file --- src/compas_fea2/job/input_file.py | 41 +++++++------------------------ 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/src/compas_fea2/job/input_file.py b/src/compas_fea2/job/input_file.py index a5d4a04f5..98b61ca5d 100644 --- a/src/compas_fea2/job/input_file.py +++ b/src/compas_fea2/job/input_file.py @@ -30,12 +30,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 +48,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,11 +69,11 @@ 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): From fdb1108b45e72694dcc89d43b6d9b6996c51a229 Mon Sep 17 00:00:00 2001 From: franaudo Date: Thu, 23 Jan 2025 09:12:29 +0100 Subject: [PATCH 07/39] new output requests --- docs/userguide/basics.model.rst | 9 - src/compas_fea2/UI/viewer/scene.py | 2 +- src/compas_fea2/model/elements.py | 5 + src/compas_fea2/model/model.py | 34 +- src/compas_fea2/model/nodes.py | 26 +- src/compas_fea2/model/sections.py | 6 +- src/compas_fea2/model/shapes.py | 305 ++++++++++++------ src/compas_fea2/problem/outputs.py | 272 +++------------- src/compas_fea2/problem/problem.py | 251 ++------------ .../problem/steps/perturbations.py | 46 +++ src/compas_fea2/problem/steps/step.py | 111 ++++++- src/compas_fea2/results/database.py | 37 +++ src/compas_fea2/results/fields.py | 2 +- src/compas_fea2/results/results.py | 270 +++++++++++++--- src/compas_fea2/utilities/_utils.py | 46 +-- tests/test_sections.py | 8 +- tests/test_shapes.py | 15 +- 17 files changed, 765 insertions(+), 680 deletions(-) diff --git a/docs/userguide/basics.model.rst b/docs/userguide/basics.model.rst index 679014222..0c3d4fe2b 100644 --- a/docs/userguide/basics.model.rst +++ b/docs/userguide/basics.model.rst @@ -39,15 +39,6 @@ Besides coordinates, nodes have many other (optional) attributes. >>> >>> 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/src/compas_fea2/UI/viewer/scene.py b/src/compas_fea2/UI/viewer/scene.py index 52882fa80..b402b3213 100644 --- a/src/compas_fea2/UI/viewer/scene.py +++ b/src/compas_fea2/UI/viewer/scene.py @@ -11,7 +11,7 @@ from .drawer import draw_field_vectors from compas_viewer.scene import Collection -from compas_viewer.scene import GroupObject +from compas_viewer.scene import GroupObject, BufferGeometry from compas_fea2.model.bcs import FixedBC from compas_fea2.model.bcs import PinnedBC from compas_fea2.model.bcs import RollerBCX diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index 1bf8576ac..1fd51a61c 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -475,6 +475,11 @@ 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): + if not hasattr(step, "stress2D_field"): + raise ValueError("The step does not have a stress2D_field") + return step.stress2D_field.get_result_at(self) + class ShellElement(_Element2D): """A 2D element that resists axial, shear, bending and torsion. diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index 655e16dfb..50ac494c2 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -34,6 +34,7 @@ from compas_fea2.utilities._utils import get_docstring from compas_fea2.utilities._utils import part_method from compas_fea2.utilities._utils import problem_method +from compas_fea2.UI import FEA2Viewer class Model(FEAData): @@ -1119,12 +1120,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 +1141,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 +1189,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.viewer.scene.add(self, model=self, opacity=0.5, show_bcs=show_bcs, kwargs=kwargs) - viewer.viewer.show() + 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 81bfe8119..d668272f7 100644 --- a/src/compas_fea2/model/nodes.py +++ b/src/compas_fea2/model/nodes.py @@ -205,14 +205,6 @@ def dof(self): 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): return self._on_boundary @@ -233,6 +225,12 @@ def point(self): def connected_elements(self): return self._connected_elements + @property + def loads(self): + problems = self.model.problems + steps = [problem.step for problem in problems] + return {step: self.loads(step) for step in steps} + def displacement(self, step): if step.displacement_field: return step.displacement_field.get_result_at(location=self) @@ -240,3 +238,15 @@ def displacement(self, step): def reaction(self, step): if step.reaction_field: return step.reaction_field.get_result_at(location=self) + + @property + def displacements(self): + problems = self.model.problems + steps = [problem.step for problem in problems] + return {step: self.displacement(step) for step in steps} + + @property + def reactions(self): + problems = self.model.problems + steps = [problem.step for problem in problems] + return {step: self.reaction(step) for step in steps} diff --git a/src/compas_fea2/model/sections.py b/src/compas_fea2/model/sections.py index b5e5c0527..e40e50492 100644 --- a/src/compas_fea2/model/sections.py +++ b/src/compas_fea2/model/sections.py @@ -28,8 +28,6 @@ def from_shape(shape, material, **kwargs): "Avx": shape.Avx, "Avy": shape.Avy, "J": shape.J, - "g0": shape.g0, - "gw": shape.gw, "material": material, **kwargs, } @@ -252,7 +250,7 @@ class BeamSection(_Section): The shape of the section. """ - def __init__(self, *, A, Ixx, Iyy, Ixy, Avx, Avy, J, g0, gw, material, **kwargs): + def __init__(self, *, A, Ixx, Iyy, Ixy, Avx, Avy, J, material, **kwargs): super(BeamSection, self).__init__(material=material, **kwargs) self.A = A self.Ixx = Ixx @@ -261,8 +259,6 @@ 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 """ diff --git a/src/compas_fea2/model/shapes.py b/src/compas_fea2/model/shapes.py index 313476f90..32dc54b8d 100644 --- a/src/compas_fea2/model/shapes.py +++ b/src/compas_fea2/model/shapes.py @@ -48,13 +48,6 @@ def __init__(self, points: List[Point], frame: Optional[Frame] = None, check_pla # Transformation from local frame to world XY self._T = Transformation.from_frame_to_frame(self._frame, Frame.worldXY()) - # Optional advanced FEA properties - self._J = None # Torsional constant - self._g0 = None # Shear modulus in the x-y plane - self._gw = None # Shear modulus in the x-z plane - self._Avx = None # Shear area in the x-direction - self._Avy = None # Shear area in the y-direction - def __str__(self) -> str: return f""" type: {self.__class__.__name__} @@ -128,7 +121,7 @@ def frame(self) -> Frame: @cached_property def inertia_xy(self) -> Tuple[float, float, float]: """ - (Ixx, Iyy, Ixy) about the centroid in the local x-y plane. + (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 @@ -199,7 +192,7 @@ def principal(self) -> Tuple[float, float, float]: theta = 0.5 * atan2(-Ixy, diff) # principal values radius = math.sqrt(diff**2 + Ixy**2) - I1 = avg + radius + I1 = avg + radius # TODO: check this I2 = avg - radius return (I1, I2, theta) @@ -243,27 +236,17 @@ def r2(self) -> float: @property def Avx(self) -> Optional[float]: """Shear area in the x-direction (if defined).""" - return self._Avx + raise NotImplementedError() @property def Avy(self) -> Optional[float]: """Shear area in the y-direction (if defined).""" - return self._Avy - - @property - def g0(self) -> Optional[float]: - """Shear modulus in the x-y plane (if defined).""" - return self._g0 + raise NotImplementedError() @property - def gw(self) -> Optional[float]: - """Shear modulus in the x-z plane (if defined).""" - return self._gw - - @property - def J(self) -> Optional[float]: - """Torsional constant (if defined).""" - return self._J + def J(self): + """Torsional constant (polar moment of inertia).""" + raise NotImplementedError() # -------------------------------------------------------------------------- # Methods @@ -416,10 +399,8 @@ def plot( f"Theta (deg): {math.degrees(theta):.2f}" ) - # Place the text to the right of the bounding box. - # For a little margin, we add ~10% of the bounding-box width. text_x = max_x + 0.1 * (max_x - min_x) - text_y = max_y # near the top of the shape + 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") @@ -449,6 +430,137 @@ def plot( # ------------------------------------------------------------------------------ +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 + + +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))) + + class Rectangle(Shape): """ Rectangle shape specified by width (w) and height (h). @@ -464,15 +576,6 @@ def __init__(self, w: float, h: float, frame: Optional[Frame] = None): Point(-w / 2, h / 2, 0.0), ] super().__init__(pts, frame=frame) - # Example placeholders for shear area, torsional const, etc. - self._Avy = 0.833 * self.area # from approximate formulas - self._Avx = 0.833 * self.area - l1 = max(w, h) - l2 = min(w, h) - # example approximate formula for J - self._J = (l1 * (l2**3)) * (0.33333 - 0.21 * (l2 / l1) * (1 - (l2**4) / (l2 * l1**4))) - self._g0 = 0 # placeholders - self._gw = 0 @property def w(self) -> float: @@ -482,6 +585,30 @@ def w(self) -> float: 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 + class Rhombus(Shape): """ @@ -648,6 +775,10 @@ def h(self) -> float: def tw(self) -> float: return self._tw + @property + def hw(self) -> float: + return self.h - self.tbf - self.ttf + @property def tbf(self) -> float: return self._tbf @@ -656,6 +787,18 @@ def tbf(self) -> float: def ttf(self) -> float: return self._ttf + @property + 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: """ @@ -664,6 +807,36 @@ def J(self) -> float: """ 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] + class LShape(Shape): """ @@ -831,66 +1004,6 @@ def c(self, val: float): self.points = self._set_points() -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() - - -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() - - class Hexagon(Shape): """ A regular hexagon specified by its side length. diff --git a/src/compas_fea2/problem/outputs.py b/src/compas_fea2/problem/outputs.py index 1ef2074be..b272cccca 100644 --- a/src/compas_fea2/problem/outputs.py +++ b/src/compas_fea2/problem/outputs.py @@ -3,6 +3,15 @@ from __future__ import print_function from compas_fea2.base import FEAData +from compas_fea2.results.results import ( + DisplacementResult, + SectionForcesResult, + ReactionResult, + VelocityResult, + AccelerationResult, + ShellStressResult, +) +from compas_fea2.results.database import ResultsDatabase class _Output(FEAData): @@ -19,110 +28,58 @@ class _Output(FEAData): """ - def __init__(self, field_name, components_names, invariants_names, **kwargs): + def __init__(self, results_cls, **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 + self._results_cls = results_cls @property - def step(self): - return self._registration + def results_cls(self): + return self._results_cls @property - def problem(self): - return self.step._registration + def sqltable_schema(self): + return self._results_cls.sqltable_schema() @property - def model(self): - return self.problem._registration + def results_func(self): + return self._results_cls._results_func @property def field_name(self): - return self._field_name - - @property - def description(self): - return self._description + return self.results_cls._field_name @property def components_names(self): - return self._components_names + return self.results_cls._components_names @property def invariants_names(self): - return self._invariants_names + return self.results_cls._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.") + @property + def step(self): + return self._registration - @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.") + @property + def problem(self): + return self.step.problem + + @property + def model(self): + return self.problem.model - def create_table_for_output_class(self, connection, results): + def create_sql_table(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()` + Delegate the table creation to the ResultsDatabase class. """ - - 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() + ResultsDatabase.create_table_for_output_class(self, connection, results) 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" + def __init__(self, results_cls, **kwargs): + super().__init__(results_cls=results_cls, **kwargs) class DisplacementFieldOutput(_NodeFieldOutput): @@ -130,29 +87,7 @@ class DisplacementFieldOutput(_NodeFieldOutput): 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"), - ("key", "INTEGER"), - ("step", "TEXT"), - ("part", "TEXT"), - ("ux", "REAL"), - ("uy", "REAL"), - ("uz", "REAL"), - ("uxx", "REAL"), - ("uyy", "REAL"), - ("uzz", "REAL"), - ], - } + super(DisplacementFieldOutput, self).__init__(DisplacementResult, **kwargs) class AccelerationFieldOutput(_NodeFieldOutput): @@ -160,29 +95,7 @@ class AccelerationFieldOutput(_NodeFieldOutput): 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"), - ("key", "INTEGER"), - ("step", "TEXT"), - ("part", "TEXT"), - ("ax", "REAL"), - ("ay", "REAL"), - ("az", "REAL"), - ("axx", "REAL"), - ("ayy", "REAL"), - ("azz", "REAL"), - ], - } + super(AccelerationFieldOutput, self).__init__(AccelerationResult, **kwargs) class VelocityFieldOutput(_NodeFieldOutput): @@ -190,29 +103,7 @@ class VelocityFieldOutput(_NodeFieldOutput): 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"), - ("key", "INTEGER"), - ("step", "TEXT"), - ("part", "TEXT"), - ("vx", "REAL"), - ("vy", "REAL"), - ("vz", "REAL"), - ("vxx", "REAL"), - ("vyy", "REAL"), - ("vzz", "REAL"), - ], - } + super(VelocityFieldOutput, self).__init__(VelocityResult, **kwargs) class ReactionFieldOutput(_NodeFieldOutput): @@ -220,97 +111,28 @@ class ReactionFieldOutput(_NodeFieldOutput): from the analysis.""" def __init__(self, **kwargs): - super(ReactionFieldOutput, self).__init__("rf", ["rfx", "rfy", "rfz", "rfxx", "rfyy", "rfzz"], ["magnitude"], **kwargs) + super(ReactionFieldOutput, self).__init__(ReactionResult, **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"), - ("key", "INTEGER"), - ("step", "TEXT"), - ("part", "TEXT"), - ("rfx", "REAL"), - ("rfy", "REAL"), - ("rfz", "REAL"), - ("rfxx", "REAL"), - ("rfyy", "REAL"), - ("rfzz", "REAL"), - ], - } + +class _ElementFieldOutput(_Output): + """ElementFieldOutput object for requesting the fields at the elements from the analysis.""" + + def __init__(self, results_cls, **kwargs): + super().__init__(results_cls=results_cls, **kwargs) 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", "sb11", "sb22", "sb12", "tq1", "tq2"], ["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"), - ("key", "INTEGER"), - ("step", "TEXT"), - ("part", "TEXT"), - ("s11", "REAL"), - ("s22", "REAL"), - ("s12", "REAL"), - ("sb11", "REAL"), - ("sb22", "REAL"), - ("sb12", "REAL"), - ("tq1", "REAL"), - ("tq2", "REAL"), - ], - } + super(Stress2DFieldOutput, self).__init__(ShellStressResult, **kwargs) class SectionForcesFieldOutput(_ElementFieldOutput): """SectionForcesFieldOutput object for requesting the section forces at the elements from the analysis.""" def __init__(self, **kwargs): - super(SectionForcesFieldOutput, self).__init__( - "sf", ["Fx_1", "Fy_1", "Fz_1", "Mx_1", "My_1", "Mz_1", "Fx_2", "Fy_2", "Fz_2", "Mx_2", "My_2", "Mz_2"], ["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": "sf", - "columns": [ - ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), - ("key", "INTEGER"), - ("step", "TEXT"), - ("part", "TEXT"), - ("Fx_1", "REAL"), - ("Fy_1", "REAL"), - ("Fz_1", "REAL"), - ("Mx_1", "REAL"), - ("My_1", "REAL"), - ("Mz_1", "REAL"), - ("Fx_2", "REAL"), - ("Fy_2", "REAL"), - ("Fz_2", "REAL"), - ("Mx_2", "REAL"), - ("My_2", "REAL"), - ("Mz_2", "REAL"), - ], - } + super(SectionForcesFieldOutput, self).__init__(SectionForcesResult, **kwargs) class HistoryOutput(_Output): diff --git a/src/compas_fea2/problem/problem.py b/src/compas_fea2/problem/problem.py index 748859b29..f761dab6a 100644 --- a/src/compas_fea2/problem/problem.py +++ b/src/compas_fea2/problem/problem.py @@ -46,6 +46,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. @@ -113,6 +115,10 @@ def steps_order(self, value): raise ValueError("{!r} must be previously added to {!r}".format(step, self)) self._steps_order = value + @property + def input_file(self): + return InputFile(self) + # ========================================================================= # Step methods # ========================================================================= @@ -278,10 +284,6 @@ def add_linear_perturbation_step(self, lp_step, base_step): """ raise NotImplementedError - # ========================================================================= - # Loads methods - # ========================================================================= - # ============================================================================== # Summary # ============================================================================== @@ -341,9 +343,7 @@ 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. @@ -359,11 +359,10 @@ def _check_analysis_path(self, path): Path where the input file will be saved. """ - 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.") + self.model.path = path + self.path = self.model.path.joinpath(self.name) + if not os.path.exists(self.path): + os.makedirs(self.path) return self.path def analyse(self, path=None, *args, **kwargs): @@ -377,9 +376,9 @@ def analyse(self, path=None, *args, **kwargs): """ raise NotImplementedError("this function is not available for the selected backend") - def analyze(self, *args, **kwargs): + def analyze(self, path=None, *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): """Analyse the problem in the selected backend and extract the results @@ -390,6 +389,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): @@ -492,7 +493,7 @@ 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=None, 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 @@ -505,227 +506,15 @@ 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_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.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(step, show_loads=show_loads) - 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_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 step: - step = self.steps_order[-1] - - if not step.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.displacement_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contour=show_contour, **kwargs) - if show_loads: - viewer.add_step(step, show_loads=show_loads) - viewer.show() - viewer.scene.clear() - - def show_reactions(self, fast=True, step=None, 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 step: - step = self.steps_order[-1] + viewer.add_model(self.model, show_parts=show_parts, opacity=0.5, show_bcs=show_bcs, **kwargs) - if not step.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(step.reaction_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contour=show_contour, **kwargs) - - if show_loads: + for step in steps: viewer.add_step(step, show_loads=show_loads) - viewer.show() - viewer.scene.clear() - - def show_stress(self, fast=True, step=None, show_bcs=1, scale_model=1, show_loads=0.1, component=None, show_vectors=1, show_contour=False, plane="mid", **kwargs): - if not step: - step = self.steps_order[-1] - - if not step.stress2D_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( - step.stress2D_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(step, show_loads=show_loads) viewer.show() viewer.scene.clear() - - # 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_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 FEA2Stress2DFieldResultsObject - 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) - - register_scene_objects() # This has to be called before registering the model object - - register(self.model.__class__.__bases__[-1], FEA2ModelObject, context="Viewer") - viewer.scene.add(self.model, model=self.model, opacity=0.5, show_bcs=show_bcs, show_loads=show_loads, **kwargs) - - register(step.stress_field.__class__.__bases__[-1], FEA2Stress2DFieldResultsObject, context="Viewer") - viewer.scene.add(step.stress_field, field=step.stress_field, step=step, scale_factor=scale_results, components=components, **kwargs) - - if show_loads: - register(step.__class__, FEA2StepObject, context="Viewer") - viewer.scene.add(step, step=step, scale_factor=show_loads) - - 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.mode_shape(mode) - if show_vectors: - viewer.add_mode_shape(shape, fast=fast, model=self.model, 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, model=self.model, 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() diff --git a/src/compas_fea2/problem/steps/perturbations.py b/src/compas_fea2/problem/steps/perturbations.py index 72f2b1ef7..b4bcb94ac 100644 --- a/src/compas_fea2/problem/steps/perturbations.py +++ b/src/compas_fea2/problem/steps/perturbations.py @@ -2,9 +2,13 @@ from __future__ import division from __future__ import print_function +from compas.geometry import Vector +from compas.geometry import sum_vectors + from .step import Step from compas_fea2.results import ModalAnalysisResult from compas_fea2.results import DisplacementResult +from compas_fea2.UI import FEA2Viewer class _Perturbation(Step): @@ -96,6 +100,48 @@ 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, model=self.model, 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, model=self.model, 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() + class ComplexEigenValue(_Perturbation): """""" diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index c25b15c7d..1b35d48d8 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -2,6 +2,9 @@ from __future__ import division from __future__ import print_function +from compas.geometry import Vector +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 @@ -12,6 +15,8 @@ from compas_fea2.results import Stress2DFieldResults from compas_fea2.results import SectionForcesFieldResults +from compas_fea2.UI import FEA2Viewer + # ============================================================================== # Base Steps # ============================================================================== @@ -67,7 +72,7 @@ def problem(self): @property def model(self): - return self.problem._registration + return self.problem.model @property def field_outputs(self): @@ -381,3 +386,107 @@ def add_load_patterns(self, load_patterns): # ============================================================================== # 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.stress2D_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.stress2D_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() diff --git a/src/compas_fea2/results/database.py b/src/compas_fea2/results/database.py index b833dab0e..3585852e6 100644 --- a/src/compas_fea2/results/database.py +++ b/src/compas_fea2/results/database.py @@ -241,5 +241,42 @@ def to_result(self, results_set, results_class, results_func): if not part: raise ValueError(f"Part {r[1]} not in model") m = getattr(part, results_func)(r[2]) + if not m: + raise ValueError(f"Member {r[2]} not in part {part.name}") results[step].append(results_class(m, *r[3:])) return results + + @staticmethod + def create_table_for_output_class(output_cls, connection, 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 = 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) + 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() diff --git a/src/compas_fea2/results/fields.py b/src/compas_fea2/results/fields.py index bf4e9e778..ebc23a788 100644 --- a/src/compas_fea2/results/fields.py +++ b/src/compas_fea2/results/fields.py @@ -5,7 +5,7 @@ from compas_fea2.base import FEAData -from .results import ( +from .results import ( # noqa: F401 AccelerationResult, DisplacementResult, ReactionResult, diff --git a/src/compas_fea2/results/results.py b/src/compas_fea2/results/results.py index c67104c01..58932d0b4 100644 --- a/src/compas_fea2/results/results.py +++ b/src/compas_fea2/results/results.py @@ -2,7 +2,10 @@ from __future__ import division from __future__ import print_function +import os import matplotlib.pyplot as plt +import base64 +from io import BytesIO import numpy as np from compas.geometry import Frame from compas.geometry import Transformation @@ -36,16 +39,22 @@ class Result(FEAData): """ + _field_name = "" # name of the field + _results_func = "" # 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._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 +66,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. @@ -83,6 +93,26 @@ def safety_factor(self, component, allowable): """ return abs(self.vector[component] / allowable) if self.vector[component] != 0 else 1 + @classmethod + def sqltable_schema(cls): + fields = [] + predefined_fields = [ + ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), + ("key", "INTEGER"), + ("step", "TEXT"), + ("part", "TEXT"), + ] + + fields.extend(predefined_fields) + + dummy = cls(None) + for comp in dummy.components_names: + fields.append((comp, "REAL")) + return { + "table_name": dummy.field_name, + "columns": fields, + } + class NodeResult(Result): """NodeResult object. @@ -112,25 +142,23 @@ 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 self._xx = xx self._yy = yy self._zz = zz - self._results_func = "find_node_by_key" @property - def node(self): - return self._registration + def field_name(self): + return self._field_name @property - def components(self): - return {self._title + component: getattr(self, component) for component in ["x", "y", "z", "xx", "yy", "zz"]} + def node(self): + return self._registration @property def vector(self): @@ -144,6 +172,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. @@ -153,8 +185,13 @@ class DisplacementResult(NodeResult): DisplacementResults are registered to a :class:`compas_fea2.model.Node` """ + _field_name = "u" + _results_func = "find_node_by_key" + _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): @@ -165,8 +202,13 @@ class AccelerationResult(NodeResult): DisplacementResults are registered to a :class:`compas_fea2.model.Node` """ + _field_name = "a" + _results_func = "find_node_by_key" + _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): @@ -177,8 +219,13 @@ class VelocityResult(NodeResult): DisplacementResults are registered to a :class:`compas_fea2.model.Node` """ + _field_name = "v" + _results_func = "find_node_by_key" + _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): @@ -221,8 +268,13 @@ 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" + _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) # --------------------------------------------------------------------------------------------- @@ -236,7 +288,6 @@ class ElementResult(Result): def __init__(self, element, **kwargs): super(ElementResult, self).__init__(**kwargs) self._registration = element - self._results_func = "find_element_by_key" @property def element(self): @@ -292,6 +343,11 @@ class SectionForcesResult(ElementResult): Export the section forces and moments to a dictionary. """ + _field_name = "sf" + _results_func = "find_element_by_key" + _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, Fy_1, Fz_1, Mx_1, My_1, Mz_1, Fx_2, Fy_2, Fz_2, Mx_2, My_2, Mz_2, **kwargs): super(SectionForcesResult, self).__init__(element, **kwargs) @@ -538,10 +594,14 @@ class StressResult(ElementResult): StressResults are registered to a :class:`compas_fea2.model._Element """ + _field_name = "s" + _results_func = "find_element_by_key" + _components_names = ["s11", "s22", "s12", "s13", "s22", "s23", "s33"] + _invariants_names = ["magnitude"] + def __init__(self, element, *, s11, s12, s13, s22, s23, s33, **kwargs): super(StressResult, self).__init__(element, **kwargs) 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))} @property @@ -552,7 +612,7 @@ def local_stress(self): @property def global_stress(self): # In global coordinates - return self._global_stress + return self.transform_stress_tensor(self._local_stress, Frame.worldXY()) @property def global_strain(self): @@ -683,7 +743,7 @@ 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 @@ -739,8 +799,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] @@ -753,13 +813,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 @@ -781,12 +841,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 @@ -815,8 +885,6 @@ def draw_mohr_circles_3d(self): plt.legend() plt.grid(True) plt.axis("equal") - - # Show the plot plt.show() # ========================================================================= @@ -861,9 +929,128 @@ 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. + + Warning + ------- + This method is a work in progress and may not be fully functional yet. + + Parameters + ---------- + file_path : str + The path where the HTML report will be saved. + + """ + + 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}
+ + + """ + + # Save the HTML file + with open(os.path.join(file_path, self.element.name + ".html"), "w") as file: + file.write(html_content) + class MembraneStressResult(StressResult): - def __init__(self, element, s11, s12, s22, **kwargs): + 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" @@ -891,12 +1078,14 @@ class ShellStressResult(Result): """ - def __init__(self, element, s11, s22, s12, sb11, sb22, sb12, tq1, tq2, **kwargs): + _field_name = "s2d" + _results_func = "find_element_by_key" + _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._title = "s2d" self._registration = element - self._components = {} - self._invariants = {} 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) @@ -921,6 +1110,9 @@ def plane_results(self, plane): } return results[plane] + 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): diff --git a/src/compas_fea2/utilities/_utils.py b/src/compas_fea2/utilities/_utils.py index 9ba2e8346..28f4bb16f 100644 --- a/src/compas_fea2/utilities/_utils.py +++ b/src/compas_fea2/utilities/_utils.py @@ -54,11 +54,11 @@ def timer(_func=None, *, message=None): 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 @@ -198,7 +198,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 @@ -231,44 +231,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/tests/test_sections.py b/tests/test_sections.py index a13edd230..1b4192b67 100644 --- a/tests/test_sections.py +++ b/tests/test_sections.py @@ -1,12 +1,12 @@ import unittest from compas_fea2.model.sections import RectangularSection, CircularSection, ISection -from compas_fea2.model.materials.material import _Material +from compas_fea2.model.materials.steel import Steel class TestSections(unittest.TestCase): def setUp(self): - self.material = _Material(name="Steel") + self.material = Steel.S355() def test_rectangular_section(self): section = RectangularSection(w=100, h=50, material=self.material) @@ -18,11 +18,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 f2dd661f9..a70de3ad9 100644 --- a/tests/test_shapes.py +++ b/tests/test_shapes.py @@ -11,12 +11,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) self.assertEqual(circle.radius, 10) - self.assertAlmostEqual(circle.A, 314.159, places=3) + 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) From 4f88c1ce4f86c50940a3693a76c0a0a43841b86c Mon Sep 17 00:00:00 2001 From: franaudo Date: Thu, 23 Jan 2025 09:32:00 +0100 Subject: [PATCH 08/39] updated field results --- src/compas_fea2/results/fields.py | 49 +++++++++--------------------- src/compas_fea2/results/results.py | 13 +++----- 2 files changed, 19 insertions(+), 43 deletions(-) diff --git a/src/compas_fea2/results/fields.py b/src/compas_fea2/results/fields.py index ebc23a788..bc620a2f7 100644 --- a/src/compas_fea2/results/fields.py +++ b/src/compas_fea2/results/fields.py @@ -55,13 +55,14 @@ class FieldResults(FEAData): FieldResults are registered to a :class:`compas_fea2.problem.Step`. """ - def __init__(self, step, field_name, *args, **kwargs): + def __init__(self, step, results_cls, *args, **kwargs): super(FieldResults, self).__init__(*args, **kwargs) self._registration = step - self._field_name = field_name - self._components_names = None - self._invariants_names = None - self._results_func = None + self._results_cls = results_cls + self._field_name = results_cls._field_name + self._components_names = results_cls._components_names + self._invariants_names = results_cls._invariants_names + self._results_func = results_cls._results_func @property def step(self): @@ -117,7 +118,7 @@ def _get_results_from_db(self, members=None, columns=None, filters=None, **kwarg results_set = self.rdb.get_rows(self._field_name, ["step", "part", "key"] + self._components_names, filters) - return self.rdb.to_result(results_set, self._results_class, self._results_func) + return self.rdb.to_result(results_set, self._results_cls, self._results_func) def get_result_at(self, location): """Get the result for a given location. @@ -308,11 +309,7 @@ class DisplacementFieldResults(FieldResults): """ def __init__(self, step, *args, **kwargs): - super(DisplacementFieldResults, self).__init__(step=step, 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" + super(DisplacementFieldResults, self).__init__(step=step, results_cls=DisplacementResult, *args, **kwargs) class AccelerationFieldResults(FieldResults): @@ -338,11 +335,7 @@ class AccelerationFieldResults(FieldResults): """ def __init__(self, step, *args, **kwargs): - super(AccelerationFieldResults, self).__init__(step=step, 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" + super(AccelerationFieldResults, self).__init__(step=step, results_cls=AccelerationResult, *args, **kwargs) class VelocityFieldResults(FieldResults): @@ -368,11 +361,7 @@ class VelocityFieldResults(FieldResults): """ def __init__(self, step, *args, **kwargs): - super(VelocityFieldResults, self).__init__(step=step, 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" + super(VelocityFieldResults, self).__init__(step=step, results_cls=VelocityResult, *args, **kwargs) class ReactionFieldResults(FieldResults): @@ -398,11 +387,7 @@ class ReactionFieldResults(FieldResults): """ def __init__(self, step, *args, **kwargs): - super(ReactionFieldResults, self).__init__(step=step, 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" + super(ReactionFieldResults, self).__init__(step=step, results_cls=ReactionResult, *args, **kwargs) # ------------------------------------------------------------------------------ @@ -433,11 +418,7 @@ class SectionForcesFieldResults(FieldResults): """ def __init__(self, step, *args, **kwargs): - super(SectionForcesFieldResults, self).__init__(step=step, field_name="sf", *args, **kwargs) - self._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"] - self._invariants_names = ["magnitude"] - self._results_class = SectionForcesResult - self._results_func = "find_element_by_key" + super(SectionForcesFieldResults, self).__init__(step=step, results_cls=SectionForcesResult, *args, **kwargs) def get_element_forces(self, element): """Get the section forces for a given element. @@ -580,6 +561,7 @@ def export_to_csv(self, file_path): # ------------------------------------------------------------------------------ +# TODO Change to PlaneStressResults class Stress2DFieldResults(FieldResults): """Stress field results for 2D elements. @@ -601,10 +583,7 @@ class Stress2DFieldResults(FieldResults): """ def __init__(self, step, *args, **kwargs): - super(Stress2DFieldResults, self).__init__(step=step, field_name="s2d", *args, **kwargs) - self._components_names = ["s11", "s22", "s12", "sb11", "sb12", "sb22", "tq1", "tq2"] - self._results_class = ShellStressResult - self._results_func = "find_element_by_key" + super(Stress2DFieldResults, self).__init__(step=step, results_cls=ShellStressResult, *args, **kwargs) def global_stresses(self, plane="mid"): """Stress field in global coordinates. diff --git a/src/compas_fea2/results/results.py b/src/compas_fea2/results/results.py index 58932d0b4..954be2142 100644 --- a/src/compas_fea2/results/results.py +++ b/src/compas_fea2/results/results.py @@ -105,11 +105,10 @@ def sqltable_schema(cls): fields.extend(predefined_fields) - dummy = cls(None) - for comp in dummy.components_names: + for comp in cls._components_names: fields.append((comp, "REAL")) return { - "table_name": dummy.field_name, + "table_name": cls._field_name, "columns": fields, } @@ -348,14 +347,12 @@ class SectionForcesResult(ElementResult): _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, Fy_1, Fz_1, Mx_1, My_1, Mz_1, Fx_2, Fy_2, Fz_2, Mx_2, My_2, Mz_2, **kwargs): + 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._end_1 = element.nodes[0] self._force_vector_1 = Vector(Fx_1, Fy_1, Fz_1) self._moment_vector_1 = Vector(Mx_1, My_1, Mz_1) - self._end_2 = element.nodes[1] self._force_vector_2 = Vector(Fx_2, Fy_2, Fz_2) self._moment_vector_2 = Vector(Mx_2, My_2, Mz_2) @@ -373,12 +370,12 @@ def __repr__(self): @property def end_1(self): """Returns the first end node of the element.""" - return self._end_1 + return self.element.nodes[0] @property def end_2(self): """Returns the second end node of the element.""" - return self._end_2 + return self.element.nodes[1] @property def force_vector_1(self): From ec246a5f3233e532b92758327c249569139ec4c3 Mon Sep 17 00:00:00 2001 From: franaudo Date: Thu, 23 Jan 2025 14:32:50 +0100 Subject: [PATCH 09/39] resultants --- src/compas_fea2/UI/viewer/__init__.py | 6 +- src/compas_fea2/UI/viewer/drawer.py | 16 +- src/compas_fea2/UI/viewer/scene.py | 73 +------ src/compas_fea2/UI/viewer/viewer.py | 14 +- src/compas_fea2/problem/problem.py | 101 +++------ src/compas_fea2/problem/steps/step.py | 64 ++++++ src/compas_fea2/results/database.py | 90 ++++---- src/compas_fea2/results/fields.py | 299 +++++++++++--------------- src/compas_fea2/results/results.py | 156 ++++++++++++++ 9 files changed, 437 insertions(+), 382 deletions(-) diff --git a/src/compas_fea2/UI/viewer/__init__.py b/src/compas_fea2/UI/viewer/__init__.py index 10cbe71ce..e6b45d77e 100644 --- a/src/compas_fea2/UI/viewer/__init__.py +++ b/src/compas_fea2/UI/viewer/__init__.py @@ -2,8 +2,7 @@ from .scene import FEA2ModelObject from .scene import FEA2StepObject from .scene import FEA2Stress2DFieldResultsObject -from .scene import FEA2DisplacementFieldResultsObject -from .scene import FEA2ReactionFieldResultsObject +from .scene import FEA2NodeFieldResultsObject from .primitives import ( _BCShape, @@ -23,6 +22,5 @@ "FEA2ModelObject", "FEA2StepObject", "FEA2Stress2DFieldResultsObject", - "FEA2DisplacementFieldResultsObject", - "FEA2ReactionFieldResultsObject", + "FEA2NodeFieldResultsObject", ] diff --git a/src/compas_fea2/UI/viewer/drawer.py b/src/compas_fea2/UI/viewer/drawer.py index db8267aec..b0e5a1364 100644 --- a/src/compas_fea2/UI/viewer/drawer.py +++ b/src/compas_fea2/UI/viewer/drawer.py @@ -1,9 +1,10 @@ from compas.colors import Color from compas.colors import ColorMap from compas.geometry import Line +from compas.geometry import Vector -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 +18,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 b402b3213..24ef34622 100644 --- a/src/compas_fea2/UI/viewer/scene.py +++ b/src/compas_fea2/UI/viewer/scene.py @@ -11,7 +11,7 @@ from .drawer import draw_field_vectors from compas_viewer.scene import Collection -from compas_viewer.scene import GroupObject, BufferGeometry +from compas_viewer.scene import GroupObject, BufferGeometry # noqa: F401 from compas_fea2.model.bcs import FixedBC from compas_fea2.model.bcs import PinnedBC from compas_fea2.model.bcs import RollerBCX @@ -277,7 +277,7 @@ def __init__(self, model, components=None, show_vectors=1, plane="mid", show_con super().__init__(item=collections, name=f"STRESS-{field.name}", **kwargs) -class FEA2DisplacementFieldResultsObject(GroupObject): +class FEA2NodeFieldResultsObject(GroupObject): """DisplacementFieldResults object for visualization. Parameters @@ -293,89 +293,34 @@ class FEA2DisplacementFieldResultsObject(GroupObject): """ - # FIXME: component is not used - def __init__(self, model, component=None, show_vectors=1, show_contour=False, **kwargs): + 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], list(field.vectors), 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})) - - if show_contour: - from compas_fea2.model.elements import BeamElement - - field_locations = list(field.locations) - field_results = list(field.component(component)) - min_value = min(field_results) - max_value = max(field_results) - part_vertexcolor = draw_field_contour(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 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})) + vectors, colors = draw_field_vectors([n.point for n in field.locations], list(field.components_vectors(components)), show_vectors, translate=0, cmap=cmap) - super().__init__(item=group_elements, name=f"RESULTS-{field.name}", **kwargs) - - -class FEA2ReactionFieldResultsObject(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. - - """ - - def __init__(self, model, component, 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], list(field.vectors), 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})) + 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) - field_results = list(field.component(component)) + 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(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 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 c66cd328d..4a1b9329d 100644 --- a/src/compas_fea2/UI/viewer/viewer.py +++ b/src/compas_fea2/UI/viewer/viewer.py @@ -7,8 +7,7 @@ from compas.scene import register_scene_objects 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 FEA2Stress2DFieldResultsObject from compas_fea2.UI.viewer.scene import FEA2StepObject @@ -159,12 +158,11 @@ def add_model(self, model, fast=True, show_parts=True, opacity=0.5, show_bcs=Tru 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, model, component=None, fast=False, show_parts=True, opacity=0.5, show_bcs=True, show_loads=True, show_vectors=True, show_contours=False, **kwargs + 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__, FEA2DisplacementFieldResultsObject, context="Viewer") + register(field.__class__.__base__, FEA2NodeFieldResultsObject, context="Viewer") self.displacements = self.scene.add( - field, - model=model, + item=field, component=component, fast=fast, show_parts=show_parts, @@ -179,7 +177,7 @@ def add_displacement_field( 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__, FEA2ReactionFieldResultsObject, context="Viewer") + register(field.__class__.__base__, FEA2NodeFieldResultsObject, context="Viewer") self.reactions = self.scene.add( field, model=model, @@ -214,7 +212,7 @@ def add_stress2D_field( ) def add_mode_shape(self, mode_shape, model, component=None, fast=False, show_parts=True, opacity=0.5, show_bcs=True, show_loads=True, **kwargs): - register(mode_shape.__class__.__base__, FEA2DisplacementFieldResultsObject, context="Viewer") + register(mode_shape.__class__.__base__, FEA2NodeFieldResultsObject, context="Viewer") self.displacements = self.scene.add( mode_shape, model=model, component=component, fast=fast, show_parts=show_parts, opacity=opacity, show_bcs=show_bcs, show_loads=show_loads, **kwargs ) diff --git a/src/compas_fea2/problem/problem.py b/src/compas_fea2/problem/problem.py index f761dab6a..28812a85d 100644 --- a/src/compas_fea2/problem/problem.py +++ b/src/compas_fea2/problem/problem.py @@ -5,11 +5,6 @@ import os from pathlib import Path -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.job.input_file import InputFile from compas_fea2.problem.steps import StaticStep @@ -345,7 +340,11 @@ def write_input_file(self, path=None): path.mkdir(parents=True) return self.input_file.write_to_file(path) - def _check_analysis_path(self, path): + def _check_analysis_path( + self, + path, + erase_data=False, + ): """Check the analysis path and adds the correct folder structure. Parameters @@ -361,11 +360,30 @@ def _check_analysis_path(self, path): """ self.model.path = path self.path = self.model.path.joinpath(self.name) - if not os.path.exists(self.path): + if os.path.exists(self.path): + # Check if the folder is an FEA2 results folder + 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): ") + asw = user_input.lower() + else: + asw = "y" + if asw == "y": + for root, dirs, files in os.walk(self.path): + for file in files: + os.remove(os.path.join(root, file)) + for dir in dirs: + os.rmdir(os.path.join(root, dir)) + else: + print(f"WARNING: The directory {self.path} already exists and contains FEA2 results. Duplicated results expected.") + else: + print(f"The directory {self.path} is not recognized as an FEA2 results folder. No files were deleted.") + else: os.makedirs(self.path) return self.path - def analyse(self, path=None, *args, **kwargs): + def analyse(self, path=None, erase_data=False, *args, **kwargs): """Analyse the problem in the selected backend. Raises @@ -376,11 +394,11 @@ def analyse(self, path=None, *args, **kwargs): """ raise NotImplementedError("this function is not available for the selected backend") - def analyze(self, path=None, *args, **kwargs): + def analyze(self, path=None, erase_data=False, *args, **kwargs): """American spelling of the analyse method""" self.analyse(path=path, *args, **kwargs) - def analyse_and_extract(self, path=None, *args, **kwargs): + def analyse_and_extract(self, path=None, erase_data=False, *args, **kwargs): """Analyse the problem in the selected backend and extract the results from the native database system to SQLite. @@ -423,69 +441,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 # ========================================================================= diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index 1b35d48d8..94cbc4d2d 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -4,6 +4,8 @@ from compas.geometry import Vector from compas.geometry import sum_vectors +from compas.geometry import Point +from compas.geometry import centroid_points_weighted from compas_fea2.base import FEAData from compas_fea2.problem.displacements import GeneralDisplacement @@ -387,6 +389,68 @@ def add_load_patterns(self, load_patterns): # Combination # ============================================================================== + # ========================================================================= + # 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]) # ============================================================================== # Visualisation # ============================================================================== diff --git a/src/compas_fea2/results/database.py b/src/compas_fea2/results/database.py index 3585852e6..762a78c45 100644 --- a/src/compas_fea2/results/database.py +++ b/src/compas_fea2/results/database.py @@ -7,12 +7,12 @@ class ResultsDatabase(FEAData): 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. """ super(ResultsDatabase, self).__init__(**kwargs) self._registration = problem @@ -28,19 +28,27 @@ def problem(self): def model(self): return self.problem.model - 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 ---------- @@ -107,7 +115,8 @@ def column_names(self, 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 ---------- @@ -123,7 +132,8 @@ def get_table(self, table_name): return self.execute_query(query) def get_column_values(self, table_name, column_name): - """Get all the values in a given column from a table. + """ + Get all the values in a given column from a table. Parameters ---------- @@ -141,7 +151,8 @@ def get_column_values(self, table_name, column_name): return self.execute_query(query) def get_column_unique_values(self, table_name, column_name): - """Get all the unique values in a given column from a table. + """ + Get all the unique values in a given column from a table. Parameters ---------- @@ -157,8 +168,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 @@ -170,6 +182,8 @@ 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. Returns ------- @@ -177,39 +191,10 @@ def get_rows(self, table_name, columns_names, filters): 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}" - 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: - - - MAX: - - 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. - - Returns - ------- - list - The result row. - """ - 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" + 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) @@ -217,8 +202,9 @@ def get_func_row(self, table_name, column_name, func, filters, columns_names): # FEA2 Methods # ========================================================================= - def to_result(self, results_set, results_class, results_func): - """Convert a set of results in the database to the appropriate + def to_result(self, results_set, results_cls): + """ + Convert a set of results in the database to the appropriate result object. Parameters @@ -227,6 +213,8 @@ def to_result(self, results_set, results_class, results_func): 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 ------- @@ -240,10 +228,10 @@ def to_result(self, results_set, results_class, results_func): 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, results_func)(r[2]) + m = getattr(part, results_cls._results_func)(r[2]) if not m: raise ValueError(f"Member {r[2]} not in part {part.name}") - results[step].append(results_class(m, *r[3:])) + results[step].append(results_cls(m, *r[3:])) return results @staticmethod @@ -255,11 +243,11 @@ def create_table_for_output_class(output_cls, connection, results): Parameters ---------- output_cls : _Output subclass - A class like NodeOutput that implements `get_table_schema()` + A class like NodeOutput that implements `get_table_schema()`. connection : sqlite3.Connection - SQLite3 connection object + SQLite3 connection object. results : list of tuples - Data to be inserted into the table + Data to be inserted into the table. """ cursor = connection.cursor() diff --git a/src/compas_fea2/results/fields.py b/src/compas_fea2/results/fields.py index bc620a2f7..798b2b9f1 100644 --- a/src/compas_fea2/results/fields.py +++ b/src/compas_fea2/results/fields.py @@ -72,23 +72,35 @@ def step(self): def problem(self): return self.step.problem - @property - def field_name(self): - return self._field_name - @property def model(self): return self.problem.model + @property + def field_name(self): + return self._field_name + @property def rdb(self): return self.problem.results_db @property def results(self): - return self._get_results_from_db(step=self.step, columns=self._components_names)[self.step] + return self._get_results_from_db(columns=self._components_names)[self.step] - def _get_results_from_db(self, members=None, columns=None, filters=None, **kwargs): + @property + def locations(self): + """Return the locations where the field is defined. + + Yields + ------ + :class:`compas.geometry.Point` + The location where the field is defined. + """ + for r in self.results: + yield r.location + + 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 @@ -105,6 +117,9 @@ def _get_results_from_db(self, members=None, columns=None, filters=None, **kwarg dict Dictionary of results. """ + if not columns: + columns = self._components_names + if not filters: filters = {} @@ -116,9 +131,9 @@ def _get_results_from_db(self, members=None, columns=None, filters=None, **kwarg filters["key"] = set([member.key for member in members]) filters["part"] = set([member.part.name for member in members]) - results_set = self.rdb.get_rows(self._field_name, ["step", "part", "key"] + self._components_names, filters) + results_set = self.rdb.get_rows(self._field_name, ["step", "part", "key"] + columns, filters, func) - return self.rdb.to_result(results_set, self._results_cls, self._results_func) + return self.rdb.to_result(results_set, self._results_cls) def get_result_at(self, location): """Get the result for a given location. @@ -148,8 +163,8 @@ def get_max_result(self, component): :class:`compas_fea2.results.Result` The appropriate Result object. """ - results_set = self.rdb.get_func_row(self.field_name, self.field_name + str(component), "MAX", {"step": [self.step.name]}, self.results_columns) - return self.rdb.to_result(results_set)[self.step][0] + func = ["DESC", component] + return self._get_results_from_db(columns=self._components_names, func=func)[self.step][0] def get_min_result(self, component): """Get the result where a component is minimum for a given step. @@ -164,11 +179,11 @@ def get_min_result(self, component): :class:`compas_fea2.results.Result` The appropriate Result object. """ - results_set = self.rdb.get_func_row(self.field_name, self.field_name + str(component), "MIN", {"step": [self.step.name]}, self.results_columns) - return self.rdb.to_result(results_set, self._results_class)[self.step][0] + func = ["ASC", component] + return self._get_results_from_db(columns=self._components_names, func=func)[self.step][0] - def get_max_component(self, component): - """Get the result where a component is maximum for a given 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 ---------- @@ -177,80 +192,82 @@ def get_max_component(self, component): Returns ------- - float - The maximum value of the component. + list + A list containing the result objects with the minimum and maximum value of the given component in the step. """ - return self.get_max_result(component, self.step).vector[component - 1] + return [self.get_min_result(component, self.step), self.get_max_result(component, self.step)] - def get_min_component(self, component): - """Get the result where a component is minimum for a given step. + def component_scalar(self, component): + """Return the value of selected component.""" + for result in self.results: + yield getattr(result, component, None) + + def filter_by_component(self, component, threshold=None): + """Filter results by a specific component, optionally using a threshold. Parameters ---------- - component : int - The index of the component to retrieve. + 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 ------- - float - The minimum value of the component. + dict + A dictionary of filtered elements and their results. """ - return self.get_min_result(component, self.step).vector[component - 1] + if component not in self._components_names: + raise ValueError(f"Component '{component}' is not valid. Choose from {self._components_names}.") - def get_limits_component(self, component): - """Get the result objects with the min and max value of a given component in a step. + 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 - ---------- - component : int - 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, self.step), self.get_max_result(component, self.step)] +# ------------------------------------------------------------------------------ +# Node Field Results +# ------------------------------------------------------------------------------ +class NodeFieldResults(FieldResults): + """Node field results. - def get_limits_absolute(self): - """Get the result objects with the absolute min and max value in a step. + This class handles the node field results from a finite element analysis. - Returns - ------- - list - A list containing the result objects with the absolute minimum and maximum value in the step. - """ - limits = [] - for func in ["MIN", "MAX"]: - limits.append(self.rdb.get_func_row(self.field_name, "magnitude", func, {"step": [self.step.name]}, self.results_columns)) - return [self.rdb.to_result(limit)[self.step][0] for limit in limits] + Parameters + ---------- + step : :class:`compas_fea2.problem._Step` + The analysis step where the results are defined. - @property - def locations(self): - """Return the locations where the field is 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. + """ - Yields - ------ - :class:`compas.geometry.Point` - The location where the field is defined. - """ - for r in self.results: - yield r.location + def __init__(self, step, results_cls, *args, **kwargs): + super(NodeFieldResults, self).__init__(step=step, results_cls=results_cls, *args, **kwargs) @property - def points(self): - """Return the locations where the field is defined. + 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. """ for r in self.results: - yield r.location + yield r.vector @property - def vectors(self): + def vectors_rotation(self): """Return the vectors where the field is defined. Yields @@ -259,34 +276,52 @@ def vectors(self): The vector where the field is defined. """ for r in self.results: - yield r.vector + yield r.vector_rotation - def component(self, dof=None): - """Return the components where the field is defined. + def compute_resultant(self, sub_set=None): + """Compute the translation resultant, moment resultant, and location of the field. Parameters ---------- - dof : int, optional - The degree of freedom to retrieve, by default None. + sub_set : list, optional + List of locations to filter the results. If None, all results are considered. - Yields - ------ - float - The component value. + 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`. """ - for r in self.results: - if dof is None: - yield r.vector.magnitude - else: - yield r.vector[dof] - - -# ------------------------------------------------------------------------------ -# Node Field Results -# ------------------------------------------------------------------------------ - - -class DisplacementFieldResults(FieldResults): + from compas.geometry import centroid_points_weighted, sum_vectors, cross_vectors, Point + + 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 resultant_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. @@ -312,7 +347,7 @@ def __init__(self, step, *args, **kwargs): super(DisplacementFieldResults, self).__init__(step=step, results_cls=DisplacementResult, *args, **kwargs) -class AccelerationFieldResults(FieldResults): +class AccelerationFieldResults(NodeFieldResults): """Acceleration field results. This class handles the acceleration field results from a finite element analysis. @@ -338,7 +373,7 @@ def __init__(self, step, *args, **kwargs): super(AccelerationFieldResults, self).__init__(step=step, results_cls=AccelerationResult, *args, **kwargs) -class VelocityFieldResults(FieldResults): +class VelocityFieldResults(NodeFieldResults): """Velocity field results. This class handles the velocity field results from a finite element analysis. @@ -364,7 +399,7 @@ def __init__(self, step, *args, **kwargs): super(VelocityFieldResults, self).__init__(step=step, results_cls=VelocityResult, *args, **kwargs) -class ReactionFieldResults(FieldResults): +class ReactionFieldResults(NodeFieldResults): """Reaction field results. This class handles the reaction field results from a finite element analysis. @@ -451,42 +486,6 @@ def get_elements_forces(self, elements): for element in elements: yield self.get_element_forces(element) - def get_all_section_forces(self): - """Retrieve section forces for all elements in the field. - - Returns - ------- - dict - A dictionary mapping elements to their section forces. - """ - return {element: self.get_result_at(element) for element in self.elements} - - def filter_by_component(self, component_name, threshold=None): - """Filter results by a specific component, optionally using a threshold. - - Parameters - ---------- - component_name : 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 - A dictionary of filtered elements and their results. - """ - if component_name not in self._components_names: - raise ValueError(f"Component '{component_name}' is not valid. Choose from {self._components_names}.") - - filtered_results = {} - for element, result in self.get_all_section_forces().items(): - component_value = getattr(result, component_name, None) - if component_value is not None and (threshold is None or component_value >= threshold): - filtered_results[element] = result - - return filtered_results - def export_to_dict(self): """Export all field results to a dictionary. @@ -495,30 +494,7 @@ def export_to_dict(self): dict A dictionary containing all section force results. """ - results_dict = {} - for element, result in self.get_all_section_forces().items(): - results_dict[element] = { - "forces": { - "Fx_1": result.force_vector_1.x, - "Fy_1": result.force_vector_1.y, - "Fz_1": result.force_vector_1.z, - "Fx_2": result.force_vector_2.x, - "Fy_2": result.force_vector_2.y, - "Fz_2": result.force_vector_2.z, - }, - "moments": { - "Mx_1": result.moment_vector_1.x, - "My_1": result.moment_vector_1.y, - "Mz_1": result.moment_vector_1.z, - "Mx_2": result.moment_vector_2.x, - "My_2": result.moment_vector_2.y, - "Mz_2": result.moment_vector_2.z, - }, - "invariants": { - "magnitude": result.net_force.length, - }, - } - return results_dict + raise NotImplementedError() def export_to_csv(self, file_path): """Export all field results to a CSV file. @@ -528,32 +504,7 @@ def export_to_csv(self, file_path): file_path : str Path to the CSV file. """ - import csv - - with open(file_path, mode="w", newline="") as csvfile: - writer = csv.writer(csvfile) - # Write headers - writer.writerow(["Element", "Fx_1", "Fy_1", "Fz_1", "Mx_1", "My_1", "Mz_1", "Fx_2", "Fy_2", "Fz_2", "Mx_2", "My_2", "Mz_2", "Magnitude"]) - # Write results - for element, result in self.get_all_section_forces().items(): - writer.writerow( - [ - element, - result.force_vector_1.x, - result.force_vector_1.y, - result.force_vector_1.z, - result.moment_vector_1.x, - result.moment_vector_1.y, - result.moment_vector_1.z, - result.force_vector_2.x, - result.force_vector_2.y, - result.force_vector_2.z, - result.moment_vector_2.x, - result.moment_vector_2.y, - result.moment_vector_2.z, - result.net_force.length, - ] - ) + raise NotImplementedError() # ------------------------------------------------------------------------------ diff --git a/src/compas_fea2/results/results.py b/src/compas_fea2/results/results.py index 954be2142..9dfb0aff0 100644 --- a/src/compas_fea2/results/results.py +++ b/src/compas_fea2/results/results.py @@ -151,6 +151,30 @@ def __init__(self, node, x=None, y=None, z=None, xx=None, yy=None, zz=None, **kw self._yy = yy self._zz = zz + @property + def x(self): + return self._x + + @property + 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 @@ -349,6 +373,18 @@ class SectionForcesResult(ElementResult): 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) @@ -367,6 +403,54 @@ def __repr__(self): f")" ) + @property + def Fx_1(self): + return self._Fx_1 + + @property + def Fy_1(self): + return self._Fy_1 + + @property + def Fz_1(self): + return self._Fz_1 + + @property + def Mx_1(self): + return self._Mx_1 + + @property + def My_1(self): + return self._My_1 + + @property + def Mz_1(self): + return self._Mz_1 + + @property + 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.""" @@ -598,9 +682,40 @@ class StressResult(ElementResult): def __init__(self, element, *, s11, s12, s13, s22, s23, s33, **kwargs): super(StressResult, self).__init__(element, **kwargs) + self._s11 = s11 + self._s12 = s12 + self._s13 = s13 + self._s22 = s22 + self._s23 = s23 + self._s33 = s33 + self._local_stress = np.array([[s11, s12, s13], [s12, s22, s23], [s13, s23, s33]]) 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))} + @property + def s11(self): + return self._s11 + + @property + 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 local_stress(self): # In local coordinates @@ -1082,11 +1197,52 @@ class ShellStressResult(Result): 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 + 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 + def s11(self): + return self._s11 + + @property + def s22(self): + return self._s22 + + @property + def s12(self): + return self._s12 + + @property + def sb11(self): + return self._sb11 + + @property + def sb22(self): + return self._sb22 + + @property + def sb12(self): + return self._sb12 + + @property + def tq1(self): + return self._tq1 + + @property + def tq2(self): + return self._tq2 + @property def mid_plane_stress_result(self): return self._mid_plane_stress_result From cc1f8c11445febaed69c378d5ab75262eda6ceb3 Mon Sep 17 00:00:00 2001 From: franaudo Date: Fri, 31 Jan 2025 07:32:18 +0100 Subject: [PATCH 10/39] type annotations and other fixes --- .editorconfig | 1 + docs/userguide/basics.model.rst | 2 +- src/compas_fea2/UI/viewer/drawer.py | 1 - src/compas_fea2/UI/viewer/scene.py | 14 +- src/compas_fea2/UI/viewer/viewer.py | 15 +- src/compas_fea2/model/bcs.py | 76 +- src/compas_fea2/model/connectors.py | 76 +- src/compas_fea2/model/constraints.py | 37 +- src/compas_fea2/model/elements.py | 215 +++-- src/compas_fea2/model/materials/concrete.py | 39 +- src/compas_fea2/model/materials/material.py | 42 +- src/compas_fea2/model/materials/steel.py | 22 +- src/compas_fea2/model/model.py | 151 ++-- src/compas_fea2/model/nodes.py | 62 +- src/compas_fea2/model/parts.py | 788 ++++++++++-------- src/compas_fea2/model/releases.py | 61 +- src/compas_fea2/model/sections.py | 170 ++-- src/compas_fea2/model/shapes.py | 31 +- src/compas_fea2/problem/outputs.py | 18 +- src/compas_fea2/problem/problem.py | 129 +-- .../problem/steps/perturbations.py | 13 +- src/compas_fea2/problem/steps/static.py | 15 +- src/compas_fea2/problem/steps/step.py | 11 +- src/compas_fea2/results/database.py | 1 + src/compas_fea2/results/fields.py | 31 +- src/compas_fea2/results/modal.py | 16 +- src/compas_fea2/results/results.py | 20 +- src/compas_fea2/utilities/_utils.py | 27 +- tests/test_bcs.py | 1 - tests/test_nodes.py | 10 +- tests/test_sections.py | 1 - tests/test_shapes.py | 1 - 32 files changed, 1114 insertions(+), 983 deletions(-) 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/userguide/basics.model.rst b/docs/userguide/basics.model.rst index 0c3d4fe2b..68ea2705b 100644 --- a/docs/userguide/basics.model.rst +++ b/docs/userguide/basics.model.rst @@ -34,7 +34,7 @@ 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 diff --git a/src/compas_fea2/UI/viewer/drawer.py b/src/compas_fea2/UI/viewer/drawer.py index b0e5a1364..470c57dc8 100644 --- a/src/compas_fea2/UI/viewer/drawer.py +++ b/src/compas_fea2/UI/viewer/drawer.py @@ -1,7 +1,6 @@ from compas.colors import Color from compas.colors import ColorMap from compas.geometry import Line -from compas.geometry import Vector def draw_field_vectors(locations, vectors, scale_results, translate=0, 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 24ef34622..58b5454f8 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, BufferGeometry # noqa: F401 +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"), @@ -294,7 +295,6 @@ class FEA2NodeFieldResultsObject(GroupObject): """ 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"] diff --git a/src/compas_fea2/UI/viewer/viewer.py b/src/compas_fea2/UI/viewer/viewer.py index 4a1b9329d..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 FEA2NodeFieldResultsObject -from compas_fea2.UI.viewer.scene import FEA2Stress2DFieldResultsObject from compas_fea2.UI.viewer.scene import FEA2StepObject +from compas_fea2.UI.viewer.scene import FEA2Stress2DFieldResultsObject def toggle_nodes(): @@ -211,18 +211,15 @@ def add_stress2D_field( **kwargs, ) - def add_mode_shape(self, mode_shape, model, component=None, fast=False, show_parts=True, opacity=0.5, show_bcs=True, show_loads=True, **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, model=model, component=component, fast=fast, show_parts=show_parts, opacity=opacity, show_bcs=show_bcs, show_loads=show_loads, **kwargs - ) + 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(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/model/bcs.py b/src/compas_fea2/model/bcs.py index 81a106269..babf97665 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,44 @@ 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"]} class GeneralBC(_BoundaryCondition): - """Costumized boundary condition.""" + """Customized boundary condition.""" __doc__ += docs __doc__ += """ @@ -117,8 +115,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 +131,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 +141,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 +179,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 +191,7 @@ class ClampBCXX(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(ClampBCXX, self).__init__(**kwargs) + super().__init__(**kwargs) self._xx = True @@ -203,7 +201,7 @@ class ClampBCYY(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(ClampBCYY, self).__init__(**kwargs) + super().__init__(**kwargs) self._yy = True @@ -213,7 +211,7 @@ class ClampBCZZ(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(ClampBCZZ, self).__init__(**kwargs) + super().__init__(**kwargs) self._zz = True @@ -223,7 +221,7 @@ class RollerBCX(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(RollerBCX, self).__init__(**kwargs) + super().__init__(**kwargs) self._x = False @@ -233,7 +231,7 @@ class RollerBCY(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(RollerBCY, self).__init__(**kwargs) + super().__init__(**kwargs) self._y = False @@ -243,7 +241,7 @@ class RollerBCZ(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(RollerBCZ, self).__init__(**kwargs) + super().__init__(**kwargs) self._z = False @@ -253,7 +251,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 +262,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 +273,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..e853d36b7 100644 --- a/src/compas_fea2/model/connectors.py +++ b/src/compas_fea2/model/connectors.py @@ -1,6 +1,7 @@ -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 _Group @@ -15,10 +16,10 @@ 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` + section : compas_fea2.model.sections.ConnectorSection The section containing the mechanical properties of the connector. Notes @@ -27,14 +28,14 @@ class Connector(FEAData): """ - def __init__(self, nodes, **kwargs): - super(Connector, self).__init__(**kwargs) - self._key = None - self._nodes = None + def __init__(self, nodes: Union[List[Node], _Group], **kwargs): + super().__init__(**kwargs) + self._key: Optional[str] = None + self._nodes: Optional[List[Node]] = None self.nodes = nodes @property - def nodes(self): + def nodes(self) -> List[Node]: return self._nodes @property @@ -42,7 +43,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): @@ -64,41 +65,41 @@ class RigidLinkConnector(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`. 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 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 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 +108,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,8 +124,8 @@ 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 @@ -135,28 +136,31 @@ 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 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 diff --git a/src/compas_fea2/model/constraints.py b/src/compas_fea2/model/constraints.py index 5a36673be..90947dba4 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,10 @@ 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) # ------------------------------------------------------------------------------ @@ -22,22 +17,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,8 +47,8 @@ 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 @@ -63,13 +62,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,8 +76,8 @@ 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. diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index 1fd51a61c..f84373dbb 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 @@ -37,7 +38,7 @@ 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 @@ -45,7 +46,7 @@ class _Element(FEAData): part : :class:`compas_fea2.model.DeformablePart` | 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 +72,8 @@ 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._nodes = self._check_nodes(nodes) self._registration = nodes[0]._registration self._section = section @@ -89,99 +88,96 @@ def __init__(self, nodes, section, implementation=None, rigid=False, **kwargs): self._shape = None @property - def part(self): + 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 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.key for n in self.nodes] @property - def nodes_inputkey(self): + def nodes_inputkey(self) -> str: return "-".join(sorted([str(node.input_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): + def section(self, value: "_Section"): # noqa: F821 if self.part: self.part.add_section(value) 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 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 -# ============================================================================== -# 0D elements -# ============================================================================== + @property + def nodal_mass(self) -> List[float]: + return [self.mass / len(self.nodes)] * 3 -# 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.""" @@ -189,8 +185,8 @@ class MassElement(_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 @@ -204,8 +200,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 +213,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 +244,16 @@ 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) 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) @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,36 +262,40 @@ 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, end="end_1", nx=100, ny=100, *args, **kwargs): + def plot_stress_distribution(self, step: "_Step", end: str = "end_1", nx: int = 100, ny: int = 100, *args, **kwargs): # noqa: F821 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): + def section_forces_result(self, step: "_Step") -> "Result": # noqa: F821 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): + def forces(self, step: "_Step") -> "Result": # noqa: F821 r = self.section_forces_result(step) return r.forces - def moments(self, step): + def moments(self, step: "_Step") -> "Result": # noqa: F821 r = self.section_forces_result(step) return r.moments @@ -318,9 +314,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): @@ -331,11 +326,6 @@ class TieElement(TrussElement): """A truss element that resists axial tensile loads.""" -# ============================================================================== -# 2D elements -# ============================================================================== - - class Face(FEAData): """Element representing a face. @@ -367,8 +357,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 @@ -376,33 +366,37 @@ def __init__(self, nodes, tag, element=None, **kwargs): self._centroid = centroid_points([node.xyz for node in nodes]) @property - def nodes(self): + 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 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.key for n in self.nodes] + class _Element2D(_Element): """Element with 2 dimensions.""" @@ -418,8 +412,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, @@ -431,35 +425,35 @@ def __init__(self, nodes, section=None, implementation=None, rigid=False, **kwar self._face_indices = None @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 @@ -475,7 +469,7 @@ 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): + def stress_results(self, step: "_Step") -> "Result": # noqa: F821 if not hasattr(step, "stress2D_field"): raise ValueError("The step does not have a stress2D_field") return step.stress2D_field.get_result_at(self) @@ -489,8 +483,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, @@ -516,14 +510,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 @@ -535,8 +521,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, @@ -547,31 +533,31 @@ def __init__(self, nodes, section, implementation=None, **kwargs): self._frame = Frame.worldXY() @property - def frame(self): + 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): + def reference_point(self) -> "Point": return centroid_points([face.centroid for face in self.faces]) - def _construct_faces(self, face_indices): + def _construct_faces(self, face_indices: Dict[str, Tuple[int]]) -> List[Face]: """Construct the face-nodes dictionary. Parameters @@ -589,18 +575,18 @@ 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() @@ -620,8 +606,8 @@ class TetrahedronElement(_Element3D): """ - def __init__(self, *, nodes, section, implementation=None, **kwargs): - super(TetrahedronElement, 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, @@ -640,9 +626,8 @@ def edges(self): seen.add((v, u)) yield u, v - # TODO use compas funcitons to compute differences and det @property - def volume(self): + def volume(self) -> float: """The volume property.""" def determinant_3x3(m): @@ -674,8 +659,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/materials/concrete.py b/src/compas_fea2/model/materials/concrete.py index 21c9b0811..9eda02e58 100644 --- a/src/compas_fea2/model/materials/concrete.py +++ b/src/compas_fea2/model/materials/concrete.py @@ -4,6 +4,9 @@ from math import log +from compas_fea2.units import UnitRegistry +from compas_fea2.units import units as u + from .material import _Material @@ -45,9 +48,9 @@ class Concrete(_Material): 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) - + # FIXME: units! de = 0.0001 fcm = fck + 8 Ecm = 22 * 10**3 * (fcm / 10) ** 0.3 @@ -58,24 +61,22 @@ def __init__(self, *, fck, v=0.2, density=2400, fr=None, **kwargs): 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] + fc = [10**6 * fcm * (k * (ei / ec1) - (ei / ec1) ** 2) / (1 + (k - 2) * (ei / ec1)) for ei in ec] ft = [1.0, 0.0] et = [0.0, 0.001] fr = fr or [1.16, fctm / fcm] 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.fc = kwargs.get("fc", fc) + self.E = E or self.fc[1] / e[1] + self.v = v or 0.17 + self.ec = kwargs.get("ec", fc) + self.ft = kwargs.get("ft", fc) + self.et = kwargs.get("et", fc) + self.fr = kwargs.get("fr", fr) + self.tension = {"f": ft, "e": et} - self.compression = {"f": f[1:], "e": ec} + self.compression = {"f": self.fc[1:], "e": self.ec} @property def G(self): @@ -97,6 +98,16 @@ def __str__(self): self.name, self.density, self.E, self.v, self.G, self.fck, self.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. diff --git a/src/compas_fea2/model/materials/material.py b/src/compas_fea2/model/materials/material.py index 771ae9b86..6a34ff13e 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,21 @@ 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): + def __str__(self) -> str: return """ {} {} @@ -66,7 +62,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 +119,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 +131,7 @@ def __init__(self, Ex, Ey, Ez, vxy, vyz, vzx, Gxy, Gyz, Gzx, density, expansion= self.Gyz = Gyz self.Gzx = Gzx - def __str__(self): + def __str__(self) -> str: return """ {} {} @@ -192,12 +188,12 @@ 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): + def __str__(self) -> str: return """ ElasticIsotropic Material ------------------------- @@ -213,14 +209,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 +250,11 @@ 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): + def __str__(self) -> str: return """ ElasticPlastic Material ----------------------- @@ -282,11 +278,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..c67c8baae 100644 --- a/src/compas_fea2/model/materials/steel.py +++ b/src/compas_fea2/model/materials/steel.py @@ -2,6 +2,9 @@ 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 +50,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] @@ -100,7 +103,7 @@ def __str__(self): # TODO check values and make unit independent @classmethod - def S355(cls): + def S355(cls, units=None): """Steel S355. Returns @@ -108,4 +111,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/model.py b/src/compas_fea2/model/model.py index 50ac494c2..abd5fe785 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,9 +6,14 @@ 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.geometry import Box from compas.geometry import Plane +from compas.geometry import Point +from compas.geometry import Polygon from compas.geometry import bounding_box from compas.geometry import centroid_points from pint import UnitRegistry @@ -22,19 +23,23 @@ 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 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.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 -from compas_fea2.UI import FEA2Viewer class Model(FEAData): @@ -42,18 +47,18 @@ 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`] The parts of the model. @@ -78,21 +83,21 @@ 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._parts: Set[_Part] = set() self._nodes = None self._bcs = {} self._ics = {} - self._connectors = set() - self._constraints = set() - self._partsgroups = set() - self._problems = set() + self._connectors: Set[Connector] = set() + self._constraints: Set[_Constraint] = set() + self._partsgroups: Set[PartsGroup] = set() + self._problems: Set[Problem] = set() self._results = {} self._loads = {} self._path = None @@ -106,31 +111,31 @@ def __data__(self): return None @property - def parts(self): + def parts(self) -> Set[_Part]: return self._parts @property - def partgroups(self): + 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): + def materials(self) -> Set[_Material]: materials = set() for part in filter(lambda p: not isinstance(p, RigidPart), self.parts): for material in part.materials: @@ -138,7 +143,7 @@ def materials(self): return materials @property - def sections(self): + def sections(self) -> Set[_Section]: sections = set() for part in filter(lambda p: not isinstance(p, RigidPart), self.parts): for section in part.sections: @@ -146,19 +151,19 @@ def sections(self): return sections @property - def problems(self): + 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) @@ -167,32 +172,32 @@ 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): + def nodes(self) -> list[Node]: n = [] for part in self.parts: n += list(part.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) @@ -201,30 +206,30 @@ def bounding_box(self): return None @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 @@ -234,8 +239,7 @@ def units(self, value): # ========================================================================= @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 @@ -276,12 +280,12 @@ def from_cfm(path): 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 @@ -302,14 +306,14 @@ 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 @@ -323,7 +327,7 @@ 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 @@ -337,7 +341,7 @@ def contains_part(self, part): """ return part in self.parts - def add_part(self, part): + def add_part(self, part: _Part) -> _Part: """Adds a DeformablePart to the Model. Parameters @@ -383,7 +387,7 @@ def add_part(self, part): return part - def add_parts(self, parts): + def add_parts(self, parts: list[_Part]) -> list[_Part]: """Add multiple parts to the model. Parameters @@ -403,62 +407,57 @@ def add_parts(self, parts): @get_docstring(_Part) @part_method - def find_node_by_key(self, key): + def find_node_by_key(self, key: int): pass @get_docstring(_Part) @part_method - def find_node_by_inputkey(self, input_key): + def find_node_by_inputkey(self, input_key: int): pass @get_docstring(_Part) @part_method - def find_nodes_by_name(self, name): + def find_nodes_by_name(self, name: str): pass @get_docstring(_Part) @part_method - def find_nodes_around_point(self, point, distance, plane=None, single=False): + def find_nodes_around_point(self, point: Point, distance: float, plane: Optional[Plane] = None, single: bool = False): pass - # @get_docstring(_Part) - # @part_method - # def find_closest_nodes_to_point(self, point, distance, number_of_nodes=1, plane=None): - # pass - @get_docstring(_Part) @part_method - def find_nodes_around_node(self, node, distance): + def find_nodes_around_node(self, node: Node, distance: float): pass @get_docstring(_Part) @part_method - def find_closest_nodes_to_node(self, node, distance, number_of_nodes=1, plane=None): + def find_closest_nodes_to_node(self, node: Node, distance: float, number_of_nodes: int = 1, plane: Optional[Plane] = None): pass @get_docstring(_Part) @part_method - def find_nodes_by_attribute(self, attr, value, tolerance=1): + def find_nodes_by_attribute(self, attr: str, value: Union[str, int, float], tolerance: float = 1): pass @get_docstring(_Part) @part_method - def find_nodes_on_plane(self, plane, tolerance=1): + def find_nodes_on_plane(self, plane: Plane, tolerance: float = 1): pass @get_docstring(_Part) @part_method - def find_nodes_in_polygon(self, polygon, tolerance=1.1): + def find_nodes_in_polygon(self, polygon: Polygon, tolerance: float = 1.1): pass @get_docstring(_Part) @part_method - def find_nodes_where(self, conditions): + def find_nodes_where(self, conditions: dict): pass @get_docstring(_Part) @part_method - def contains_node(self, node): + def contains_node(self, node: Node): pass # ========================================================================= @@ -467,24 +466,24 @@ 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): pass @get_docstring(_Part) @part_method - def find_element_by_inputkey(self, key): + def find_element_by_inputkey(self, key: int): pass @get_docstring(_Part) @part_method - def find_elements_by_name(self, name): + def find_elements_by_name(self, name: str): pass # ========================================================================= # Groups methods # ========================================================================= - def add_parts_group(self, group): + def add_parts_group(self, group: PartsGroup) -> PartsGroup: """Add a PartsGroup object to the Model. Parameters @@ -504,30 +503,30 @@ def add_parts_group(self, group): group._registration = self # FIXME wrong because the members of the group might have a different registation 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 @@ -542,7 +541,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 @@ -584,13 +583,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` diff --git a/src/compas_fea2/model/nodes.py b/src/compas_fea2/model/nodes.py index d668272f7..79d3cc6cb 100644 --- a/src/compas_fea2/model/nodes.py +++ b/src/compas_fea2/model/nodes.py @@ -1,6 +1,6 @@ -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.tolerance import TOL @@ -74,11 +74,11 @@ 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.xyz = xyz + self._xyz = xyz self._x = xyz[0] self._y = xyz[1] self._z = xyz[2] @@ -86,7 +86,7 @@ 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 @@ -98,7 +98,7 @@ def __init__(self, xyz, mass=None, temperature=None, **kwargs): self._connected_elements = [] @classmethod - def from_compas_point(cls, point, mass=None, temperature=None): + 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 @@ -131,19 +131,19 @@ 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 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] @@ -151,51 +151,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: @@ -206,11 +206,11 @@ def bc(self): return self._bc @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 @@ -218,11 +218,11 @@ 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 @@ -240,13 +240,13 @@ def reaction(self, step): return step.reaction_field.get_result_at(location=self) @property - def displacements(self): + 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): + def reactions(self) -> Dict: problems = self.model.problems steps = [problem.step for problem in problems] return {step: self.reaction(step) for step in steps} diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index 0ec3806df..b94cae0ca 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -1,11 +1,14 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - 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 from compas.geometry import Box from compas.geometry import Frame from compas.geometry import Plane @@ -14,7 +17,7 @@ from compas.geometry import Transformation from compas.geometry import Vector from compas.geometry import bounding_box -from compas.geometry import centroid_points +from compas.geometry import centroid_points, centroid_points_weighted from compas.geometry import distance_point_point_sqrd from compas.geometry import is_point_in_polygon_xy from compas.geometry import is_point_on_plane @@ -49,13 +52,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,18 +66,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 + 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`] @@ -93,17 +96,17 @@ 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._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._nodesgroups: Set[NodesGroup] = set() + self._elementsgroups: Set[ElementsGroup] = set() + self._facesgroups: Set[FacesGroup] = set() self._boundary_mesh = None self._discretized_boundary_mesh = None @@ -113,43 +116,43 @@ def __init__(self, **kwargs): self._weight = None @property - def nodes(self): + def nodes(self) -> Set[Node]: return self._nodes @property - def points(self): + def points(self) -> List[List[float]]: return [node.xyz for node in self.nodes] @property - def elements(self): + def elements(self) -> Set[_Element]: return self._elements @property - def sections(self): + def sections(self) -> Set[_Section]: return self._sections @property - def materials(self): + def materials(self) -> Set[_Material]: return self._materials @property - def releases(self): + def releases(self) -> Set[_BeamEndRelease]: return self._releases @property - def nodesgroups(self): + def nodesgroups(self) -> Set[NodesGroup]: return self._nodesgroups @property - def elementsgroups(self): + def elementsgroups(self) -> Set[ElementsGroup]: return self._elementsgroups @property - def facesgroups(self): + def facesgroups(self) -> Set[FacesGroup]: return self._facesgroups @property - def gkey_node(self): + def gkey_node(self) -> Dict[str, Node]: return self._gkey_node @property @@ -161,7 +164,7 @@ def discretized_boundary_mesh(self): return self._discretized_boundary_mesh @property - def bounding_box(self): + def bounding_box(self) -> Optional[Box]: try: return Box.from_bounding_box(bounding_box([n.xyz for n in self.nodes])) except Exception: @@ -169,19 +172,26 @@ def bounding_box(self): return None @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() + return centroid_points_weighted([node.point for node in self.nodes], [sum(node.mass) / len(node.mass) for node in self.nodes]) + + @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 +199,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 +215,45 @@ 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): + 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]: if dimension == 1: return filter(lambda x: isinstance(x, _Element1D), self.elements) elif dimension == 2: @@ -234,7 +268,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 +301,10 @@ 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 = [prt.find_nodes_around_point(list(p), 1, single=True) or Node(list(p), mass=mass) for p in [line.start, line.end]] prt.add_nodes(nodes) element = getattr(compas_fea2.model, element_model)(nodes=nodes, section=section, frame=frame) if not isinstance(element, _Element1D): @@ -273,7 +313,7 @@ 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): + def shell_from_compas_mesh(cls, mesh, section: ShellSection, name: Optional[str] = None, **kwargs) -> "_Part": """Creates a DeformablePart object from a :class:`compas.datastructures.Mesh`. To each face of the mesh is assigned a :class:`compas_fea2.model.ShellElement` @@ -311,7 +351,7 @@ def shell_from_compas_mesh(cls, mesh, section, name=None, **kwargs): return part @classmethod - def from_gmsh(cls, gmshModel, section=None, name=None, **kwargs): + 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. According to the `section` type provided, :class:`compas_fea2.model._Element2D` or @@ -320,9 +360,9 @@ def from_gmsh(cls, gmshModel, section=None, name=None, **kwargs): Parameters ---------- - gmshModel : obj + gmshModel : object gmsh Model to convert. See [1]_. - section : obj + section : Union[SolidSection, ShellSection], optional `compas_fea2` :class:`SolidSection` or :class:`ShellSection` sub-class object to apply to the elements. name : str, optional @@ -339,7 +379,7 @@ def from_gmsh(cls, gmshModel, section=None, name=None, **kwargs): Returns ------- - :class:`compas_fea2.model.Part` + _Part The part meshed. Notes @@ -382,46 +422,38 @@ 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] 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.add_element(TetrahedronElement(nodes=element_nodes, section=section)) part.ndf = 3 # FIXME try to move outside the loop elif ntags.size == 8: - k = part.add_element(HexahedronElement(nodes=element_nodes, section=section)) + part.add_element(HexahedronElement(nodes=element_nodes, section=section)) 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 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 - 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. @@ -446,7 +478,7 @@ def from_boundary_mesh(cls, boundary_mesh, name=None, **kwargs): Returns ------- - :class:`compas_fea2.model.Part` + _Part The part. """ @@ -482,7 +514,7 @@ def from_boundary_mesh(cls, boundary_mesh, name=None, **kwargs): 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 +534,7 @@ def from_step_file(cls, step_file, name=None, **kwargs): Returns ------- - :class:`compas_fea2.model.Part` + _Part The part. """ @@ -543,7 +575,7 @@ def from_step_file(cls, step_file, name=None, **kwargs): # Nodes methods # ========================================================================= - def find_node_by_key(self, key): + def find_node_by_key(self, key: int) -> Optional[Node]: """Retrieve a node in the model using its key. Parameters @@ -553,33 +585,35 @@ def find_node_by_key(self, key): Returns ------- - :class:`compas_fea2.model.Node` - The corresponding node. + Optional[Node] + The corresponding node, or None if not found. """ for node in self.nodes: if node.key == key: return node + return None - def find_node_by_inputkey(self, input_key): - """Retrieve a node in the model using its key. + def find_node_by_inputkey(self, input_key: int) -> Optional[Node]: + """Retrieve a node in the model using its input key. Parameters ---------- input_key : int - The node's inputkey. + The node's input key. Returns ------- - :class:`compas_fea2.model.Node` - The corresponding node. + Optional[Node] + The corresponding node, or None if not found. """ for node in self.nodes: if node.input_key == input_key: return node + return None - def find_nodes_by_name(self, name): + def find_nodes_by_name(self, name: str) -> List[Node]: """Find all nodes with a given name. Parameters @@ -588,21 +622,24 @@ def find_nodes_by_name(self, name): Returns ------- - list[:class:`compas_fea2.model.Node`] + List[Node] + List of nodes with the given name. """ return [node for node in self.nodes if node.name == name] - def find_nodes_around_point(self, point, distance, plane=None, report=False, single=False, **kwargs): + def find_nodes_around_point( + self, point: List[float], distance: float, plane: Optional[Plane] = None, report: bool = False, single: bool = False, **kwargs + ) -> Union[List[Node], Dict[Node, float], Optional[Node]]: """Find all nodes within a distance of a given geometrical location. Parameters ---------- - point : :class:`compas.geometry.Point` + point : List[float] A geometrical location. distance : float Distance from the location. - plane : :class:`compas.geometry.Plane`, optional + plane : Optional[Plane], optional Limit the search to one plane. report : bool, optional If True, return a dictionary with the node and its distance to the @@ -612,44 +649,73 @@ def find_nodes_around_point(self, point, distance, plane=None, report=False, sin Returns ------- - list[:class:`compas_fea2.model.Node`] + Union[List[Node], Dict[Node, float], Optional[Node]] + List of nodes, or dictionary with nodes and distances if report=True, + or the closest node if single=True. """ 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} + return {node: sqrt(distance_point_point_sqrd(node.xyz, point)) 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 not nodes: if compas_fea2.VERBOSE: print(f"No nodes found at {point}") - return [] - if single: - return nodes[0] - else: - return nodes + return [] if not single else None + return nodes[0] if single else nodes + + def find_nodes_around_node( + self, node: Node, distance: float, plane: Optional[Plane] = None, report: bool = False, single: bool = False + ) -> Union[List[Node], Dict[Node, float], Optional[Node]]: + """Find all nodes around a given node (excluding the node itself). + + Parameters + ---------- + node : Node + The given node. + distance : float + Search radius. + plane : Optional[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. + + Returns + ------- + Union[List[Node], Dict[Node, float], Optional[Node]] + List of nodes, or dictionary with nodes and distances if report=True, or the closest node if single=True. + """ + 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 - def find_closest_nodes_to_point(self, point, number_of_nodes, report=False): + def find_closest_nodes_to_point(self, point: List[float], number_of_nodes: int = 1, report: bool = False) -> Union[List[Node], Dict[Node, float]]: """ Find the closest number_of_nodes nodes to a given point in the part. Parameters ---------- - point): list - List of coordinates representing the point in x,y,z. - number_of_nodes: int + point : List[float] + List of coordinates representing the point in x, y, z. + number_of_nodes : int The number of closest points to find. - report: bool + report : bool Whether to return distances along with the nodes. Returns ------- - list or dict: A list of the closest nodes, or a dictionary with nodes - and distances if report=True. + 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 <= 0: raise ValueError("The number of nodes to find must be greater than 0.") - if number_of_nodes > len(self.points): + if number_of_nodes > len(self.nodes): raise ValueError("The number of nodes to find exceeds the available nodes.") tree = KDTree(self.points) @@ -665,109 +731,78 @@ def find_closest_nodes_to_point(self, point, number_of_nodes, report=False): return closest_nodes - 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). - - 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. - - Returns - ------- - list[:class:`compas_fea2.model.Node`] - - """ - 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 - - def find_closest_nodes_to_node(self, node, distance, number_of_nodes=1, plane=None): + def find_closest_nodes_to_node(self, node: Node, number_of_nodes: int = 1, report: Optional[bool] = False) -> List[Node]: """Find the n closest nodes around a given node (excluding the node itself). Parameters ---------- - node : :class:`compas_fea2.model.Node` + node : 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 + plane : Optional[Plane], optional Limit the search to one plane. Returns ------- - list[:class:`compas_fea2.model.Node`] - + List[Node] + List of the closest nodes. """ - 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 self.find_closest_nodes_to_point(node.xyz, number_of_nodes, report=report) - def find_nodes_by_attribute(self, attr, value, tolerance=0.001): + def find_nodes_by_attribute(self, attr: str, value: float, tolerance: float = 0.001) -> List[Node]: """Find all nodes with a given value for the given attribute. Parameters ---------- attr : str Attribute name. - value : any + value : float Appropriate value for the given attribute. tolerance : float, optional Tolerance for numeric attributes, by default 0.001. Returns ------- - list[:class:`compas_fea2.model.Node`] - - Notes - ----- - Only numeric attributes are supported. - + List[Node] + List of nodes with the given attribute value. """ return list(filter(lambda x: abs(getattr(x, attr) - value) <= tolerance, self.nodes)) - def find_nodes_on_plane(self, plane, tolerance=1): + def find_nodes_on_plane(self, plane: Plane, tolerance: float = 1.0) -> List[Node]: """Find all nodes on a given plane. Parameters ---------- - plane : :class:`compas.geometry.Plane` + plane : Plane The plane. tolerance : float, optional - Tolerance for the search, by default 1. + Tolerance for the search, by default 1.0. Returns ------- - list[:class:`compas_fea2.model.Node`] - + List[Node] + List of nodes on the given plane. """ return list(filter(lambda x: is_point_on_plane(Point(*x.xyz), plane, tolerance), self.nodes)) - def find_nodes_in_polygon(self, polygon, tolerance=1.1): + def find_nodes_in_polygon(self, polygon: "compas.geometry.Polygon", tolerance: float = 1.1) -> List[Node]: """Find the nodes of the part that are contained within a planar polygon. Parameters ---------- - polygon : :class:`compas.geometry.Polygon` + polygon : compas.geometry.Polygon The polygon for the search. tolerance : float, optional Tolerance for the search, by default 1.1. Returns ------- - list[:class:`compas_fea2.model.Node`] - + List[Node] + List of nodes within the polygon. """ - # TODO quick fix...change! if not hasattr(polygon, "plane"): try: polygon.plane = Frame.from_points(*polygon.points[:3]) @@ -781,25 +816,23 @@ def find_nodes_in_polygon(self, polygon, tolerance=1.1): 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)) - # TODO quite slow...check how to make it faster - def find_nodes_where(self, conditions): + def find_nodes_where(self, conditions: List[str]) -> List[Node]: """Find the nodes where some conditions are met. Parameters ---------- - conditions : list[str] + conditions : List[str] List with the strings of the required conditions. Returns ------- - list[:class:`compas_fea2.model.Node`] - + List[Node] + List of nodes meeting the conditions. """ import re 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)) try: eval(condition) @@ -808,22 +841,22 @@ def find_nodes_where(self, conditions): nodes.append(set(filter(lambda n: eval(condition.replace(var_name, str(getattr(n, var_name)))), part_nodes))) return list(set.intersection(*nodes)) - 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 @@ -858,7 +891,7 @@ def add_node(self, node): if self.contains_node(node): if compas_fea2.VERBOSE: print("NODE SKIPPED: Node {!r} already in part.".format(node)) - return + return node if not compas_fea2.POINT_OVERLAP: existing_node = self.find_nodes_around_point(node.xyz, distance=compas_fea2.GLOBAL_TOLERANCE) @@ -875,7 +908,7 @@ def add_node(self, node): 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 @@ -898,7 +931,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 +944,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,7 +967,7 @@ def remove_nodes(self, nodes): for node in nodes: self.remove_node(node) - def is_node_on_boundary(self, node, precision=None): + def is_node_on_boundary(self, node: Node, precision: Optional[float] = None) -> bool: """Check if a node is on the boundary mesh of the DeformablePart. Parameters @@ -958,13 +990,36 @@ 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 + def compute_nodal_masses(self) -> List[float]: + """Compute the nodal mass of the part. + + Warnings + -------- + Rotational masses are not considered. + + Returns + ------- + 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: + 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)] + # ========================================================================= # Elements methods # ========================================================================= - def find_element_by_key(self, key): + def find_element_by_key(self, key: int) -> Optional[_Element]: """Retrieve an element in the model using its key. Parameters @@ -974,33 +1029,33 @@ def find_element_by_key(self, 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.key == key: return element + return None - def find_element_by_inputkey(self, input_key): - """Retrieve an element in the model using its key. + def find_element_by_inputkey(self, input_key: int) -> Optional[_Element]: + """Retrieve an element in the model using its input key. Parameters ---------- input_key : int - The element's inputkey. + The element's input 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: return element + return None - def find_elements_by_name(self, name): + def find_elements_by_name(self, name: str) -> List[_Element]: """Find all elements with a given name. Parameters @@ -1009,50 +1064,48 @@ 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] - 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) -> _Element: """Add an element to the part. Parameters ---------- - element : :class:`compas_fea2.model._Element` + element : _Element The element instance. 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)) + raise TypeError(f"{element!r} is not an element.") if self.contains_element(element): if compas_fea2.VERBOSE: - print("SKIPPED: Element {!r} already in part.".format(element)) - return + print(f"SKIPPED: Element {element!r} already in part.") + return element self.add_nodes(element.nodes) @@ -1060,88 +1113,80 @@ def add_element(self, element): if element not in node.connected_elements: node.connected_elements.append(element) - if hasattr(element, "section"): - if element.section: - self.add_section(element.section) + if hasattr(element, "section") and element.section: + self.add_section(element.section) - if hasattr(element.section, "material"): - if element.section.material: - self.add_material(element.section.material) + if hasattr(element.section, "material") and element.section.material: + self.add_material(element.section.material) element._key = len(self.elements) self.elements.add(element) element._registration = self 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: @@ -1165,8 +1210,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) -> List["compas_fea2.model.Face"]: + """Find the faces of the elements that belong to a given plane, if any. Parameters ---------- @@ -1181,12 +1226,11 @@ 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]): + if all(is_point_on_plane(node.xyz, plane) for node in face.nodes): faces.append(face) return faces @@ -1194,31 +1238,33 @@ def find_faces_on_plane(self, plane): # Groups methods # ========================================================================= - def find_groups_by_name(self, name): + def find_groups_by_name(self, name: str) -> List[Union[NodesGroup, ElementsGroup, FacesGroup]]: """Find all groups with a given name. Parameters ---------- name : str + The name of the group. Returns ------- - list[:class:`compas_fea2.model.Group`] - + List[Union[NodesGroup, ElementsGroup, FacesGroup]] + List of groups with the given name. """ return [group for group in self.groups if group.name == name] - def contains_group(self, group): + def contains_group(self, group: Union[NodesGroup, ElementsGroup, FacesGroup]) -> bool: """Verify that the part contains a specific group. Parameters ---------- - group : :class:`compas_fea2.model.Group` + group : Union[NodesGroup, ElementsGroup, FacesGroup] + The group to check. Returns ------- bool - + True if the group is in the part, False otherwise. """ if isinstance(group, NodesGroup): return group in self._nodesgroups @@ -1227,14 +1273,14 @@ def contains_group(self, group): elif isinstance(group, FacesGroup): return group in self._facesgroups else: - raise TypeError("{!r} is not a valid Group".format(group)) + raise TypeError(f"{group!r} is not a valid Group") - def add_group(self, group): + def add_group(self, group: Union[NodesGroup, ElementsGroup, FacesGroup]) -> Union[NodesGroup, ElementsGroup, FacesGroup]: """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 ------- @@ -1255,7 +1301,7 @@ def add_group(self, group): if self.contains_group(group): if compas_fea2.VERBOSE: print("SKIPPED: Group {!r} already in part.".format(group)) - return + return group if isinstance(group, NodesGroup): self._nodesgroups.add(group) elif isinstance(group, ElementsGroup): @@ -1264,10 +1310,10 @@ def add_group(self, group): 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 + group._registration = self # BUG wrong because the members of the group might have a different registration return group - def add_groups(self, groups): + def add_groups(self, groups: List[Union[NodesGroup, ElementsGroup, FacesGroup]]) -> List[Union[NodesGroup, ElementsGroup, FacesGroup]]: """Add multiple groups to the part. Parameters @@ -1285,92 +1331,99 @@ 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"): + def get_max_displacement(self, problem: "Problem", step: Optional["_Step"] = None, component: str = "length") -> Tuple[Node, float]: # noqa: F821 """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", step: Optional["_Step"] = None, 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 +1431,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 @@ -1437,29 +1489,29 @@ class DeformablePart(_Part): """ def __init__(self, **kwargs): - super(DeformablePart, self).__init__(**kwargs) - self._materials = set() - self._sections = set() - self._releases = set() + super().__init__(**kwargs) + self._materials: Set[_Material] = set() + self._sections: Set[_Section] = set() + self._releases: Set[_BeamEndRelease] = set() @property - def materials(self): + def materials(self) -> Set[_Material]: return self._materials @property - def sections(self): + def sections(self) -> Set[_Section]: return self._sections @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 DeformablePart 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. @@ -1471,8 +1523,12 @@ def frame_from_compas_mesh(cls, mesh, section, name=None, **kwargs): 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,35 +1536,66 @@ 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) + 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 + ---------- + 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 + ------- + _Part + The part created from the gmsh model. + """ + return super().from_gmsh(gmshModel, section=section, name=name, **kwargs) @classmethod - def from_boundary_mesh(cls, boundary_mesh, section, name=None, **kwargs): - """ """ + 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 + ---------- + boundary_mesh : :class:`compas.datastructures.Mesh` + Boundary envelope of the DeformablePart. + 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 + ------- + _Part + The part created from the boundary mesh. + """ return super().from_boundary_mesh(boundary_mesh, section=section, name=name, **kwargs) # ========================================================================= # Materials methods # ========================================================================= - def find_materials_by_name(self, name): - # type: (str) -> list + def find_materials_by_name(self, name: str) -> List[_Material]: """Find all materials with a given name. Parameters @@ -1517,69 +1604,62 @@ def find_materials_by_name(self, name): Returns ------- - list[:class:`compas_fea2.model.Material`] - + List[_Material] """ return [material for material in self.materials if material.name == name] - def contains_material(self, material): - # type: (_Material) -> _Material + def contains_material(self, material: _Material) -> bool: """Verify that the part contains a specific material. Parameters ---------- - material : :class:`compas_fea2.model.Material` + material : _Material Returns ------- bool - """ return material in self.materials - def add_material(self, material): - # type: (_Material) -> _Material + 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 ---------- - material : :class:`compas_fea2.model.Material` + material : _Material Returns ------- - None + _Material Raises ------ TypeError If the material is not a material. - """ if not isinstance(material, _Material): - raise TypeError("{!r} is not a material.".format(material)) + raise TypeError(f"{material!r} is not a material.") if self.contains_material(material): if compas_fea2.VERBOSE: - print("SKIPPED: Material {!r} already in part.".format(material)) - return + print(f"SKIPPED: Material {material!r} already in part.") + return material material._key = len(self._materials) self._materials.add(material) material._registration = self._registration return material - def add_materials(self, materials): - # type: (_Material) -> list + def add_materials(self, materials: List[_Material]) -> List[_Material]: """Add multiple materials to the part. Parameters ---------- - materials : list[:class:`compas_fea2.model.Material`] + materials : List[_Material] Returns ------- - None - + List[_Material] """ return [self.add_material(material) for material in materials] @@ -1587,8 +1667,7 @@ def add_materials(self, materials): # Sections methods # ========================================================================= - def find_sections_by_name(self, name): - # type: (str) -> list + def find_sections_by_name(self, name: str) -> List[_Section]: """Find all sections with a given name. Parameters @@ -1597,28 +1676,24 @@ def find_sections_by_name(self, name): Returns ------- - list[:class:`compas_fea2.model.Section`] - + List[_Section] """ return [section for section in self.sections if section.name == name] - def contains_section(self, section): - # type: (_Section) -> _Section + def contains_section(self, section: _Section) -> bool: """Verify that the part contains a specific section. Parameters ---------- - section : :class:`compas_fea2.model.Section` + section : _Section Returns ------- bool - """ return section in self.sections - def add_section(self, section): - # type: (_Section) -> _Section + def add_section(self, section: _Section) -> _Section: """Add a section to the part so that it can be referenced in element definitions. Parameters @@ -1627,7 +1702,7 @@ def add_section(self, section): Returns ------- - None + _Section Raises ------ @@ -1641,7 +1716,7 @@ def add_section(self, section): if self.contains_section(section): if compas_fea2.VERBOSE: print("SKIPPED: Section {!r} already in part.".format(section)) - return + return section self.add_material(section.material) section._key = len(self.sections) @@ -1649,8 +1724,7 @@ def add_section(self, section): section._registration = self._registration return section - def add_sections(self, sections): - # type: (list) -> _Section + def add_sections(self, sections: List[_Section]) -> List[_Section]: """Add multiple sections to the part. Parameters @@ -1659,8 +1733,7 @@ def add_sections(self, sections): Returns ------- - None - + list[:class:`compas_fea2.model.Section`] """ return [self.add_section(section) for section in sections] @@ -1668,9 +1741,8 @@ def add_sections(self, sections): # 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 +1753,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,37 +1771,63 @@ 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): + def reference_point(self) -> Optional[Node]: return self._reference_point @reference_point.setter - def reference_point(self, value): + def reference_point(self, value: Node): self._reference_point = self.add_node(value) value._is_reference = True @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) @@ -1734,7 +1836,7 @@ def from_boundary_mesh(cls, boundary_mesh, name=None, **kwargs): # ========================================================================= # TODO this can be removed and the checks on the rigid part can be done in _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..1acb2d5f6 100644 --- a/src/compas_fea2/model/releases.py +++ b/src/compas_fea2/model/releases.py @@ -1,6 +1,4 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from __future__ import annotations import compas_fea2.model from compas_fea2.base import FEAData @@ -18,7 +16,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,49 +28,48 @@ 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: compas_fea2.model.BeamElement | None = None + 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) -> compas_fea2.model.BeamElement | None: return self._element @element.setter - def element(self, value): + def element(self, value: compas_fea2.model.BeamElement): if not isinstance(value, compas_fea2.model.BeamElement): - raise TypeError("{!r} is not a beam element.".format(value)) + 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 @@ -84,7 +81,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 +89,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 +105,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 e40e50492..0da156eb7 100644 --- a/src/compas_fea2/model/sections.py +++ b/src/compas_fea2/model/sections.py @@ -1,25 +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 numpy as np + 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 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 -from .materials.material import _Material -from .shapes import Circle -from .shapes import IShape -from .shapes import Rectangle -from .shapes import LShape - -def from_shape(shape, material, **kwargs): +def from_shape(shape, material: "_Material", **kwargs) -> dict: # noqa: F821 return { "A": shape.A, "Ixx": shape.Ixx, @@ -59,31 +54,31 @@ class _Section(FEAData): 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 - def __str__(self): - return """ -Section {} ---------{} -model : {!r} -key : {} -material : {!r} -""".format( - self.name, "-" * len(self.name), self.model, self.key, self.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`.") @@ -114,19 +109,17 @@ class MassSection(FEAData): 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} +""" class SpringSection(FEAData): @@ -159,31 +152,29 @@ 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 """ + 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} # ============================================================================== @@ -250,8 +241,8 @@ class BeamSection(_Section): The shape of the section. """ - def __init__(self, *, A, Ixx, Iyy, Ixy, Avx, Avy, J, 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 @@ -260,40 +251,26 @@ def __init__(self, *, A, Ixx, Iyy, Ixy, Avx, Avy, J, material, **kwargs): self.Avy = Avy self.J = J - 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, - ) - - @classmethod - def from_shape(cls, shape, material, **kwargs): + 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: "_Material", **kwargs): # noqa: F821 section = cls(**from_shape(shape, material, **kwargs)) section._shape = shape return section @@ -305,7 +282,7 @@ def shape(self): def plot(self): self.shape.plot() - def compute_stress(self, N=0.0, Mx=0.0, My=0.0, Vx=0.0, Vy=0.0, x=0.0, y=0.0): + 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. @@ -336,7 +313,7 @@ def compute_stress(self, N=0.0, Mx=0.0, My=0.0, Vx=0.0, Vy=0.0, x=0.0, y=0.0): tau_y = Vy / self.Avy if self.Avy else Vy / self.A return sigma, tau_x, tau_y - def compute_stress_distribution(self, N=0.0, Mx=0.0, My=0.0, Vx=0.0, Vy=0.0, nx=50, ny=50): + 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. @@ -389,7 +366,7 @@ def compute_stress_distribution(self, N=0.0, Mx=0.0, My=0.0, Vx=0.0, Vy=0.0, nx= return grid_x, grid_y, grid_sigma, grid_tau_x, grid_tau_y - def compute_neutral_axis(self, N=0.0, Mx=0.0, My=0.0): + 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. @@ -434,7 +411,9 @@ def compute_neutral_axis(self, N=0.0, Mx=0.0, My=0.0): return slope, intercept - def plot_stress_distribution(self, N=0.0, Mx=0.0, My=0.0, Vx=0.0, Vy=0.0, nx=50, ny=50, cmap="coolwarm", show_tau=True): + 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. @@ -554,7 +533,9 @@ def plot_stress_distribution(self, N=0.0, Mx=0.0, My=0.0, Vx=0.0, Vy=0.0, nx=50, plt.tight_layout() plt.show() - def plot_section_with_stress(self, N=0.0, Mx=0.0, My=0.0, Vx=0.0, Vy=0.0, direction=(1, 0), point=None, nx=50, ny=50): + 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. @@ -692,10 +673,9 @@ class GenericBeamSection(BeamSection): 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) - - self._shape = Circle(radius=sqrt(self.A) / pi) + 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)) class AngleSection(BeamSection): diff --git a/src/compas_fea2/model/shapes.py b/src/compas_fea2/model/shapes.py index 32dc54b8d..dfa65b926 100644 --- a/src/compas_fea2/model/shapes.py +++ b/src/compas_fea2/model/shapes.py @@ -1,22 +1,25 @@ import math -from math import atan2, degrees, pi, sqrt from functools import cached_property -from typing import List, Optional, Tuple -import matplotlib.pyplot as plt -from matplotlib.patches import Polygon as MplPolygon -from matplotlib.lines import Line2D -from compas.geometry import Point +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, - Polygon, - Rotation, - Transformation, - Translation, -) +from compas.geometry import Frame +from compas.geometry import Point +from compas.geometry import Polygon +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 diff --git a/src/compas_fea2/problem/outputs.py b/src/compas_fea2/problem/outputs.py index b272cccca..b00eb1664 100644 --- a/src/compas_fea2/problem/outputs.py +++ b/src/compas_fea2/problem/outputs.py @@ -3,15 +3,13 @@ from __future__ import print_function from compas_fea2.base import FEAData -from compas_fea2.results.results import ( - DisplacementResult, - SectionForcesResult, - ReactionResult, - VelocityResult, - AccelerationResult, - ShellStressResult, -) from compas_fea2.results.database import ResultsDatabase +from compas_fea2.results.results import AccelerationResult +from compas_fea2.results.results import DisplacementResult +from compas_fea2.results.results import ReactionResult +from compas_fea2.results.results import SectionForcesResult +from compas_fea2.results.results import ShellStressResult +from compas_fea2.results.results import VelocityResult class _Output(FEAData): @@ -44,6 +42,10 @@ def sqltable_schema(self): def results_func(self): return self._results_cls._results_func + @property + def results_func_output(self): + return self._results_cls._results_func_output + @property def field_name(self): return self.results_cls._field_name diff --git a/src/compas_fea2/problem/problem.py b/src/compas_fea2/problem/problem.py index 28812a85d..cb0c0b9ed 100644 --- a/src/compas_fea2/problem/problem.py +++ b/src/compas_fea2/problem/problem.py @@ -3,14 +3,17 @@ from __future__ import print_function import os +import shutil from pathlib import Path +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.database import ResultsDatabase - from compas_fea2.UI.viewer import FEA2Viewer @@ -66,7 +69,7 @@ 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 @@ -75,50 +78,50 @@ def __init__(self, description=None, **kwargs): self._steps_order = [] # TODO make steps a list @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): + def results_db(self) -> ResultsDatabase: return ResultsDatabase(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): + 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. @@ -135,7 +138,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 @@ -167,7 +170,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 @@ -193,7 +196,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 @@ -209,14 +212,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) @@ -224,7 +221,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 @@ -238,7 +235,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 @@ -260,7 +257,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 @@ -283,7 +280,7 @@ def add_linear_perturbation_step(self, lp_step, base_step): # Summary # ============================================================================== - def summary(self): + def summary(self) -> str: # type: () -> str """Prints a summary of the Problem object. @@ -318,7 +315,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. @@ -340,50 +337,76 @@ def write_input_file(self, path=None): path.mkdir(parents=True) return self.input_file.write_to_file(path) - def _check_analysis_path( - self, - path, - erase_data=False, - ): - """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. """ + + 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 os.path.exists(self.path): - # Check if the folder is an FEA2 results folder + + 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): ") - asw = user_input.lower() - else: - asw = "y" - if asw == "y": - for root, dirs, files in os.walk(self.path): - for file in files: - os.remove(os.path.join(root, file)) - for dir in dirs: - os.rmdir(os.path.join(root, dir)) + 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.") + print(f"WARNING: The directory {self.path} already exists and contains FEA2 results. " "Duplicated results expected.") else: - print(f"The directory {self.path} is not recognized as an FEA2 results folder. No files were deleted.") + # Folder exists but is not an FEA2 results folder + if erase_data: + 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: - os.makedirs(self.path) + # Create the directory if it does not exist + self.path.mkdir(parents=True, exist_ok=True) + return self.path - def analyse(self, path=None, erase_data=False, *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 @@ -394,11 +417,11 @@ def analyse(self, path=None, erase_data=False, *args, **kwargs): """ raise NotImplementedError("this function is not available for the selected backend") - def analyze(self, path=None, erase_data=False, *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(path=path, *args, **kwargs) - def analyse_and_extract(self, path=None, erase_data=False, *args, **kwargs): + 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. @@ -448,7 +471,9 @@ def restart_analysis(self, *args, **kwargs): # ========================================================================= # Viewer methods # ========================================================================= - def show(self, steps=None, 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 diff --git a/src/compas_fea2/problem/steps/perturbations.py b/src/compas_fea2/problem/steps/perturbations.py index b4bcb94ac..a617df192 100644 --- a/src/compas_fea2/problem/steps/perturbations.py +++ b/src/compas_fea2/problem/steps/perturbations.py @@ -5,11 +5,12 @@ from compas.geometry import Vector from compas.geometry import sum_vectors -from .step import Step -from compas_fea2.results import ModalAnalysisResult from compas_fea2.results import DisplacementResult +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 @@ -70,8 +71,8 @@ def _get_results_from_db(self, mode, **kwargs): eigenvalue = self.rdb.get_rows("eigenvalues", ["lambda"], filters)[0][0] # Get the eiginvectors - results_set = self.rdb.get_rows("eigenvectors", ["step", "part", "key", "dof_1", "dof_2", "dof_3", "dof_4", "dof_5", "dof_6"], filters) - eigenvector = self.rdb.to_result(results_set, DisplacementResult, "find_node_by_key")[self] + results_set = self.rdb.get_rows("eigenvectors", ["step", "part", "key", "x", "y", "z", "xx", "yy", "zz"], filters) + eigenvector = self.rdb.to_result(results_set, DisplacementResult)[self] return eigenvalue, eigenvector @@ -130,7 +131,7 @@ def show_mode_shape(self, mode, fast=True, opacity=1, scale_results=1, show_bcs= shape = self.mode_shape(mode) if show_vectors: - viewer.add_mode_shape(shape, fast=fast, model=self.model, component=None, show_vectors=show_vectors, show_contour=show_contour, **kwargs) + 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: @@ -138,7 +139,7 @@ def show_mode_shape(self, mode, fast=True, opacity=1, scale_results=1, show_bcs= displacement.node.xyz = sum_vectors([Vector(*displacement.location.xyz), vector]) if show_contour: - viewer.add_mode_shape(shape, fast=fast, model=self.model, component=None, show_vectors=show_vectors, show_contour=show_contour, **kwargs) + 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() diff --git a/src/compas_fea2/problem/steps/static.py b/src/compas_fea2/problem/steps/static.py index ee98da568..ff167389e 100644 --- a/src/compas_fea2/problem/steps/static.py +++ b/src/compas_fea2/problem/steps/static.py @@ -6,7 +6,6 @@ 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 @@ -277,10 +276,18 @@ def add_gravity_load_pattern(self, parts, g=9.81, x=0.0, y=0.0, z=-1.0, load_cas model! """ - raise NotImplementedError() - from compas_fea2.problem import GravityLoad + from compas_fea2.problem import ConcentratedLoad + + for part in parts: + part.compute_nodal_masses() + for node in part.nodes: + self.add_load_pattern( + NodeLoadPattern(load=ConcentratedLoad(x=node.mass[0] * g * x, y=node.mass[1] * g * y, z=node.mass[2] * g * z), nodes=[node], load_case=load_case, **kwargs) + ) + + # 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)) + # 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 diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index 94cbc4d2d..90a651e4b 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -2,21 +2,19 @@ from __future__ import division from __future__ import print_function -from compas.geometry import Vector -from compas.geometry import sum_vectors 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.results import DisplacementFieldResults from compas_fea2.results import ReactionFieldResults -from compas_fea2.results import Stress2DFieldResults from compas_fea2.results import SectionForcesFieldResults - +from compas_fea2.results import Stress2DFieldResults from compas_fea2.UI import FEA2Viewer # ============================================================================== @@ -337,7 +335,7 @@ def restart(self, value): # ============================================================================== # Patterns # ============================================================================== - def add_load_pattern(self, load_pattern): + def add_load_pattern(self, load_pattern, *kwargs): """Add a general :class:`compas_fea2.problem.patterns.Pattern` to the Step. Parameters @@ -540,7 +538,6 @@ def show_reactions(self, fast=True, show_bcs=1, scale_model=1, show_loads=0.1, c 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.stress2D_field: raise ValueError("No reaction field results available for this step") diff --git a/src/compas_fea2/results/database.py b/src/compas_fea2/results/database.py index 762a78c45..e2912c91e 100644 --- a/src/compas_fea2/results/database.py +++ b/src/compas_fea2/results/database.py @@ -1,4 +1,5 @@ import sqlite3 + from compas_fea2.base import FEAData diff --git a/src/compas_fea2/results/fields.py b/src/compas_fea2/results/fields.py index 798b2b9f1..155a89a50 100644 --- a/src/compas_fea2/results/fields.py +++ b/src/compas_fea2/results/fields.py @@ -1,19 +1,19 @@ from typing import Iterable import numpy as np -from compas.geometry import Frame, Transformation, Vector +from compas.geometry import Frame +from compas.geometry import Transformation +from compas.geometry import Vector from compas_fea2.base import FEAData -from .results import ( # noqa: F401 - AccelerationResult, - DisplacementResult, - ReactionResult, - ShellStressResult, - SolidStressResult, - VelocityResult, - SectionForcesResult, -) +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): @@ -87,6 +87,10 @@ def rdb(self): @property def results(self): return self._get_results_from_db(columns=self._components_names)[self.step] + + @property + def results_sorted(self): + return sorted(self.results, key=lambda x: x.key) @property def locations(self): @@ -148,7 +152,7 @@ def get_result_at(self, location): object The result at the given location. """ - return self._get_results_from_db(location, self.step)[self.step][0] + return self._get_results_from_db(members=location, columns=self._components_names)[self.step][0] def get_max_result(self, component): """Get the result where a component is maximum for a given step. @@ -291,7 +295,10 @@ def compute_resultant(self, sub_set=None): tuple The translation resultant as :class:`compas.geometry.Vector`, moment resultant as :class:`compas.geometry.Vector`, and location as a :class:`compas.geometry.Point`. """ - from compas.geometry import centroid_points_weighted, sum_vectors, cross_vectors, Point + 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] diff --git a/src/compas_fea2/results/modal.py b/src/compas_fea2/results/modal.py index 4412cc7e8..7ed96637b 100644 --- a/src/compas_fea2/results/modal.py +++ b/src/compas_fea2/results/modal.py @@ -1,9 +1,10 @@ -from compas_fea2.base import FEAData -from .fields import FieldResults - # from .results import Result import numpy as np +from compas_fea2.base import FEAData + +from .fields import NodeFieldResults + class ModalAnalysisResult(FEAData): """Modal analysis result. @@ -33,6 +34,11 @@ class ModalAnalysisResult(FEAData): 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 @@ -126,7 +132,7 @@ def __repr__(self): return f"ModalAnalysisResult(mode={self.mode}, eigenvalue={self.eigenvalue:.4f}, " f"frequency={self.frequency:.4f} Hz, period={self.period:.4f} s)" -class ModalShape(FieldResults): +class ModalShape(NodeFieldResults): """ModalShape result applied as Displacement field. Parameters @@ -138,7 +144,7 @@ class ModalShape(FieldResults): """ def __init__(self, step, results, *args, **kwargs): - super(ModalShape, self).__init__(step=step, field_name=None, *args, **kwargs) + super(ModalShape, self).__init__(step=step, results_cls=ModalAnalysisResult, *args, **kwargs) self._results = results @property diff --git a/src/compas_fea2/results/results.py b/src/compas_fea2/results/results.py index 9dfb0aff0..539ef7c5a 100644 --- a/src/compas_fea2/results/results.py +++ b/src/compas_fea2/results/results.py @@ -1,18 +1,15 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -import os -import matplotlib.pyplot as plt import base64 +import os from io import BytesIO + +import matplotlib.pyplot as plt import numpy as np from compas.geometry import Frame from compas.geometry import Transformation 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): @@ -41,6 +38,7 @@ 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 @@ -48,6 +46,7 @@ def __init__(self, **kwargs): super(Result, self).__init__(**kwargs) 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 @@ -210,6 +209,7 @@ class DisplacementResult(NodeResult): _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"] @@ -227,6 +227,7 @@ class AccelerationResult(NodeResult): _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"] @@ -244,6 +245,7 @@ class VelocityResult(NodeResult): _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"] @@ -293,6 +295,7 @@ class ReactionResult(NodeResult): _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"] @@ -368,6 +371,7 @@ class SectionForcesResult(ElementResult): _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"] @@ -677,6 +681,7 @@ class StressResult(ElementResult): _field_name = "s" _results_func = "find_element_by_key" + _results_func_output = "find_element_by_inputkey" _components_names = ["s11", "s22", "s12", "s13", "s22", "s23", "s33"] _invariants_names = ["magnitude"] @@ -1192,6 +1197,7 @@ class ShellStressResult(Result): _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"] diff --git a/src/compas_fea2/utilities/_utils.py b/src/compas_fea2/utilities/_utils.py index 28f4bb16f..41f4eee14 100644 --- a/src/compas_fea2/utilities/_utils.py +++ b/src/compas_fea2/utilities/_utils.py @@ -5,12 +5,13 @@ import itertools import os import subprocess -from functools import wraps -from time import perf_counter -from typing import Generator, Optional -import threading 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 @@ -168,13 +169,17 @@ def part_method(f): @wraps(f) 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): - res = list(itertools.chain.from_iterable(res)) - # res = list(itertools.chain.from_iterable(res)) - return res + try: + 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): + res = list(itertools.chain.from_iterable(res)) + # res = list(itertools.chain.from_iterable(res)) + return res + except Exception as e: + print(f"An error occurred in part_method: {e}") + raise return wrapper diff --git a/tests/test_bcs.py b/tests/test_bcs.py index 42f4cf0c0..c2c7af368 100644 --- a/tests/test_bcs.py +++ b/tests/test_bcs.py @@ -3,7 +3,6 @@ class TestBCs(unittest.TestCase): - def test_fixed_bc(self): bc = FixedBC() self.assertTrue(bc.x) 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_sections.py b/tests/test_sections.py index 1b4192b67..26d37a2fe 100644 --- a/tests/test_sections.py +++ b/tests/test_sections.py @@ -4,7 +4,6 @@ class TestSections(unittest.TestCase): - def setUp(self): self.material = Steel.S355() diff --git a/tests/test_shapes.py b/tests/test_shapes.py index a70de3ad9..3dddb5b70 100644 --- a/tests/test_shapes.py +++ b/tests/test_shapes.py @@ -4,7 +4,6 @@ class TestShapes(unittest.TestCase): - def test_rectangle(self): rect = Rectangle(w=100, h=50) self.assertEqual(rect.w, 100) From d76fdb5de24084ed54ee9ebfe5e5e4eebf2e7eaa Mon Sep 17 00:00:00 2001 From: franaudo Date: Fri, 31 Jan 2025 17:34:44 +0100 Subject: [PATCH 11/39] deepcopy almost working --- scripts/deepcopytest.py | 31 ++ scripts/graph_structure.py | 119 +++++++ scripts/mdl2.json | 30 ++ src/compas_fea2/base.py | 30 +- src/compas_fea2/model/bcs.py | 25 ++ src/compas_fea2/model/connectors.py | 107 +++++- src/compas_fea2/model/constraints.py | 35 ++ src/compas_fea2/model/elements.py | 85 ++++- src/compas_fea2/model/groups.py | 62 ++++ src/compas_fea2/model/ics.py | 40 +++ src/compas_fea2/model/materials/concrete.py | 150 +++++++-- src/compas_fea2/model/materials/material.py | 85 ++++- src/compas_fea2/model/materials/steel.py | 26 ++ src/compas_fea2/model/materials/timber.py | 14 + src/compas_fea2/model/model.py | 59 +++- src/compas_fea2/model/nodes.py | 45 ++- src/compas_fea2/model/parts.py | 90 ++++- src/compas_fea2/model/releases.py | 28 ++ src/compas_fea2/model/sections.py | 197 +++++++++++ src/compas_fea2/model/shapes.py | 316 ++++++++++++++++++ src/compas_fea2/problem/combinations.py | 10 + src/compas_fea2/problem/displacements.py | 23 ++ src/compas_fea2/problem/problem.py | 19 ++ src/compas_fea2/problem/steps/dynamic.py | 11 + .../problem/steps/perturbations.py | 70 ++++ src/compas_fea2/problem/steps/quasistatic.py | 22 ++ src/compas_fea2/problem/steps/static.py | 23 ++ src/compas_fea2/problem/steps/step.py | 58 ++++ 28 files changed, 1763 insertions(+), 47 deletions(-) create mode 100644 scripts/deepcopytest.py create mode 100644 scripts/graph_structure.py create mode 100644 scripts/mdl2.json diff --git a/scripts/deepcopytest.py b/scripts/deepcopytest.py new file mode 100644 index 000000000..50816794a --- /dev/null +++ b/scripts/deepcopytest.py @@ -0,0 +1,31 @@ +from compas_fea2.model import Model +from compas_fea2.model import DeformablePart +from compas_fea2.model import Node +from compas_fea2.model import BeamElement +from compas_fea2.model import RectangularSection +from compas_fea2.model import Steel + + +n1 = Node(xyz=[0, 0, 0]) +n2 = Node(xyz=[1, 0, 0]) +p1 = DeformablePart() +mdl1 = Model() + +mat = Steel.S355() +sec = RectangularSection(w=1, h=2, material=mat) +beam = BeamElement(nodes=[n1, n2], section=sec, frame=[0, 0, 1]) + +# print(beam.__data__()) + +p1.add_element(beam) + +mdl1.add_part(p1) +p1.add_node(n1) +p2 = p1.copy() +# print(mdl.__data__) + +mdl2 = mdl1.copy() +mdl2.show() +# print(list(mdl1.parts)[0].nodes) +# print(list(mdl2.parts)[0].nodes) +# print(mdl2) diff --git a/scripts/graph_structure.py b/scripts/graph_structure.py new file mode 100644 index 000000000..92f3dd29e --- /dev/null +++ b/scripts/graph_structure.py @@ -0,0 +1,119 @@ +import networkx as nx +from matplotlib import pyplot as plt + + +class Model: + """A model that manages parts and nodes using a graph structure.""" + + def __init__(self): + self.graph = nx.DiGraph() + self.graph.add_node(self, type="model") + + def add_part(self, part): + """Adds a part to the model and registers its nodes if any.""" + self.graph.add_node(part, type="part") + self.graph.add_edge(self, part, relation="contains") + part._model = self # Store reference to the model in the part + + # Register any nodes that were added before the part was in the model + for node in part._pending_nodes: + self.add_node(part, node) + part._pending_nodes.clear() # Clear the pending nodes list + + def add_node(self, part, node): + """Adds a node to the graph under the given part.""" + self.graph.add_node(node, type="node") + self.graph.add_edge(part, node, relation="contains") + + def get_part_of_node(self, node): + """Retrieves the part where a node is registered.""" + for predecessor in self.graph.predecessors(node): + if self.graph.nodes[predecessor]["type"] == "part": + return predecessor + return None + + def visualize_graph(self): + """Visualizes the graph structure.""" + plt.figure(figsize=(8, 6)) + pos = nx.spring_layout(self.graph) # Positioning + + # Get node types + node_types = nx.get_node_attributes(self.graph, "type") + colors = {"model": "red", "part": "blue", "node": "green"} + + # Draw nodes with different colors + node_colors = [colors.get(node_types.get(n, "node"), "gray") for n in self.graph.nodes] + nx.draw(self.graph, pos, with_labels=True, node_size=2000, node_color=node_colors, font_size=10, edge_color="gray") + + # Draw edge labels + edge_labels = nx.get_edge_attributes(self.graph, "relation") + nx.draw_networkx_edge_labels(self.graph, pos, edge_labels=edge_labels, font_size=8) + + plt.title("Model Graph Visualization") + plt.show() + + def __repr__(self): + return "Model()" + + +class Part: + def __init__(self, name): + self.name = name + self._model = None # Model is assigned when added to a Model + self._pending_nodes = [] # Store nodes before the part is added to a Model + + def add_node(self, node): + """Registers a node to this part, even if the part is not yet in a model.""" + if self._model: + self._model.add_node(self, node) + else: + self._pending_nodes.append(node) # Store node until part is added to model + + def add_nodes(self, nodes): + """Registers multiple nodes to this part.""" + for node in nodes: + self.add_node(node) + + def __repr__(self): + return f"Part({self.name})" + + +class Node: + def __init__(self, name): + self.name = name + + @property + def part(self): + """Retrieves the part where this node is registered.""" + model = next((m for m in self.__dict__.values() if isinstance(m, Model)), None) + return model.get_part_of_node(self) if model else None + + def __repr__(self): + return f"Node({self.name})" + + +# Example usage +model = Model() +p1 = Part("P1") +p2 = Part("P1") +n1 = Node("N1") +n2 = Node("N2") +n3 = Node("N3") + +nodes = [Node(f"N{i}") for i in range(100)] +model.add_part(p1) # Now part is registered in the model +p1.add_node(n1) # Uses part.add_node() which delegates to model.add_node() +p1.add_nodes(nodes) # Uses part.add_node() which delegates to model.add_node() + +p2.add_node(n2) # Part is not yet in the model, so node is stored in pending list +p2.add_node(n3) # Part is not yet in the model, so node is stored in pending list + +model.add_part(p2) # Now part is registered in the model, pending nodes are added + +print(f"{n1} is in {n1.part}") # Outputs: Node(N1) is in Part(P1) +print(f"{n3} is in {n3.part}") # Outputs: Node(N3) is in Part(P2) +# print(model.graph.nodes) +# print(model.graph.edges) + +# Visualize the graph +# model.visualize_graph() diff --git a/scripts/mdl2.json b/scripts/mdl2.json new file mode 100644 index 000000000..90cf062b4 --- /dev/null +++ b/scripts/mdl2.json @@ -0,0 +1,30 @@ +{ + "dtype": "compas_fea2.model/Model", + "data": { + "description": null, + "author": null, + "parts": [ + { + "name": "DP_5271063680", + "nodes": [], + "elements": [], + "sections": [], + "materials": [], + "releases": [], + "nodesgroups": [], + "elementsgroups": [], + "facesgroups": [] + } + ], + "bcs": {}, + "ics": {}, + "constraints": [], + "partgroups": [], + "materials": [], + "sections": [], + "problems": [], + "path": null + }, + "name": "M_5269925536", + "guid": "932b3ee0-7cec-4b6c-a8e8-b37a573c2676" +} \ No newline at end of file diff --git a/src/compas_fea2/base.py b/src/compas_fea2/base.py index 7e57ebc22..dfb4a222a 100644 --- a/src/compas_fea2/base.py +++ b/src/compas_fea2/base.py @@ -6,6 +6,7 @@ import uuid from abc import abstractmethod from typing import Iterable +from copy import deepcopy from compas.data import Data @@ -69,7 +70,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 @@ -142,5 +143,28 @@ def from_name(cls, name, **kwargs): obj = getattr(importlib.import_module(".".join([*module_info[:-1]])), "_" + name) return obj(**kwargs) - def data(self): - pass + def copy(self, cls=None, copy_guid=False, copy_name=False): # type: (...) -> D + """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 diff --git a/src/compas_fea2/model/bcs.py b/src/compas_fea2/model/bcs.py index babf97665..b8238d23b 100644 --- a/src/compas_fea2/model/bcs.py +++ b/src/compas_fea2/model/bcs.py @@ -93,6 +93,31 @@ def axes(self, value: str): 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): """Customized boundary condition.""" diff --git a/src/compas_fea2/model/connectors.py b/src/compas_fea2/model/connectors.py index e853d36b7..903f07e8e 100644 --- a/src/compas_fea2/model/connectors.py +++ b/src/compas_fea2/model/connectors.py @@ -31,8 +31,21 @@ class Connector(FEAData): def __init__(self, nodes: Union[List[Node], _Group], **kwargs): super().__init__(**kwargs) self._key: Optional[str] = None - self._nodes: Optional[List[Node]] = None - self.nodes = nodes + self._nodes: Optional[List[Node]] = nodes + + @property + 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]: @@ -76,6 +89,18 @@ def __init__(self, nodes: Union[List[Node], _Group], dofs: str = "beam", **kwarg 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) -> str: return self._dofs @@ -90,6 +115,29 @@ def __init__(self, nodes: Union[List[Node], _Group], section, yielding: Optional 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 @@ -128,6 +176,18 @@ 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 @@ -143,6 +203,29 @@ def __init__(self, nodes: Union[List[Node], _Group], direction, section, yieldin 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.""" @@ -164,3 +247,23 @@ def Kt(self) -> float: @property 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 90947dba4..e0153ba78 100644 --- a/src/compas_fea2/model/constraints.py +++ b/src/compas_fea2/model/constraints.py @@ -10,6 +10,16 @@ class _Constraint(FEAData): 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) + # ------------------------------------------------------------------------------ # MPC @@ -51,6 +61,21 @@ 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.""" @@ -84,6 +109,16 @@ class _SurfaceConstraint(_Constraint): """ + @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 f84373dbb..ddacf3d70 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -1,4 +1,6 @@ from operator import itemgetter +from importlib import import_module + from typing import Dict from typing import List from typing import Optional @@ -87,6 +89,25 @@ def __init__(self, nodes: List["Node"], section: "_Section", implementation: Opt self._reference_point = None self._shape = None + @property + 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 = [data["nodes"]["class"].__from_data__(node_data) for node_data in data["nodes"]] + # sections_module = import_module("compas_fea2.model.sections") + section_cls = getattr(sections_module, data["section"]["class"]) + section = section_cls.__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 @@ -181,6 +202,16 @@ def nodal_mass(self) -> List[float]: 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.""" @@ -189,6 +220,12 @@ def __init__(self, nodes: List["Node"], frame: Frame, implementation: Optional[s super().__init__(nodes, section=None, implementation=implementation, rigid=rigid, **kwargs) self._frame = frame + @property + def __data__(self): + data = super().__data__() + data["frame"] = self.frame.__data__ + return data + class SpringElement(_Element0D): """A 0D spring element. @@ -246,8 +283,34 @@ class _Element1D(_Element): 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) + + @property + def __data__(self): + data = super().__data__ + data["frame"] = self.frame.__data__ + return data + + @classmethod + def __from_data__(cls, data): + from compas_fea2.model import Node + from compas.geometry import Frame + + from importlib import import_module + + section_module = import_module("compas_fea2.model.sections") + section_class = [getattr(section_module, section_data["class"]).__from_data__(section_data) for section_data in data["section"]] + + nodes = [Node.__from_data__(node_data) for node_data in data["nodes"]] + section = section_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) -> Mesh: @@ -365,6 +428,26 @@ def __init__(self, nodes: List["Node"], tag: str, element: Optional["_Element"] self._registration = element self._centroid = centroid_points([node.xyz for node in nodes]) + @property + 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 compas_fea2.model import Node + from importlib import import_module + + 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 diff --git a/src/compas_fea2/model/groups.py b/src/compas_fea2/model/groups.py index b8c80d7e9..56e0d34f4 100644 --- a/src/compas_fea2/model/groups.py +++ b/src/compas_fea2/model/groups.py @@ -27,6 +27,16 @@ def __init__(self, members=None, **kwargs): super(_Group, self).__init__(**kwargs) self._members = set() if not members else self._check_members(members) + @property + def __data__(self): + return { + "class": self.__class__.__base__.__name__, + } + + @classmethod + def __from_data__(cls, data): + raise NotImplementedError("This method must be implemented in the subclass") + def __str__(self): return """ {} @@ -127,6 +137,18 @@ 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 @@ -197,6 +219,20 @@ class ElementsGroup(_Group): 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): + from importlib import import_module + + 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 @@ -277,6 +313,18 @@ class FacesGroup(_Group): 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 @@ -358,6 +406,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 diff --git a/src/compas_fea2/model/ics.py b/src/compas_fea2/model/ics.py index 5122fe80f..535de2d31 100644 --- a/src/compas_fea2/model/ics.py +++ b/src/compas_fea2/model/ics.py @@ -18,6 +18,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 +62,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 +111,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/materials/concrete.py b/src/compas_fea2/model/materials/concrete.py index 9eda02e58..df6dacf71 100644 --- a/src/compas_fea2/model/materials/concrete.py +++ b/src/compas_fea2/model/materials/concrete.py @@ -11,13 +11,13 @@ 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. @@ -35,13 +35,13 @@ 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 ----- @@ -50,32 +50,49 @@ class Concrete(_Material): def __init__(self, *, fck, E=None, v=None, density=None, fr=None, units=None, **kwargs): super(Concrete, self).__init__(density=density, **kwargs) - # FIXME: units! + + # 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 + # 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 + + # Stress-strain model parameters 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) + 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] - et = [0.0, 0.001] - fr = fr or [1.16, fctm / fcm] - self.fck = fck * 10**6 + 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.E = E or self.fc[1] / e[1] - self.v = v or 0.17 - self.ec = kwargs.get("ec", fc) - self.ft = kwargs.get("ft", fc) - self.et = kwargs.get("et", 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) - self.tension = {"f": ft, "e": et} + # 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 @@ -98,6 +115,31 @@ 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): @@ -194,6 +236,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. @@ -245,3 +320,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 6a34ff13e..4092d153d 100644 --- a/src/compas_fea2/model/materials/material.py +++ b/src/compas_fea2/model/materials/material.py @@ -41,7 +41,6 @@ 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) -> int: @@ -51,6 +50,21 @@ def key(self) -> int: def model(self): return self._registration + @property + def __data__(self): + return { + "class": self.__class__.__base__, + "density": self.density, + "expansion": self.expansion, + } + + @classmethod + def __from_data__(cls, data): + return cls( + density=data["density"], + expansion=data["expansion"], + ) + def __str__(self) -> str: return """ {} @@ -131,6 +145,40 @@ def __init__(self, Ex: float, Ey: float, Ez: float, vxy: float, vyz: float, vzx: self.Gyz = Gyz self.Gzx = Gzx + @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 """ {} @@ -193,6 +241,21 @@ def __init__(self, E: float, v: float, density: float, expansion: float = None, self.E = E self.v = v + @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 @@ -254,6 +317,26 @@ def __init__(self, *, E: float, v: float, density: float, strain_stress: list[tu super().__init__(E=E, v=v, density=density, expansion=expansion, **kwargs) self.strain_stress = strain_stress + @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 diff --git a/src/compas_fea2/model/materials/steel.py b/src/compas_fea2/model/materials/steel.py index c67c8baae..c6be3d48a 100644 --- a/src/compas_fea2/model/materials/steel.py +++ b/src/compas_fea2/model/materials/steel.py @@ -101,6 +101,32 @@ 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, units=None): diff --git a/src/compas_fea2/model/materials/timber.py b/src/compas_fea2/model/materials/timber.py index e1bc4fa70..7e05cc39d 100644 --- a/src/compas_fea2/model/materials/timber.py +++ b/src/compas_fea2/model/materials/timber.py @@ -18,3 +18,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 abd5fe785..702928cfa 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -107,8 +107,61 @@ def __init__(self, description: Optional[str] = None, author: Optional[str] = No self._top_plane = None self._volume = None + @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 @property def parts(self) -> Set[_Part]: @@ -277,8 +330,8 @@ def from_cfm(path: str) -> "Model": # De-constructor methods # ========================================================================= - def to_json(self): - raise NotImplementedError() + # def to_json(self): + # raise NotImplementedError() def to_cfm(self, path: Union[str, Path]): """Exports the Model object to a .cfm file using Pickle. diff --git a/src/compas_fea2/model/nodes.py b/src/compas_fea2/model/nodes.py index 79d3cc6cb..5cedb7c84 100644 --- a/src/compas_fea2/model/nodes.py +++ b/src/compas_fea2/model/nodes.py @@ -7,6 +7,7 @@ import compas_fea2 from compas_fea2.base import FEAData +from compas.geometry import transform_points class Node(FEAData): @@ -225,11 +226,19 @@ def point(self) -> Point: def connected_elements(self) -> List: return self._connected_elements - @property - def loads(self): - problems = self.model.problems - steps = [problem.step for problem in problems] - return {step: self.loads(step) for step in steps} + # @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): + self.xyz = transform_points([self.xyz], transformation)[0] + + def transformed(self, transformation): + node = self.copy() + node.transform(transformation) + return node def displacement(self, step): if step.displacement_field: @@ -250,3 +259,29 @@ 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 __data__(self): + return { + "class": self.__class__.__base__.__name__, + "xyz": self.xyz, + "mass": self._mass, + "temperature": self._temperature, + "on_boundary": self._on_boundary, + "is_reference": self._is_reference, + "dof": self._dof, + "connected_elements": [e.name for e in self._connected_elements], + } + + @classmethod + def __from_data__(cls, data): + node = cls( + xyz=data["xyz"], + mass=data.get("mass"), + temperature=data.get("temperature"), + ) + node._on_boundary = data.get("on_boundary") + node._is_reference = data.get("is_reference") + node._dof = data.get("dof", {"x": True, "y": True, "z": True, "xx": True, "yy": True, "zz": True}) + node._connected_elements = data.get("connected_elements", []) + return node diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index b94cae0ca..05741031f 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -1,5 +1,6 @@ from math import pi from math import sqrt +from importlib import import_module from typing import Dict from typing import Iterable from typing import List @@ -112,8 +113,51 @@ def __init__(self, **kwargs): self._discretized_boundary_mesh = None self._bounding_box = None - self._volume = None - self._weight = None + @property + def __data__(self): + return { + "class": self.__class__.__base__.__name__, + "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], + "nodesgroups": [group.__data__ for group in self.nodesgroups], + "elementsgroups": [group.__data__ for group in self.elementsgroups], + "facesgroups": [group.__data__ for group in self.facesgroups], + } + + @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() + + # Import module once instead of inside the loop + model_module = import_module("compas_fea2.model") + + elements = [] # Preallocate list for bulk adding + + for element_data in data.get("elements", []): + element_cls_name = element_data.pop("class") + element_cls = getattr(model_module, element_cls_name) # Retrieve class reference + elements.append(element_cls.__from_data__(element_data)) # Deserialize element + + # Use a bulk-add method instead of looping `add_element` + part.add_elements_bulk(elements) if hasattr(part, "add_elements_bulk") else [part.add_element(e) for e in elements] + + return part @property def nodes(self) -> Set[Node]: @@ -568,6 +612,7 @@ def from_step_file(cls, step_file: str, name: Optional[str] = None, **kwargs) -> part = cls.from_gmsh(gmshModel=gmshModel, name=name, **kwargs) del gmshModel + print("Part created.") return part @@ -1490,21 +1535,18 @@ class DeformablePart(_Part): def __init__(self, **kwargs): super().__init__(**kwargs) - self._materials: Set[_Material] = set() - self._sections: Set[_Section] = set() - self._releases: Set[_BeamEndRelease] = set() @property def materials(self) -> Set[_Material]: - return self._materials + return set(section.material for section in self.sections if section.material) @property def sections(self) -> Set[_Section]: - return self._sections + return set(element.section for element in self.elements if element.section) @property def releases(self) -> Set[_BeamEndRelease]: - return self._releases + pass # ========================================================================= # Constructor methods @@ -1783,6 +1825,38 @@ def __init__(self, reference_point: Optional[Node] = None, **kwargs): super().__init__(**kwargs) self._reference_point = reference_point + @property + 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 + + @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 + @property def reference_point(self) -> Optional[Node]: return self._reference_point diff --git a/src/compas_fea2/model/releases.py b/src/compas_fea2/model/releases.py index 1acb2d5f6..beeeb7098 100644 --- a/src/compas_fea2/model/releases.py +++ b/src/compas_fea2/model/releases.py @@ -74,6 +74,34 @@ def location(self, value: str): 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`. diff --git a/src/compas_fea2/model/sections.py b/src/compas_fea2/model/sections.py index 0da156eb7..6e32f36f7 100644 --- a/src/compas_fea2/model/sections.py +++ b/src/compas_fea2/model/sections.py @@ -58,6 +58,22 @@ def __init__(self, *, material: "_Material", **kwargs): # noqa: F821 super().__init__(**kwargs) self._material = material + @property + def __data__(self): + return { + "class": self.__class__.__base__.__name__, + "material": self.material.__data__, + } + + @classmethod + def __from_data__(cls, data): + from importlib import import_module + + m = import_module("compas_fea2.model") + mat_cls = getattr(m, data["material"]["class"]) + material = mat_cls.__from_data__(data["material"]) + return cls(material=material) + def __str__(self) -> str: return f""" Section {self.name} @@ -121,6 +137,17 @@ def __str__(self) -> str: mass : {self.mass} """ + @property + def __data__(self): + return { + "class": self.__class__.__base__.__name__, + "mass": self.mass, + } + + @classmethod + def __from_data__(cls, data): + return cls(mass=data["mass"], **data) + class SpringSection(FEAData): """ @@ -158,6 +185,19 @@ def __init__(self, axial: float, lateral: float, rotational: float, **kwargs): self.lateral = lateral self.rotational = rotational + @property + def __data__(self): + return { + "class": self.__class__.__base__.__name__, + "axial": self.axial, + "lateral": self.lateral, + "rotational": self.rotational, + } + + @classmethod + def __from_data__(cls, data): + return cls(axial=data["axial"], lateral=data["lateral"], rotational=data["rotational"]) + def __str__(self) -> str: return f""" Spring Section @@ -251,6 +291,27 @@ def __init__(self, *, A: float, Ixx: float, Iyy: float, Ixy: float, Avx: float, self.Avy = Avy self.J = J + @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__} @@ -677,6 +738,17 @@ def __init__(self, A: float, Ixx: float, Iyy: float, Ixy: float, Avx: float, Avy 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)) + @property + def __data__(self): + data = super().__data__ + data.update( + { + "g0": self.g0, + "gw": self.gw, + } + ) + return data + class AngleSection(BeamSection): """ @@ -737,6 +809,19 @@ def __init__(self, w, h, t1, t2, material, **kwargs): self._shape = LShape(w, h, t1, t2) super().__init__(**from_shape(self._shape, 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 + # FIXME: implement 'from_shape' method class BoxSection(BeamSection): @@ -831,6 +916,19 @@ 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): """ @@ -875,6 +973,16 @@ def __init__(self, r, material, **kwargs): self._shape = Circle(r, 360) super().__init__(**from_shape(self._shape, material, **kwargs)) + @property + def __data__(self): + data = super().__data__ + data.update( + { + "r": self._shape.radius, + } + ) + return data + # FIXME: implement 'from_shape' method class HexSection(BeamSection): @@ -928,6 +1036,17 @@ class HexSection(BeamSection): 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): """ @@ -988,6 +1107,20 @@ 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 __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) @@ -1408,6 +1541,17 @@ 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): """ @@ -1456,6 +1600,17 @@ def __init__(self, w, h, material, **kwargs): self._shape = Rectangle(w, h) super().__init__(**from_shape(self._shape, material, **kwargs)) + @property + def __data__(self): + data = super().__data__ + data.update( + { + "w": self._shape.w, + "h": self._shape.h, + } + ) + return data + class TrapezoidalSection(BeamSection): """ @@ -1539,6 +1694,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 @@ -1606,6 +1773,16 @@ 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): """ @@ -1719,6 +1896,16 @@ 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): """ @@ -1745,6 +1932,16 @@ 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 diff --git a/src/compas_fea2/model/shapes.py b/src/compas_fea2/model/shapes.py index dfa65b926..2e496df53 100644 --- a/src/compas_fea2/model/shapes.py +++ b/src/compas_fea2/model/shapes.py @@ -58,6 +58,22 @@ def __str__(self) -> str: 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 # -------------------------------------------------------------------------- @@ -484,6 +500,22 @@ 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): """ @@ -563,6 +595,23 @@ def circumference(self) -> float: 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): """ @@ -612,6 +661,22 @@ def Avx(self) -> float: 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): """ @@ -638,6 +703,22 @@ def a(self) -> float: 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): """ @@ -684,6 +765,26 @@ def t2(self) -> float: 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): """ @@ -725,6 +826,25 @@ def t1(self) -> float: 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): """ @@ -840,6 +960,26 @@ def Avx(self) -> float: 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): """ @@ -879,6 +1019,25 @@ def t1(self) -> float: 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): """ @@ -907,6 +1066,24 @@ def __init__(self, height: float, flange_width: float, web_thickness: float, fla ] 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): """ @@ -954,6 +1131,34 @@ def __init__( ] 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): """ @@ -1006,6 +1211,23 @@ def c(self, val: float): self._c = val self.points = self._set_points() + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "a": self._a, + "b": self._b, + "c": self._c, + } + ) + return data + + @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): """ @@ -1036,6 +1258,21 @@ 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): """ @@ -1058,6 +1295,21 @@ def _set_points(self) -> List[Point]: 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): """ @@ -1080,6 +1332,21 @@ def _set_points(self) -> List[Point]: 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): """ @@ -1102,6 +1369,21 @@ def _set_points(self) -> List[Point]: 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): """ @@ -1125,6 +1407,23 @@ def _set_points(self) -> List[Point]: 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): """ @@ -1146,3 +1445,20 @@ def _set_points(self) -> List[Point]: 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/combinations.py b/src/compas_fea2/problem/combinations.py index e4404fc87..97d7836be 100644 --- a/src/compas_fea2/problem/combinations.py +++ b/src/compas_fea2/problem/combinations.py @@ -50,6 +50,16 @@ 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): diff --git a/src/compas_fea2/problem/displacements.py b/src/compas_fea2/problem/displacements.py index 93d3e0f9d..cb8db33fb 100644 --- a/src/compas_fea2/problem/displacements.py +++ b/src/compas_fea2/problem/displacements.py @@ -75,3 +75,26 @@ 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/problem.py b/src/compas_fea2/problem/problem.py index cb0c0b9ed..e2d68bf4c 100644 --- a/src/compas_fea2/problem/problem.py +++ b/src/compas_fea2/problem/problem.py @@ -498,3 +498,22 @@ def show( viewer.show() viewer.scene.clear() + + 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/dynamic.py b/src/compas_fea2/problem/steps/dynamic.py index 6ba527562..91ca29081 100644 --- a/src/compas_fea2/problem/steps/dynamic.py +++ b/src/compas_fea2/problem/steps/dynamic.py @@ -12,6 +12,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 a617df192..b5a18676a 100644 --- a/src/compas_fea2/problem/steps/perturbations.py +++ b/src/compas_fea2/problem/steps/perturbations.py @@ -26,6 +26,17 @@ class _Perturbation(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 @@ -143,6 +154,17 @@ def show_mode_shape(self, mode, fast=True, opacity=1, scale_results=1, show_bcs= 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): """""" @@ -151,6 +173,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): """""" @@ -184,6 +213,26 @@ 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): """""" @@ -192,6 +241,13 @@ 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): """""" @@ -200,6 +256,13 @@ def __init__(self, **kwargs): super().__init__(**kwargs) raise NotImplementedError + def __data__(self): + return super(StedyStateDynamic, self).__data__() + + @classmethod + def __from_data__(cls, data): + return cls(**data) + class SubstructureGeneration(_Perturbation): """""" @@ -207,3 +270,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..575c6f60c 100644 --- a/src/compas_fea2/problem/steps/quasistatic.py +++ b/src/compas_fea2/problem/steps/quasistatic.py @@ -12,6 +12,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 +30,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 ff167389e..464607619 100644 --- a/src/compas_fea2/problem/steps/static.py +++ b/src/compas_fea2/problem/steps/static.py @@ -92,6 +92,29 @@ def __init__( **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_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. diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index 90a651e4b..5ce2006a6 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -199,6 +199,31 @@ def stress2D_field(self): 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._patterns), + '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._patterns = set(data['patterns']) + obj._load_cases = set(data['load_cases']) + obj._combination = data['combination'] + return obj + # ============================================================================== # General Steps @@ -551,3 +576,36 @@ def show_stress(self, fast=True, show_bcs=1, scale_model=1, show_loads=0.1, comp 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._patterns = set(data['patterns']) + obj._load_cases = set(data['load_cases']) + obj._combination = data['combination'] + return obj From cb715b16b2841ba7b8e34a5ea8a4ac68442c0789 Mon Sep 17 00:00:00 2001 From: franaudo Date: Sat, 1 Feb 2025 11:22:29 +0100 Subject: [PATCH 12/39] copy working - needs clean up --- src/compas_fea2/base.py | 6 +- src/compas_fea2/model/elements.py | 23 +- src/compas_fea2/model/materials/material.py | 7 +- src/compas_fea2/model/model.py | 424 +++++++++++++++++-- src/compas_fea2/model/nodes.py | 52 ++- src/compas_fea2/model/parts.py | 447 ++++++++++++-------- src/compas_fea2/model/sections.py | 18 +- src/compas_fea2/problem/problem.py | 2 + 8 files changed, 708 insertions(+), 271 deletions(-) diff --git a/src/compas_fea2/base.py b/src/compas_fea2/base.py index dfb4a222a..7a5ed54f7 100644 --- a/src/compas_fea2/base.py +++ b/src/compas_fea2/base.py @@ -85,9 +85,9 @@ def input_key(self): 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 + # 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)) diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index ddacf3d70..a1ee0b35d 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -1,5 +1,5 @@ from operator import itemgetter -from importlib import import_module +from concurrent.futures import ThreadPoolExecutor from typing import Dict from typing import List @@ -101,11 +101,8 @@ def __data__(self): @classmethod def __from_data__(cls, data): - - nodes = [data["nodes"]["class"].__from_data__(node_data) for node_data in data["nodes"]] - # sections_module = import_module("compas_fea2.model.sections") - section_cls = getattr(sections_module, data["section"]["class"]) - section = section_cls.__from_data__(data["section"]) + 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 @@ -142,8 +139,6 @@ def section(self) -> "_Section": # noqa: F821 @section.setter def section(self, value: "_Section"): # noqa: F821 - if self.part: - self.part.add_section(value) self._section = value @property @@ -295,16 +290,8 @@ def __data__(self): @classmethod def __from_data__(cls, data): - from compas_fea2.model import Node - from compas.geometry import Frame - - from importlib import import_module - - section_module = import_module("compas_fea2.model.sections") - section_class = [getattr(section_module, section_data["class"]).__from_data__(section_data) for section_data in data["section"]] - - nodes = [Node.__from_data__(node_data) for node_data in data["nodes"]] - section = section_class.__from_data__(data["section"]) + 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")) diff --git a/src/compas_fea2/model/materials/material.py b/src/compas_fea2/model/materials/material.py index 4092d153d..000de5738 100644 --- a/src/compas_fea2/model/materials/material.py +++ b/src/compas_fea2/model/materials/material.py @@ -56,14 +56,19 @@ def __data__(self): "class": self.__class__.__base__, "density": self.density, "expansion": self.expansion, + "name": self.name, + "uid": self.uid, } @classmethod def __from_data__(cls, data): - return cls( + mat = cls( density=data["density"], expansion=data["expansion"], ) + mat.uid = data["uid"] + mat.name = data["name"] + return mat def __str__(self) -> str: return """ diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index 702928cfa..f6146403d 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -9,6 +9,7 @@ from typing import Optional from typing import Set from typing import Union +from typing import Dict from compas.geometry import Box from compas.geometry import Plane @@ -16,6 +17,7 @@ from compas.geometry import Polygon from compas.geometry import bounding_box from compas.geometry import centroid_points +from compas.geometry import Transformation from pint import UnitRegistry import compas_fea2 @@ -90,22 +92,17 @@ def __init__(self, description: Optional[str] = None, author: Optional[str] = No self._key = 0 self._starting_key = 0 self._units = None + self._path = None + self._parts: Set[_Part] = set() - self._nodes = None + self._materials: Set[_Material] = set() + self._sections: Set[_Section] = set() self._bcs = {} self._ics = {} self._connectors: Set[Connector] = set() self._constraints: Set[_Constraint] = set() self._partsgroups: Set[PartsGroup] = set() self._problems: Set[Problem] = 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 @property def __data__(self): @@ -163,6 +160,28 @@ def __from_data__(cls, data): 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) -> Set[_Part]: return self._parts @@ -188,21 +207,25 @@ def connectors(self) -> Set[Connector]: return self._connectors @property - def materials(self) -> Set[_Material]: - 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) -> Set[_Section]: - 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 sections(self) -> Set[_Section]: + return set(chain(*list(self.sections_dict.values()))) + @property def problems(self) -> Set[Problem]: return self._problems @@ -287,6 +310,32 @@ def units(self, value: UnitRegistry): return ValueError("Pint UnitRegistry required") self._units = value + def assign_keys(self, start: int = None): + """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, node in enumerate(self.nodes): + node._key = i + start + + for i, element in enumerate(self.elements): + element._key = i + start + # ========================================================================= # Constructor methods # ========================================================================= @@ -416,28 +465,12 @@ def add_part(self, part: _Part) -> _Part: if not isinstance(part, _Part): raise TypeError("{!r} is not a part.".format(part)) - if self.contains_part(part): - if compas_fea2.VERBOSE: - print("SKIPPED: DeformablePart {!r} is already in the model.".format(part)) - return - - if self.find_part_by_name(part.name): - raise ValueError("Duplicate name! The name '{}' is already in use.".format(part.name)) - 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 - return part def add_parts(self, parts: list[_Part]) -> list[_Part]: @@ -454,6 +487,325 @@ def add_parts(self, parts: list[_Part]) -> list[_Part]: """ return [self.add_part(part) for part in parts] + def copy_part(self, part: _Part) -> _Part: + """Copy a part n times. + + Parameters + ---------- + part : :class:`compas_fea2.model.DeformablePart` + The part to copy. + + Returns + ------- + :class:`compas_fea2.model.DeformablePart` + The copied part. + + """ + # new_part = part.copy() + data = part.__data__ + + new_part = part.__class__.__base__ + for material in data.get("materials", []): + material_data = material.__data__ + mat = new_part.add_material(material_data.pop("class").__from_data__(material_data)) + mat.uid = material_data["uid"] + + for section_data in data.get("sections", []): + if mat := new_part.find_material_by_uid(section_data["material"]["uid"]): + section_data.pop("material") + section = new_part.add_section(section_data.pop("class")(material=mat, **section_data)) + section.uid = section_data["uid"] + else: + raise ValueError("Material not found") + + for element_data in data.get("elements", []): + if sec := new_part.find_section_by_uid(element_data["section"]["uid"]): + element_data.pop("section") + nodes = [Node.__from_data__(node_data) for node_data in element_data.pop("nodes")] + element = element_data.pop("class")(nodes=nodes, section=sec, **element_data) + new_part.add_element(element, checks=False) + else: + raise ValueError("Section not found") + + 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.DeformablePart`] + 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.DeformablePart`] + 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 + + # ========================================================================= + # Materials methods + # ========================================================================= + + def add_material(self, material: _Material) -> _Material: + """Add a material to the model. + + Parameters + ---------- + material : :class:`compas_fea2.model.materials.Material` + + Returns + ------- + :class:`compas_fea2.model.materials.Material` + + """ + 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 + + def add_materials(self, materials: list[_Material]) -> list[_Material]: + """Add multiple materials to the model. + + 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.input_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.input_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 # ========================================================================= diff --git a/src/compas_fea2/model/nodes.py b/src/compas_fea2/model/nodes.py index 5cedb7c84..469f8e295 100644 --- a/src/compas_fea2/model/nodes.py +++ b/src/compas_fea2/model/nodes.py @@ -96,7 +96,31 @@ def __init__(self, xyz: List[float], mass: Optional[float] = None, temperature: self._loads = {} self._total_load = None - self._connected_elements = [] + self._connected_elements = set() + + @property + def __data__(self): + return { + "class": self.__class__.__base__, + "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_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") + return node @classmethod def from_compas_point(cls, point: Point, mass: Optional[float] = None, temperature: Optional[float] = None) -> "Node": @@ -259,29 +283,3 @@ 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 __data__(self): - return { - "class": self.__class__.__base__.__name__, - "xyz": self.xyz, - "mass": self._mass, - "temperature": self._temperature, - "on_boundary": self._on_boundary, - "is_reference": self._is_reference, - "dof": self._dof, - "connected_elements": [e.name for e in self._connected_elements], - } - - @classmethod - def __from_data__(cls, data): - node = cls( - xyz=data["xyz"], - mass=data.get("mass"), - temperature=data.get("temperature"), - ) - node._on_boundary = data.get("on_boundary") - node._is_reference = data.get("is_reference") - node._dof = data.get("dof", {"x": True, "y": True, "z": True, "xx": True, "yy": True, "zz": True}) - node._connected_elements = data.get("connected_elements", []) - return node diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index 05741031f..43a800309 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -1,6 +1,5 @@ from math import pi from math import sqrt -from importlib import import_module from typing import Dict from typing import Iterable from typing import List @@ -116,13 +115,15 @@ def __init__(self, **kwargs): @property def __data__(self): return { - "class": self.__class__.__base__.__name__, + "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], + "releases": [release.__data__ for release in self.releases], "nodesgroups": [group.__data__ for group in self.nodesgroups], "elementsgroups": [group.__data__ for group in self.elementsgroups], "facesgroups": [group.__data__ for group in self.facesgroups], @@ -143,19 +144,35 @@ def __from_data__(cls, data): The part instance. """ part = cls() + part._ndm = data.get("ndm", None) + part._ndf = data.get("ndf", None) - # Import module once instead of inside the loop - model_module = import_module("compas_fea2.model") + uid_node = {node_data["uid"]: Node.__from_data__(node_data) for node_data in data.get("nodes", {})} - elements = [] # Preallocate list for bulk adding + for material_data in data.get("materials", []): + mat = part.add_material(material_data.pop("class").__from_data__(material_data)) + mat.uid = material_data["uid"] - for element_data in data.get("elements", []): - element_cls_name = element_data.pop("class") - element_cls = getattr(model_module, element_cls_name) # Retrieve class reference - elements.append(element_cls.__from_data__(element_data)) # Deserialize element + for section_data in data.get("sections", []): + if mat := part.find_material_by_uid(section_data["material"]["uid"]): + section_data.pop("material") + section = part.add_section(section_data.pop("class")(material=mat, **section_data)) + section.uid = section_data["uid"] + else: + raise ValueError("Material not found") - # Use a bulk-add method instead of looping `add_element` - part.add_elements_bulk(elements) if hasattr(part, "add_elements_bulk") else [part.add_element(e) for e in elements] + for element_data in data.get("elements", []): + if sec := part.find_section_by_uid(element_data["section"]["uid"]): + element_data.pop("section") + nodes = [uid_node[node_data["uid"]] for node_data in element_data.pop("nodes")] + for node in nodes: + node._registration = part + element = element_data.pop("class")(nodes=nodes, section=sec, **element_data) + part.add_element(element, checks=False) + else: + raise ValueError("Section not found") + # for element in data.get("elements", []): + # part.add_element(element.pop("class").__from_data__(element)) return part @@ -273,6 +290,12 @@ def element_types(self) -> Dict[type, List[_Element]]: element_types.setdefault(type(element), []).append(element) return element_types + def assign_keys(self, start=1): + [setattr(node, "_key", c) for c, node in enumerate(self.nodes, start)] + [setattr(element, "_key", c) for c, element in enumerate(self.elements, start)] + [setattr(section, "_key", c) for c, section in enumerate(self.sections, start)] + [setattr(material, "_key", c) for c, material in enumerate(self.materials, start)] + def transform(self, transformation: Transformation) -> None: """Transform the part. @@ -616,9 +639,225 @@ def from_step_file(cls, step_file: str, name: Optional[str] = None, **kwargs) -> return part + # ========================================================================= + # Materials methods + # ========================================================================= + + def find_materials_by_name(self, name: str) -> List[_Material]: + """Find all materials with a given name. + + Parameters + ---------- + name : str + + Returns + ------- + List[_Material] + """ + return [material for material in self.materials if material.name == name] + + def find_material_by_uid(self, uid: str) -> Optional[_Material]: + """Find a material with a given unique identifier. + + Parameters + ---------- + uid : str + + Returns + ------- + Optional[_Material] + """ + for material in self.materials: + if material.uid == uid: + return material + return None + + def contains_material(self, material: _Material) -> bool: + """Verify that the part contains a specific material. + + Parameters + ---------- + material : _Material + + Returns + ------- + bool + """ + return material in self.materials + + 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 + ---------- + material : _Material + + Returns + ------- + _Material + + Raises + ------ + TypeError + If the material is not a material. + """ + if not isinstance(material, _Material): + raise TypeError(f"{material!r} is not a material.") + + 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 + ---------- + materials : List[_Material] + + Returns + ------- + List[_Material] + """ + return [self.add_material(material) for material in materials] + + def find_material_by_name(self, name: str) -> Optional[_Material]: + """Find a material with a given name. + + Parameters + ---------- + name : str + + Returns + ------- + Optional[_Material] + """ + for material in self.materials: + if material.name == name: + return material + return None + + # ========================================================================= + # Sections methods + # ========================================================================= + + def find_sections_by_name(self, name: str) -> List[_Section]: + """Find all sections with a given name. + + Parameters + ---------- + name : str + + Returns + ------- + List[_Section] + """ + return [section for section in self.sections if section.name == name] + + def find_section_by_uid(self, uid: str) -> Optional[_Section]: + """Find a section with a given unique identifier. + + Parameters + ---------- + uid : str + + Returns + ------- + Optional[_Section] + """ + for section in self.sections: + if section.uid == uid: + return section + return None + + def contains_section(self, section: _Section) -> bool: + """Verify that the part contains a specific section. + + Parameters + ---------- + section : _Section + + Returns + ------- + 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. + + """ + if not isinstance(section, _Section): + raise TypeError("{!r} is not a section.".format(section)) + + self.add_material(section.material) + self._sections.add(section) + section._registration = self._registration + return section + + def add_sections(self, sections: List[_Section]) -> List[_Section]: + """Add multiple sections to the part. + + Parameters + ---------- + sections : list[:class:`compas_fea2.model.Section`] + + Returns + ------- + 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] + """ + for section in self.sections: + if section.name == name: + return section + return + # ========================================================================= # Nodes methods # ========================================================================= + def find_node_by_uid(self, uid: str) -> Optional[Node]: + """Retrieve a node in the part using its unique identifier. + + 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. @@ -933,8 +1172,8 @@ def add_node(self, node: Node) -> Node: if not isinstance(node, Node): raise TypeError("{!r} is not a node.".format(node)) - if self.contains_node(node): - if compas_fea2.VERBOSE: + if compas_fea2.VERBOSE: + if self.contains_node(node): print("NODE SKIPPED: Node {!r} already in part.".format(node)) return node @@ -945,7 +1184,8 @@ def add_node(self, node: Node) -> Node: print("NODE SKIPPED: Part {!r} has already a node at {}.".format(self, node.xyz)) return existing_node[0] - node._key = len(self._nodes) + if self.model: + self._key = len(self.model.nodes) self._nodes.add(node) self._gkey_node[node.gkey] = node node._registration = self @@ -1127,13 +1367,16 @@ def contains_element(self, element: _Element) -> bool: """ return element in self.elements - def add_element(self, element: _Element) -> _Element: + def add_element(self, element: _Element, checks=True) -> _Element: """Add an element to the part. Parameters ---------- element : _Element The element instance. + checks : bool, optional + Perform checks before adding the element, by default True. + Turned off during copy operations. Returns ------- @@ -1144,31 +1387,21 @@ def add_element(self, element: _Element) -> _Element: TypeError If the element is not an instance of _Element. """ - if not isinstance(element, _Element): - raise TypeError(f"{element!r} is not an element.") - - if self.contains_element(element): + if checks and (not isinstance(element, _Element) or self.contains_element(element)): if compas_fea2.VERBOSE: - print(f"SKIPPED: Element {element!r} already in part.") + 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") and element.section: - self.add_section(element.section) - - if hasattr(element.section, "material") and element.section.material: - self.add_material(element.section.material) - - element._key = len(self.elements) + node.connected_elements.add(element) + self.add_section(element.section) self.elements.add(element) element._registration = self + if compas_fea2.VERBOSE: print(f"Element {element!r} registered to {self!r}.") + return element def add_elements(self, elements: List[_Element]) -> List[_Element]: @@ -1538,15 +1771,17 @@ def __init__(self, **kwargs): @property def materials(self) -> Set[_Material]: + return self._materials return set(section.material for section in self.sections if section.material) @property def sections(self) -> Set[_Section]: + return self._sections return set(element.section for element in self.elements if element.section) @property def releases(self) -> Set[_BeamEndRelease]: - pass + return self._releases # ========================================================================= # Constructor methods @@ -1633,152 +1868,6 @@ def from_boundary_mesh( """ return super().from_boundary_mesh(boundary_mesh, section=section, name=name, **kwargs) - # ========================================================================= - # Materials methods - # ========================================================================= - - def find_materials_by_name(self, name: str) -> List[_Material]: - """Find all materials with a given name. - - Parameters - ---------- - name : str - - Returns - ------- - List[_Material] - """ - return [material for material in self.materials if material.name == name] - - def contains_material(self, material: _Material) -> bool: - """Verify that the part contains a specific material. - - Parameters - ---------- - material : _Material - - Returns - ------- - bool - """ - return material in self.materials - - 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 - ---------- - material : _Material - - Returns - ------- - _Material - - Raises - ------ - TypeError - If the material is not a material. - """ - if not isinstance(material, _Material): - raise TypeError(f"{material!r} is not a material.") - - if self.contains_material(material): - if compas_fea2.VERBOSE: - print(f"SKIPPED: Material {material!r} already in part.") - return material - - material._key = len(self._materials) - self._materials.add(material) - material._registration = self._registration - return material - - def add_materials(self, materials: List[_Material]) -> List[_Material]: - """Add multiple materials to the part. - - Parameters - ---------- - materials : List[_Material] - - Returns - ------- - List[_Material] - """ - return [self.add_material(material) for material in materials] - - # ========================================================================= - # Sections methods - # ========================================================================= - - def find_sections_by_name(self, name: str) -> List[_Section]: - """Find all sections with a given name. - - Parameters - ---------- - name : str - - Returns - ------- - List[_Section] - """ - return [section for section in self.sections if section.name == name] - - def contains_section(self, section: _Section) -> bool: - """Verify that the part contains a specific section. - - Parameters - ---------- - section : _Section - - Returns - ------- - 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. - - """ - if not isinstance(section, _Section): - raise TypeError("{!r} is not a section.".format(section)) - - if self.contains_section(section): - if compas_fea2.VERBOSE: - print("SKIPPED: Section {!r} already in part.".format(section)) - return section - - 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: List[_Section]) -> List[_Section]: - """Add multiple sections to the part. - - Parameters - ---------- - sections : list[:class:`compas_fea2.model.Section`] - - Returns - ------- - list[:class:`compas_fea2.model.Section`] - """ - return [self.add_section(section) for section in sections] - # ========================================================================= # Releases methods # ========================================================================= diff --git a/src/compas_fea2/model/sections.py b/src/compas_fea2/model/sections.py index 6e32f36f7..939d9cd86 100644 --- a/src/compas_fea2/model/sections.py +++ b/src/compas_fea2/model/sections.py @@ -61,17 +61,15 @@ def __init__(self, *, material: "_Material", **kwargs): # noqa: F821 @property def __data__(self): return { - "class": self.__class__.__base__.__name__, + "class": self.__class__.__base__, "material": self.material.__data__, + "name": self.name, + "uid": self.uid, } @classmethod def __from_data__(cls, data): - from importlib import import_module - - m = import_module("compas_fea2.model") - mat_cls = getattr(m, data["material"]["class"]) - material = mat_cls.__from_data__(data["material"]) + material = data["material"].pop("class").__from_data__(data["material"]) return cls(material=material) def __str__(self) -> str: @@ -142,6 +140,7 @@ def __data__(self): return { "class": self.__class__.__base__.__name__, "mass": self.mass, + "uid": self.uid, } @classmethod @@ -192,11 +191,16 @@ def __data__(self): "axial": self.axial, "lateral": self.lateral, "rotational": self.rotational, + "uid": self.uid, + "name": self.name, } @classmethod def __from_data__(cls, data): - return cls(axial=data["axial"], lateral=data["lateral"], rotational=data["rotational"]) + 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""" diff --git a/src/compas_fea2/problem/problem.py b/src/compas_fea2/problem/problem.py index e2d68bf4c..03d007bea 100644 --- a/src/compas_fea2/problem/problem.py +++ b/src/compas_fea2/problem/problem.py @@ -415,6 +415,8 @@ def analyse(self, path: Optional[Union[Path, str]] = None, erase_data: bool = Fa 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, path: Optional[Union[Path, str]] = None, erase_data: bool = False, *args, **kwargs): From 7557bbfee539b5cd1b4392cb366d2344461da40a Mon Sep 17 00:00:00 2001 From: franaudo Date: Sat, 1 Feb 2025 12:07:38 +0100 Subject: [PATCH 13/39] clean up --- src/compas_fea2/__init__.py | 2 -- src/compas_fea2/model/model.py | 32 +++----------------------------- 2 files changed, 3 insertions(+), 31 deletions(-) diff --git a/src/compas_fea2/__init__.py b/src/compas_fea2/__init__.py index 6297683d5..eb1c135b8 100644 --- a/src/compas_fea2/__init__.py +++ b/src/compas_fea2/__init__.py @@ -38,7 +38,6 @@ 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), ] ) ) @@ -105,7 +104,6 @@ def _get_backend_implementation(cls): 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) diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index f6146403d..23d69f669 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -9,7 +9,6 @@ from typing import Optional from typing import Set from typing import Union -from typing import Dict from compas.geometry import Box from compas.geometry import Plane @@ -21,7 +20,6 @@ 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 @@ -487,7 +485,7 @@ def add_parts(self, parts: list[_Part]) -> list[_Part]: """ return [self.add_part(part) for part in parts] - def copy_part(self, part: _Part) -> _Part: + def copy_part(self, part: _Part, transformation: Transformation) -> _Part: """Copy a part n times. Parameters @@ -501,32 +499,8 @@ def copy_part(self, part: _Part) -> _Part: The copied part. """ - # new_part = part.copy() - data = part.__data__ - - new_part = part.__class__.__base__ - for material in data.get("materials", []): - material_data = material.__data__ - mat = new_part.add_material(material_data.pop("class").__from_data__(material_data)) - mat.uid = material_data["uid"] - - for section_data in data.get("sections", []): - if mat := new_part.find_material_by_uid(section_data["material"]["uid"]): - section_data.pop("material") - section = new_part.add_section(section_data.pop("class")(material=mat, **section_data)) - section.uid = section_data["uid"] - else: - raise ValueError("Material not found") - - for element_data in data.get("elements", []): - if sec := new_part.find_section_by_uid(element_data["section"]["uid"]): - element_data.pop("section") - nodes = [Node.__from_data__(node_data) for node_data in element_data.pop("nodes")] - element = element_data.pop("class")(nodes=nodes, section=sec, **element_data) - new_part.add_element(element, checks=False) - else: - raise ValueError("Section not found") - + 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]: From 94f411d86adadf6ddff74a330c447f405ad2d0a4 Mon Sep 17 00:00:00 2001 From: franaudo Date: Sat, 1 Feb 2025 17:19:12 +0100 Subject: [PATCH 14/39] part_keys --- src/compas_fea2/model/elements.py | 10 ++- src/compas_fea2/model/groups.py | 2 +- src/compas_fea2/model/model.py | 3 - src/compas_fea2/model/nodes.py | 7 ++ src/compas_fea2/model/parts.py | 106 +++++++++++++++++------------- 5 files changed, 75 insertions(+), 53 deletions(-) diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index a1ee0b35d..23f1b191a 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -1,5 +1,4 @@ from operator import itemgetter -from concurrent.futures import ThreadPoolExecutor from typing import Dict from typing import List @@ -76,6 +75,7 @@ class _Element(FEAData): 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 @@ -123,7 +123,7 @@ def nodes(self, value: List["Node"]): # noqa: F821 @property def nodes_key(self) -> str: - return [n.key for n in self.nodes] + return [n.part_key for n in self.nodes] @property def nodes_inputkey(self) -> str: @@ -162,6 +162,10 @@ def _check_nodes(self, nodes: List["Node"]) -> List["Node"]: # noqa: F821 raise ValueError("At least one of node is registered to a different part or not registered") return nodes + @property + def part_key(self) -> int: + return self._part_key + @property def area(self) -> float: raise NotImplementedError() @@ -465,7 +469,7 @@ def centroid(self) -> "Point": @property def nodes_key(self) -> List: - return [n.key for n in self.nodes] + return [n._part_key for n in self.nodes] class _Element2D(_Element): diff --git a/src/compas_fea2/model/groups.py b/src/compas_fea2/model/groups.py index 56e0d34f4..ed56c800c 100644 --- a/src/compas_fea2/model/groups.py +++ b/src/compas_fea2/model/groups.py @@ -230,7 +230,7 @@ def __from_data__(cls, data): from importlib import import_module 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"]] + elements = [getattr(elements_module, element_data["class"]).__from_data__(element_data) for element_data in data["elements"]] return cls(elements=elements) @property diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index 23d69f669..76ca9fe88 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -377,9 +377,6 @@ def from_cfm(path: str) -> "Model": # De-constructor methods # ========================================================================= - # def to_json(self): - # raise NotImplementedError() - def to_cfm(self, path: Union[str, Path]): """Exports the Model object to a .cfm file using Pickle. diff --git a/src/compas_fea2/model/nodes.py b/src/compas_fea2/model/nodes.py index 469f8e295..68cd7fe15 100644 --- a/src/compas_fea2/model/nodes.py +++ b/src/compas_fea2/model/nodes.py @@ -78,6 +78,7 @@ class Node(FEAData): 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._x = xyz[0] @@ -102,6 +103,7 @@ def __init__(self, xyz: List[float], mass: Optional[float] = None, temperature: def __data__(self): return { "class": self.__class__.__base__, + "part_key": self._part_key, "uid": self.uid, "xyz": self.xyz, "mass": self._mass, @@ -120,6 +122,7 @@ def __from_data__(cls, data): 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 @@ -159,6 +162,10 @@ def from_compas_point(cls, point: Point, mass: Optional[float] = None, temperatu def part(self) -> "_Part": # noqa: F821 return self._registration + @property + def part_key(self) -> int: + return self._part_key + @property def model(self) -> "Model": # noqa: F821 return self.part._registration diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index 43a800309..b5c3e7fa3 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -144,35 +144,57 @@ def __from_data__(cls, data): The part instance. """ part = cls() - part._ndm = data.get("ndm", None) - part._ndf = data.get("ndf", None) + part._ndm = data.get("ndm") + part._ndf = data.get("ndf") - uid_node = {node_data["uid"]: Node.__from_data__(node_data) for node_data in data.get("nodes", {})} + # 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", []): - mat = part.add_material(material_data.pop("class").__from_data__(material_data)) + 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", []): - if mat := part.find_material_by_uid(section_data["material"]["uid"]): - section_data.pop("material") - section = part.add_section(section_data.pop("class")(material=mat, **section_data)) - section.uid = section_data["uid"] - else: - raise ValueError("Material not found") + 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", []): - if sec := part.find_section_by_uid(element_data["section"]["uid"]): - element_data.pop("section") - nodes = [uid_node[node_data["uid"]] for node_data in element_data.pop("nodes")] - for node in nodes: - node._registration = part - element = element_data.pop("class")(nodes=nodes, section=sec, **element_data) - part.add_element(element, checks=False) - else: - raise ValueError("Section not found") - # for element in data.get("elements", []): - # part.add_element(element.pop("class").__from_data__(element)) + 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) return part @@ -290,12 +312,6 @@ def element_types(self) -> Dict[type, List[_Element]]: element_types.setdefault(type(element), []).append(element) return element_types - def assign_keys(self, start=1): - [setattr(node, "_key", c) for c, node in enumerate(self.nodes, start)] - [setattr(element, "_key", c) for c, element in enumerate(self.elements, start)] - [setattr(section, "_key", c) for c, section in enumerate(self.sections, start)] - [setattr(material, "_key", c) for c, material in enumerate(self.materials, start)] - def transform(self, transformation: Transformation) -> None: """Transform the part. @@ -1172,25 +1188,20 @@ def add_node(self, node: Node) -> Node: if not isinstance(node, Node): raise TypeError("{!r} is not a node.".format(node)) - if compas_fea2.VERBOSE: - if self.contains_node(node): - print("NODE SKIPPED: Node {!r} already in part.".format(node)) - return 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 self.model: - self._key = len(self.model.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)) + # 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 {!r} registered to {!r}.".format(node, self)) return node def add_nodes(self, nodes: List[Node]) -> List[Node]: @@ -1395,7 +1406,10 @@ def add_element(self, element: _Element, checks=True) -> _Element: self.add_nodes(element.nodes) for node in element.nodes: node.connected_elements.add(element) + self.add_section(element.section) + + element._part_key = len(self.elements) self.elements.add(element) element._registration = self From 3927a99fe812178b6298bced3dea5a5f0d4c3ec8 Mon Sep 17 00:00:00 2001 From: franaudo Date: Sat, 1 Feb 2025 22:42:16 +0100 Subject: [PATCH 15/39] interface detection --- src/compas_fea2/model/interfaces.py | 324 ++++++++++++++++++ src/compas_fea2/model/model.py | 12 +- src/compas_fea2/model/parts.py | 66 ++++ src/compas_fea2/utilities/interfaces_numpy.py | 130 +++++++ 4 files changed, 531 insertions(+), 1 deletion(-) create mode 100644 src/compas_fea2/model/interfaces.py create mode 100644 src/compas_fea2/utilities/interfaces_numpy.py diff --git a/src/compas_fea2/model/interfaces.py b/src/compas_fea2/model/interfaces.py new file mode 100644 index 000000000..1e412d90e --- /dev/null +++ b/src/compas_fea2/model/interfaces.py @@ -0,0 +1,324 @@ +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +from compas_fea2.base import FEAData +from compas.datastructures import Mesh +from compas.geometry import Frame +from compas.geometry import Line +from compas.geometry import Point +from compas.geometry import Polygon +from compas.geometry import Transformation +from compas.geometry import centroid_points_weighted +from compas.geometry import dot_vectors +from compas.geometry import transform_points +from compas.itertools import pairwise + + +def outer_product(u, v): + return [[ui * vi for vi in v] for ui in u] + + +def scale_matrix(M, scale): + r = len(M) + c = len(M[0]) + for i in range(r): + for j in range(c): + M[i][j] *= scale + return M + + +def sum_matrices(A, B): + r = len(A) + c = len(A[0]) + M = [[None for j in range(c)] for i in range(r)] + for i in range(r): + for j in range(c): + M[i][j] = A[i][j] + B[i][j] + return M + + +class Interface(FEAData): + """ + A data structure for representing interfaces between blocks + and managing their geometrical and structural properties. + + Parameters + ---------- + size + points + frame + forces + mesh + + Attributes + ---------- + points : list[:class:`Point`] + The corner points of the interface polygon. + size : float + The area of the interface polygon. + frame : :class:`Frame` + The local coordinate frame of the interface polygon. + polygon : :class:`Polygon` + The polygon defining the contact interface. + mesh : :class:`Mesh` + A mesh representation of the interface. + kern : :class:`Polygon` + The "kern" part of the interface polygon. + forces : list[dict] + A dictionary of force components per interface point. + Each dictionary contains the following items: ``{"c_np": ..., "c_nn": ..., "c_u": ..., "c_v": ...}``. + stressdistribution : ??? + ??? + normalforces : list[:class:`Line`] + A list of lines representing the normal components of the contact forces at the corners of the interface. + The length of each line is proportional to the magnitude of the corresponding force. + compressionforces : list[:class:`Line`] + A list of lines representing the compression components of the normal contact forces + at the corners of the interface. + The length of each line is proportional to the magnitude of the corresponding force. + tensionforces : list[:class:`Line`] + A list of lines representing the tension components of the normal contact forces + at the corners of the interface. + The length of each line is proportional to the magnitude of the corresponding force. + frictionforces : list[:class:`Line`] + A list of lines representing the friction or tangential components of the contact forces + at the corners of the interface. + The length of each line is proportional to the magnitude of the corresponding force. + resultantforce : list[:class:`Line`] + A list with a single line representing the resultant of all the contact forces at the corners of the interface. + The length of the line is proportional to the magnitude of the resultant force. + resultantpoint : :class:`Point` + The point of application of the resultant force on the interface. + + """ + + @property + def __data__(self): + return { + "points": self.points, + "size": self.size, + "frame": self.frame, + "forces": self.forces, + "mesh": self.mesh, + } + + @classmethod + def __from_data__(cls, data): + """Construct an interface from a data dict. + + Parameters + ---------- + data : dict + The data dictionary. + + Returns + ------- + :class:`compas_assembly.datastructures.Interface` + + """ + return cls(**data) + + def __init__( + self, + size=None, + points=None, + frame=None, + forces=None, + mesh=None, + ): + super(Interface, self).__init__() + self._frame = None + self._mesh = None + self._size = None + self._points = None + self._polygon = None + self._points2 = None + self._polygon2 = None + + self.points = points + self.mesh = mesh + self.size = size + self.forces = forces + + self._frame = frame + + @property + def points(self): + return self._points + + @points.setter + def points(self, items): + self._points = [] + for item in items: + self._points.append(Point(*item)) + + @property + def polygon(self): + if self._polygon is None: + self._polygon = Polygon(self.points) + return self._polygon + + @property + def frame(self): + if self._frame is None: + from compas.geometry import bestfit_frame_numpy + + self._frame = Frame(*bestfit_frame_numpy(self.points)) + return self._frame + + @property + def mesh(self): + if not self._mesh: + self._mesh = Mesh.from_polygons([self.polygon]) + return self._mesh + + @mesh.setter + def mesh(self, mesh): + self._mesh = mesh + + @property + def points2(self): + if not self._points2: + X = Transformation.from_frame_to_frame(self.frame, Frame.worldXY()) + self._points2 = [Point(*point) for point in transform_points(self.points, X)] + return self._points2 + + @property + def polygon2(self): + if not self._polygon2: + X = Transformation.from_frame_to_frame(self.frame, Frame.worldXY()) + self._polygon2 = self.polygon.transformed(X) + return self._polygon2 + + @property + def M0(self): + m0 = 0 + for a, b in pairwise(self.points2 + self.points2[:1]): + d = b - a + n = [d[1], -d[0], 0] + m0 += dot_vectors(a, n) + return 0.5 * m0 + + @property + def M1(self): + m1 = Point(0, 0, 0) + for a, b in pairwise(self.points2 + self.points2[:1]): + d = b - a + n = [d[1], -d[0], 0] + m0 = dot_vectors(a, n) + m1 += (a + b) * m0 + return m1 / 6 + + @property + def M2(self): + m2 = outer_product([0, 0, 0], [0, 0, 0]) + for a, b in pairwise(self.points2 + self.points2[:1]): + d = b - a + n = [d[1], -d[0], 0] + m0 = dot_vectors(a, n) + aa = outer_product(a, a) + ab = outer_product(a, b) + ba = outer_product(b, a) + bb = outer_product(b, b) + m2 = sum_matrices( + m2, + scale_matrix( + sum_matrices(sum_matrices(aa, bb), scale_matrix(sum_matrices(ab, ba), 0.5)), + m0, + ), + ) + return scale_matrix(m2, 1 / 12.0) + + @property + def kern(self): + # points = [] + # for a, b in pairwise(self.points2 + self.points2[:1]): + # pass + pass + + @property + def stressdistribution(self): + pass + + @property + def normalforces(self): + lines = [] + if not self.forces: + return lines + frame = self.frame + w = frame.zaxis + for point, force in zip(self.points, self.forces): + force = force["c_np"] - force["c_nn"] + p1 = point + w * force * 0.5 + p2 = point - w * force * 0.5 + lines.append(Line(p1, p2)) + return lines + + @property + def compressionforces(self): + lines = [] + if not self.forces: + return lines + frame = self.frame + w = frame.zaxis + for point, force in zip(self.points, self.forces): + force = force["c_np"] - force["c_nn"] + if force > 0: + p1 = point + w * force * 0.5 + p2 = point - w * force * 0.5 + lines.append(Line(p1, p2)) + return lines + + @property + def tensionforces(self): + lines = [] + if not self.forces: + return lines + frame = self.frame + w = frame.zaxis + for point, force in zip(self.points, self.forces): + force = force["c_np"] - force["c_nn"] + if force < 0: + p1 = point + w * force * 0.5 + p2 = point - w * force * 0.5 + lines.append(Line(p1, p2)) + return lines + + @property + def frictionforces(self): + lines = [] + if not self.forces: + return lines + frame = self.frame + u, v = frame.xaxis, frame.yaxis + for point, force in zip(self.points, self.forces): + ft_uv = (u * force["c_u"] + v * force["c_v"]) * 0.5 + p1 = point + ft_uv + p2 = point - ft_uv + lines.append(Line(p1, p2)) + return lines + + @property + def resultantpoint(self): + if not self.forces: + return [] + normalcomponents = [f["c_np"] - f["c_nn"] for f in self.forces] + if sum(normalcomponents): + return Point(*centroid_points_weighted(self.points, normalcomponents)) + + @property + def resultantforce(self): + if not self.forces: + return [] + normalcomponents = [f["c_np"] - f["c_nn"] for f in self.forces] + sum_n = sum(normalcomponents) + sum_u = sum(f["c_u"] for f in self.forces) + sum_v = sum(f["c_v"] for f in self.forces) + position = Point(*centroid_points_weighted(self.points, normalcomponents)) + frame = self.frame + w, u, v = frame.zaxis, frame.xaxis, frame.yaxis + forcevector = (w * sum_n + u * sum_u + v * sum_v) * 0.5 + p1 = position + forcevector + p2 = position - forcevector + return [Line(p1, p2)] diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index 76ca9fe88..ba5da7831 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -2,10 +2,13 @@ import importlib import os import pathlib +from pathlib import Path import pickle from itertools import chain from itertools import groupby -from pathlib import Path + +from compas.datastructures import Graph + from typing import Optional from typing import Set from typing import Union @@ -92,6 +95,7 @@ def __init__(self, description: Optional[str] = None, author: Optional[str] = No self._units = None self._path = None + self._graph = Graph() self._parts: Set[_Part] = set() self._materials: Set[_Material] = set() self._sections: Set[_Section] = set() @@ -184,6 +188,10 @@ def from_template(cls, template: str, **kwargs) -> "Model": def parts(self) -> Set[_Part]: return self._parts + @property + def graph(self) -> Graph: + return self._graph + @property def partgroups(self) -> Set[PartsGroup]: return self._partsgroups @@ -466,6 +474,8 @@ def add_part(self, part: _Part) -> _Part: part._key = len(self._parts) self._parts.add(part) + self.graph.add_node(part, type="part") + self.graph.add_edge(self, part, relation="contains") return part def add_parts(self, parts: list[_Part]) -> list[_Part]: diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index b5c3e7fa3..917184a11 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -8,6 +8,9 @@ from typing import Tuple from typing import Union +import networkx as nx +import matplotlib.pyplot as plt + import compas from compas.geometry import Box from compas.geometry import Frame @@ -21,6 +24,7 @@ from compas.geometry import distance_point_point_sqrd from compas.geometry import is_point_in_polygon_xy from compas.geometry import is_point_on_plane +from compas.datastructures import Mesh from compas.tolerance import TOL from scipy.spatial import KDTree @@ -97,6 +101,7 @@ class _Part(FEAData): def __init__(self, **kwargs): super().__init__(**kwargs) + self._graph = nx.DiGraph() self._nodes: Set[Node] = set() self._gkey_node: Dict[str, Node] = {} self._sections: Set[_Section] = set() @@ -127,6 +132,8 @@ def __data__(self): "nodesgroups": [group.__data__ for group in self.nodesgroups], "elementsgroups": [group.__data__ for group in self.elementsgroups], "facesgroups": [group.__data__ for group in self.facesgroups], + "boundary_mesh": self.boundary_mesh.__data__ if self.boundary_mesh else None, + "discretized_boundary_mesh": self.discretized_boundary_mesh.__data__ if self.discretized_boundary_mesh else None, } @classmethod @@ -196,8 +203,14 @@ def __from_data__(cls, data): 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 + part._discretized_boundary_mesh = Mesh.__from_data__(data.get("discretized_boundary_mesh")) if data.get("discretized_boundary_mesh") else None return part + @property + def graph(self): + return self._graph + @property def nodes(self) -> Set[Node]: return self._nodes @@ -1312,6 +1325,54 @@ def compute_nodal_masses(self) -> List[float]: 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"): + 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") + + # # 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 # ========================================================================= @@ -1413,6 +1474,11 @@ def add_element(self, element: _Element, checks=True) -> _Element: 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(f"Element {element!r} registered to {self!r}.") diff --git a/src/compas_fea2/utilities/interfaces_numpy.py b/src/compas_fea2/utilities/interfaces_numpy.py new file mode 100644 index 000000000..3563928cb --- /dev/null +++ b/src/compas_fea2/utilities/interfaces_numpy.py @@ -0,0 +1,130 @@ +import numpy as np +from shapely.geometry import Polygon +from scipy.spatial import KDTree +from numpy.linalg import solve +from compas.geometry import Frame, centroid_points, cross_vectors, local_to_world_coordinates_numpy +from compas_fea2.model.interfaces import Interface + + +def mesh_mesh_interfaces(a, b, tmax=1e-6, amin=1e-1): + """Optimized face-face contact detection between two meshes.""" + + # ------------------------------------------------------------------------- + # 1. Precompute data for B + # ------------------------------------------------------------------------- + # (a) Store B's vertices once as a NumPy array + b_xyz = np.array(b.vertices_attributes("xyz"), dtype=float).T + # (b) Map each vertex key to index + k_i = {key: index for index, key in enumerate(b.vertices())} + # (c) Precompute face centers for B (used in KDTree) + face_centers_b = np.array([b.face_center(f) for f in b.faces()]) + # (d) Build a KDTree from B’s face centers + if len(face_centers_b) == 0: + print("No faces in mesh B. Exiting.") + return [] + tree = KDTree(face_centers_b) + # (e) Precompute face-to-vertex indices so we don’t call b.face_vertices(f1) repeatedly + b_face_vertex_indices = [] + for f1 in b.faces(): + vertex_keys = b.face_vertices(f1) + b_face_vertex_indices.append([k_i[vk] for vk in vertex_keys]) + + # ------------------------------------------------------------------------- + # 2. Precompute frames for each face in A + # ------------------------------------------------------------------------- + frames = {} + for face in a.faces(): + xyz = np.array(a.face_coordinates(face)) + # Face center & normal data + o = np.mean(xyz, axis=0) + w = np.array(a.face_normal(face)) + + # Compute longest edge direction for stable local frame + edge_vectors = xyz[1:] - xyz[:-1] + if len(edge_vectors) == 0: + continue # Skip degenerate face + longest_edge = max(edge_vectors, key=lambda e: np.linalg.norm(e)) + + u = longest_edge + v = cross_vectors(w, u) + + frames[face] = Frame(o, u, v) + + interfaces = [] + + # ------------------------------------------------------------------------- + # 3. Loop over faces in A, transform all of B’s vertices once per face + # ------------------------------------------------------------------------- + for f0, frame in frames.items(): + origin = frame.point + uvw = np.array([frame.xaxis, frame.yaxis, frame.zaxis]) + A = uvw.astype(float) + o = np.array(origin, dtype=float).reshape((-1, 1)) + + # 3a) Transform face A’s coordinates (check for singular) + a_xyz0 = np.array(a.face_coordinates(f0), dtype=float).T + try: + rst0 = solve(A.T, a_xyz0 - o).T + except np.linalg.LinAlgError: + # Skip if frame is degenerate + continue + + p0 = Polygon(rst0.tolist()) + + # 3b) Transform **all** B vertices in one shot + try: + rst_b = solve(A.T, b_xyz - o).T # shape: (N_b_vertices, 3) + except np.linalg.LinAlgError: + continue + + # 3c) KD-tree to find nearby faces in B + # Use bounding box diagonal around face in A + bbox_diag = np.linalg.norm(np.max(a_xyz0, axis=1) - np.min(a_xyz0, axis=1)) + search_radius = bbox_diag * 1.0 # Adjust for your geometry + nearby_faces = tree.query_ball_point(a.face_center(f0), r=search_radius) + if not nearby_faces: + continue + + # --------------------------------------------------------------------- + # 4. Check each nearby face in B + # --------------------------------------------------------------------- + for f1 in nearby_faces: + # 4a) Sub-index already-transformed coords for B’s face f1 + indices_f1 = b_face_vertex_indices[f1] + rst1 = rst_b[indices_f1] + + # 4b) Check planarity threshold + if any(abs(t) > tmax for r, s, t in rst1): + continue + + # 4c) Construct shapely Polygon & check area + p1 = Polygon(rst1.tolist()) + if p1.area < amin: + continue + + # 4d) Intersection with face A + if not p0.intersects(p1): + continue + + intersection = p0.intersection(p1) + if intersection.is_empty: + continue + area = intersection.area + if area < amin: + continue + + # 4e) Build interface from intersection + coords_2d = list(intersection.exterior.coords)[:-1] # remove closing coordinate + coords_2d_3 = [[xy[0], xy[1], 0.0] for xy in coords_2d] + + # Re-project intersection to 3D + coords_3d = local_to_world_coordinates_numpy(Frame(o, A[0], A[1]), coords_2d_3).tolist() + + new_interface = Interface( + size=area, + points=coords_3d, + frame=Frame(centroid_points(coords_3d), frame.xaxis, frame.yaxis), + ) + interfaces.append(new_interface) + + return interfaces From 32c7fb3ced4c01eb1a055733b78fcc273356b2cd Mon Sep 17 00:00:00 2001 From: franaudo Date: Sun, 2 Feb 2025 22:24:30 +0100 Subject: [PATCH 16/39] cluster planes --- src/compas_fea2/__init__.py | 5 - src/compas_fea2/model/elements.py | 12 ++ src/compas_fea2/model/parts.py | 208 +++++++++++++++++++++++++++++- 3 files changed, 216 insertions(+), 9 deletions(-) diff --git a/src/compas_fea2/__init__.py b/src/compas_fea2/__init__.py index eb1c135b8..1db748d5c 100644 --- a/src/compas_fea2/__init__.py +++ b/src/compas_fea2/__init__.py @@ -44,11 +44,6 @@ def init_fea2(verbose=False, point_overlap=True, global_tolerance=1, precision=3 load_dotenv(env_path) -def set_precision(precision): - global PRECISION - PRECISION = precision - - # pluggable function to be def _register_backend(): """Create the class registry for the plugin. diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index 23f1b191a..946b3d3e4 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -471,6 +471,18 @@ def centroid(self) -> "Point": 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))]]) + class _Element2D(_Element): """Element with 2 dimensions.""" diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index 917184a11..b877879d9 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -10,6 +10,9 @@ import networkx as nx import matplotlib.pyplot as plt +import numpy as np +from compas.topology import connected_components +from collections import defaultdict import compas from compas.geometry import Box @@ -219,10 +222,26 @@ def nodes(self) -> Set[Node]: def points(self) -> List[List[float]]: return [node.xyz for node in self.nodes] + @property + def points_sorted(self) -> List[List[float]]: + return [node.xyz for node in sorted(self.nodes, key=lambda x: x.part_key)] + @property def elements(self) -> Set[_Element]: return self._elements + @property + def elements_faces(self) -> List[List[List["Face"]]]: + return [face for element in self.elements for face in element.faces] + + @property + def elements_faces_indices(self) -> List[List[List[float]]]: + return [face.nodes_key for face in self.elements_faces] + + @property + def elements_connectivity(self) -> List[List[int]]: + return [element.nodes_key for element in self.elements] + @property def sections(self) -> Set[_Section]: return self._sections @@ -259,8 +278,163 @@ def boundary_mesh(self): def discretized_boundary_mesh(self): return self._discretized_boundary_mesh + @property + def outer_faces(self): + """Extract the outer faces of the part.""" + 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 bounding_box(self) -> Optional[Box]: + # FIXME: add bounding box for lienar elements (bb of the section outer boundary) try: return Box.from_bounding_box(bounding_box([n.xyz for n in self.nodes])) except Exception: @@ -1548,10 +1722,10 @@ def is_element_on_boundary(self, element: _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 @@ -1585,6 +1759,7 @@ def find_faces_on_plane(self, plane: Plane) -> List["compas_fea2.model.Face"]: ----- The search is limited to solid elements. """ + # FIXME: review this method 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: @@ -1592,6 +1767,31 @@ def find_faces_on_plane(self, plane: Plane) -> List["compas_fea2.model.Face"]: faces.append(face) return faces + 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. + """ + 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 # ========================================================================= From 7969fd12b5dccc7a64f0a40bbfb944b4a54d18fb Mon Sep 17 00:00:00 2001 From: franaudo Date: Wed, 5 Feb 2025 18:03:36 +0100 Subject: [PATCH 17/39] group tools --- src/compas_fea2/model/parts.py | 50 ++- src/compas_fea2/utilities/interfaces_numpy.py | 286 +++++++++++------- 2 files changed, 233 insertions(+), 103 deletions(-) diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index b877879d9..0eecbb3ba 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -13,6 +13,7 @@ import numpy as np from compas.topology import connected_components from collections import defaultdict +from itertools import groupby import compas from compas.geometry import Box @@ -218,6 +219,10 @@ def graph(self): def nodes(self) -> Set[Node]: return self._nodes + @property + def nodes_sorted(self) -> List[Node]: + return sorted(self.nodes, key=lambda x: x.part_key) + @property def points(self) -> List[List[float]]: return [node.xyz for node in self.nodes] @@ -230,26 +235,66 @@ def points_sorted(self) -> List[List[float]]: def elements(self) -> Set[_Element]: return self._elements + @property + def elements_sorted(self) -> List[_Element]: + return sorted(self.elements, key=lambda x: x.part_key) + + @property + def elements_grouped(self) -> Dict[int, List[_Element]]: + elements_group = groupby(self.elements, key=lambda x: x.__class__.__base__) + return {key: list(group) for key, group in elements_group} + @property def elements_faces(self) -> List[List[List["Face"]]]: - return [face for element in self.elements for face in element.faces] + return [face for face in element.faces] + + @property + def elements_faces_grouped(self) -> Dict[int, List[List["Face"]]]: + 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 sections(self) -> Set[_Section]: return self._sections + @property + def sections_sorted(self) -> List[_Section]: + return sorted(self.sections, key=lambda x: x.part_key) + + @property + def sections_grouped_by_element(self) -> Dict[int, List[_Section]]: + sections_group = groupby(self.sections, key=lambda x: x.element) + return {key: list(group) for key, group in sections_group} + @property def materials(self) -> Set[_Material]: return self._materials + @property + def materials_sorted(self) -> List[_Material]: + return sorted(self.materials, key=lambda x: x.part_key) + + @property + def materials_grouped_by_section(self) -> Dict[int, List[_Material]]: + materials_group = groupby(self.materials, key=lambda x: x.section) + return {key: list(group) for key, group in materials_group} + @property def releases(self) -> Set[_BeamEndRelease]: return self._releases @@ -281,6 +326,7 @@ def discretized_boundary_mesh(self): @property 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 = [ @@ -1008,7 +1054,7 @@ def add_section(self, section: _Section) -> _Section: self.add_material(section.material) self._sections.add(section) - section._registration = self._registration + section._registration = self return section def add_sections(self, sections: List[_Section]) -> List[_Section]: diff --git a/src/compas_fea2/utilities/interfaces_numpy.py b/src/compas_fea2/utilities/interfaces_numpy.py index 3563928cb..2b0474a75 100644 --- a/src/compas_fea2/utilities/interfaces_numpy.py +++ b/src/compas_fea2/utilities/interfaces_numpy.py @@ -6,125 +6,209 @@ from compas_fea2.model.interfaces import Interface -def mesh_mesh_interfaces(a, b, tmax=1e-6, amin=1e-1): - """Optimized face-face contact detection between two meshes.""" +def face_bounding_sphere(mesh, face): + """ + Compute a bounding sphere for a given face: + center = average of face vertices + radius = max distance from center to any vertex + """ + coords = mesh.face_coordinates(face) + if not coords: + return None, 0.0 + center = np.mean(coords, axis=0) + radius = max(np.linalg.norm(c - center) for c in coords) + return center, radius + - # ------------------------------------------------------------------------- - # 1. Precompute data for B - # ------------------------------------------------------------------------- - # (a) Store B's vertices once as a NumPy array +def mesh_mesh_interfaces(a, b, tmax=1e-6, amin=1e-1): + """ + Face-face contact detection between two meshes, using + broad-phase bounding spheres + narrow-phase 2D polygon intersection. + + Parameters + ---------- + a : Mesh (compas.datastructures.Mesh) + b : Mesh (compas.datastructures.Mesh) + tmax : float + Maximum allowable Z-deviation in the local frame. + amin : float + Minimum area for a valid intersection polygon. + + Returns + ------- + List[Interface] + A list of face-face intersection interfaces. + """ + + # --------------------------------------------------------------------- + # 1. Precompute B’s data once + # --------------------------------------------------------------------- b_xyz = np.array(b.vertices_attributes("xyz"), dtype=float).T - # (b) Map each vertex key to index k_i = {key: index for index, key in enumerate(b.vertices())} - # (c) Precompute face centers for B (used in KDTree) - face_centers_b = np.array([b.face_center(f) for f in b.faces()]) - # (d) Build a KDTree from B’s face centers + + # We also store face center for each face in B (for the KDTree) + faces_b = list(b.faces()) + face_centers_b = [] + face_radii_b = [] + face_vertex_indices_b = [] + + for fb in faces_b: + centerB, radiusB = face_bounding_sphere(b, fb) + face_centers_b.append(centerB) # bounding sphere center + face_radii_b.append(radiusB) + + # Store the vertex indices for this face + face_vs = b.face_vertices(fb) + face_vertex_indices_b.append([k_i[vk] for vk in face_vs]) + + face_centers_b = np.array(face_centers_b) + face_radii_b = np.array(face_radii_b) + + # Build a KDTree for B’s face centers if len(face_centers_b) == 0: print("No faces in mesh B. Exiting.") return [] - tree = KDTree(face_centers_b) - # (e) Precompute face-to-vertex indices so we don’t call b.face_vertices(f1) repeatedly - b_face_vertex_indices = [] - for f1 in b.faces(): - vertex_keys = b.face_vertices(f1) - b_face_vertex_indices.append([k_i[vk] for vk in vertex_keys]) - - # ------------------------------------------------------------------------- - # 2. Precompute frames for each face in A - # ------------------------------------------------------------------------- - frames = {} - for face in a.faces(): - xyz = np.array(a.face_coordinates(face)) - # Face center & normal data - o = np.mean(xyz, axis=0) - w = np.array(a.face_normal(face)) - - # Compute longest edge direction for stable local frame - edge_vectors = xyz[1:] - xyz[:-1] - if len(edge_vectors) == 0: - continue # Skip degenerate face - longest_edge = max(edge_vectors, key=lambda e: np.linalg.norm(e)) + + # --------------------------------------------------------------------- + # 2. Precompute A’s bounding spheres & KDTree + # --------------------------------------------------------------------- + faces_a = list(a.faces()) + face_centers_a = [] + face_radii_a = [] + frames_a = {} # local 2D frames for each face in A (for narrow-phase) + + for fa in faces_a: + centerA, radiusA = face_bounding_sphere(a, fa) + face_centers_a.append(centerA) + face_radii_a.append(radiusA) + + # Precompute stable local frame for face A + coordsA = np.array(a.face_coordinates(fa)) + if coordsA.shape[0] < 2: + continue + w = np.array(a.face_normal(fa)) + edge_vecs = coordsA[1:] - coordsA[:-1] + if len(edge_vecs) == 0: + continue + longest_edge = max(edge_vecs, key=lambda e: np.linalg.norm(e)) u = longest_edge v = cross_vectors(w, u) - frames[face] = Frame(o, u, v) + frames_a[fa] = Frame(centerA, u, v) - interfaces = [] + face_centers_a = np.array(face_centers_a) + face_radii_a = np.array(face_radii_a) + + # KDTree for A’s face centers + tree_a = KDTree(face_centers_a) + + # --------------------------------------------------------------------- + # 3. Helper: 2D polygon from face in local frame + # --------------------------------------------------------------------- + def face_polygon_in_frame(mesh, face, frame): + """ + Project a face into `frame`'s local XY, returning a shapely Polygon. + """ + coords_3d = np.array(mesh.face_coordinates(face)).T + A = np.array([frame.xaxis, frame.yaxis, frame.zaxis], dtype=float).T + o = np.array(frame.point, dtype=float).reshape(-1, 1) - # ------------------------------------------------------------------------- - # 3. Loop over faces in A, transform all of B’s vertices once per face - # ------------------------------------------------------------------------- - for f0, frame in frames.items(): - origin = frame.point - uvw = np.array([frame.xaxis, frame.yaxis, frame.zaxis]) - A = uvw.astype(float) - o = np.array(origin, dtype=float).reshape((-1, 1)) - - # 3a) Transform face A’s coordinates (check for singular) - a_xyz0 = np.array(a.face_coordinates(f0), dtype=float).T try: - rst0 = solve(A.T, a_xyz0 - o).T + rst = solve(A, coords_3d - o).T # shape: (n,3), but z ~ 0 except np.linalg.LinAlgError: - # Skip if frame is degenerate - continue + return None + # If the Z-values are large, it might fail tmax + return Polygon(rst[:, :2]) # polygon in local 2D plane + + # --------------------------------------------------------------------- + # 4. Narrow-phase intersection + # --------------------------------------------------------------------- + def intersect_faces(fa, fb): + """ + Return an Interface if face fa intersects face fb, else None. + """ + # bounding sphere overlap is already assumed; we do a final check for planarity + polygon intersection + + # local frame of face A + fA_center, fA_radius = face_bounding_sphere(a, fa) + frameA = frames_a.get(fa) + if not frameA: + return None + + # Build polygon for face A + pA = face_polygon_in_frame(a, fa, frameA) + if pA is None or pA.is_empty or pA.area < amin: + return None + + # Transform all B vertices once for the frame of A: + # But we only need face fb’s vertices + # Instead, let's do a minimal local transform of face fb + coords_3d_b = np.array(b.face_coordinates(fb)).T + A_mat = np.array([frameA.xaxis, frameA.yaxis, frameA.zaxis], dtype=float).T + o_mat = np.array(frameA.point, dtype=float).reshape(-1, 1) - p0 = Polygon(rst0.tolist()) - - # 3b) Transform **all** B vertices in one shot try: - rst_b = solve(A.T, b_xyz - o).T # shape: (N_b_vertices, 3) + rst_b = solve(A_mat, coords_3d_b - o_mat).T except np.linalg.LinAlgError: - continue + return None - # 3c) KD-tree to find nearby faces in B - # Use bounding box diagonal around face in A - bbox_diag = np.linalg.norm(np.max(a_xyz0, axis=1) - np.min(a_xyz0, axis=1)) - search_radius = bbox_diag * 1.0 # Adjust for your geometry - nearby_faces = tree.query_ball_point(a.face_center(f0), r=search_radius) - if not nearby_faces: - continue + # Check planarity threshold + if any(abs(z) > tmax for x, y, z in rst_b): + return None + + pB = Polygon(rst_b[:, :2]) + if pB.is_empty or pB.area < amin: + return None + + if not pA.intersects(pB): + return None + + intersection = pA.intersection(pB) + if intersection.is_empty: + return None + area = intersection.area + if area < amin: + return None + + # Re-project intersection to 3D + coords_2d = list(intersection.exterior.coords)[:-1] # exclude closing point + coords_2d_3 = [[x, y, 0.0] for x, y in coords_2d] + + coords_3d = local_to_world_coordinates_numpy(Frame(o_mat.ravel(), A_mat[:, 0], A_mat[:, 1]), coords_2d_3).tolist() + + return Interface( + size=area, + points=coords_3d, + frame=Frame(centroid_points(coords_3d), frameA.xaxis, frameA.yaxis), + ) + + # --------------------------------------------------------------------- + # 5. Broad-Phase + Narrow-Phase + # --------------------------------------------------------------------- + interfaces = [] + + # A. For each face in B, find overlapping faces in A + for idxB, fb in enumerate(faces_b): + centerB = face_centers_b[idxB] + radiusB = face_radii_b[idxB] + + # Search in A’s KDTree + candidate_indices = tree_a.query_ball_point(centerB, r=radiusB + np.max(face_radii_a)) + + for idxA in candidate_indices: + centerA = face_centers_a[idxA] + radiusA = face_radii_a[idxA] + + # Check actual bounding sphere overlap + dist_centers = np.linalg.norm(centerB - centerA) + if dist_centers > (radiusA + radiusB): + continue # No overlap in bounding sphere - # --------------------------------------------------------------------- - # 4. Check each nearby face in B - # --------------------------------------------------------------------- - for f1 in nearby_faces: - # 4a) Sub-index already-transformed coords for B’s face f1 - indices_f1 = b_face_vertex_indices[f1] - rst1 = rst_b[indices_f1] - - # 4b) Check planarity threshold - if any(abs(t) > tmax for r, s, t in rst1): - continue - - # 4c) Construct shapely Polygon & check area - p1 = Polygon(rst1.tolist()) - if p1.area < amin: - continue - - # 4d) Intersection with face A - if not p0.intersects(p1): - continue - - intersection = p0.intersection(p1) - if intersection.is_empty: - continue - area = intersection.area - if area < amin: - continue - - # 4e) Build interface from intersection - coords_2d = list(intersection.exterior.coords)[:-1] # remove closing coordinate - coords_2d_3 = [[xy[0], xy[1], 0.0] for xy in coords_2d] - - # Re-project intersection to 3D - coords_3d = local_to_world_coordinates_numpy(Frame(o, A[0], A[1]), coords_2d_3).tolist() - - new_interface = Interface( - size=area, - points=coords_3d, - frame=Frame(centroid_points(coords_3d), frame.xaxis, frame.yaxis), - ) - interfaces.append(new_interface) + # Now do narrow-phase + fa = faces_a[idxA] + interface = intersect_faces(fa, fb) + if interface: + interfaces.append(interface) return interfaces From d77dc24606d474201a03efc2fd1e4cfe0f118ae5 Mon Sep 17 00:00:00 2001 From: franaudo Date: Thu, 6 Feb 2025 16:04:04 +0100 Subject: [PATCH 18/39] remove outputs --- src/compas_fea2/model/elements.py | 27 +++- src/compas_fea2/model/nodes.py | 8 + src/compas_fea2/problem/__init__.py | 10 -- .../problem/{outputs.py => _outputs.py} | 28 ++-- src/compas_fea2/problem/steps/step.py | 96 ++++++------ src/compas_fea2/results/__init__.py | 4 +- src/compas_fea2/results/database.py | 6 +- src/compas_fea2/results/fields.py | 148 +++++++++++++----- src/compas_fea2/results/results.py | 20 --- 9 files changed, 208 insertions(+), 139 deletions(-) rename src/compas_fea2/problem/{outputs.py => _outputs.py} (84%) diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index 946b3d3e4..35d344578 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -17,8 +17,13 @@ from compas.geometry import distance_point_point from compas.itertools import pairwise +import compas_fea2 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): """Initialises a base Element object. @@ -113,6 +118,10 @@ def part(self) -> "_Part": # noqa: F821 def model(self) -> "Model": # noqa: F821 return self.part.model + @property + 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 @@ -340,12 +349,12 @@ def plot_stress_distribution(self, step: "_Step", end: str = "end_1", nx: int = 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 + def section_forces_result(self, step: "Step") -> "Result": # noqa: F821 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 + def forces(self, step: "Step") -> "Result": # noqa: F821 r = self.section_forces_result(step) return r.forces @@ -556,9 +565,9 @@ def _construct_faces(self, face_indices: Dict[str, Tuple[int]]) -> List[Face]: 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 - if not hasattr(step, "stress2D_field"): - raise ValueError("The step does not have a stress2D_field") - return step.stress2D_field.get_result_at(self) + 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): @@ -581,6 +590,10 @@ def __init__(self, nodes: List["Node"], section: Optional["_Section"] = None, im 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. @@ -618,6 +631,10 @@ def __init__(self, nodes: List["Node"], section: "_Section", implementation: Opt self._faces = None self._frame = Frame.worldXY() + @property + def results_cls(self) -> Result: + return {"s": SolidStressResult} + @property def frame(self) -> Frame: return self._frame diff --git a/src/compas_fea2/model/nodes.py b/src/compas_fea2/model/nodes.py index 68cd7fe15..34b41b645 100644 --- a/src/compas_fea2/model/nodes.py +++ b/src/compas_fea2/model/nodes.py @@ -290,3 +290,11 @@ 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 + + return { + "u": DisplacementResult, + } diff --git a/src/compas_fea2/problem/__init__.py b/src/compas_fea2/problem/__init__.py index fa30393ab..3e4a251ee 100644 --- a/src/compas_fea2/problem/__init__.py +++ b/src/compas_fea2/problem/__init__.py @@ -37,16 +37,6 @@ DirectCyclicStep, ) -from .outputs import ( - DisplacementFieldOutput, - AccelerationFieldOutput, - VelocityFieldOutput, - Stress2DFieldOutput, - SectionForcesFieldOutput, - # StrainFieldOutput, - ReactionFieldOutput, - HistoryOutput, -) __all__ = [ "Problem", diff --git a/src/compas_fea2/problem/outputs.py b/src/compas_fea2/problem/_outputs.py similarity index 84% rename from src/compas_fea2/problem/outputs.py rename to src/compas_fea2/problem/_outputs.py index b00eb1664..3569036cf 100644 --- a/src/compas_fea2/problem/outputs.py +++ b/src/compas_fea2/problem/_outputs.py @@ -26,37 +26,37 @@ class _Output(FEAData): """ - def __init__(self, results_cls, **kwargs): + def __init__(self, field_results_cls, **kwargs): super(_Output, self).__init__(**kwargs) - self._results_cls = results_cls + self._field_results_cls = field_results_cls @property - def results_cls(self): - return self._results_cls + def field_results_cls(self): + return self._field_results_cls @property def sqltable_schema(self): - return self._results_cls.sqltable_schema() + return self._field_results_cls.sqltable_schema() @property def results_func(self): - return self._results_cls._results_func + return self._field_results_cls._results_func @property def results_func_output(self): - return self._results_cls._results_func_output + return self._field_results_cls._results_func_output @property def field_name(self): - return self.results_cls._field_name + return self.field_results_cls._field_name @property def components_names(self): - return self.results_cls._components_names + return self.field_results_cls._components_names @property def invariants_names(self): - return self.results_cls._invariants_names + return self.field_results_cls._invariants_names @property def step(self): @@ -81,7 +81,7 @@ class _NodeFieldOutput(_Output): """NodeFieldOutput object for requesting the fields at the nodes from the analysis.""" def __init__(self, results_cls, **kwargs): - super().__init__(results_cls=results_cls, **kwargs) + super().__init__(field_results_cls=results_cls, **kwargs) class DisplacementFieldOutput(_NodeFieldOutput): @@ -120,14 +120,14 @@ class _ElementFieldOutput(_Output): """ElementFieldOutput object for requesting the fields at the elements from the analysis.""" def __init__(self, results_cls, **kwargs): - super().__init__(results_cls=results_cls, **kwargs) + super().__init__(field_results_cls=results_cls, **kwargs) -class Stress2DFieldOutput(_ElementFieldOutput): +class StressFieldOutput(_ElementFieldOutput): """StressFieldOutput object for requesting the stresses at the elements from the analysis.""" def __init__(self, **kwargs): - super(Stress2DFieldOutput, self).__init__(ShellStressResult, **kwargs) + super(StressFieldOutput, self).__init__(ShellStressResult, **kwargs) class SectionForcesFieldOutput(_ElementFieldOutput): diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index 5ce2006a6..de18597d9 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -14,7 +14,7 @@ from compas_fea2.results import DisplacementFieldResults from compas_fea2.results import ReactionFieldResults from compas_fea2.results import SectionForcesFieldResults -from compas_fea2.results import Stress2DFieldResults +from compas_fea2.results import StressFieldResults from compas_fea2.UI import FEA2Viewer # ============================================================================== @@ -138,7 +138,7 @@ def add_output(self, output): Parameters ---------- - output : :class:`compas_fea2.problem._Output` + output : :class:`compas_fea2.Results.FieldResults` The requested output. Returns @@ -151,8 +151,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): @@ -192,8 +192,8 @@ def temperature_field(self): raise NotImplementedError @property - def stress2D_field(self): - return Stress2DFieldResults(self) + def stress_field(self): + return StressFieldResults(self) @property def section_forces_field(self): @@ -201,27 +201,27 @@ def section_forces_field(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._patterns), - 'load_cases': list(self._load_cases), - 'combination': self._combination, + "name": self.name, + "field_outputs": list(self._field_outputs), + "history_outputs": list(self._history_outputs), + "results": self._results, + "key": self._key, + "patterns": list(self._patterns), + "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._patterns = set(data['patterns']) - obj._load_cases = set(data['load_cases']) - obj._combination = data['combination'] + 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._patterns = set(data["patterns"]) + obj._load_cases = set(data["load_cases"]) + obj._combination = data["combination"] return obj @@ -563,13 +563,13 @@ def show_reactions(self, fast=True, show_bcs=1, scale_model=1, show_loads=0.1, c 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.stress2D_field: + 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.stress2D_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contour=show_contour, plane=plane, **kwargs + self.stress_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contour=show_contour, plane=plane, **kwargs ) if show_loads: @@ -579,33 +579,35 @@ def show_stress(self, fast=True, show_bcs=1, scale_model=1, show_loads=0.1, comp 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, - }) + 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'], + 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._patterns = set(data['patterns']) - obj._load_cases = set(data['load_cases']) - obj._combination = data['combination'] + obj._field_outputs = set(data["field_outputs"]) + obj._history_outputs = set(data["history_outputs"]) + obj._results = data["results"] + obj._key = data["key"] + obj._patterns = set(data["patterns"]) + obj._load_cases = set(data["load_cases"]) + obj._combination = data["combination"] return obj diff --git a/src/compas_fea2/results/__init__.py b/src/compas_fea2/results/__init__.py index 515f709a4..de376a781 100644 --- a/src/compas_fea2/results/__init__.py +++ b/src/compas_fea2/results/__init__.py @@ -17,7 +17,7 @@ DisplacementFieldResults, AccelerationFieldResults, VelocityFieldResults, - Stress2DFieldResults, + StressFieldResults, ReactionFieldResults, SectionForcesFieldResults, ) @@ -41,7 +41,7 @@ "AccelerationFieldResults", "VelocityFieldResults", "ReactionFieldResults", - "Stress2DFieldResults", + "StressFieldResults", "SectionForcesFieldResults", "ModalAnalysisResult", "ModalShape", diff --git a/src/compas_fea2/results/database.py b/src/compas_fea2/results/database.py index e2912c91e..c7a097cf0 100644 --- a/src/compas_fea2/results/database.py +++ b/src/compas_fea2/results/database.py @@ -203,7 +203,7 @@ def get_rows(self, table_name, columns_names, filters, func=None): # FEA2 Methods # ========================================================================= - def to_result(self, results_set, results_cls): + def to_result(self, results_set, results_func, field_name): """ Convert a set of results in the database to the appropriate result object. @@ -229,10 +229,10 @@ def to_result(self, results_set, results_cls): 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, results_cls._results_func)(r[2]) + m = getattr(part, results_func)(r[2]) if not m: raise ValueError(f"Member {r[2]} not in part {part.name}") - results[step].append(results_cls(m, *r[3:])) + results[step].append(m.results_cls[field_name](m, *r[3:])) return results @staticmethod diff --git a/src/compas_fea2/results/fields.py b/src/compas_fea2/results/fields.py index 155a89a50..d49c926de 100644 --- a/src/compas_fea2/results/fields.py +++ b/src/compas_fea2/results/fields.py @@ -14,6 +14,7 @@ from .results import ShellStressResult # noqa: F401 from .results import SolidStressResult # noqa: F401 from .results import VelocityResult # noqa: F401 +from .database import ResultsDatabase # noqa: F401 class FieldResults(FEAData): @@ -55,45 +56,63 @@ class FieldResults(FEAData): FieldResults are registered to a :class:`compas_fea2.problem.Step`. """ - def __init__(self, step, results_cls, *args, **kwargs): + def __init__(self, step, *args, **kwargs): super(FieldResults, self).__init__(*args, **kwargs) self._registration = step - self._results_cls = results_cls - self._field_name = results_cls._field_name - self._components_names = results_cls._components_names - self._invariants_names = results_cls._invariants_names - self._results_func = results_cls._results_func @property - def step(self): + 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 step(self) -> "Step": return self._registration @property - def problem(self): + def problem(self) -> "Problem": return self.step.problem @property - def model(self): + def model(self) -> "Model": return self.problem.model @property - def field_name(self): - return self._field_name + def field_name(self) -> str: + raise NotImplementedError("This method should be implemented in the subclass.") @property - def rdb(self): + def results_func(self) -> str: + raise NotImplementedError("This method should be implemented in the subclass.") + + @property + def rdb(self) -> ResultsDatabase: return self.problem.results_db @property - def results(self): - return self._get_results_from_db(columns=self._components_names)[self.step] - + def results(self) -> list: + return self._get_results_from_db(columns=self.components_names)[self.step] + @property - def results_sorted(self): + def results_sorted(self) -> list: return sorted(self.results, key=lambda x: x.key) @property - def locations(self): + def locations(self) -> Iterable: """Return the locations where the field is defined. Yields @@ -122,7 +141,7 @@ def _get_results_from_db(self, members=None, columns=None, filters=None, func=No Dictionary of results. """ if not columns: - columns = self._components_names + columns = self.components_names if not filters: filters = {} @@ -135,9 +154,9 @@ def _get_results_from_db(self, members=None, columns=None, filters=None, func=No filters["key"] = set([member.key for member in members]) filters["part"] = set([member.part.name for member in members]) - results_set = self.rdb.get_rows(self._field_name, ["step", "part", "key"] + columns, filters, func) + results_set = self.rdb.get_rows(self.field_name, ["step", "part", "key"] + columns, filters, func) - return self.rdb.to_result(results_set, self._results_cls) + 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. @@ -152,7 +171,7 @@ def get_result_at(self, location): object The result at the given location. """ - return self._get_results_from_db(members=location, columns=self._components_names)[self.step][0] + return self._get_results_from_db(members=location, columns=self.components_names)[self.step][0] def get_max_result(self, component): """Get the result where a component is maximum for a given step. @@ -168,7 +187,7 @@ def get_max_result(self, component): The appropriate Result object. """ func = ["DESC", component] - return self._get_results_from_db(columns=self._components_names, func=func)[self.step][0] + return self._get_results_from_db(columns=self.components_names, func=func)[self.step][0] def get_min_result(self, component): """Get the result where a component is minimum for a given step. @@ -184,7 +203,7 @@ def get_min_result(self, component): The appropriate Result object. """ func = ["ASC", component] - return self._get_results_from_db(columns=self._components_names, func=func)[self.step][0] + return self._get_results_from_db(columns=self.components_names, func=func)[self.step][0] def get_limits_component(self, component): """Get the result objects with the min and max value of a given component in a step. @@ -221,14 +240,20 @@ def filter_by_component(self, component, threshold=None): dict A dictionary of filtered elements and their results. """ - if component not in self._components_names: - raise ValueError(f"Component '{component}' is not valid. Choose from {self._components_names}.") + if component not in self.components_names: + raise ValueError(f"Component '{component}' is not valid. Choose from {self.components_names}.") 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 + def create_sql_table(self, connection, results): + """ + Delegate the table creation to the ResultsDatabase class. + """ + ResultsDatabase.create_table_for_output_class(self, connection, results) + # ------------------------------------------------------------------------------ # Node Field Results @@ -255,8 +280,21 @@ class NodeFieldResults(FieldResults): The function used to find nodes by key. """ - def __init__(self, step, results_cls, *args, **kwargs): - super(NodeFieldResults, self).__init__(step=step, results_cls=results_cls, *args, **kwargs) + 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): @@ -351,7 +389,8 @@ class DisplacementFieldResults(NodeFieldResults): """ def __init__(self, step, *args, **kwargs): - super(DisplacementFieldResults, self).__init__(step=step, results_cls=DisplacementResult, *args, **kwargs) + super(DisplacementFieldResults, self).__init__(step=step, *args, **kwargs) + self._field_name = "u" class AccelerationFieldResults(NodeFieldResults): @@ -377,7 +416,8 @@ class AccelerationFieldResults(NodeFieldResults): """ def __init__(self, step, *args, **kwargs): - super(AccelerationFieldResults, self).__init__(step=step, results_cls=AccelerationResult, *args, **kwargs) + super(AccelerationFieldResults, self).__init__(step=step, *args, **kwargs) + self._field_name = "a" class VelocityFieldResults(NodeFieldResults): @@ -403,7 +443,8 @@ class VelocityFieldResults(NodeFieldResults): """ def __init__(self, step, *args, **kwargs): - super(VelocityFieldResults, self).__init__(step=step, results_cls=VelocityResult, *args, **kwargs) + super(VelocityFieldResults, self).__init__(step=step, *args, **kwargs) + self._field_name = "v" class ReactionFieldResults(NodeFieldResults): @@ -429,15 +470,25 @@ class ReactionFieldResults(NodeFieldResults): """ def __init__(self, step, *args, **kwargs): - super(ReactionFieldResults, self).__init__(step=step, results_cls=ReactionResult, *args, **kwargs) + super(ReactionFieldResults, self).__init__(step=step, *args, **kwargs) + self._field_name = "rf" # ------------------------------------------------------------------------------ # 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" -class SectionForcesFieldResults(FieldResults): + +class SectionForcesFieldResults(ElementFieldResults): """Section forces field results. This class handles the section forces field results from a finite element analysis. @@ -460,7 +511,17 @@ class SectionForcesFieldResults(FieldResults): """ def __init__(self, step, *args, **kwargs): - super(SectionForcesFieldResults, self).__init__(step=step, results_cls=SectionForcesResult, *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 results_func(self): + return self._results_func def get_element_forces(self, element): """Get the section forces for a given element. @@ -519,8 +580,7 @@ def export_to_csv(self, file_path): # ------------------------------------------------------------------------------ -# TODO Change to PlaneStressResults -class Stress2DFieldResults(FieldResults): +class StressFieldResults(ElementFieldResults): """Stress field results for 2D elements. This class handles the stress field results for 2D elements from a finite element analysis. @@ -534,14 +594,26 @@ class Stress2DFieldResults(FieldResults): ---------- components_names : list of str Names of the stress components. - results_class : class - The class used to instantiate the stress results. results_func : str The function used to find elements by key. """ def __init__(self, step, *args, **kwargs): - super(Stress2DFieldResults, self).__init__(step=step, results_cls=ShellStressResult, *args, **kwargs) + super(StressFieldResults, self).__init__(step=step, *args, **kwargs) + self._results_func = "find_element_by_key" + self._field_name = "s" + + @property + def components_names(self): + return ["sxx", "syy", "sxy", "szz", "syz", "szx"] + + @property + def field_name(self): + return self._field_name + + @property + def results_func(self): + return self._results_func def global_stresses(self, plane="mid"): """Stress field in global coordinates. diff --git a/src/compas_fea2/results/results.py b/src/compas_fea2/results/results.py index 539ef7c5a..8c88771ab 100644 --- a/src/compas_fea2/results/results.py +++ b/src/compas_fea2/results/results.py @@ -92,26 +92,6 @@ def safety_factor(self, component, allowable): """ return abs(self.vector[component] / allowable) if self.vector[component] != 0 else 1 - @classmethod - def sqltable_schema(cls): - fields = [] - predefined_fields = [ - ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), - ("key", "INTEGER"), - ("step", "TEXT"), - ("part", "TEXT"), - ] - - fields.extend(predefined_fields) - - for comp in cls._components_names: - fields.append((comp, "REAL")) - return { - "table_name": cls._field_name, - "columns": fields, - } - - class NodeResult(Result): """NodeResult object. From 0b4466e6fa6a200f568deb1e45fd689644cad068 Mon Sep 17 00:00:00 2001 From: franaudo Date: Mon, 10 Feb 2025 22:01:11 +0100 Subject: [PATCH 19/39] multiple results databases --- docs/api/compas_fea2.model.rst | 2 +- scripts/calculixtest.py | 51 +++++ scripts/deepcopytest.py | 4 +- scripts/hdf5serialization.py | 27 +++ scripts/node.hdf5 | Bin 0 -> 7296 bytes src/compas_fea2/UI/viewer/scene.py | 2 +- src/compas_fea2/base.py | 79 +++++++- src/compas_fea2/model/__init__.py | 8 +- src/compas_fea2/model/elements.py | 26 ++- src/compas_fea2/model/groups.py | 12 +- src/compas_fea2/model/interfaces.py | 220 ++------------------ src/compas_fea2/model/model.py | 46 +++-- src/compas_fea2/model/nodes.py | 3 + src/compas_fea2/model/parts.py | 63 +++--- src/compas_fea2/problem/fields.py | 4 - src/compas_fea2/problem/problem.py | 35 +++- src/compas_fea2/problem/steps/static.py | 46 ++--- src/compas_fea2/results/__init__.py | 2 + src/compas_fea2/results/database.py | 153 ++++++++++++-- src/compas_fea2/results/fields.py | 259 ++++++++++++++++-------- src/compas_fea2/results/results.py | 148 ++++++++------ src/compas_fea2/units/fea2_en.txt | 6 + tests/test_connectors.py | 10 +- tests/test_groups.py | 4 +- tests/test_model.py | 6 +- tests/test_parts.py | 12 +- 26 files changed, 750 insertions(+), 478 deletions(-) create mode 100644 scripts/calculixtest.py create mode 100644 scripts/hdf5serialization.py create mode 100644 scripts/node.hdf5 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/scripts/calculixtest.py b/scripts/calculixtest.py new file mode 100644 index 000000000..f4c44e7e0 --- /dev/null +++ b/scripts/calculixtest.py @@ -0,0 +1,51 @@ +from compas_fea2.model import Model +from compas_fea2.model.parts import BeamElement +from compas_fea2.model.materials import ElasticIsotropic +from compas_fea2.model.sections import RectangleSection +from compas_fea2.model.nodes import Node +from compas_fea2.model.elements import Element +from compas_fea2.model.bcs import FixedBC +from compas_fea2.model.loads import PointLoad +from compas_fea2.problem import Problem +from compas_fea2.results import Results +from compas_fea2.fea.calculix.calculix import CalculiX + +# 1. Create the FEA model +model = Model(name="beam_model") + +# 2. Define material properties (Steel) +steel = ElasticIsotropic(name="steel", E=210e9, v=0.3, p=7850) +model.add_material(steel) + +# 3. Define a rectangular cross-section (100mm x 10mm) +section = RectangleSection(name="beam_section", b=0.1, h=0.01) +model.add_section(section) + +# 4. Create nodes (simple cantilever beam: 1m long) +n1 = Node(0, 0, 0) +n2 = Node(1, 0, 0) +model.add_nodes([n1, n2]) + +# 5. Create a beam element connecting the two nodes +beam = BeamElement(nodes=[n1, n2], material=steel, section=section) +model.add_element(beam) + +# 6. Apply boundary conditions (Fix left end) +bc_fixed = FixedBC(nodes=[n1]) +model.add_boundary_conditions([bc_fixed]) + +# 7. Apply a downward point load at the free end (1000N) +load = PointLoad(nodes=[n2], z=-1000) +model.add_loads([load]) + +# 8. Create the analysis problem +problem = Problem(model=model, name="static_analysis") +solver = CalculiX(problem=problem) + +# 9. Run the analysis +solver.solve() + +# 10. Extract and display results +results = Results(problem) +displacements = results.get_nodal_displacements() +print("Nodal Displacements:", displacements) diff --git a/scripts/deepcopytest.py b/scripts/deepcopytest.py index 50816794a..9bbd17b26 100644 --- a/scripts/deepcopytest.py +++ b/scripts/deepcopytest.py @@ -1,5 +1,5 @@ from compas_fea2.model import Model -from compas_fea2.model import DeformablePart +from compas_fea2.model import Part from compas_fea2.model import Node from compas_fea2.model import BeamElement from compas_fea2.model import RectangularSection @@ -8,7 +8,7 @@ n1 = Node(xyz=[0, 0, 0]) n2 = Node(xyz=[1, 0, 0]) -p1 = DeformablePart() +p1 = Part() mdl1 = Model() mat = Steel.S355() diff --git a/scripts/hdf5serialization.py b/scripts/hdf5serialization.py new file mode 100644 index 000000000..463c7f1e2 --- /dev/null +++ b/scripts/hdf5serialization.py @@ -0,0 +1,27 @@ +import h5py +from compas_fea2.model import Model, Part, Node + +output_path = "/Users/francesco/code/fea2/compas_fea2/scripts/node.hdf5" +# output_path = "/Users/francesco/code/fea2/compas_fea2/scripts/node.json" + +# Create and save the node +node = Node([0.0, 0.0, 0.0], mass=1.0, temperature=20.0) + +mdl = Model(name="test_hdf5") +prt = mdl.add_new_part(name="part") + +# prt.to_hdf5(output_path, group_name="parts", mode="w") + +n = prt.add_node(node) +# prt.to_json(filepath=output_path) +prt.to_hdf5(output_path, group_name="parts", mode="w") +# # n.save_to_hdf5(output_path, group_name="nodes", erase_data=True) +# mdl.save_to_hdf5(output_path, group_name="models", erase_data=True) +# # n_new = Node.load_from_hdf5(output_path, "nodes", n.uid) + +# # Load the node from the file +# new_mdl = Model.load_from_hdf5(output_path, "models", mdl.uid) +# print(new_mdl.uid) +# # print(n_new._registration) + +# # print(f"Loaded Node: {loaded_node.xyz}, Mass: {loaded_node.mass}, Temperature: {loaded_node.temperature}") diff --git a/scripts/node.hdf5 b/scripts/node.hdf5 new file mode 100644 index 0000000000000000000000000000000000000000..8a3816b50bb4d017feda283c21cd6f28bd684488 GIT binary patch literal 7296 zcmeHM&2G~`5MD!|q5P;wJ@9vkd}$9jvK==~5kgcXB?r`2zyYbM(zU%-D-tKN6N!S5 zdMvj*29LsHaOB8=JM8Sv(8?$Zv|J#&kvu!IGqby2#YC;TC z+*L4c$-WrJ6Wq+a*kW@Eb2`Ts@EmimYH0QlA)N=<#B*%sft+ywig|$E%Q2o01P{HR z>d+(MIcPBtEYE3YKj%SyP*}$~ru;8W>AfG{)!X}uyS&C8tDeO!vqhP0qb|?k9&+2V zRHlIYipE-hzNt@1KCdN0sl}rt}I3U-hk(wL~y?v@?2CmTI)?d zB8~rhj?Q*Nx=Fe?$4sJ6p|UKd9K#=d|xvY`A%KUW-F%;YxON z5QD8>3s(|R6-*Kzbm>#<@(vv`{ArFQo zJe1~8^adj1!%-%rfv>#S{j?MIM`_G6SXnyicBRjQypxGUWFn0~HR0V{yx+yGD{^$> zG-vt-0fT@+z#w1{FbEg~3<3rLgMdN6AYc&qy9m(#W7~y#%t?Krr0NT4+*1=zt1rOs jda5r#P2g8Q$shZH8p4SmUGGTyvG|>O2h D + # ========================================================================== + # Copy and Serialization + # ========================================================================== + def copy(self, cls=None, copy_guid=False, copy_name=False): """Make an independent copy of the data object. Parameters @@ -168,3 +174,74 @@ def copy(self, cls=None, copy_guid=False, copy_name=False): # type: (...) -> D 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/model/__init__.py b/src/compas_fea2/model/__init__.py index 5340d665e..2041d7915 100644 --- a/src/compas_fea2/model/__init__.py +++ b/src/compas_fea2/model/__init__.py @@ -4,7 +4,7 @@ from .model import Model from .parts import ( - DeformablePart, + Part, RigidPart, ) from .nodes import Node @@ -114,9 +114,13 @@ InitialStressField, ) +from .interfaces import ( + Interface, +) + __all__ = [ "Model", - "DeformablePart", + "Part", "RigidPart", "Node", "_Element", diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index 35d344578..09c24e141 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -49,7 +49,7 @@ class _Element(FEAData): 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` if the element has a face on the boundary mesh of the part, `False` @@ -206,6 +206,10 @@ def weight(self, g: float) -> float: def nodal_mass(self) -> List[float]: return [self.mass / len(self.nodes)] * 3 + @property + def ndim(self) -> int: + return self._ndim + class MassElement(_Element): """A 0D element for concentrated point mass.""" @@ -227,6 +231,7 @@ class _Element0D(_Element): 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): @@ -294,6 +299,7 @@ def __init__(self, nodes: List["Node"], section: "_Section", frame: Optional[Fra 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._ndim = 1 @property def __data__(self): @@ -518,6 +524,7 @@ def __init__(self, nodes: List["Node"], section: Optional["_Section"] = None, im self._faces = None self._face_indices = None + self._ndim = 2 @property def nodes(self) -> List["Node"]: # noqa: F821 @@ -630,6 +637,7 @@ def __init__(self, nodes: List["Node"], section: "_Section", implementation: Opt self._face_indices = None self._faces = None self._frame = Frame.worldXY() + self._ndim = 3 @property def results_cls(self) -> Result: @@ -656,9 +664,23 @@ def face_indices(self) -> Optional[Dict[str, Tuple[int]]]: def faces(self) -> Optional[List[Face]]: return self._faces + @property + 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 + + @property + def centroid(self) -> "Point": + return centroid_points([node.point for node in self.nodes]) + @property def reference_point(self) -> "Point": - return centroid_points([face.centroid for face in self.faces]) + return self._reference_point or self.centroid def _construct_faces(self, face_indices: Dict[str, Tuple[int]]) -> List[Face]: """Construct the face-nodes dictionary. diff --git a/src/compas_fea2/model/groups.py b/src/compas_fea2/model/groups.py index ed56c800c..4b635a48b 100644 --- a/src/compas_fea2/model/groups.py +++ b/src/compas_fea2/model/groups.py @@ -18,7 +18,7 @@ class _Group(FEAData): Attributes ---------- - registration : :class:`compas_fea2.model.DeformablePart` | :class:`compas_fea2.model.Model` + registration : :class:`compas_fea2.model.Part` | :class:`compas_fea2.model.Model` The parent object where the members of the Group belong. """ @@ -211,8 +211,8 @@ 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. """ @@ -305,7 +305,7 @@ 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. """ @@ -386,12 +386,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`. diff --git a/src/compas_fea2/model/interfaces.py b/src/compas_fea2/model/interfaces.py index 1e412d90e..f040c3d84 100644 --- a/src/compas_fea2/model/interfaces.py +++ b/src/compas_fea2/model/interfaces.py @@ -13,29 +13,7 @@ from compas.geometry import dot_vectors from compas.geometry import transform_points from compas.itertools import pairwise - - -def outer_product(u, v): - return [[ui * vi for vi in v] for ui in u] - - -def scale_matrix(M, scale): - r = len(M) - c = len(M[0]) - for i in range(r): - for j in range(c): - M[i][j] *= scale - return M - - -def sum_matrices(A, B): - r = len(A) - c = len(A[0]) - M = [[None for j in range(c)] for i in range(r)] - for i in range(r): - for j in range(c): - M[i][j] = A[i][j] + B[i][j] - return M +from compas.geometry import bestfit_frame_numpy class Interface(FEAData): @@ -96,7 +74,7 @@ class Interface(FEAData): @property def __data__(self): return { - "points": self.points, + "points": self.boundary_points, "size": self.size, "frame": self.frame, "forces": self.forces, @@ -121,56 +99,14 @@ def __from_data__(cls, data): def __init__( self, - size=None, - points=None, - frame=None, - forces=None, - mesh=None, + mesh: Mesh = None, ): super(Interface, self).__init__() + self._mesh = mesh self._frame = None - self._mesh = None - self._size = None - self._points = None - self._polygon = None - self._points2 = None - self._polygon2 = None - - self.points = points - self.mesh = mesh - self.size = size - self.forces = forces - - self._frame = frame - - @property - def points(self): - return self._points - - @points.setter - def points(self, items): - self._points = [] - for item in items: - self._points.append(Point(*item)) - - @property - def polygon(self): - if self._polygon is None: - self._polygon = Polygon(self.points) - return self._polygon - - @property - def frame(self): - if self._frame is None: - from compas.geometry import bestfit_frame_numpy - - self._frame = Frame(*bestfit_frame_numpy(self.points)) - return self._frame @property - def mesh(self): - if not self._mesh: - self._mesh = Mesh.from_polygons([self.polygon]) + def mesh(self) -> Mesh: return self._mesh @mesh.setter @@ -178,147 +114,27 @@ def mesh(self, mesh): self._mesh = mesh @property - def points2(self): - if not self._points2: - X = Transformation.from_frame_to_frame(self.frame, Frame.worldXY()) - self._points2 = [Point(*point) for point in transform_points(self.points, X)] - return self._points2 - - @property - def polygon2(self): - if not self._polygon2: - X = Transformation.from_frame_to_frame(self.frame, Frame.worldXY()) - self._polygon2 = self.polygon.transformed(X) - return self._polygon2 - - @property - def M0(self): - m0 = 0 - for a, b in pairwise(self.points2 + self.points2[:1]): - d = b - a - n = [d[1], -d[0], 0] - m0 += dot_vectors(a, n) - return 0.5 * m0 - - @property - def M1(self): - m1 = Point(0, 0, 0) - for a, b in pairwise(self.points2 + self.points2[:1]): - d = b - a - n = [d[1], -d[0], 0] - m0 = dot_vectors(a, n) - m1 += (a + b) * m0 - return m1 / 6 - - @property - def M2(self): - m2 = outer_product([0, 0, 0], [0, 0, 0]) - for a, b in pairwise(self.points2 + self.points2[:1]): - d = b - a - n = [d[1], -d[0], 0] - m0 = dot_vectors(a, n) - aa = outer_product(a, a) - ab = outer_product(a, b) - ba = outer_product(b, a) - bb = outer_product(b, b) - m2 = sum_matrices( - m2, - scale_matrix( - sum_matrices(sum_matrices(aa, bb), scale_matrix(sum_matrices(ab, ba), 0.5)), - m0, - ), - ) - return scale_matrix(m2, 1 / 12.0) - - @property - def kern(self): - # points = [] - # for a, b in pairwise(self.points2 + self.points2[:1]): - # pass + def average_plane(self): pass @property - def stressdistribution(self): - pass - - @property - def normalforces(self): - lines = [] - if not self.forces: - return lines - frame = self.frame - w = frame.zaxis - for point, force in zip(self.points, self.forces): - force = force["c_np"] - force["c_nn"] - p1 = point + w * force * 0.5 - p2 = point - w * force * 0.5 - lines.append(Line(p1, p2)) - return lines - - @property - def compressionforces(self): - lines = [] - if not self.forces: - return lines - frame = self.frame - w = frame.zaxis - for point, force in zip(self.points, self.forces): - force = force["c_np"] - force["c_nn"] - if force > 0: - p1 = point + w * force * 0.5 - p2 = point - w * force * 0.5 - lines.append(Line(p1, p2)) - return lines + def points(self): + return [Point(*self.mesh.vertex_coordinates(v)) for v in self.mesh.vertices()] @property - def tensionforces(self): - lines = [] - if not self.forces: - return lines - frame = self.frame - w = frame.zaxis - for point, force in zip(self.points, self.forces): - force = force["c_np"] - force["c_nn"] - if force < 0: - p1 = point + w * force * 0.5 - p2 = point - w * force * 0.5 - lines.append(Line(p1, p2)) - return lines + def boundary_points(self): + return [Point(*self.mesh.vertex_coordinates(v)) for v in self.mesh.vertices_on_boundary()] @property - def frictionforces(self): - lines = [] - if not self.forces: - return lines - frame = self.frame - u, v = frame.xaxis, frame.yaxis - for point, force in zip(self.points, self.forces): - ft_uv = (u * force["c_u"] + v * force["c_v"]) * 0.5 - p1 = point + ft_uv - p2 = point - ft_uv - lines.append(Line(p1, p2)) - return lines + def polygon(self): + return Polygon(self.boundary_points) @property - def resultantpoint(self): - if not self.forces: - return [] - normalcomponents = [f["c_np"] - f["c_nn"] for f in self.forces] - if sum(normalcomponents): - return Point(*centroid_points_weighted(self.points, normalcomponents)) + def area(self): + return self.mesh.area() @property - def resultantforce(self): - if not self.forces: - return [] - normalcomponents = [f["c_np"] - f["c_nn"] for f in self.forces] - sum_n = sum(normalcomponents) - sum_u = sum(f["c_u"] for f in self.forces) - sum_v = sum(f["c_v"] for f in self.forces) - position = Point(*centroid_points_weighted(self.points, normalcomponents)) - frame = self.frame - w, u, v = frame.zaxis, frame.xaxis, frame.yaxis - forcevector = (w * sum_n + u * sum_u + v * sum_v) * 0.5 - p1 = position + forcevector - p2 = position - forcevector - return [Line(p1, p2)] + def frame(self): + if self._frame is None: + self._frame = Frame(*bestfit_frame_numpy(self.boundary_points)) + return self._frame diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index ba5da7831..14584b6fd 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -63,7 +63,7 @@ class Model(FEAData): Some description of the model. 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 @@ -280,12 +280,8 @@ def elements(self) -> list[_Element]: @property 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 + 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) @property def center(self) -> Point: @@ -423,7 +419,7 @@ def find_part_by_name(self, name: str, casefold: bool = False) -> Optional[_Part Returns ------- - :class:`compas_fea2.model.DeformablePart` + :class:`compas_fea2.model.Part` """ for part in self.parts: @@ -437,7 +433,7 @@ def contains_part(self, part: _Part) -> bool: Parameters ---------- - part : :class:`compas_fea2.model.DeformablePart` + part : :class:`compas_fea2.model.Part` Returns ------- @@ -446,8 +442,26 @@ def contains_part(self, part: _Part) -> bool: """ return part in self.parts + 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) -> _Part: - """Adds a DeformablePart to the Model. + """Adds a Part to the Model. Parameters ---------- @@ -483,11 +497,11 @@ def add_parts(self, parts: list[_Part]) -> list[_Part]: 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] @@ -497,12 +511,12 @@ def copy_part(self, part: _Part, transformation: Transformation) -> _Part: Parameters ---------- - part : :class:`compas_fea2.model.DeformablePart` + part : :class:`compas_fea2.model._Part` The part to copy. Returns ------- - :class:`compas_fea2.model.DeformablePart` + :class:`compas_fea2.model._Part` The copied part. """ @@ -515,7 +529,7 @@ def array_parts(self, parts: list[_Part], n: int, transformation: Transformation Parameters ---------- - parts : list[:class:`compas_fea2.model.DeformablePart`] + parts : list[:class:`compas_fea2.model.Part`] The part to array. n : int The number of times to array the part. @@ -524,7 +538,7 @@ def array_parts(self, parts: list[_Part], n: int, transformation: Transformation Returns ------- - list[:class:`compas_fea2.model.DeformablePart`] + list[:class:`compas_fea2.model.Part`] The list of arrayed parts. """ diff --git a/src/compas_fea2/model/nodes.py b/src/compas_fea2/model/nodes.py index 34b41b645..740d306e1 100644 --- a/src/compas_fea2/model/nodes.py +++ b/src/compas_fea2/model/nodes.py @@ -1,6 +1,7 @@ from typing import Dict from typing import List from typing import Optional +import numpy as np from compas.geometry import Point from compas.tolerance import TOL @@ -294,7 +295,9 @@ def reactions(self) -> Dict: @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 0eecbb3ba..17181450b 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -14,6 +14,8 @@ from compas.topology import connected_components from collections import defaultdict from itertools import groupby +import h5py +import json import compas from compas.geometry import Box @@ -105,6 +107,8 @@ class _Part(FEAData): def __init__(self, **kwargs): super().__init__(**kwargs) + self._ndm = None + self._ndf = None self._graph = nx.DiGraph() self._nodes: Set[Node] = set() self._gkey_node: Dict[str, Node] = {} @@ -119,14 +123,13 @@ def __init__(self, **kwargs): self._boundary_mesh = None self._discretized_boundary_mesh = None - self._bounding_box = None @property def __data__(self): return { "class": self.__class__.__base__, - "ndm": self.ndm or None, - "ndf": self.ndf or None, + "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], @@ -136,10 +139,12 @@ def __data__(self): "nodesgroups": [group.__data__ for group in self.nodesgroups], "elementsgroups": [group.__data__ for group in self.elementsgroups], "facesgroups": [group.__data__ for group in self.facesgroups], - "boundary_mesh": self.boundary_mesh.__data__ if self.boundary_mesh else None, - "discretized_boundary_mesh": self.discretized_boundary_mesh.__data__ if self.discretized_boundary_mesh 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. @@ -246,7 +251,7 @@ def elements_grouped(self) -> Dict[int, List[_Element]]: @property def elements_faces(self) -> List[List[List["Face"]]]: - return [face for face in element.faces] + return [face for element in self.elements for face in element.faces] @property def elements_faces_grouped(self) -> Dict[int, List[List["Face"]]]: @@ -269,6 +274,10 @@ 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) -> Set[_Section]: return self._sections @@ -478,14 +487,18 @@ def split_into_subsubmeshes(submeshes): 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 lienar elements (bb of the section outer boundary) - try: - return Box.from_bounding_box(bounding_box([n.xyz for n in self.nodes])) - except Exception: - print("WARNING: BoundingBox not generated") - return None + return Box.from_bounding_box(bounding_box([n.xyz for n in self.nodes])) @property def center(self) -> Point: @@ -630,7 +643,7 @@ def from_compas_lines( @classmethod def shell_from_compas_mesh(cls, mesh, section: ShellSection, name: Optional[str] = None, **kwargs) -> "_Part": - """Creates a DeformablePart object from a :class:`compas.datastructures.Mesh`. + """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. @@ -638,7 +651,7 @@ def shell_from_compas_mesh(cls, mesh, section: ShellSection, name: Optional[str] 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 @@ -711,7 +724,7 @@ def from_gmsh(cls, gmshModel, section: Optional[Union[SolidSection, ShellSection -------- >>> 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) + >>> part = Part.from_gmsh("part_gmsh", gmshModel, sec) """ import numpy as np @@ -778,7 +791,7 @@ def from_boundary_mesh(cls, boundary_mesh, name: Optional[str] = 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 @@ -825,7 +838,8 @@ def from_boundary_mesh(cls, boundary_mesh, name: Optional[str] = None, **kwargs) part = cls.from_gmsh(gmshModel=gmshModel, name=name, **kwargs) - del gmshModel + if gmshModel: + del gmshModel return part @@ -883,7 +897,8 @@ def from_step_file(cls, step_file: str, name: Optional[str] = None, **kwargs) -> part = cls.from_gmsh(gmshModel=gmshModel, name=name, **kwargs) - del gmshModel + if gmshModel: + del gmshModel print("Part created.") return part @@ -1413,7 +1428,7 @@ def add_node(self, node: Node) -> Node: Examples -------- - >>> part = DeformablePart() + >>> part = Part() >>> node = Node(xyz=(1.0, 2.0, 3.0)) >>> part.add_node(node) @@ -1451,7 +1466,7 @@ def add_nodes(self, nodes: List[Node]) -> List[Node]: 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]) @@ -1497,7 +1512,7 @@ def remove_nodes(self, nodes: List[Node]) -> None: self.remove_node(node) def is_node_on_boundary(self, node: Node, precision: Optional[float] = None) -> bool: - """Check if a node is on the boundary mesh of the DeformablePart. + """Check if a node is on the boundary mesh of the Part. Parameters ---------- @@ -2076,7 +2091,7 @@ def show(self, scale_factor: float = 1, draw_nodes: bool = False, node_labels: b v.show() -class DeformablePart(_Part): +class Part(_Part): """Deformable part.""" __doc__ += _Part.__doc__ @@ -2114,7 +2129,7 @@ def releases(self) -> Set[_BeamEndRelease]: # ========================================================================= @classmethod def frame_from_compas_mesh(cls, mesh: "compas.datastructures.Mesh", section: "compas_fea2.model.BeamSection", name: Optional[str] = None, **kwargs) -> "_Part": - """Creates a DeformablePart object from a :class:`compas.datastructures.Mesh`. + """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. @@ -2122,7 +2137,7 @@ def frame_from_compas_mesh(cls, mesh: "compas.datastructures.Mesh", section: "co 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 @@ -2181,7 +2196,7 @@ def from_boundary_mesh( Parameters ---------- boundary_mesh : :class:`compas.datastructures.Mesh` - Boundary envelope of the DeformablePart. + Boundary envelope of the Part. section : Union[compas_fea2.model.SolidSection, compas_fea2.model.ShellSection] Section to assign to the elements. name : str, optional diff --git a/src/compas_fea2/problem/fields.py b/src/compas_fea2/problem/fields.py index 2a90249b1..37894681d 100644 --- a/src/compas_fea2/problem/fields.py +++ b/src/compas_fea2/problem/fields.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/problem/problem.py b/src/compas_fea2/problem/problem.py index 03d007bea..39b3932e8 100644 --- a/src/compas_fea2/problem/problem.py +++ b/src/compas_fea2/problem/problem.py @@ -76,6 +76,7 @@ def __init__(self, description: Optional[str] = None, **kwargs): self._path_db = None self._steps = set() self._steps_order = [] # TODO make steps a list + self._rdb = None @property def model(self) -> "Model": # noqa: F821 @@ -99,8 +100,14 @@ def path_db(self) -> str: return self._path_db @property - def results_db(self) -> ResultsDatabase: - return ResultsDatabase(self) + def rdb(self) -> ResultsDatabase: + return self._rdb or ResultsDatabase.sqlite(self) + + @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) -> List[Step]: @@ -389,7 +396,9 @@ def _delete_folder_contents(folder_path: Path): 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: + 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() @@ -423,6 +432,26 @@ def analyze(self, path: Optional[Union[Path, str]] = None, erase_data: bool = Fa """American spelling of the analyse method""" self.analyse(path=path, *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. diff --git a/src/compas_fea2/problem/steps/static.py b/src/compas_fea2/problem/steps/static.py index 464607619..74854ab09 100644 --- a/src/compas_fea2/problem/steps/static.py +++ b/src/compas_fea2/problem/steps/static.py @@ -94,24 +94,24 @@ def __init__( 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, + "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'], + 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 ) @@ -197,7 +197,7 @@ def add_line_load(self, polyline, load_case=None, discretization=10, x=None, y=N name : str name of the point load part : str - name of the :class:`compas_fea2.problem.DeformablePart` where the load is applied + 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. @@ -238,7 +238,7 @@ def add_areaload_pattern(self, polygon, load_case=None, x=None, y=None, z=None, name : str name of the point load part : str - name of the :class:`compas_fea2.problem.DeformablePart` where the load is applied + 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. @@ -297,16 +297,16 @@ def add_gravity_load_pattern(self, parts, g=9.81, x=0.0, y=0.0, z=-1.0, load_cas -------- Be careful to assign a value of *g* consistent with the units in your model! - """ - from compas_fea2.problem import ConcentratedLoad - - for part in parts: - part.compute_nodal_masses() - for node in part.nodes: - self.add_load_pattern( - NodeLoadPattern(load=ConcentratedLoad(x=node.mass[0] * g * x, y=node.mass[1] * g * y, z=node.mass[2] * g * z), nodes=[node], load_case=load_case, **kwargs) - ) + pass + # from compas_fea2.problem import ConcentratedLoad + + # for part in parts: + # part.compute_nodal_masses() + # for node in part.nodes: + # self.add_load_pattern( + # NodeLoadPattern(load=ConcentratedLoad(x=node.mass[0] * g * x, y=node.mass[1] * g * y, z=node.mass[2] * g * z), nodes=[node], load_case=load_case, **kwargs) + # ) # from compas_fea2.problem import GravityLoad diff --git a/src/compas_fea2/results/__init__.py b/src/compas_fea2/results/__init__.py index de376a781..b9ff21796 100644 --- a/src/compas_fea2/results/__init__.py +++ b/src/compas_fea2/results/__init__.py @@ -7,6 +7,7 @@ DisplacementResult, AccelerationResult, VelocityResult, + ReactionResult, StressResult, MembraneStressResult, ShellStressResult, @@ -33,6 +34,7 @@ "DisplacementResult", "AccelerationResult", "VelocityResult", + "ReactionResult", "StressResult", "MembraneStressResult", "ShellStressResult", diff --git a/src/compas_fea2/results/database.py b/src/compas_fea2/results/database.py index c7a097cf0..0fb4865dd 100644 --- a/src/compas_fea2/results/database.py +++ b/src/compas_fea2/results/database.py @@ -1,9 +1,127 @@ import sqlite3 - +import h5py +import numpy as np +import json 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) + + +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, problem, **kwargs): @@ -15,20 +133,12 @@ def __init__(self, problem, **kwargs): problem : object The problem instance containing the database path. """ - super(ResultsDatabase, self).__init__(**kwargs) - self._registration = problem + super().__init__(problem=problem, **kwargs) + self.db_uri = problem.path_db self.connection = self.db_connection() self.cursor = self.connection.cursor() - @property - def problem(self): - return self._registration - - @property - def model(self): - return self.problem.model - def db_connection(self, remove=False): """ Create and return a connection to the SQLite database. @@ -224,19 +334,18 @@ def to_result(self, results_set, results_func, field_name): """ results = {} for r in results_set: - step = self.problem.find_step_by_name(r[0]) + step = self.problem.find_step_by_name(r.pop("step")) results.setdefault(step, []) - part = self.model.find_part_by_name(r[1]) or self.model.find_part_by_name(r[1], casefold=True) + part = self.model.find_part_by_name(r.pop("part")) or self.model.find_part_by_name(r.pop("part"), casefold=True) if not part: - raise ValueError(f"Part {r[1]} not in model") - m = getattr(part, results_func)(r[2]) + raise ValueError("Part not in model") + m = getattr(part, results_func)(r.pop("key")) if not m: - raise ValueError(f"Member {r[2]} not in part {part.name}") - results[step].append(m.results_cls[field_name](m, *r[3:])) + raise ValueError(f"Member not in part {part.name}") + results[step].append(m.results_cls[field_name](m, **r)) return results - @staticmethod - def create_table_for_output_class(output_cls, connection, 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. @@ -250,7 +359,7 @@ def create_table_for_output_class(output_cls, connection, results): results : list of tuples Data to be inserted into the table. """ - cursor = connection.cursor() + cursor = self.connection.cursor() schema = output_cls.sqltable_schema table_name = schema["table_name"] @@ -260,7 +369,7 @@ def create_table_for_output_class(output_cls, connection, results): 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) - connection.commit() + 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()] @@ -268,4 +377,4 @@ def create_table_for_output_class(output_cls, connection, results): placeholders_str = ", ".join(["?"] * len(insert_columns)) sql = f"INSERT INTO {table_name} ({col_names_str}) VALUES ({placeholders_str})" cursor.executemany(sql, results) - connection.commit() + self.connection.commit() diff --git a/src/compas_fea2/results/fields.py b/src/compas_fea2/results/fields.py index d49c926de..d78f19b83 100644 --- a/src/compas_fea2/results/fields.py +++ b/src/compas_fea2/results/fields.py @@ -15,6 +15,7 @@ from .results import SolidStressResult # noqa: F401 from .results import VelocityResult # noqa: F401 from .database import ResultsDatabase # noqa: F401 +from itertools import groupby class FieldResults(FEAData): @@ -101,7 +102,7 @@ def results_func(self) -> str: @property def rdb(self) -> ResultsDatabase: - return self.problem.results_db + return self.problem.rdb @property def results(self) -> list: @@ -154,7 +155,10 @@ def _get_results_from_db(self, members=None, columns=None, filters=None, func=No filters["key"] = set([member.key for member in members]) filters["part"] = set([member.part.name for member in members]) - results_set = self.rdb.get_rows(self.field_name, ["step", "part", "key"] + columns, filters, func) + 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) @@ -248,12 +252,6 @@ def filter_by_component(self, component, threshold=None): if component_value is not None and (threshold is None or component_value >= threshold): yield result - def create_sql_table(self, connection, results): - """ - Delegate the table creation to the ResultsDatabase class. - """ - ResultsDatabase.create_table_for_output_class(self, connection, results) - # ------------------------------------------------------------------------------ # Node Field Results @@ -345,7 +343,7 @@ def compute_resultant(self, sub_set=None): resultant_vector = sum_vectors(vectors) moment_vector = sum_vectors(cross_vectors(Vector(*loc) - resultant_location, vec) for loc, vec in zip(locations, vectors)) - return resultant_vector, moment_vector, resultant_location + return Vector(*resultant_vector), Vector(*moment_vector), resultant_location def components_vectors(self, components): """Return a vector representing the given components.""" @@ -581,31 +579,22 @@ def export_to_csv(self, file_path): class StressFieldResults(ElementFieldResults): - """Stress field results for 2D elements. - - This class handles the stress field results for 2D elements 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 stress components. - results_func : str - The function used to find elements by key. + """ + 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. """ def __init__(self, step, *args, **kwargs): - super(StressFieldResults, self).__init__(step=step, *args, **kwargs) + super().__init__(step=step, *args, **kwargs) self._results_func = "find_element_by_key" self._field_name = "s" @property - def components_names(self): - return ["sxx", "syy", "sxy", "szz", "syz", "szx"] + 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)} @property def field_name(self): @@ -615,87 +604,185 @@ def field_name(self): 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"): - """Stress field in global coordinates. + """Compute stress tensors in the global coordinate system.""" + new_frame = Frame.worldXY() + transformed_tensors = [] - Parameters - ---------- - plane : str, optional - The plane to retrieve the stress field for, by default "mid". + 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 ------- - numpy.ndarray - The stress tensor defined at each location of the field in global coordinates. + np.ndarray + (N_nodes,) array containing the averaged von Mises stress per node. """ - n_locations = len(self.results) - new_frame = Frame.worldXY() + # Compute von Mises stress at element level + element_von_mises = self.von_mises_stress() # Shape: (N_elements,) - # 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 von Mises stress for each node in the corresponding element + repeated_von_mises = np.repeat(element_von_mises, repeats=[len(e.element.nodes) for e in self.results], axis=0) # Shape (N_total_entries,) - for i, r in enumerate(self.results): - r = r.plane_results(plane) - 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) # Summed von Mises stresses + nodal_counts = np.zeros(max_node_index) # Count occurrences per node - return transformed_tensors + # 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) - def principal_components(self, plane): - """Compute the eigenvalues and eigenvectors of the stress field at each location. + # Prevent division by zero + nodal_counts[nodal_counts == 0] = 1 - Parameters - ---------- - plane : str - The plane to retrieve the principal components for. + # 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 ------- - tuple(numpy.ndarray, numpy.ndarray) - The eigenvalues and the eigenvectors, not ordered. + np.ndarray + (N_nodes, 3, 3) array containing the averaged stress tensor per node. """ - return np.linalg.eig(self.global_stresses(plane)) + # Compute global stress tensor at the element level + element_stresses = self.global_stresses() # Shape: (N_elements, 3, 3) - def principal_components_vectors(self, plane): - """Compute the principal components of the stress field at each location as vectors. + # 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 - ---------- - plane : str - The plane to retrieve the principal components for. + # 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) - Yields - ------ - list(:class:`compas.geometry.Vector`) - List with the vectors corresponding to max, mid and min principal components. + # Get the number of unique nodes + max_node_index = node_indices.max() + 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 + + # 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) + + # Prevent division by zero + nodal_counts[nodal_counts == 0] = 1 + + # 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 + ------- + np.ndarray + Von Mises stress values per element. """ - eigenvalues, eigenvectors = self.principal_components(plane=plane) - 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])] + stress_tensors = self.global_stresses(plane) # Shape: (N_elements, 3, 3) - def vonmieses(self, plane): - """Compute the von Mises stress field at each location. + # 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 - ---------- - plane : str - The plane to retrieve the von Mises stress for. + # 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))) - Yields - ------ - float - The von Mises stress at each location. + return sigma_vm + + def principal_components(self, plane="mid"): + """ + Compute sorted principal stresses and directions. + + 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. """ - for r in self.plane_results: - r = r.plane_results(plane) - yield r.von_mises_stress + 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))) + + # **✅ 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/results.py b/src/compas_fea2/results/results.py index 8c88771ab..2fc292889 100644 --- a/src/compas_fea2/results/results.py +++ b/src/compas_fea2/results/results.py @@ -92,6 +92,7 @@ def safety_factor(self, component, allowable): """ return abs(self.vector[component] / allowable) if self.vector[component] != 0 else 1 + class NodeResult(Result): """NodeResult object. @@ -659,14 +660,14 @@ class StressResult(ElementResult): 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", "s12", "s13", "s22", "s23", "s33"] - _invariants_names = ["magnitude"] + # _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) + super().__init__(element, **kwargs) self._s11 = s11 self._s12 = s12 self._s13 = s13 @@ -674,8 +675,11 @@ def __init__(self, element, *, s11, s12, s13, s22, s23, s33, **kwargs): 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._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 s11(self): @@ -701,16 +705,6 @@ def s23(self): def s33(self): return self._s33 - @property - def local_stress(self): - # In local coordinates - return self._local_stress - - @property - def global_stress(self): - # In global coordinates - return self.transform_stress_tensor(self._local_stress, Frame.worldXY()) - @property def global_strain(self): if not isinstance(self.location.section.material, ElasticIsotropic): @@ -735,37 +729,49 @@ def global_strain(self): return strain_tensor @property - # First invariant + 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 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 @@ -774,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): @@ -844,26 +878,6 @@ def strain_energy_density(self): 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. 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/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_parts.py b/tests/test_parts.py index 067bbc4fc..e0b36d408 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) @@ -24,15 +24,15 @@ def test_add_element(self): self.assertIn(element, part.elements) -class TestDeformablePart(unittest.TestCase): +class TestPart(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) From eec995fef5578689b7be6d19b1e3e765452dc37a Mon Sep 17 00:00:00 2001 From: franaudo Date: Sun, 16 Feb 2025 17:17:56 +0100 Subject: [PATCH 20/39] support for c3d10 --- src/compas_fea2/model/elements.py | 77 ++++++++++++++++------ src/compas_fea2/model/parts.py | 102 ++++++++++++++++++++---------- 2 files changed, 124 insertions(+), 55 deletions(-) diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index 09c24e141..54e388b9e 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -715,37 +715,83 @@ def outermesh(self) -> Mesh: return Polyhedron(self.points, list(self._face_indices.values())).to_mesh() +from typing import List, Optional + + 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: List["Node"], section: "_Section", implementation: Optional[str] = None, **kwargs): # noqa: F821 + def __init__(self, *, nodes: List["Node"], section: "_Section", 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)) @@ -753,7 +799,7 @@ def edges(self): @property def volume(self) -> float: - """The volume property.""" + """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]) @@ -761,20 +807,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): diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index 17181450b..7ce6326d7 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -681,30 +681,24 @@ def shell_from_compas_mesh(cls, mesh, section: ShellSection, name: Optional[str] @classmethod 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. - - 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. + """Create a Part object from a gmshModel object with support for C3D4 and C3D10 elements. Parameters ---------- gmshModel : object - gmsh Model to convert. See [1]_. + Gmsh Model to convert. section : Union[SolidSection, ShellSection], optional - `compas_fea2` :class:`SolidSection` or :class:`ShellSection` sub-class - object to apply to the elements. + 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 ------- @@ -713,33 +707,27 @@ def from_gmsh(cls, gmshModel, section: Optional[Union[SolidSection, ShellSection 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 = Part.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 @@ -750,26 +738,35 @@ def from_gmsh(cls, gmshModel, section: Optional[Union[SolidSection, ShellSection for ntags in ntags_per_element: if kwargs.get("split", False): - raise NotImplementedError("this feature is under development") + raise NotImplementedError("This feature is under development") + element_nodes = fea2_nodes[ntags] if ntags.size == 3: part.add_element(ShellElement(nodes=element_nodes, section=section, rigid=rigid, implementation=implementation)) + elif ntags.size == 4: if isinstance(section, ShellSection): part.add_element(ShellElement(nodes=element_nodes, section=section, rigid=rigid, implementation=implementation)) else: part.add_element(TetrahedronElement(nodes=element_nodes, section=section)) - part.ndf = 3 # FIXME try to move outside the loop + part.ndf = 3 # FIXME: try to move outside the loop + + elif ntags.size == 10: # C3D10 tetrahedral element + part.add_element(TetrahedronElement(nodes=element_nodes, section=section)) # Automatically supports C3D10 + part.ndf = 3 + elif ntags.size == 8: part.add_element(HexahedronElement(nodes=element_nodes, section=section)) + else: raise NotImplementedError(f"Element with {ntags.size} nodes not supported") + if verbose: - print(f"element {ntags} added") + 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) # FIXME: Get the volumes without the mesh part._boundary_mesh = gmshModel.mesh_to_compas() if not part._discretized_boundary_mesh: @@ -903,6 +900,43 @@ def from_step_file(cls, step_file: str, name: Optional[str] = None, **kwargs) -> return part + @classmethod + def from_brep(cls, brep, name: Optional[str] = None, **kwargs) -> "_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 + # ========================================================================= # Materials methods # ========================================================================= From 66e16bf15edeec1312b4dc2cc9f6316b50250227 Mon Sep 17 00:00:00 2001 From: franaudo Date: Sun, 16 Feb 2025 17:19:44 +0100 Subject: [PATCH 21/39] patterns fix --- src/compas_fea2/problem/loads.py | 20 ++++++++ src/compas_fea2/problem/patterns.py | 64 ++++++++++++++++--------- src/compas_fea2/problem/steps/static.py | 26 +++++----- src/compas_fea2/problem/steps/step.py | 38 ++++++--------- 4 files changed, 89 insertions(+), 59 deletions(-) diff --git a/src/compas_fea2/problem/loads.py b/src/compas_fea2/problem/loads.py index fa15e1f63..97e8a5b03 100644 --- a/src/compas_fea2/problem/loads.py +++ b/src/compas_fea2/problem/loads.py @@ -217,6 +217,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/patterns.py b/src/compas_fea2/problem/patterns.py index 945ed85f8..b3633ca98 100644 --- a/src/compas_fea2/problem/patterns.py +++ b/src/compas_fea2/problem/patterns.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import itertools from typing import Iterable @@ -25,7 +21,7 @@ class Pattern(FEAData): 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 + load_case : str, optional The load case to which this pattern belongs. axes : str, optional Coordinate system for the load components. Default is "global". @@ -48,21 +44,20 @@ class Pattern(FEAData): def __init__( self, - load, + loads, 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._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 load(self): - return self._load + def loads(self): + return self._loads @property def distribution(self): @@ -70,7 +65,10 @@ def distribution(self): @property def step(self): - return self._registration + if self._registration: + return self._registration + else: + raise ValueError("Register the Pattern to a Step first.") @property def problem(self): @@ -113,19 +111,20 @@ class NodeLoadPattern(Pattern): """ def __init__(self, load, nodes, load_case=None, **kwargs): - super(NodeLoadPattern, self).__init__(load=load, distribution=nodes, load_case=load_case, **kwargs) + super(NodeLoadPattern, self).__init__(loads=load, distribution=nodes, load_case=load_case, **kwargs) @property def nodes(self): return self._distribution @property - def load(self): - return self._load + def loads(self): + return self._loads @property def node_load(self): - return zip(self.nodes, [self.load] * len(self.nodes)) + """Return a list of tuples with the nodes and the assigned load.""" + return zip(self.nodes, self.loads) class PointLoadPattern(NodeLoadPattern): @@ -143,11 +142,12 @@ class PointLoadPattern(NodeLoadPattern): 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) + def __init__(self, loads, points, load_case=None, tolerance=1, **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] + 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): @@ -196,7 +196,7 @@ def nodes(self): @property def node_load(self): - return zip(self.nodes, [self.load] * self.nodes) + return zip(self.nodes, [self.loads] * self.nodes) class AreaLoadPattern(Pattern): @@ -217,7 +217,8 @@ class AreaLoadPattern(Pattern): 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) + compute_tributary_areas = False + super().__init__(loads=load, distribution=polygon, load_case=load_case, **kwargs) self.tolerance = tolerance @property @@ -230,7 +231,7 @@ def nodes(self): @property def node_load(self): - return zip(self.nodes, [self.load] * self.nodes) + return zip(self.nodes, [self.loads] * self.nodes) class VolumeLoadPattern(Pattern): @@ -267,10 +268,27 @@ def node_load(self): 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()}) + load = ConcentratedLoad(**{k: v * vol * den / n_nodes if v else v for k, v in self.loads.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())) + + +class GravityLoadPattern(Pattern): + """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(GravityLoadPattern, self).__init__(GravityLoad(g=g), parts, load_case, **kwargs) diff --git a/src/compas_fea2/problem/steps/static.py b/src/compas_fea2/problem/steps/static.py index 74854ab09..198b2dd3e 100644 --- a/src/compas_fea2/problem/steps/static.py +++ b/src/compas_fea2/problem/steps/static.py @@ -149,7 +149,7 @@ def add_node_pattern(self, nodes, load_case=None, x=None, y=None, z=None, xx=Non """ 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)) + return self.add_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 @@ -183,7 +183,7 @@ def add_point_pattern(self, points, load_case=None, x=None, y=None, z=None, xx=N 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)) + return self.add_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 @@ -225,7 +225,7 @@ def add_line_load(self, polyline, load_case=None, discretization=10, x=None, y=N local axes are not supported yet """ - return self.add_load_pattern( + return self.add_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) ) @@ -266,7 +266,7 @@ def add_areaload_pattern(self, polygon, load_case=None, x=None, y=None, z=None, 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)) + return self.add_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 @@ -298,15 +298,15 @@ def add_gravity_load_pattern(self, parts, g=9.81, x=0.0, y=0.0, z=-1.0, load_cas Be careful to assign a value of *g* consistent with the units in your model! """ - pass - # from compas_fea2.problem import ConcentratedLoad - - # for part in parts: - # part.compute_nodal_masses() - # for node in part.nodes: - # self.add_load_pattern( - # NodeLoadPattern(load=ConcentratedLoad(x=node.mass[0] * g * x, y=node.mass[1] * g * y, z=node.mass[2] * g * z), nodes=[node], load_case=load_case, **kwargs) - # ) + + from compas_fea2.problem import ConcentratedLoad + + for part in parts: + part.compute_nodal_masses() + for node in part.nodes: + self.add_pattern( + NodeLoadPattern(load=ConcentratedLoad(x=node.mass[0] * g * x, y=node.mass[1] * g * y, z=node.mass[2] * g * z), nodes=[node], load_case=load_case, **kwargs) + ) # from compas_fea2.problem import GravityLoad diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index de18597d9..f4e95ad51 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -360,7 +360,7 @@ def restart(self, value): # ============================================================================== # Patterns # ============================================================================== - def add_load_pattern(self, load_pattern, *kwargs): + def add_pattern(self, pattern, *kwargs): """Add a general :class:`compas_fea2.problem.patterns.Pattern` to the Step. Parameters @@ -375,29 +375,20 @@ def add_load_pattern(self, load_pattern, *kwargs): """ from compas_fea2.problem.patterns import Pattern - if not isinstance(load_pattern, Pattern): - raise TypeError("{!r} is not a LoadPattern.".format(load_pattern)) + if not isinstance(pattern, Pattern): + raise TypeError("{!r} is not a LoadPattern.".format(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(pattern) + self._load_cases.add(pattern.load_case) + pattern._registration = self + return pattern - 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): + def add_patterns(self, patterns): """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 @@ -405,8 +396,11 @@ 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) + from typing import Iterable + + patterns = patterns if isinstance(patterns, Iterable) else [patterns] + for pattern in patterns: + self.add_pattern(pattern) # ============================================================================== # Combination @@ -568,9 +562,7 @@ def show_stress(self, fast=True, show_bcs=1, scale_model=1, show_loads=0.1, comp 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 - ) + 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) From 77331718d780c3d195e6871e0d7617904dab7390 Mon Sep 17 00:00:00 2001 From: franaudo Date: Sat, 22 Feb 2025 09:21:37 +0100 Subject: [PATCH 22/39] new groups --- src/compas_fea2/model/__init__.py | 4 - src/compas_fea2/model/elements.py | 1 - src/compas_fea2/model/groups.py | 505 ++++++++++++++++++++++++------ src/compas_fea2/model/model.py | 12 +- src/compas_fea2/model/nodes.py | 1 - src/compas_fea2/model/parts.py | 92 ++---- 6 files changed, 447 insertions(+), 168 deletions(-) diff --git a/src/compas_fea2/model/__init__.py b/src/compas_fea2/model/__init__.py index 2041d7915..d12914fed 100644 --- a/src/compas_fea2/model/__init__.py +++ b/src/compas_fea2/model/__init__.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from .model import Model from .parts import ( Part, diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index 54e388b9e..f5660231d 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -17,7 +17,6 @@ from compas.geometry import distance_point_point from compas.itertools import pairwise -import compas_fea2 from compas_fea2.base import FEAData from compas_fea2.results import Result diff --git a/src/compas_fea2/model/groups.py b/src/compas_fea2/model/groups.py index 4b635a48b..9957792bb 100644 --- a/src/compas_fea2/model/groups.py +++ b/src/compas_fea2/model/groups.py @@ -1,113 +1,272 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Callable, Iterable, TypeVar, Set, Dict, Any, List +from importlib import import_module +from itertools import groupby +import logging +from compas_fea2.base import FEAData -from typing import Iterable +# Define a generic type for members +T = TypeVar("T") -from compas_fea2.base import FEAData +# 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.Part` | :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 __init__(self, members: Iterable[T] = None, **kwargs): + super().__init__(**kwargs) + self._members: Set[T] = set(members) if members else set() + + 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 __data__(self): - return { - "class": self.__class__.__base__.__name__, - } + def members(self) -> Set[T]: + """Return the members of the group.""" + return self._members - @classmethod - def __from_data__(cls, data): - raise NotImplementedError("This method must be implemented in the subclass") - - 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. + @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 ---------- - 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 sorting. + reverse : bool, optional + Whether to sort in descending order. Default is False. Returns ------- - var - The memeber. + List[T] + A sorted list of group members based on the key function. """ - self._members.add(self._check_member(member)) - return member + return sorted(self._members, key=key, reverse=reverse) - def _add_members(self, members): - """Add multiple members to the group. + def create_subgroup(self, condition: Callable[[T], bool], **kwargs) -> "_Group": + """ + Create a subgroup based on a given condition. Parameters ---------- - members : [var] - The members to add. These depend on the specific group type. + 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 ------- - [var] - The memebers. + _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"]: """ - self._check_members(members) - for member in members: - self.members.add(member) - return members + Group members into multiple subgroups based on a key function. + + Parameters + ---------- + key : Callable[[T], any] + A function that extracts a key from a member for grouping. + + Returns + ------- + Dict[any, _Group] + A dictionary where keys are the grouping values and values are `_Group` instances. + """ + sorted_members = sorted(self._members, key=key) + grouped_members = {k: set(v) for k, v in groupby(sorted_members, key=key)} + return {k: self.__class__(members=v, name=f"{self.name}_{k}") for k, v in grouped_members.items()} + + def union(self, other: "_Group") -> "_Group": + """ + Create a new group containing all members from this group and another 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 + ---------- + other : _Group + Another group to find common members with. + + Returns + ------- + _Group + A new group containing only members found in both groups. + """ + 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): @@ -162,7 +321,8 @@ def nodes(self): return self._members def add_node(self, node): - """Add a node to the group. + """ + Add a node to the group. Parameters ---------- @@ -177,7 +337,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 ---------- @@ -227,8 +388,6 @@ def __data__(self): @classmethod def __from_data__(cls, data): - from importlib import import_module - 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) @@ -246,7 +405,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 ---------- @@ -257,12 +417,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 ---------- @@ -273,7 +433,6 @@ def add_elements(self, elements): ------- [:class:`compas_fea2.model.Element`] The elements added. - """ return self._add_members(elements) @@ -346,7 +505,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 ---------- @@ -356,13 +516,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 ---------- @@ -373,7 +533,6 @@ def add_faces(self, faces): ------- [:class:`compas_fea2.model.Face`] The faces added. - """ return self._add_members(faces) @@ -429,7 +588,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/model.py b/src/compas_fea2/model/model.py index 14584b6fd..7360ca922 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -460,7 +460,7 @@ def add_new_part(self, **kwargs) -> _Part: part = _Part(**kwargs) return self.add_part(part) - def add_part(self, part: _Part) -> _Part: + def add_part(self, part: _Part = None, **kwargs) -> _Part: """Adds a Part to the Model. Parameters @@ -479,6 +479,16 @@ def add_part(self, part: _Part) -> _Part: If a part with the same name already exists in the model. """ + if not part: + if "rigig" in kwargs: + from compas_fea2.model.parts import RigidPart + + part = RigidPart(**kwargs) + else: + from compas_fea2.model.parts import Part + + part = Part(**kwargs) + if not isinstance(part, _Part): raise TypeError("{!r} is not a part.".format(part)) diff --git a/src/compas_fea2/model/nodes.py b/src/compas_fea2/model/nodes.py index 740d306e1..d978a061c 100644 --- a/src/compas_fea2/model/nodes.py +++ b/src/compas_fea2/model/nodes.py @@ -1,7 +1,6 @@ from typing import Dict from typing import List from typing import Optional -import numpy as np from compas.geometry import Point from compas.tolerance import TOL diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index 7ce6326d7..c7d5c4823 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -45,9 +45,8 @@ from .elements import _Element1D from .elements import _Element2D from .elements import _Element3D -from .groups import ElementsGroup -from .groups import FacesGroup -from .groups import NodesGroup +from .groups import ElementsGroup, FacesGroup, NodesGroup, MaterialsGroup, SectionsGroup + from .materials.material import _Material from .nodes import Node from .releases import _BeamEndRelease @@ -88,12 +87,6 @@ class _Part(FEAData): 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. boundary_mesh : :class:`compas.datastructures.Mesh` The outer boundary mesh enveloping the Part. discretized_boundary_mesh : :class:`compas.datastructures.Mesh` @@ -117,10 +110,6 @@ def __init__(self, **kwargs): self._elements: Set[_Element] = set() self._releases: Set[_BeamEndRelease] = set() - self._nodesgroups: Set[NodesGroup] = set() - self._elementsgroups: Set[ElementsGroup] = set() - self._facesgroups: Set[FacesGroup] = set() - self._boundary_mesh = None self._discretized_boundary_mesh = None @@ -136,9 +125,6 @@ def __data__(self): "sections": [section.__data__ for section in self.sections], "elements": [element.__data__ for element in self.elements], "releases": [release.__data__ for release in self.releases], - "nodesgroups": [group.__data__ for group in self.nodesgroups], - "elementsgroups": [group.__data__ for group in self.elementsgroups], - "facesgroups": [group.__data__ for group in self.facesgroups], } def to_hdf5_data(self, hdf5_file, mode="a"): @@ -222,11 +208,11 @@ def graph(self): @property def nodes(self) -> Set[Node]: - return self._nodes + return NodesGroup(self._nodes) @property def nodes_sorted(self) -> List[Node]: - return sorted(self.nodes, key=lambda x: x.part_key) + return self.nodes.sorted_by(key=lambda x: x.part_key) @property def points(self) -> List[List[float]]: @@ -238,20 +224,21 @@ def points_sorted(self) -> List[List[float]]: @property def elements(self) -> Set[_Element]: - return self._elements + return ElementsGroup(self._elements) @property def elements_sorted(self) -> List[_Element]: - return sorted(self.elements, key=lambda x: x.part_key) + return self.elments.sorted_by(key=lambda x: x.part_key) @property def elements_grouped(self) -> Dict[int, List[_Element]]: - elements_group = groupby(self.elements, key=lambda x: x.__class__.__base__) - return {key: list(group) for key, group in elements_group} + 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"]]]: - return [face for element in self.elements for face in element.faces] + face_group = FacesGroup([face for element in self.elements for face in element.faces]) + return face_group @property def elements_faces_grouped(self) -> Dict[int, List[List["Face"]]]: @@ -279,47 +266,35 @@ def elements_centroids(self) -> List[List[float]]: return [element.centroid for element in self.elements] @property - def sections(self) -> Set[_Section]: - return self._sections + def sections(self) -> SectionsGroup: + return SectionsGroup(self._sections) @property def sections_sorted(self) -> List[_Section]: - return sorted(self.sections, key=lambda x: x.part_key) + return self.sections.sorted_by(key=lambda x: x.part_key) @property def sections_grouped_by_element(self) -> Dict[int, List[_Section]]: - sections_group = groupby(self.sections, key=lambda x: x.element) - return {key: list(group) for key, group in sections_group} + 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) -> Set[_Material]: - return self._materials + def materials(self) -> MaterialsGroup: + return MaterialsGroup(self._materials) @property def materials_sorted(self) -> List[_Material]: - return sorted(self.materials, key=lambda x: x.part_key) + return self.materials.sorted_by(key=lambda x: x.part_key) @property def materials_grouped_by_section(self) -> Dict[int, List[_Material]]: - materials_group = groupby(self.materials, key=lambda x: x.section) - return {key: list(group) for key, group in materials_group} + materials_group = self.materials.group_by(key=lambda x: x.section) + return {key: group.members for key, group in materials_group} @property def releases(self) -> Set[_BeamEndRelease]: return self._releases - @property - def nodesgroups(self) -> Set[NodesGroup]: - return self._nodesgroups - - @property - def elementsgroups(self) -> Set[ElementsGroup]: - return self._elementsgroups - - @property - def facesgroups(self) -> Set[FacesGroup]: - return self._facesgroups - @property def gkey_node(self) -> Dict[str, Node]: return self._gkey_node @@ -952,7 +927,8 @@ def find_materials_by_name(self, name: str) -> List[_Material]: ------- List[_Material] """ - return [material for material in self.materials if material.name == name] + mg = MaterialsGroup(self.materials) + return mg.create_subgroup(condition=lambda x: x.name == name).materials def find_material_by_uid(self, uid: str) -> Optional[_Material]: """Find a material with a given unique identifier. @@ -1740,7 +1716,7 @@ def add_element(self, element: _Element, checks=True) -> _Element: self.add_section(element.section) element._part_key = len(self.elements) - self.elements.add(element) + self._elements.add(element) element._registration = self self.graph.add_node(element, type="element") @@ -1906,28 +1882,6 @@ def find_groups_by_name(self, name: str) -> List[Union[NodesGroup, ElementsGroup """ return [group for group in self.groups if group.name == name] - def contains_group(self, group: Union[NodesGroup, ElementsGroup, FacesGroup]) -> bool: - """Verify that the part contains a specific group. - - Parameters - ---------- - group : Union[NodesGroup, ElementsGroup, FacesGroup] - The group to check. - - Returns - ------- - bool - True if the group is in the part, False otherwise. - """ - 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(f"{group!r} is not a valid Group") - def add_group(self, group: Union[NodesGroup, ElementsGroup, FacesGroup]) -> Union[NodesGroup, ElementsGroup, FacesGroup]: """Add a node or element group to the part. From 0c73e7adf30e33c9212f3bd05d263a47dbab599f Mon Sep 17 00:00:00 2001 From: franaudo Date: Sat, 22 Feb 2025 10:47:07 +0100 Subject: [PATCH 23/39] part_method --- src/compas_fea2/model/model.py | 38 +--- src/compas_fea2/model/parts.py | 332 +++++++++++----------------- src/compas_fea2/utilities/_utils.py | 21 +- 3 files changed, 143 insertions(+), 248 deletions(-) diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index 7360ca922..44c659e31 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -822,22 +822,7 @@ def find_node_by_key(self, key: int): @get_docstring(_Part) @part_method - def find_node_by_inputkey(self, input_key: int): - pass - - @get_docstring(_Part) - @part_method - def find_nodes_by_name(self, name: str): - pass - - @get_docstring(_Part) - @part_method - def find_nodes_around_point(self, point: Point, distance: float, plane: Optional[Plane] = None, single: bool = False): - pass - - @get_docstring(_Part) - @part_method - def find_nodes_around_node(self, node: Node, distance: float): + def find_node_by_name(self, name: str): pass @get_docstring(_Part) @@ -847,22 +832,12 @@ def find_closest_nodes_to_node(self, node: Node, distance: float, number_of_node @get_docstring(_Part) @part_method - def find_nodes_by_attribute(self, attr: str, value: Union[str, int, float], tolerance: float = 1): + def find_nodes_on_plane(self, plane: Plane, tol: float = 1) -> NodesGroup: pass @get_docstring(_Part) @part_method - def find_nodes_on_plane(self, plane: Plane, tolerance: float = 1): - pass - - @get_docstring(_Part) - @part_method - def find_nodes_in_polygon(self, polygon: Polygon, tolerance: float = 1.1): - pass - - @get_docstring(_Part) - @part_method - def find_nodes_where(self, conditions: dict): + def find_nodes_in_polygon(self, polygon: Polygon, tol: float = 1.1): pass @get_docstring(_Part) @@ -881,12 +856,7 @@ def find_element_by_key(self, key: int): @get_docstring(_Part) @part_method - def find_element_by_inputkey(self, key: int): - pass - - @get_docstring(_Part) - @part_method - def find_elements_by_name(self, name: str): + def find_element_by_name(self, name: str): pass # ========================================================================= diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index c7d5c4823..a59ff9149 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -207,7 +207,7 @@ def graph(self): return self._graph @property - def nodes(self) -> Set[Node]: + def nodes(self) -> NodesGroup: return NodesGroup(self._nodes) @property @@ -215,17 +215,21 @@ def nodes_sorted(self) -> List[Node]: return self.nodes.sorted_by(key=lambda x: x.part_key) @property - def points(self) -> List[List[float]]: - return [node.xyz for node in self.nodes] + def points(self) -> List[Point]: + return [node.point for node in self._nodes] @property - def points_sorted(self) -> List[List[float]]: - return [node.xyz for node in sorted(self.nodes, key=lambda x: x.part_key)] + def points_sorted(self) -> List[Point]: + return [node.point for node in self.nodes.sorted_by(key=lambda x: x.part_key)] @property - def elements(self) -> Set[_Element]: + 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) @@ -238,6 +242,7 @@ def elements_grouped(self) -> Dict[int, List[_Element]]: @property def elements_faces(self) -> List[List[List["Face"]]]: 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 @@ -1128,7 +1133,7 @@ def find_node_by_uid(self, uid: str) -> Optional[Node]: The corresponding node, or None if not found. """ - for node in self.nodes: + for node in self._nodes: if node.uid == uid: return node return None @@ -1147,32 +1152,14 @@ def find_node_by_key(self, key: int) -> Optional[Node]: The corresponding node, or None if not found. """ - for node in self.nodes: + 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_inputkey(self, input_key: int) -> Optional[Node]: - """Retrieve a node in the model using its input key. - - Parameters - ---------- - input_key : int - The node's input key. - - Returns - ------- - Optional[Node] - The corresponding node, or None if not found. - - """ - for node in self.nodes: - if node.input_key == input_key: - return node - return None - - def find_nodes_by_name(self, name: str) -> List[Node]: - """Find all nodes with a given name. + def find_node_by_name(self, name: str) -> List[Node]: + """Find a node with a given name. Parameters ---------- @@ -1184,73 +1171,93 @@ def find_nodes_by_name(self, name: str) -> List[Node]: List of nodes with the given name. """ - return [node for node in self.nodes if node.name == name] - - def find_nodes_around_point( - self, point: List[float], distance: float, plane: Optional[Plane] = None, report: bool = False, single: bool = False, **kwargs - ) -> Union[List[Node], Dict[Node, float], Optional[Node]]: - """Find all nodes within a distance of a given geometrical location. - - Parameters - ---------- - point : List[float] - A geometrical location. - distance : float - Distance from the location. - plane : Optional[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. - - Returns - ------- - Union[List[Node], Dict[Node, float], Optional[Node]] - List of nodes, or dictionary with nodes and distances if report=True, - or the closest node if single=True. - - """ - d2 = distance**2 - nodes = self.find_nodes_on_plane(plane) if plane else self.nodes - if report: - return {node: sqrt(distance_point_point_sqrd(node.xyz, point)) 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 not nodes: - if compas_fea2.VERBOSE: - print(f"No nodes found at {point}") - return [] if not single else None - return nodes[0] if single else nodes + for node in self._nodes: + if node.name == name: + return node + print(f"No nodes found with name {name}") + return None - def find_nodes_around_node( - self, node: Node, distance: float, plane: Optional[Plane] = None, report: bool = False, single: bool = False - ) -> Union[List[Node], Dict[Node, float], Optional[Node]]: - """Find all nodes around a given node (excluding the node itself). + def find_nodes_on_plane(self, plane: Plane, tol: float = 1.0) -> List[Node]: + """Find all nodes on a given plane. Parameters ---------- - node : Node - The given node. - distance : float - Search radius. - plane : Optional[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. + plane : Plane + The plane. + tol : float, optional + Tolerance for the search, by default 1.0. Returns ------- - Union[List[Node], Dict[Node, float], Optional[Node]] - List of nodes, or dictionary with nodes and distances if report=True, or the closest node if single=True. + List[Node] + List of nodes on the given plane. """ - 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 + return self.nodes.create_subgroup(condition=lambda x: is_point_on_plane(x.point, plane, tol)) + + # def find_nodes_around_point( + # self, point: List[float], distance: float, plane: Optional[Plane] = None, report: bool = False, single: bool = False, **kwargs + # ) -> Union[List[Node], Dict[Node, float], Optional[Node]]: + # """Find all nodes within a distance of a given geometrical location. + + # Parameters + # ---------- + # point : List[float] + # A geometrical location. + # distance : float + # Distance from the location. + # plane : Optional[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. + + # Returns + # ------- + # Union[List[Node], Dict[Node, float], Optional[Node]] + # List of nodes, or dictionary with nodes and distances if report=True, + # or the closest node if single=True. + + # """ + # d2 = distance**2 + # nodes = self.find_nodes_on_plane(plane) if plane else self.nodes + # if report: + # return {node: sqrt(distance_point_point_sqrd(node.xyz, point)) 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 not nodes: + # print(f"No nodes found at {point} within {distance}") + # return None + # return nodes[0] if single else NodesGroup(nodes) + + # def find_nodes_around_node( + # self, node: Node, distance: float, plane: Optional[Plane] = None, report: bool = False, single: bool = False + # ) -> Union[List[Node], Dict[Node, float], Optional[Node]]: + # """Find all nodes around a given node (excluding the node itself). + + # Parameters + # ---------- + # node : Node + # The given node. + # distance : float + # Search radius. + # plane : Optional[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. + + # Returns + # ------- + # Union[List[Node], Dict[Node, float], Optional[Node]] + # List of nodes, or dictionary with nodes and distances if report=True, or the closest node if single=True. + # """ + # 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 def find_closest_nodes_to_point(self, point: List[float], number_of_nodes: int = 1, report: bool = False) -> Union[List[Node], Dict[Node, float]]: """ @@ -1287,7 +1294,7 @@ def find_closest_nodes_to_point(self, point: List[float], number_of_nodes: int = # Return a dictionary with nodes and their distances return {node: distance for node, distance in zip(closest_nodes, distances)} - return closest_nodes + return NodesGroup(closest_nodes) def find_closest_nodes_to_node(self, node: Node, number_of_nodes: int = 1, report: Optional[bool] = False) -> List[Node]: """Find the n closest nodes around a given node (excluding the node itself). @@ -1310,42 +1317,6 @@ def find_closest_nodes_to_node(self, node: Node, number_of_nodes: int = 1, repor """ return self.find_closest_nodes_to_point(node.xyz, number_of_nodes, report=report) - def find_nodes_by_attribute(self, attr: str, value: float, tolerance: float = 0.001) -> List[Node]: - """Find all nodes with a given value for the given attribute. - - Parameters - ---------- - attr : str - Attribute name. - value : float - Appropriate value for the given attribute. - tolerance : float, optional - Tolerance for numeric attributes, by default 0.001. - - Returns - ------- - List[Node] - List of nodes with the given attribute value. - """ - return list(filter(lambda x: abs(getattr(x, attr) - value) <= tolerance, self.nodes)) - - def find_nodes_on_plane(self, plane: Plane, tolerance: float = 1.0) -> List[Node]: - """Find all nodes on a given plane. - - Parameters - ---------- - plane : Plane - The plane. - tolerance : float, optional - Tolerance for the search, by default 1.0. - - Returns - ------- - List[Node] - List of nodes on the given plane. - """ - return list(filter(lambda x: is_point_on_plane(Point(*x.xyz), plane, tolerance), self.nodes)) - def find_nodes_in_polygon(self, polygon: "compas.geometry.Polygon", tolerance: float = 1.1) -> List[Node]: """Find the nodes of the part that are contained within a planar polygon. @@ -1366,38 +1337,12 @@ def find_nodes_in_polygon(self, polygon: "compas.geometry.Polygon", tolerance: f polygon.plane = Frame.from_points(*polygon.points[:3]) except Exception: polygon.plane = Frame.from_points(*polygon.points[-3:]) - 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)) + 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 list(filter(lambda x: is_point_in_polygon_xy(Point(*x.xyz).transformed(T), polygon_xy), nodes_on_plane)) - - def find_nodes_where(self, conditions: List[str]) -> List[Node]: - """Find the nodes where some conditions are met. - - Parameters - ---------- - conditions : List[str] - List with the strings of the required conditions. - - Returns - ------- - List[Node] - List of nodes meeting the conditions. - """ - import re - - nodes = [] - for condition in conditions: - part_nodes = self.nodes if not nodes else list(set.intersection(*nodes)) - 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)) + return nodes_on_plane.create_subgroup(condition=lambda x: is_point_in_polygon_xy(Point(*x.xyz).transformed(T), polygon_xy)) def contains_node(self, node: Node) -> bool: """Verify that the part contains a given node. @@ -1564,7 +1509,6 @@ def compute_nodal_masses(self) -> List[float]: for node in self.nodes: for i in range(len(node.mass)): node.mass[i] = 0.0 - for element in self.elements: 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] @@ -1639,25 +1583,7 @@ def find_element_by_key(self, key: int) -> Optional[_Element]: return element return None - def find_element_by_inputkey(self, input_key: int) -> Optional[_Element]: - """Retrieve an element in the model using its input key. - - Parameters - ---------- - input_key : int - The element's input key. - - Returns - ------- - Optional[_Element] - The corresponding element, or None if not found. - """ - for element in self.elements: - if element.input_key == input_key: - return element - return None - - def find_elements_by_name(self, name: str) -> List[_Element]: + def find_element_by_name(self, name: str) -> List[_Element]: """Find all elements with a given name. Parameters @@ -1669,7 +1595,10 @@ def find_elements_by_name(self, name: str) -> List[_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: _Element) -> bool: """Verify that the part contains a specific element. @@ -1830,13 +1759,20 @@ def find_faces_on_plane(self, plane: Plane) -> List["compas_fea2.model.Face"]: ----- The search is limited to solid elements. """ - # FIXME: review this method - 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.create_subgroup(condition=lambda x: isinstance(x, (_Element2D, _Element3D))) + faces_group = FacesGroup([face for element in elements_sub_group for face in element.faces]) + faces_group.create_subgroup(condition=lambda x: all(is_point_on_plane(node.xyz, plane) for node in x.nodes)) + return faces_group + + 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.create_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. @@ -1867,7 +1803,7 @@ def find_boundary_meshes(self, tol) -> List["compas.datastructures.Mesh"]: # Groups methods # ========================================================================= - def find_groups_by_name(self, name: str) -> List[Union[NodesGroup, ElementsGroup, FacesGroup]]: + def find_group_by_name(self, name: str) -> List[Union[NodesGroup, ElementsGroup, FacesGroup]]: """Find all groups with a given name. Parameters @@ -1880,7 +1816,11 @@ def find_groups_by_name(self, name: str) -> List[Union[NodesGroup, ElementsGroup List[Union[NodesGroup, ElementsGroup, FacesGroup]] List of groups with the given name. """ - return [group for group in self.groups if group.name == name] + 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: Union[NodesGroup, ElementsGroup, FacesGroup]) -> Union[NodesGroup, ElementsGroup, FacesGroup]: """Add a node or element group to the part. @@ -1899,26 +1839,10 @@ def add_group(self, group: Union[NodesGroup, ElementsGroup, FacesGroup]) -> Unio 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 group - 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 members of the group might have a different registration - return group + 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 + self._groups.add(group) def add_groups(self, groups: List[Union[NodesGroup, ElementsGroup, FacesGroup]]) -> List[Union[NodesGroup, ElementsGroup, FacesGroup]]: """Add multiple groups to the part. @@ -1954,7 +1878,7 @@ def sorted_nodes_by_displacement(self, step: "_Step", component: str = "length") The nodes sorted by displacement (ascending). """ - return sorted(self.nodes, key=lambda n: getattr(Vector(*n.results[step.problem][step].get("U", None)), component)) + return self.nodes.sorted_by(lambda n: getattr(Vector(*n.results[step].get("U", None)), component)) def get_max_displacement(self, problem: "Problem", step: Optional["_Step"] = None, component: str = "length") -> Tuple[Node, float]: # noqa: F821 """Retrieve the node with the maximum displacement diff --git a/src/compas_fea2/utilities/_utils.py b/src/compas_fea2/utilities/_utils.py index 41f4eee14..e6c8c589c 100644 --- a/src/compas_fea2/utilities/_utils.py +++ b/src/compas_fea2/utilities/_utils.py @@ -169,17 +169,18 @@ def part_method(f): @wraps(f) def wrapper(*args, **kwargs): - try: - 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): - res = list(itertools.chain.from_iterable(res)) - # res = list(itertools.chain.from_iterable(res)) + 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): + res = list(itertools.chain.from_iterable(res)) + return res + # if res is a Group + if "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 - except Exception as e: - print(f"An error occurred in part_method: {e}") - raise return wrapper From 1edbf53c3a5f92b2cd71b7d6812f315f48d01305 Mon Sep 17 00:00:00 2001 From: franaudo Date: Sun, 23 Feb 2025 08:41:10 +0100 Subject: [PATCH 24/39] groups --- src/compas_fea2/model/groups.py | 9 ++-- src/compas_fea2/model/model.py | 19 ++++--- src/compas_fea2/model/parts.py | 83 ++++------------------------- src/compas_fea2/model/sections.py | 6 +++ src/compas_fea2/results/fields.py | 4 ++ src/compas_fea2/utilities/_utils.py | 7 ++- 6 files changed, 41 insertions(+), 87 deletions(-) diff --git a/src/compas_fea2/model/groups.py b/src/compas_fea2/model/groups.py index 9957792bb..a82712139 100644 --- a/src/compas_fea2/model/groups.py +++ b/src/compas_fea2/model/groups.py @@ -94,7 +94,7 @@ def sorted_by(self, key: Callable[[T], any], reverse: bool = False) -> List[T]: """ return sorted(self._members, key=key, reverse=reverse) - def create_subgroup(self, condition: Callable[[T], bool], **kwargs) -> "_Group": + def subgroup(self, condition: Callable[[T], bool], **kwargs) -> "_Group": """ Create a subgroup based on a given condition. @@ -126,9 +126,12 @@ def group_by(self, key: Callable[[T], any]) -> Dict[any, "_Group"]: Dict[any, _Group] A dictionary where keys are the grouping values and values are `_Group` instances. """ - sorted_members = sorted(self._members, key=key) + 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__(members=v, name=f"{self.name}_{k}") for k, v in grouped_members.items()} + return {k: self.__class__(v, name=f"{self.name}") for k, v in grouped_members.items()} def union(self, other: "_Group") -> "_Group": """ diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index 44c659e31..1088ed5ea 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -280,7 +280,10 @@ def elements(self) -> list[_Element]: @property def bounding_box(self) -> Optional[Box]: - bb = bounding_box(list(chain.from_iterable([part.bounding_box.points for part in self.parts if part.bounding_box]))) + try: + bb = bounding_box(list(chain.from_iterable([part.bounding_box.points for part in self.parts if part.bounding_box]))) + except Exception: + return None return Box.from_bounding_box(bb) @property @@ -817,17 +820,17 @@ def find_sections_by_attribute(self, attr: str, value: Union[str, int, float], t @get_docstring(_Part) @part_method - def find_node_by_key(self, key: int): + def find_node_by_key(self, key: int) -> Node: pass @get_docstring(_Part) @part_method - def find_node_by_name(self, name: str): + def find_node_by_name(self, name: str) -> Node: pass @get_docstring(_Part) @part_method - def find_closest_nodes_to_node(self, node: Node, distance: float, number_of_nodes: int = 1, plane: Optional[Plane] = None): + 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) @@ -837,12 +840,12 @@ def find_nodes_on_plane(self, plane: Plane, tol: float = 1) -> NodesGroup: @get_docstring(_Part) @part_method - def find_nodes_in_polygon(self, polygon: Polygon, tol: float = 1.1): + def find_nodes_in_polygon(self, polygon: Polygon, tol: float = 1.1) -> NodesGroup: pass @get_docstring(_Part) @part_method - def contains_node(self, node: Node): + def contains_node(self, node: Node) -> Node: pass # ========================================================================= @@ -851,12 +854,12 @@ def contains_node(self, node: Node): @get_docstring(_Part) @part_method - def find_element_by_key(self, key: int): + def find_element_by_key(self, key: int) -> _Element: pass @get_docstring(_Part) @part_method - def find_element_by_name(self, name: str): + def find_element_by_name(self, name: str) -> _Element: pass # ========================================================================= diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index a59ff9149..30e84ad89 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -933,7 +933,7 @@ def find_materials_by_name(self, name: str) -> List[_Material]: List[_Material] """ mg = MaterialsGroup(self.materials) - return mg.create_subgroup(condition=lambda x: x.name == name).materials + return mg.subgroup(condition=lambda x: x.name == name).materials def find_material_by_uid(self, uid: str) -> Optional[_Material]: """Find a material with a given unique identifier. @@ -1192,81 +1192,16 @@ def find_nodes_on_plane(self, plane: Plane, tol: float = 1.0) -> List[Node]: List[Node] List of nodes on the given plane. """ - return self.nodes.create_subgroup(condition=lambda x: is_point_on_plane(x.point, plane, tol)) - - # def find_nodes_around_point( - # self, point: List[float], distance: float, plane: Optional[Plane] = None, report: bool = False, single: bool = False, **kwargs - # ) -> Union[List[Node], Dict[Node, float], Optional[Node]]: - # """Find all nodes within a distance of a given geometrical location. - - # Parameters - # ---------- - # point : List[float] - # A geometrical location. - # distance : float - # Distance from the location. - # plane : Optional[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. - - # Returns - # ------- - # Union[List[Node], Dict[Node, float], Optional[Node]] - # List of nodes, or dictionary with nodes and distances if report=True, - # or the closest node if single=True. - - # """ - # d2 = distance**2 - # nodes = self.find_nodes_on_plane(plane) if plane else self.nodes - # if report: - # return {node: sqrt(distance_point_point_sqrd(node.xyz, point)) 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 not nodes: - # print(f"No nodes found at {point} within {distance}") - # return None - # return nodes[0] if single else NodesGroup(nodes) - - # def find_nodes_around_node( - # self, node: Node, distance: float, plane: Optional[Plane] = None, report: bool = False, single: bool = False - # ) -> Union[List[Node], Dict[Node, float], Optional[Node]]: - # """Find all nodes around a given node (excluding the node itself). - - # Parameters - # ---------- - # node : Node - # The given node. - # distance : float - # Search radius. - # plane : Optional[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. - - # Returns - # ------- - # Union[List[Node], Dict[Node, float], Optional[Node]] - # List of nodes, or dictionary with nodes and distances if report=True, or the closest node if single=True. - # """ - # 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 + 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) -> Union[List[Node], Dict[Node, float]]: """ - Find the closest number_of_nodes nodes to a given point in the part. + Find the closest number_of_nodes nodes to a given point. Parameters ---------- - point : List[float] - List of coordinates representing the point in x, y, z. + 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 @@ -1342,7 +1277,7 @@ def find_nodes_in_polygon(self, polygon: "compas.geometry.Polygon", tolerance: f 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.create_subgroup(condition=lambda x: is_point_in_polygon_xy(Point(*x.xyz).transformed(T), polygon_xy)) + 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: Node) -> bool: """Verify that the part contains a given node. @@ -1759,9 +1694,9 @@ def find_faces_on_plane(self, plane: Plane) -> List["compas_fea2.model.Face"]: ----- The search is limited to solid elements. """ - elements_sub_group = self.elements.create_subgroup(condition=lambda x: isinstance(x, (_Element2D, _Element3D))) + 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_group.create_subgroup(condition=lambda x: all(is_point_on_plane(node.xyz, plane) for node in x.nodes)) + faces_group.subgroup(condition=lambda x: all(is_point_on_plane(node.xyz, plane) for node in x.nodes)) return faces_group def find_boudary_faces(self) -> List["compas_fea2.model.Face"]: @@ -1772,7 +1707,7 @@ def find_boudary_faces(self) -> List["compas_fea2.model.Face"]: list[:class:`compas_fea2.model.Face`] List with the boundary faces. """ - return self.faces.create_subgroup(condition=lambda x: all(node.on_boundary for node in x.nodes)) + 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. diff --git a/src/compas_fea2/model/sections.py b/src/compas_fea2/model/sections.py index 939d9cd86..01da98a52 100644 --- a/src/compas_fea2/model/sections.py +++ b/src/compas_fea2/model/sections.py @@ -294,6 +294,7 @@ def __init__(self, *, A: float, Ixx: float, Iyy: float, Ixy: float, Avx: float, self.Avx = Avx self.Avy = Avy self.J = J + @property def __data__(self): @@ -1110,6 +1111,10 @@ class ISection(BeamSection): 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): @@ -1603,6 +1608,7 @@ class RectangularSection(BeamSection): 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): diff --git a/src/compas_fea2/results/fields.py b/src/compas_fea2/results/fields.py index d78f19b83..fffd3e99a 100644 --- a/src/compas_fea2/results/fields.py +++ b/src/compas_fea2/results/fields.py @@ -521,6 +521,10 @@ def field_name(self): def results_func(self): return self._results_func + @property + def components_names(self): + return ["Fx1", "Fy1", "Fz1", "Mx1", "My1", "Mz1", "Fx2", "Fy2", "Fz2", "Mx2", "My2", "Mz2"] + def get_element_forces(self, element): """Get the section forces for a given element. diff --git a/src/compas_fea2/utilities/_utils.py b/src/compas_fea2/utilities/_utils.py index e6c8c589c..d28fdc66e 100644 --- a/src/compas_fea2/utilities/_utils.py +++ b/src/compas_fea2/utilities/_utils.py @@ -172,11 +172,14 @@ 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)) return res # if res is a Group - if "Group" in str(res[0].__class__): + elif "Group" in str(res[0].__class__): combined_members = set.union(*(group._members for group in res)) return res[0].__class__(combined_members) else: From bff7866b54e52fd71a4203e89bc724fbf8cc9060 Mon Sep 17 00:00:00 2001 From: franaudo Date: Sun, 9 Mar 2025 14:09:10 +0100 Subject: [PATCH 25/39] interfaces --- src/compas_fea2/model/__init__.py | 9 + src/compas_fea2/model/groups.py | 2 +- src/compas_fea2/model/interactions.py | 197 ++++++++++++++++++ src/compas_fea2/model/interfaces.py | 175 ++++++---------- src/compas_fea2/model/model.py | 44 ++++ src/compas_fea2/model/parts.py | 39 ++-- .../problem/steps/perturbations.py | 46 ++-- src/compas_fea2/problem/steps/static.py | 2 +- src/compas_fea2/problem/steps/step.py | 4 +- src/compas_fea2/results/database.py | 12 +- src/compas_fea2/results/modal.py | 1 + 11 files changed, 371 insertions(+), 160 deletions(-) create mode 100644 src/compas_fea2/model/interactions.py diff --git a/src/compas_fea2/model/__init__.py b/src/compas_fea2/model/__init__.py index d12914fed..77a6a4842 100644 --- a/src/compas_fea2/model/__init__.py +++ b/src/compas_fea2/model/__init__.py @@ -114,6 +114,15 @@ Interface, ) +from .interactions import ( + _Interaction, + Contact, + HardContactFrictionPenalty, + HardContactNoFriction, + LinearContactFrictionPenalty, + HardContactRough, +) + __all__ = [ "Model", "Part", diff --git a/src/compas_fea2/model/groups.py b/src/compas_fea2/model/groups.py index a82712139..3e70ee933 100644 --- a/src/compas_fea2/model/groups.py +++ b/src/compas_fea2/model/groups.py @@ -472,7 +472,7 @@ class FacesGroup(_Group): """ - def __init__(self, *, faces, **kwargs): + def __init__(self, faces, **kwargs): super(FacesGroup, self).__init__(members=faces, **kwargs) @property diff --git a/src/compas_fea2/model/interactions.py b/src/compas_fea2/model/interactions.py new file mode 100644 index 000000000..e0e75f55f --- /dev/null +++ b/src/compas_fea2/model/interactions.py @@ -0,0 +1,197 @@ +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +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, mu, tolerance, **kwargs) -> None: + super(HardContactFrictionPenalty, self).__init__(normal="HARD", tangent=mu, **kwargs) + self.tolerance = tolerance + + +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, tolerance, **kwargs) -> None: + super(HardContactFrictionPenalty, self).__init__(normal="HARD", tangent=mu, **kwargs) + self._tolerance = tolerance + + @property + def tolerance(self): + return self._tolerance + + @tolerance.setter + def tolerance(self, value): + self._tolerance = 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 index f040c3d84..051b27a89 100644 --- a/src/compas_fea2/model/interfaces.py +++ b/src/compas_fea2/model/interfaces.py @@ -3,138 +3,79 @@ from __future__ import print_function from compas_fea2.base import FEAData -from compas.datastructures import Mesh -from compas.geometry import Frame -from compas.geometry import Line -from compas.geometry import Point -from compas.geometry import Polygon -from compas.geometry import Transformation -from compas.geometry import centroid_points_weighted -from compas.geometry import dot_vectors -from compas.geometry import transform_points -from compas.itertools import pairwise -from compas.geometry import bestfit_frame_numpy class Interface(FEAData): - """ - A data structure for representing interfaces between blocks - and managing their geometrical and structural properties. + """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 ---------- - size - points - frame - forces - mesh + 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 ---------- - points : list[:class:`Point`] - The corner points of the interface polygon. - size : float - The area of the interface polygon. - frame : :class:`Frame` - The local coordinate frame of the interface polygon. - polygon : :class:`Polygon` - The polygon defining the contact interface. - mesh : :class:`Mesh` - A mesh representation of the interface. - kern : :class:`Polygon` - The "kern" part of the interface polygon. - forces : list[dict] - A dictionary of force components per interface point. - Each dictionary contains the following items: ``{"c_np": ..., "c_nn": ..., "c_u": ..., "c_v": ...}``. - stressdistribution : ??? - ??? - normalforces : list[:class:`Line`] - A list of lines representing the normal components of the contact forces at the corners of the interface. - The length of each line is proportional to the magnitude of the corresponding force. - compressionforces : list[:class:`Line`] - A list of lines representing the compression components of the normal contact forces - at the corners of the interface. - The length of each line is proportional to the magnitude of the corresponding force. - tensionforces : list[:class:`Line`] - A list of lines representing the tension components of the normal contact forces - at the corners of the interface. - The length of each line is proportional to the magnitude of the corresponding force. - frictionforces : list[:class:`Line`] - A list of lines representing the friction or tangential components of the contact forces - at the corners of the interface. - The length of each line is proportional to the magnitude of the corresponding force. - resultantforce : list[:class:`Line`] - A list with a single line representing the resultant of all the contact forces at the corners of the interface. - The length of the line is proportional to the magnitude of the resultant force. - resultantpoint : :class:`Point` - The point of application of the resultant force on the interface. + 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. """ - @property - def __data__(self): - return { - "points": self.boundary_points, - "size": self.size, - "frame": self.frame, - "forces": self.forces, - "mesh": self.mesh, - } - - @classmethod - def __from_data__(cls, data): - """Construct an interface from a data dict. - - Parameters - ---------- - data : dict - The data dictionary. - - Returns - ------- - :class:`compas_assembly.datastructures.Interface` - - """ - return cls(**data) - - def __init__( - self, - mesh: Mesh = None, - ): - super(Interface, self).__init__() - self._mesh = mesh - self._frame = None - - @property - def mesh(self) -> Mesh: - return self._mesh - - @mesh.setter - def mesh(self, mesh): - self._mesh = mesh - - @property - def average_plane(self): - pass - - @property - def points(self): - return [Point(*self.mesh.vertex_coordinates(v)) for v in self.mesh.vertices()] - - @property - def boundary_points(self): - return [Point(*self.mesh.vertex_coordinates(v)) for v in self.mesh.vertices_on_boundary()] + 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 polygon(self): - return Polygon(self.boundary_points) + def master(self): + return self._master @property - def area(self): - return self.mesh.area() + def slave(self): + return self._slave @property - def frame(self): - if self._frame is None: - self._frame = Frame(*bestfit_frame_numpy(self.boundary_points)) - return self._frame + 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/model.py b/src/compas_fea2/model/model.py index 1088ed5ea..ba994026c 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -26,6 +26,7 @@ from compas_fea2.base import FEAData from compas_fea2.model.bcs import _BoundaryCondition from compas_fea2.model.connectors import Connector +from compas_fea2.model.interfaces import Interface from compas_fea2.model.constraints import _Constraint from compas_fea2.model.elements import _Element from compas_fea2.model.groups import ElementsGroup @@ -101,6 +102,7 @@ def __init__(self, description: Optional[str] = None, author: Optional[str] = No self._sections: Set[_Section] = set() self._bcs = {} self._ics = {} + self._interfaces: Set[Interface] = set() self._connectors: Set[Connector] = set() self._constraints: Set[_Constraint] = set() self._partsgroups: Set[PartsGroup] = set() @@ -278,6 +280,10 @@ def elements(self) -> list[_Element]: e += list(part.elements) return e + @property + def interfaces(self) -> Set[Interface]: + return self._interfaces + @property def bounding_box(self) -> Optional[Box]: try: @@ -1318,6 +1324,44 @@ def add_connector(self, connector): return connector + # ============================================================================== + # 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)) + interface._key = len(self._interfaces) + 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 # ============================================================================== diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index 30e84ad89..be6460af2 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -613,7 +613,14 @@ def from_compas_lines( mass = kwargs.get("mass", None) for line in lines: frame = Frame(line.start, xaxis, line.vector) - nodes = [prt.find_nodes_around_point(list(p), 1, single=True) or Node(list(p), mass=mass) for p in [line.start, line.end]] + + 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): @@ -1194,7 +1201,7 @@ def find_nodes_on_plane(self, plane: Plane, tol: float = 1.0) -> List[Node]: """ 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) -> Union[List[Node], Dict[Node, float]]: + 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. @@ -1213,17 +1220,25 @@ def find_closest_nodes_to_point(self, point: List[float], number_of_nodes: int = A list of the closest nodes, or a dictionary with nodes and distances if report=True. """ - if number_of_nodes <= 0: - raise ValueError("The number of nodes to find must be greater than 0.") if number_of_nodes > len(self.nodes): - raise ValueError("The number of nodes to find exceeds the available 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(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 single: + return list(self.nodes)[indices] + else: + distances = [distances] + indices = [indices] + closest_nodes = [list(self.nodes)[i] for i in indices] if report: # Return a dictionary with nodes and their distances @@ -1231,7 +1246,7 @@ def find_closest_nodes_to_point(self, point: List[float], number_of_nodes: int = return NodesGroup(closest_nodes) - def find_closest_nodes_to_node(self, node: Node, number_of_nodes: int = 1, report: Optional[bool] = False) -> List[Node]: + 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 @@ -1250,7 +1265,7 @@ def find_closest_nodes_to_node(self, node: Node, number_of_nodes: int = 1, repor List[Node] List of the closest nodes. """ - return self.find_closest_nodes_to_point(node.xyz, number_of_nodes, report=report) + 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", tolerance: float = 1.1) -> List[Node]: """Find the nodes of the part that are contained within a planar polygon. @@ -1696,8 +1711,8 @@ def find_faces_on_plane(self, plane: Plane) -> List["compas_fea2.model.Face"]: """ 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_group.subgroup(condition=lambda x: all(is_point_on_plane(node.xyz, plane) for node in x.nodes)) - return faces_group + faces_subgroup = faces_group.subgroup(condition=lambda x: all(is_point_on_plane(node.xyz, plane) for node in x.nodes)) + return faces_subgroup def find_boudary_faces(self) -> List["compas_fea2.model.Face"]: """Find the boundary faces of the part. diff --git a/src/compas_fea2/problem/steps/perturbations.py b/src/compas_fea2/problem/steps/perturbations.py index b5a18676a..9e3723233 100644 --- a/src/compas_fea2/problem/steps/perturbations.py +++ b/src/compas_fea2/problem/steps/perturbations.py @@ -28,9 +28,11 @@ def __init__(self, **kwargs): def __data__(self): data = super(_Perturbation, self).__data__() - data.update({ - 'type': self.__class__.__name__, - }) + data.update( + { + "type": self.__class__.__name__, + } + ) return data @classmethod @@ -57,7 +59,7 @@ def __init__(self, modes=1, **kwargs): @property def rdb(self): - return self.problem.results_db + return self.problem.rdb def _get_results_from_db(self, mode, **kwargs): """Get the results for the given members and steps. @@ -82,8 +84,10 @@ def _get_results_from_db(self, mode, **kwargs): 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) - eigenvector = self.rdb.to_result(results_set, DisplacementResult)[self] + 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 @@ -156,14 +160,16 @@ def show_mode_shape(self, mode, fast=True, opacity=1, scale_results=1, show_bcs= def __data__(self): data = super(ModalAnalysis, self).__data__() - data.update({ - 'modes': self.modes, - }) + data.update( + { + "modes": self.modes, + } + ) return data @classmethod def __from_data__(cls, data): - return cls(modes=data['modes'], **data) + return cls(modes=data["modes"], **data) class ComplexEigenValue(_Perturbation): @@ -215,23 +221,19 @@ def Subspace( def __data__(self): data = super(BucklingAnalysis, self).__data__() - data.update({ - 'modes': self._modes, - 'vectors': self._vectors, - 'iterations': self._iterations, - 'algorithm': self._algorithm, - }) + 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 - ) + return cls(modes=data["_modes"], vectors=data["_vectors"], iterations=data["_iterations"], algorithm=data["_algorithm"], **data) class LinearStaticPerturbation(_Perturbation): diff --git a/src/compas_fea2/problem/steps/static.py b/src/compas_fea2/problem/steps/static.py index 198b2dd3e..fa6510c3c 100644 --- a/src/compas_fea2/problem/steps/static.py +++ b/src/compas_fea2/problem/steps/static.py @@ -82,7 +82,7 @@ def __init__( 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, diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index f4e95ad51..e5fb0c307 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -342,8 +342,8 @@ def time(self): return self._time @property - def nlgeometry(self): - return self.nlgeom + def nlgeom(self): + return self._nlgeom @property def modify(self): diff --git a/src/compas_fea2/results/database.py b/src/compas_fea2/results/database.py index 0fb4865dd..1fb62eba8 100644 --- a/src/compas_fea2/results/database.py +++ b/src/compas_fea2/results/database.py @@ -36,6 +36,11 @@ class JSONResultsDatabase(ResultsDatabase): def __init__(self, problem, **kwargs): super().__init__(problem=problem, **kwargs) + def get_results_set(self, filename, field): + 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.""" @@ -336,12 +341,9 @@ def to_result(self, results_set, results_func, field_name): for r in results_set: step = self.problem.find_step_by_name(r.pop("step")) results.setdefault(step, []) - part = self.model.find_part_by_name(r.pop("part")) or self.model.find_part_by_name(r.pop("part"), casefold=True) - if not part: - raise ValueError("Part not in model") - m = getattr(part, results_func)(r.pop("key")) + m = getattr(self.model, results_func)(r.pop("key"))[0] if not m: - raise ValueError(f"Member not in part {part.name}") + raise ValueError(f"Member not in {self.model}") results[step].append(m.results_cls[field_name](m, **r)) return results diff --git a/src/compas_fea2/results/modal.py b/src/compas_fea2/results/modal.py index 7ed96637b..d73cdb204 100644 --- a/src/compas_fea2/results/modal.py +++ b/src/compas_fea2/results/modal.py @@ -146,6 +146,7 @@ class ModalShape(NodeFieldResults): 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): From 19d2fa121f8bd943586d91eda92982530d0af1b2 Mon Sep 17 00:00:00 2001 From: Francesco Ranaudo Date: Thu, 20 Mar 2025 17:40:16 +0100 Subject: [PATCH 26/39] interfacesGroup --- src/compas_fea2/model/elements.py | 8 ++++++++ src/compas_fea2/model/model.py | 21 +++++++++++++-------- src/compas_fea2/utilities/_utils.py | 4 +--- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index f5660231d..4a9a02ed4 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -469,6 +469,14 @@ def plane(self) -> Plane: def element(self) -> Optional["_Element"]: return self._registration + @property + def part(self) -> "_Part": + return self.element.part + + @property + def model(self) -> "Model": + return self.element.model + @property def polygon(self) -> Polygon: return Polygon([n.xyz for n in self.nodes]) diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index ba994026c..22dc058b6 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -32,6 +32,7 @@ from compas_fea2.model.groups import ElementsGroup from compas_fea2.model.groups import NodesGroup from compas_fea2.model.groups import PartsGroup +from compas_fea2.model.groups import InterfacesGroup from compas_fea2.model.groups import _Group from compas_fea2.model.ics import _InitialCondition from compas_fea2.model.materials.material import _Material @@ -234,6 +235,14 @@ def sections_dict(self) -> dict[Union[_Part, "Model"], list[_Section]]: 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 @@ -264,9 +273,10 @@ def nodes_set(self) -> Set[Node]: @property def nodes(self) -> list[Node]: - n = [] - for part in self.parts: - n += list(part.nodes) + groups = [part.nodes for part in self.parts] + n = groups.pop(0) + for nodes in groups: + n += nodes return n @property @@ -280,10 +290,6 @@ def elements(self) -> list[_Element]: e += list(part.elements) return e - @property - def interfaces(self) -> Set[Interface]: - return self._interfaces - @property def bounding_box(self) -> Optional[Box]: try: @@ -1342,7 +1348,6 @@ def add_interface(self, interface): """ if not isinstance(interface, Interface): raise TypeError("{!r} is not an Interface.".format(interface)) - interface._key = len(self._interfaces) self._interfaces.add(interface) interface._registration = self diff --git a/src/compas_fea2/utilities/_utils.py b/src/compas_fea2/utilities/_utils.py index d28fdc66e..880cf48c6 100644 --- a/src/compas_fea2/utilities/_utils.py +++ b/src/compas_fea2/utilities/_utils.py @@ -101,9 +101,7 @@ def launch_process(cmd_args: list[str], cwd: Optional[str] = None, verbose: bool 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: - if verbose: - print(line.decode().strip()) - yield line + yield line.decode().strip() process.wait() if process.returncode != 0: From d705a8eee56a7108d1c3021ef3b2c04f6019fdd1 Mon Sep 17 00:00:00 2001 From: franaudo Date: Sat, 22 Mar 2025 11:29:30 +0100 Subject: [PATCH 27/39] removed patterns --- scripts/groupstest.py | 18 + scripts/pyvistatest.py | 9 + scripts/tributaryload.py | 54 +++ src/compas_fea2/model/elements.py | 8 + src/compas_fea2/model/model.py | 11 +- src/compas_fea2/model/parts.py | 39 ++- src/compas_fea2/problem/__init__.py | 25 +- src/compas_fea2/problem/combinations.py | 23 +- src/compas_fea2/problem/fields.py | 298 ++++++++++++++++ src/compas_fea2/problem/loads.py | 4 - src/compas_fea2/problem/patterns.py | 294 ---------------- src/compas_fea2/problem/steps/__init__.py | 8 +- src/compas_fea2/problem/steps/dynamic.py | 4 - .../problem/steps/perturbations.py | 16 +- src/compas_fea2/problem/steps/quasistatic.py | 4 - src/compas_fea2/problem/steps/static.py | 248 ------------- src/compas_fea2/problem/steps/step.py | 326 +++++++++++++++--- 17 files changed, 739 insertions(+), 650 deletions(-) create mode 100644 scripts/groupstest.py create mode 100644 scripts/pyvistatest.py create mode 100644 scripts/tributaryload.py delete mode 100644 src/compas_fea2/problem/patterns.py diff --git a/scripts/groupstest.py b/scripts/groupstest.py new file mode 100644 index 000000000..86596a627 --- /dev/null +++ b/scripts/groupstest.py @@ -0,0 +1,18 @@ +from compas_fea2.model import Model, Part, Steel, SolidSection, Node +from compas_fea2.model import PartsGroup, ElementsGroup, NodesGroup + + +mdl = Model(name="multibay") +prt = mdl.add_part(name="part1") + +nodes = [Node(xyz=[i, x, 0]) for i, x in enumerate(range(10))] +ng = NodesGroup(nodes=nodes, name="test") +print(ng) + +ng_sg = ng.create_subgroup(name="x1", condition=lambda n: n.x == 1) +print(ng_sg) + +new_group = ng-ng_sg +print(new_group) +# prt.add_nodes(nodes) +# print(mdl) diff --git a/scripts/pyvistatest.py b/scripts/pyvistatest.py new file mode 100644 index 000000000..6fd4ce007 --- /dev/null +++ b/scripts/pyvistatest.py @@ -0,0 +1,9 @@ +import pyvista as pv + +# Create a sphere +sphere = pv.Sphere() + +# Create a plotter and show the mesh +plotter = pv.Plotter() +plotter.add_mesh(sphere, color="blue") +plotter.show() \ No newline at end of file diff --git a/scripts/tributaryload.py b/scripts/tributaryload.py new file mode 100644 index 000000000..77702d915 --- /dev/null +++ b/scripts/tributaryload.py @@ -0,0 +1,54 @@ +import numpy as np +import matplotlib.pyplot as plt +from scipy.spatial import Delaunay +from compas.geometry import Polygon, Point + +# Define the polygon as a Shapely object +polygon_coords = [ + [0, 0], [5, 0], [6, 3], [3, 5], [0, 3], [0, 0] # Closing the polygon +] +polygon = Polygon(polygon_coords) + +# Generate random points inside the polygon +num_points = 20 + +points = [] + +while len(points) < num_points: + x, y = np.random.uniform(min_x, max_x), np.random.uniform(min_y, max_y) + if polygon.contains(Point(x, y)): + points.append([x, y]) + +points = np.array(points) + +# Add polygon vertices to ensure boundary fidelity +points = np.vstack((points, polygon_coords[:-1])) # Exclude duplicate last point + +# Perform Delaunay triangulation +tri = Delaunay(points) + +# Filter triangles to keep only those inside the polygon +valid_triangles = [] +for simplex in tri.simplices: + # Get the triangle vertices + triangle = Polygon(points[simplex]) + + # Check if the triangle is fully inside the polygon + if polygon.contains(triangle): + valid_triangles.append(simplex) + +# Plot results +plt.figure(figsize=(8, 6)) +plt.plot(*polygon.exterior.xy, 'k-', linewidth=2, label="Polygon Boundary") + +# Plot valid Delaunay triangles +for simplex in valid_triangles: + triangle = points[simplex] + plt.fill(triangle[:, 0], triangle[:, 1], edgecolor='black', fill=False) + +# Plot points +plt.scatter(points[:, 0], points[:, 1], color='red', zorder=3, label="Points") + +plt.legend() +plt.title("Delaunay Triangulation Inside a Polygon") +plt.show() \ No newline at end of file diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index 4a9a02ed4..4c5c2f3c1 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -15,6 +15,8 @@ from compas.geometry import Vector from compas.geometry import centroid_points from compas.geometry import distance_point_point + + from compas.itertools import pairwise from compas_fea2.base import FEAData @@ -504,6 +506,12 @@ def points(self) -> List["Point"]: @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): diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index 22dc058b6..91b587b8e 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -241,7 +241,7 @@ def interfaces(self) -> Set[Interface]: @property def interactions(self) -> Set[Interface]: - return self.interfaces.group_by(lambda x: getattr(x, 'behavior')) + return self.interfaces.group_by(lambda x: getattr(x, "behavior")) @property def problems(self) -> Set[Problem]: @@ -874,6 +874,15 @@ def find_element_by_key(self, key: int) -> _Element: def find_element_by_name(self, name: str) -> _Element: pass + # ========================================================================= + # Faces methods + # ========================================================================= + + @get_docstring(_Part) + @part_method + def find_faces_in_polygon(self, key: int) -> Node: + pass + # ========================================================================= # Groups methods # ========================================================================= diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index be6460af2..48857e6d8 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -1267,14 +1267,14 @@ def find_closest_nodes_to_node(self, node: Node, number_of_nodes: int = 1, repor """ 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", tolerance: float = 1.1) -> List[Node]: + 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. - tolerance : float, optional + tol : float, optional Tolerance for the search, by default 1.1. Returns @@ -1287,7 +1287,7 @@ def find_nodes_in_polygon(self, polygon: "compas.geometry.Polygon", tolerance: f polygon.plane = Frame.from_points(*polygon.points[:3]) except Exception: polygon.plane = Frame.from_points(*polygon.points[-3:]) - S = Scale.from_factors([tolerance] * 3, polygon.frame) + 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) @@ -1714,6 +1714,39 @@ def find_faces_on_plane(self, plane: Plane) -> List["compas_fea2.model.Face"]: faces_subgroup = faces_group.subgroup(condition=lambda x: all(is_point_on_plane(node.xyz, plane) for node in x.nodes)) return faces_subgroup + 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 + ---------- + polygon : compas.geometry.Polygon + The polygon for the search. + tol : float, optional + Tolerance for the search, by default 1.1. + + Returns + ------- + :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. diff --git a/src/compas_fea2/problem/__init__.py b/src/compas_fea2/problem/__init__.py index 3e4a251ee..d7ccb82fe 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 ( @@ -20,7 +16,14 @@ PrescribedTemperatureField, ) -from .patterns import Pattern, NodeLoadPattern, PointLoadPattern, LineLoadPattern, AreaLoadPattern, VolumeLoadPattern +from .fields import ( + LoadField, + NodeLoadField, + PointLoadField, + # LineLoadField, + # PressureLoadField, + # VolumeLoadField, +) from .combinations import LoadCombination from .steps import ( @@ -50,12 +53,12 @@ "HarmonicPointLoad", "HarmonicPressureLoad", "ThermalLoad", - "Pattern", - "NodeLoadPattern", - "PointLoadPattern", - "LineLoadPattern", - "AreaLoadPattern", - "VolumeLoadPattern", + "LoadField", + "NodeLoadField", + "PointLoadField", + "LineLoadField", + "PressureLoadField", + "VolumeLoadField", "_PrescribedField", "PrescribedTemperatureField", "LoadCombination", diff --git a/src/compas_fea2/problem/combinations.py b/src/compas_fea2/problem/combinations.py index 97d7836be..413f0c81d 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 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): @@ -72,11 +65,11 @@ 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: + for load_field in self.step.load_fields: + if load_field.load_case in self.factors: + for node, load in load_field.node_load: if node in nodes_loads: - nodes_loads[node] += load * self.factors[pattern.load_case] + nodes_loads[node] += load * self.factors[load_field.load_case] else: - nodes_loads[node] = load * self.factors[pattern.load_case] + 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/fields.py b/src/compas_fea2/problem/fields.py index 37894681d..3e635e9e4 100644 --- a/src/compas_fea2/problem/fields.py +++ b/src/compas_fea2/problem/fields.py @@ -1,4 +1,302 @@ +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 PressureLoad +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 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 LineLoadField(LoadField): +# """A distribution of a concentrated load over a given polyline. + +# 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, loads, polyline, load_case=None, tolerance=1, discretization=10, **kwargs): +# if not isinstance(loads, ConcentratedLoad): +# raise TypeError("LineLoadPattern only supports ConcentratedLoad") +# super(LineLoadField, self).__init__(loads, 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.loads] * self.nodes) + + +# class PressureLoadField(LoadField): +# """A distribution of a pressure load over a region defined by a polygon. +# The loads are distributed over the nodes within the region using their tributary area. + +# Parameters +# ---------- +# load : object +# The load to be applied. +# polygon : object +# The polygon defining the area where the load is distributed. +# planar : bool, optional +# If True, only the nodes in the same plane of the polygon are considered. Default is False. +# 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, pressure, polygon, planar=False, load_case=None, tolerance=1.05, **kwargs): +# if not isinstance(pressure, PressureLoad): +# raise TypeError("For the moment PressureLoadField only supports PressureLoad") + +# super().__init__(loads=pressure, 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.loads] * self.nodes) + + +# class VolumeLoadField(LoadField): +# """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(VolumeLoadField, 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.loads.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())) + + +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 97e8a5b03..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 diff --git a/src/compas_fea2/problem/patterns.py b/src/compas_fea2/problem/patterns.py deleted file mode 100644 index b3633ca98..000000000 --- a/src/compas_fea2/problem/patterns.py +++ /dev/null @@ -1,294 +0,0 @@ -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 : 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(Pattern, 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._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__(loads=load, 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 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, 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 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.loads] * 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") - compute_tributary_areas = False - super().__init__(loads=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.loads] * 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.loads.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())) - - -class GravityLoadPattern(Pattern): - """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(GravityLoadPattern, self).__init__(GravityLoad(g=g), parts, load_case, **kwargs) 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 91ca29081..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 diff --git a/src/compas_fea2/problem/steps/perturbations.py b/src/compas_fea2/problem/steps/perturbations.py index 9e3723233..d14b27d1b 100644 --- a/src/compas_fea2/problem/steps/perturbations.py +++ b/src/compas_fea2/problem/steps/perturbations.py @@ -14,13 +14,7 @@ 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): @@ -46,10 +40,8 @@ class ModalAnalysis(_Perturbation): Parameters ---------- - name : str - Name of the ModalStep. modes : int - Number of modes to analyse. + Number of modes. """ @@ -251,7 +243,7 @@ def __from_data__(cls, data): return cls(**data) -class StedyStateDynamic(_Perturbation): +class SteadyStateDynamic(_Perturbation): """""" def __init__(self, **kwargs): @@ -259,7 +251,7 @@ def __init__(self, **kwargs): raise NotImplementedError def __data__(self): - return super(StedyStateDynamic, self).__data__() + return super(SteadyStateDynamic, self).__data__() @classmethod def __from_data__(cls, data): diff --git a/src/compas_fea2/problem/steps/quasistatic.py b/src/compas_fea2/problem/steps/quasistatic.py index 575c6f60c..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 diff --git a/src/compas_fea2/problem/steps/static.py b/src/compas_fea2/problem/steps/static.py index fa6510c3c..b29313bac 100644 --- a/src/compas_fea2/problem/steps/static.py +++ b/src/compas_fea2/problem/steps/static.py @@ -1,12 +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 .step import GeneralStep @@ -65,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. @@ -115,243 +104,6 @@ def __from_data__(cls, data): # Add other attributes as needed ) - 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_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_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.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 - - """ - return self.add_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 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.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 - - """ - return self.add_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! - """ - - from compas_fea2.problem import ConcentratedLoad - - for part in parts: - part.compute_nodal_masses() - for node in part.nodes: - self.add_pattern( - NodeLoadPattern(load=ConcentratedLoad(x=node.mass[0] * g * x, y=node.mass[1] * g * y, z=node.mass[2] * g * z), nodes=[node], load_case=load_case, **kwargs) - ) - - # 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 e5fb0c307..3c70cbee3 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -1,6 +1,4 @@ -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 @@ -9,7 +7,6 @@ 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.results import DisplacementFieldResults from compas_fea2.results import ReactionFieldResults @@ -17,6 +14,9 @@ from compas_fea2.results import StressFieldResults from compas_fea2.UI import FEA2Viewer +from compas_fea2.problem.fields import NodeLoadField +from compas_fea2.problem.fields import PointLoadField + # ============================================================================== # Base Steps # ============================================================================== @@ -55,14 +55,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 @@ -83,8 +84,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): @@ -109,13 +110,13 @@ 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 + node.loads.setdefault(self, {}).setdefault(combination, {})[field] = factored_load if node._total_load: node._total_load += factored_load else: @@ -129,10 +130,9 @@ 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. @@ -206,7 +206,7 @@ def __data__(self): "history_outputs": list(self._history_outputs), "results": self._results, "key": self._key, - "patterns": list(self._patterns), + "patterns": list(self._load_fields), "load_cases": list(self._load_cases), "combination": self._combination, } @@ -219,7 +219,7 @@ def __from_data__(cls, data): obj._history_outputs = set(data["history_outputs"]) obj._results = data["results"] obj._key = data["key"] - obj._patterns = set(data["patterns"]) + obj._load_fields = set(data["load_fields"]) obj._load_cases = set(data["load_cases"]) obj._combination = data["combination"] return obj @@ -306,24 +306,14 @@ def __init__(self, max_increments, initial_inc_size, min_inc_size, time, nlgeom= 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)) + return list(filter(lambda p: isinstance(p.load, GeneralDisplacement), self._load_fields)) @property - def load_patterns(self): - return list(filter(lambda p: isinstance(p.load, Load), self._patterns)) - - @property - def fields(self): - return list(filter(lambda p: isinstance(p.load, _PrescribedField), self._patterns)) + def loads(self): + return list(filter(lambda p: isinstance(p.load, Load), self._load_fields)) @property def max_increments(self): @@ -358,9 +348,9 @@ def restart(self, value): self._restart = value # ============================================================================== - # Patterns + # Load Fields # ============================================================================== - def add_pattern(self, pattern, *kwargs): + def add_load_field(self, field, *kwargs): """Add a general :class:`compas_fea2.problem.patterns.Pattern` to the Step. Parameters @@ -373,17 +363,17 @@ def add_pattern(self, pattern, *kwargs): :class:`compas_fea2.problem.patterns.Pattern` """ - from compas_fea2.problem.patterns import Pattern + from compas_fea2.problem.fields import LoadField - if not isinstance(pattern, Pattern): - raise TypeError("{!r} is not a LoadPattern.".format(pattern)) + if not isinstance(field, LoadField): + raise TypeError("{!r} is not a LoadPattern.".format(field)) - self._patterns.add(pattern) - self._load_cases.add(pattern.load_case) - pattern._registration = self - return pattern + self._load_fields.add(field) + self._load_cases.add(field.load_case) + field._registration = self + return field - def add_patterns(self, patterns): + def add_load_fields(self, fields): """Add multiple :class:`compas_fea2.problem.patterns.Pattern` to the Problem. Parameters @@ -396,14 +386,254 @@ def add_patterns(self, patterns): list(:class:`compas_fea2.problem.patterns.Pattern`) """ - from typing import Iterable + 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 + + """ + return self.add_load_field( + LineLoadField(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 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' - patterns = patterns if isinstance(patterns, Iterable) else [patterns] - for pattern in patterns: - self.add_pattern(pattern) + 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): + 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_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 + + """ + 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)) # ============================================================================== - # Combination + # Combinations # ============================================================================== # ========================================================================= @@ -599,7 +829,7 @@ def __from_data__(cls, data): obj._history_outputs = set(data["history_outputs"]) obj._results = data["results"] obj._key = data["key"] - obj._patterns = set(data["patterns"]) + obj._load_fields = set(data["patterns"]) obj._load_cases = set(data["load_cases"]) obj._combination = data["combination"] return obj From 035176f104d98034200c7b3b2ec8a3b789db4950 Mon Sep 17 00:00:00 2001 From: franaudo Date: Sat, 22 Mar 2025 15:49:25 +0100 Subject: [PATCH 28/39] remove input_key --- src/compas_fea2/base.py | 10 ---------- src/compas_fea2/model/elements.py | 2 +- src/compas_fea2/model/groups.py | 9 +++++---- src/compas_fea2/model/interactions.py | 26 +++++++++++++++----------- src/compas_fea2/model/model.py | 22 +++++++++++++++------- src/compas_fea2/model/parts.py | 20 +++++++++++++------- 6 files changed, 49 insertions(+), 40 deletions(-) diff --git a/src/compas_fea2/base.py b/src/compas_fea2/base.py index 4eebb9834..10f92c98b 100644 --- a/src/compas_fea2/base.py +++ b/src/compas_fea2/base.py @@ -82,16 +82,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)) diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index 4c5c2f3c1..769e4d7b2 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -137,7 +137,7 @@ def nodes_key(self) -> str: @property def nodes_inputkey(self) -> str: - return "-".join(sorted([str(node.input_key) for node in self.nodes], key=int)) + return "-".join(sorted([str(node.key) for node in self.nodes], key=int)) @property def points(self) -> List["Point"]: diff --git a/src/compas_fea2/model/groups.py b/src/compas_fea2/model/groups.py index 3e70ee933..f2f8f7dd4 100644 --- a/src/compas_fea2/model/groups.py +++ b/src/compas_fea2/model/groups.py @@ -126,10 +126,11 @@ def group_by(self, key: Callable[[T], any]) -> Dict[any, "_Group"]: Dict[any, _Group] A dictionary where keys are the grouping values and values are `_Group` instances. """ - try: - sorted_members = sorted(self._members, key=key) - except TypeError: - sorted_members = sorted(self._members, key=lambda x: x.key) + 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()} diff --git a/src/compas_fea2/model/interactions.py b/src/compas_fea2/model/interactions.py index e0e75f55f..61708335b 100644 --- a/src/compas_fea2/model/interactions.py +++ b/src/compas_fea2/model/interactions.py @@ -88,9 +88,9 @@ class HardContactNoFriction(Contact): Slippage tollerance during contact. """ - def __init__(self, mu, tolerance, **kwargs) -> None: - super(HardContactFrictionPenalty, self).__init__(normal="HARD", tangent=mu, **kwargs) - self.tolerance = tolerance + def __init__(self, tol, **kwargs) -> None: + super().__init__(normal="HARD", tangent=None, **kwargs) + self._tol = tol class HardContactFrictionPenalty(Contact): @@ -115,17 +115,21 @@ class HardContactFrictionPenalty(Contact): Slippage tollerance during contact. """ - def __init__(self, *, mu, tolerance, **kwargs) -> None: + def __init__(self, mu, tol, **kwargs) -> None: super(HardContactFrictionPenalty, self).__init__(normal="HARD", tangent=mu, **kwargs) - self._tolerance = tolerance + self._tol = tol - @property - def tolerance(self): - return self._tolerance + @property + def mu(self): + return self._tangent - @tolerance.setter - def tolerance(self, value): - self._tolerance = value + @property + def tol(self): + return self._tol + + @tol.setter + def tol(self, value): + self._tol = value class LinearContactFrictionPenalty(Contact): diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index 91b587b8e..23d20c9e3 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -327,7 +327,7 @@ def units(self, value: UnitRegistry): return ValueError("Pint UnitRegistry required") self._units = value - def assign_keys(self, start: int = None): + def assign_keys(self, start: int = None, restart=False): """Assign keys to the model and its parts. Parameters @@ -347,11 +347,19 @@ def assign_keys(self, start: int = None): for i, section in enumerate(self.sections): section._key = i + start - for i, node in enumerate(self.nodes): - node._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 + 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 @@ -675,7 +683,7 @@ def find_material_by_inputkey(self, key: int) -> Optional[_Material]: """ for material in self.materials: - if material.input_key == key: + if material.key == key: return material def find_materials_by_attribute(self, attr: str, value: Union[str, int, float], tolerance: float = 1) -> list[_Material]: @@ -800,7 +808,7 @@ def find_section_by_inputkey(self, key: int) -> Optional[_Section]: """ for section in self.sections: - if section.input_key == key: + if section.key == key: return section def find_sections_by_attribute(self, attr: str, value: Union[str, int, float], tolerance: float = 1) -> list[_Section]: diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index 48857e6d8..e3ccf6123 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -45,7 +45,7 @@ from .elements import _Element1D from .elements import _Element2D from .elements import _Element3D -from .groups import ElementsGroup, FacesGroup, NodesGroup, MaterialsGroup, SectionsGroup +from .groups import ElementsGroup, FacesGroup, NodesGroup, MaterialsGroup, SectionsGroup, _Group from .materials.material import _Material from .nodes import Node @@ -110,6 +110,8 @@ def __init__(self, **kwargs): self._elements: Set[_Element] = set() self._releases: Set[_BeamEndRelease] = set() + self._groups: Set[_Group] = set() + self._boundary_mesh = None self._discretized_boundary_mesh = None @@ -538,6 +540,10 @@ def element_types(self) -> Dict[type, List[_Element]]: element_types.setdefault(type(element), []).append(element) return element_types + @property + def groups(self) -> Set[_Group]: + return self._groups + def transform(self, transformation: Transformation) -> None: """Transform the part. @@ -736,15 +742,15 @@ def from_gmsh(cls, gmshModel, section: Optional[Union[SolidSection, ShellSection if isinstance(section, ShellSection): part.add_element(ShellElement(nodes=element_nodes, section=section, rigid=rigid, implementation=implementation)) else: - part.add_element(TetrahedronElement(nodes=element_nodes, section=section)) + part.add_element(TetrahedronElement(nodes=element_nodes, section=section, rigid=rigid)) part.ndf = 3 # FIXME: try to move outside the loop elif ntags.size == 10: # C3D10 tetrahedral element - part.add_element(TetrahedronElement(nodes=element_nodes, section=section)) # Automatically supports C3D10 + part.add_element(TetrahedronElement(nodes=element_nodes, section=section, rigid=rigid)) # Automatically supports C3D10 part.ndf = 3 elif ntags.size == 8: - part.add_element(HexahedronElement(nodes=element_nodes, section=section)) + part.add_element(HexahedronElement(nodes=element_nodes, section=section, rigid=rigid)) else: raise NotImplementedError(f"Element with {ntags.size} nodes not supported") @@ -761,7 +767,7 @@ def from_gmsh(cls, gmshModel, section: Optional[Union[SolidSection, ShellSection if rigid: point = part._discretized_boundary_mesh.centroid() - part.reference_point = Node(xyz=[point.x, point.y, point.z]) + part.reference_point = Node(xyz=point) return part @@ -1822,8 +1828,8 @@ def add_group(self, group: Union[NodesGroup, ElementsGroup, FacesGroup]) -> Unio If the group is not a node or element group. """ - if self.__class__ not in group.__class__.allowed_registration: - raise TypeError(f"{group.__class__!r} cannot be registered to {self.__class__!r}.") + # 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 self._groups.add(group) From 16e0c977d7b0f292f8d536f648195cb672453563 Mon Sep 17 00:00:00 2001 From: franaudo Date: Sat, 22 Mar 2025 18:48:07 +0100 Subject: [PATCH 29/39] clean up --- src/compas_fea2/base.py | 5 ---- src/compas_fea2/results/__init__.py | 4 ---- src/compas_fea2/results/fields.py | 27 +++++++++++++++++++++ src/compas_fea2/utilities/__init__.py | 9 ------- src/compas_fea2/utilities/loads.py | 34 --------------------------- 5 files changed, 27 insertions(+), 52 deletions(-) delete mode 100644 src/compas_fea2/utilities/loads.py diff --git a/src/compas_fea2/base.py b/src/compas_fea2/base.py index 10f92c98b..f63a04855 100644 --- a/src/compas_fea2/base.py +++ b/src/compas_fea2/base.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import importlib import uuid from abc import abstractmethod @@ -177,7 +173,6 @@ def to_hdf5(self, hdf5_path, group_name, mode="w"): group.create_dataset(key, data=value) else: group.attrs[key] = json.dumps(value) - @classmethod def from_hdf5( diff --git a/src/compas_fea2/results/__init__.py b/src/compas_fea2/results/__init__.py index b9ff21796..45074a0ca 100644 --- a/src/compas_fea2/results/__init__.py +++ b/src/compas_fea2/results/__init__.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from .results import ( Result, DisplacementResult, diff --git a/src/compas_fea2/results/fields.py b/src/compas_fea2/results/fields.py index fffd3e99a..ce70c7b02 100644 --- a/src/compas_fea2/results/fields.py +++ b/src/compas_fea2/results/fields.py @@ -472,6 +472,33 @@ def __init__(self, step, *args, **kwargs): self._field_name = "rf" +class ContactForcesFieldResults(NodeFieldResults): + """Reaction field results. + + This class handles the reaction 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 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, step, *args, **kwargs): + super().__init__(step=step, *args, **kwargs) + self._field_name = "c" + + # ------------------------------------------------------------------------------ # Section Forces Field Results # ------------------------------------------------------------------------------ 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/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 From 24256047ee51495d82d6349b9154d90b12483ada Mon Sep 17 00:00:00 2001 From: franaudo Date: Sun, 23 Mar 2025 14:22:08 +0100 Subject: [PATCH 30/39] imposed disp --- src/compas_fea2/model/parts.py | 20 ++++++++++-------- src/compas_fea2/problem/__init__.py | 7 +++---- src/compas_fea2/problem/fields.py | 30 +++++++++++++++++++++++++++ src/compas_fea2/problem/steps/step.py | 19 ++++++++--------- src/compas_fea2/results/__init__.py | 2 ++ 5 files changed, 55 insertions(+), 23 deletions(-) diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index e3ccf6123..5750f899c 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -115,6 +115,8 @@ def __init__(self, **kwargs): self._boundary_mesh = None self._discretized_boundary_mesh = None + self._reference_point = None + @property def __data__(self): return { @@ -204,6 +206,15 @@ def __from_data__(cls, data): part._discretized_boundary_mesh = Mesh.__from_data__(data.get("discretized_boundary_mesh")) if data.get("discretized_boundary_mesh") else None 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 graph(self): return self._graph @@ -2188,15 +2199,6 @@ def __from_data__(cls, data): part.add_element(_Element.__from_data__(element_data)) 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 - @classmethod def from_gmsh(cls, gmshModel: object, name: Optional[str] = None, **kwargs) -> "_Part": """Create a RigidPart object from a gmshModel object. diff --git a/src/compas_fea2/problem/__init__.py b/src/compas_fea2/problem/__init__.py index d7ccb82fe..bf91180d1 100644 --- a/src/compas_fea2/problem/__init__.py +++ b/src/compas_fea2/problem/__init__.py @@ -11,15 +11,14 @@ HarmonicPressureLoad, ThermalLoad, ) -from .fields import ( - _PrescribedField, - PrescribedTemperatureField, -) from .fields import ( LoadField, + DisplacementField, NodeLoadField, PointLoadField, + _PrescribedField, + PrescribedTemperatureField, # LineLoadField, # PressureLoadField, # VolumeLoadField, diff --git a/src/compas_fea2/problem/fields.py b/src/compas_fea2/problem/fields.py index 3e635e9e4..6dfd38896 100644 --- a/src/compas_fea2/problem/fields.py +++ b/src/compas_fea2/problem/fields.py @@ -97,6 +97,36 @@ def model(self): # 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. diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index 3c70cbee3..3b264f615 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -8,6 +8,8 @@ from compas_fea2.base import FEAData from compas_fea2.problem.displacements import GeneralDisplacement from compas_fea2.problem.loads import Load +from compas_fea2.problem.fields import DisplacementField + from compas_fea2.results import DisplacementFieldResults from compas_fea2.results import ReactionFieldResults from compas_fea2.results import SectionForcesFieldResults @@ -309,11 +311,11 @@ def __init__(self, max_increments, initial_inc_size, min_inc_size, time, nlgeom= @property def displacements(self): - return list(filter(lambda p: isinstance(p.load, GeneralDisplacement), self._load_fields)) + return list(filter(lambda p: isinstance(p, DisplacementField), self._load_fields)) @property def loads(self): - return list(filter(lambda p: isinstance(p.load, Load), self._load_fields)) + return list(filter(lambda p: not isinstance(p, DisplacementField), self._load_fields)) @property def max_increments(self): @@ -611,7 +613,7 @@ def add_temperature_field(self, field, node): # self._fields.setdefault(node.part, {}).setdefault(field, set()).add(node) # return field - def add_displacement_field(self, nodes, x=None, y=None, z=None, xx=None, yy=None, zz=None, axes="global", **kwargs): + 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 @@ -624,13 +626,10 @@ def add_displacement_field(self, nodes, x=None, y=None, z=None, xx=None, yy=None 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)) + 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 diff --git a/src/compas_fea2/results/__init__.py b/src/compas_fea2/results/__init__.py index 45074a0ca..bee254bf1 100644 --- a/src/compas_fea2/results/__init__.py +++ b/src/compas_fea2/results/__init__.py @@ -17,6 +17,7 @@ StressFieldResults, ReactionFieldResults, SectionForcesFieldResults, + ContactForcesFieldResults, ) from .modal import ( @@ -40,6 +41,7 @@ "VelocityFieldResults", "ReactionFieldResults", "StressFieldResults", + "ContactForcesFieldResults", "SectionForcesFieldResults", "ModalAnalysisResult", "ModalShape", From 49bdeec41780a232a97af0b3d7de157b2ea01242 Mon Sep 17 00:00:00 2001 From: franaudo Date: Sun, 23 Mar 2025 14:44:12 +0100 Subject: [PATCH 31/39] step increments --- src/compas_fea2/problem/__init__.py | 1 + src/compas_fea2/problem/steps/static.py | 2 ++ src/compas_fea2/problem/steps/step.py | 9 ++++++--- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/compas_fea2/problem/__init__.py b/src/compas_fea2/problem/__init__.py index bf91180d1..75ab5b893 100644 --- a/src/compas_fea2/problem/__init__.py +++ b/src/compas_fea2/problem/__init__.py @@ -53,6 +53,7 @@ "HarmonicPressureLoad", "ThermalLoad", "LoadField", + "DisplacementField", "NodeLoadField", "PointLoadField", "LineLoadField", diff --git a/src/compas_fea2/problem/steps/static.py b/src/compas_fea2/problem/steps/static.py index b29313bac..77f72d302 100644 --- a/src/compas_fea2/problem/steps/static.py +++ b/src/compas_fea2/problem/steps/static.py @@ -66,6 +66,7 @@ def __init__( max_increments=100, initial_inc_size=1, min_inc_size=0.00001, + max_inc_size=1, time=1, nlgeom=False, modify=True, @@ -75,6 +76,7 @@ def __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, diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index 3b264f615..6ca4213b3 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -7,7 +7,6 @@ from compas_fea2.base import FEAData from compas_fea2.problem.displacements import GeneralDisplacement -from compas_fea2.problem.loads import Load from compas_fea2.problem.fields import DisplacementField from compas_fea2.results import DisplacementFieldResults @@ -298,12 +297,12 @@ 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 @@ -329,6 +328,10 @@ 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 From 7d3c4e74c800303e1d189685fefd1d545c73104a Mon Sep 17 00:00:00 2001 From: franaudo Date: Wed, 26 Mar 2025 19:08:53 +0100 Subject: [PATCH 32/39] linear connectors --- src/compas_fea2/model/__init__.py | 7 ++- src/compas_fea2/model/connectors.py | 73 +++++++++++++++++++++++++++-- src/compas_fea2/model/groups.py | 6 ++- src/compas_fea2/model/model.py | 58 +++++++++++++++++++++-- src/compas_fea2/model/parts.py | 3 ++ src/compas_fea2/model/sections.py | 37 +++++++++++++-- 6 files changed, 169 insertions(+), 15 deletions(-) diff --git a/src/compas_fea2/model/__init__.py b/src/compas_fea2/model/__init__.py index 77a6a4842..394816e8e 100644 --- a/src/compas_fea2/model/__init__.py +++ b/src/compas_fea2/model/__init__.py @@ -40,9 +40,10 @@ from .sections import ( _Section, MassSection, + SpringSection, + ConnectorSection, BeamSection, GenericBeamSection, - SpringSection, AngleSection, BoxSection, CircularSection, @@ -67,6 +68,7 @@ ) from .connectors import ( Connector, + LinearConnector, RigidLinkConnector, SpringConnector, ZeroLengthConnector, @@ -157,6 +159,7 @@ "Timber", "_Section", "MassSection", + "ConnectorSection", "BeamSection", "GenericBeamSection", "SpringSection", @@ -175,7 +178,6 @@ "StrutSection", "TieSection", "_Constraint", - "RigidLinkConnector", "_MultiPointConstraint", "TieMPC", "BeamMPC", @@ -208,6 +210,7 @@ "InitialTemperatureField", "InitialStressField", "Connector", + "LinearConnector", "SpringConnector", "RigidLinkConnector", "ZeroLengthConnector", diff --git a/src/compas_fea2/model/connectors.py b/src/compas_fea2/model/connectors.py index 903f07e8e..7a0ac2f49 100644 --- a/src/compas_fea2/model/connectors.py +++ b/src/compas_fea2/model/connectors.py @@ -7,6 +7,7 @@ from compas_fea2.model.groups import _Group from compas_fea2.model.nodes import Node from compas_fea2.model.parts import RigidPart +from compas_fea2.model.groups import NodesGroup class Connector(FEAData): @@ -19,8 +20,6 @@ class Connector(FEAData): 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 : compas_fea2.model.sections.ConnectorSection - The section containing the mechanical properties of the connector. Notes ----- @@ -73,6 +72,74 @@ def nodes(self, nodes: Union[List[Node], _Group]): 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. @@ -80,7 +147,7 @@ class RigidLinkConnector(Connector): ---------- 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. """ diff --git a/src/compas_fea2/model/groups.py b/src/compas_fea2/model/groups.py index f2f8f7dd4..bfcd5b829 100644 --- a/src/compas_fea2/model/groups.py +++ b/src/compas_fea2/model/groups.py @@ -31,6 +31,8 @@ class _Group(FEAData): 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.""" @@ -314,11 +316,11 @@ def __from_data__(cls, data): @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): diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index 23d20c9e3..eb91f9522 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -107,6 +107,7 @@ def __init__(self, description: Optional[str] = None, author: Optional[str] = No 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 @@ -195,6 +196,10 @@ def parts(self) -> Set[_Part]: 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 @@ -347,6 +352,9 @@ def assign_keys(self, start: int = None, restart=False): 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 @@ -894,6 +902,37 @@ def find_faces_in_polygon(self, key: int) -> Node: # ========================================================================= # Groups methods # ========================================================================= + def add_group(self, group: _Group) -> _Group: + """Add a group to the model. + + 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. @@ -912,7 +951,7 @@ def add_parts_group(self, group: PartsGroup) -> PartsGroup: 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: list[PartsGroup]) -> list[PartsGroup]: @@ -1341,12 +1380,25 @@ 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 # ============================================================================== diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index 5750f899c..f7e120edc 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -1842,7 +1842,10 @@ def add_group(self, group: Union[NodesGroup, ElementsGroup, FacesGroup]) -> Unio # 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: List[Union[NodesGroup, ElementsGroup, FacesGroup]]) -> List[Union[NodesGroup, ElementsGroup, FacesGroup]]: """Add multiple groups to the part. diff --git a/src/compas_fea2/model/sections.py b/src/compas_fea2/model/sections.py index 01da98a52..dff0a58bd 100644 --- a/src/compas_fea2/model/sections.py +++ b/src/compas_fea2/model/sections.py @@ -54,7 +54,7 @@ class _Section(FEAData): to elements in different Parts. """ - def __init__(self, *, material: "_Material", **kwargs): # noqa: F821 + def __init__(self, material: "_Material", **kwargs): # noqa: F821 super().__init__(**kwargs) self._material = material @@ -221,6 +221,34 @@ 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) + + # ============================================================================== # 1D # ============================================================================== @@ -294,7 +322,6 @@ def __init__(self, *, A: float, Ixx: float, Iyy: float, Ixy: float, Avx: float, self.Avx = Avx self.Avy = Avy self.J = J - @property def __data__(self): @@ -1111,10 +1138,10 @@ class ISection(BeamSection): 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) + return 0.3 + 0.1 * ((self.shape.abf + self.shape.atf) / self.shape.area) @property def __data__(self): @@ -1608,7 +1635,7 @@ class RectangularSection(BeamSection): def __init__(self, w, h, material, **kwargs): self._shape = Rectangle(w, h) super().__init__(**from_shape(self._shape, material, **kwargs)) - self.k = 5/6 + self.k = 5 / 6 @property def __data__(self): From 419f7db47c5475faeb0e0d054575ead6114fd913 Mon Sep 17 00:00:00 2001 From: Francesco Ranaudo Date: Wed, 26 Mar 2025 19:10:20 +0100 Subject: [PATCH 33/39] reference point --- src/compas_fea2/model/parts.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index 5750f899c..b2e51ae9f 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -129,6 +129,7 @@ def __data__(self): "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"): @@ -204,6 +205,8 @@ def __from_data__(cls, data): part._boundary_mesh = Mesh.__from_data__(data.get("boundary_mesh")) if data.get("boundary_mesh") else None part._discretized_boundary_mesh = Mesh.__from_data__(data.get("discretized_boundary_mesh")) if data.get("discretized_boundary_mesh") else None + if rp:= data.get("reference_point"): + part.reference_point = Node.__from_data__(rp) return part @property @@ -2169,7 +2172,7 @@ def __init__(self, reference_point: Optional[Node] = None, **kwargs): @property def __data__(self): - data = super().__data__() + data = super().__data__ data.update( { "class": self.__class__.__name__, From 6b37ffb0e8ee268866f92b3b4ca3f8038690dc59 Mon Sep 17 00:00:00 2001 From: Francesco Ranaudo Date: Mon, 5 May 2025 20:54:09 +0200 Subject: [PATCH 34/39] tol in find_faces --- requirements.txt | 4 +++- src/compas_fea2/model/interfaces.py | 4 ---- src/compas_fea2/model/parts.py | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) 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/src/compas_fea2/model/interfaces.py b/src/compas_fea2/model/interfaces.py index 051b27a89..0b94909b5 100644 --- a/src/compas_fea2/model/interfaces.py +++ b/src/compas_fea2/model/interfaces.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/model/parts.py b/src/compas_fea2/model/parts.py index 014415edd..93de39884 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -1712,7 +1712,7 @@ def is_element_on_boundary(self, element: _Element) -> bool: # Faces methods # ========================================================================= - def find_faces_on_plane(self, plane: Plane) -> List["compas_fea2.model.Face"]: + 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 @@ -1731,7 +1731,7 @@ def find_faces_on_plane(self, plane: Plane) -> List["compas_fea2.model.Face"]: """ 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) for node in x.nodes)) + 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 def find_faces_in_polygon(self, polygon: "compas.geometry.Polygon", tol: float = 1.1) -> List["compas_fea2.model.Face"]: From 12d762fbbb74c1c25d99757ba7430c1f3547298f Mon Sep 17 00:00:00 2001 From: Francesco Ranaudo Date: Mon, 5 May 2025 20:55:36 +0200 Subject: [PATCH 35/39] kdtree --- src/compas_fea2/model/parts.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index 014415edd..04a7bb1e3 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -1250,7 +1250,7 @@ def find_closest_nodes_to_point(self, point: List[float], number_of_nodes: int = if number_of_nodes == 0: return None - tree = KDTree(self.points) + 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: @@ -1260,6 +1260,8 @@ def find_closest_nodes_to_point(self, point: List[float], number_of_nodes: int = 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)} From 3b93068697df5594c321e12274e1c393ce2500d3 Mon Sep 17 00:00:00 2001 From: Francesco Ranaudo Date: Wed, 7 May 2025 09:50:57 +0200 Subject: [PATCH 36/39] clean-up --- docs/api/compas_fea2.units.rst | 2 +- docs/api/compas_fea2.utilities.rst | 25 -- scripts/calculixtest.py | 51 ----- scripts/deepcopytest.py | 31 --- scripts/graph_structure.py | 119 ---------- scripts/groupstest.py | 18 -- scripts/hdf5serialization.py | 27 --- scripts/mdl2.json | 30 --- scripts/node.hdf5 | Bin 7296 -> 0 bytes scripts/pyvistatest.py | 9 - scripts/shape_transformation.py | 17 -- scripts/tributaryload.py | 54 ----- src/compas_fea2/base.py | 8 +- src/compas_fea2/job/__init__.py | 4 - src/compas_fea2/job/input_file.py | 11 +- src/compas_fea2/model/__init__.py | 7 + src/compas_fea2/model/connectors.py | 2 +- src/compas_fea2/model/elements.py | 89 +++++++- src/compas_fea2/model/groups.py | 11 +- src/compas_fea2/model/ics.py | 4 - src/compas_fea2/model/interactions.py | 4 - src/compas_fea2/model/materials/concrete.py | 4 - src/compas_fea2/model/materials/steel.py | 4 - src/compas_fea2/model/materials/timber.py | 4 - src/compas_fea2/model/model.py | 12 +- src/compas_fea2/model/nodes.py | 46 +++- src/compas_fea2/model/parts.py | 149 +++++++++--- src/compas_fea2/model/releases.py | 11 +- src/compas_fea2/model/sections.py | 6 +- src/compas_fea2/problem/__init__.py | 3 - src/compas_fea2/problem/_outputs.py | 159 ------------- src/compas_fea2/problem/combinations.py | 6 +- src/compas_fea2/problem/displacements.py | 28 +-- src/compas_fea2/problem/fields.py | 127 +---------- src/compas_fea2/problem/problem.py | 12 +- .../problem/steps/perturbations.py | 5 - src/compas_fea2/problem/steps/step.py | 26 ++- src/compas_fea2/problem/steps_combinations.py | 4 - src/compas_fea2/results/database.py | 10 +- src/compas_fea2/results/fields.py | 34 ++- src/compas_fea2/results/modal.py | 2 +- src/compas_fea2/results/results.py | 10 +- src/compas_fea2/utilities/interfaces_numpy.py | 214 ------------------ tests/test_parts.py | 2 - 44 files changed, 342 insertions(+), 1059 deletions(-) delete mode 100644 docs/api/compas_fea2.utilities.rst delete mode 100644 scripts/calculixtest.py delete mode 100644 scripts/deepcopytest.py delete mode 100644 scripts/graph_structure.py delete mode 100644 scripts/groupstest.py delete mode 100644 scripts/hdf5serialization.py delete mode 100644 scripts/mdl2.json delete mode 100644 scripts/node.hdf5 delete mode 100644 scripts/pyvistatest.py delete mode 100644 scripts/shape_transformation.py delete mode 100644 scripts/tributaryload.py delete mode 100644 src/compas_fea2/problem/_outputs.py delete mode 100644 src/compas_fea2/utilities/interfaces_numpy.py 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/scripts/calculixtest.py b/scripts/calculixtest.py deleted file mode 100644 index f4c44e7e0..000000000 --- a/scripts/calculixtest.py +++ /dev/null @@ -1,51 +0,0 @@ -from compas_fea2.model import Model -from compas_fea2.model.parts import BeamElement -from compas_fea2.model.materials import ElasticIsotropic -from compas_fea2.model.sections import RectangleSection -from compas_fea2.model.nodes import Node -from compas_fea2.model.elements import Element -from compas_fea2.model.bcs import FixedBC -from compas_fea2.model.loads import PointLoad -from compas_fea2.problem import Problem -from compas_fea2.results import Results -from compas_fea2.fea.calculix.calculix import CalculiX - -# 1. Create the FEA model -model = Model(name="beam_model") - -# 2. Define material properties (Steel) -steel = ElasticIsotropic(name="steel", E=210e9, v=0.3, p=7850) -model.add_material(steel) - -# 3. Define a rectangular cross-section (100mm x 10mm) -section = RectangleSection(name="beam_section", b=0.1, h=0.01) -model.add_section(section) - -# 4. Create nodes (simple cantilever beam: 1m long) -n1 = Node(0, 0, 0) -n2 = Node(1, 0, 0) -model.add_nodes([n1, n2]) - -# 5. Create a beam element connecting the two nodes -beam = BeamElement(nodes=[n1, n2], material=steel, section=section) -model.add_element(beam) - -# 6. Apply boundary conditions (Fix left end) -bc_fixed = FixedBC(nodes=[n1]) -model.add_boundary_conditions([bc_fixed]) - -# 7. Apply a downward point load at the free end (1000N) -load = PointLoad(nodes=[n2], z=-1000) -model.add_loads([load]) - -# 8. Create the analysis problem -problem = Problem(model=model, name="static_analysis") -solver = CalculiX(problem=problem) - -# 9. Run the analysis -solver.solve() - -# 10. Extract and display results -results = Results(problem) -displacements = results.get_nodal_displacements() -print("Nodal Displacements:", displacements) diff --git a/scripts/deepcopytest.py b/scripts/deepcopytest.py deleted file mode 100644 index 9bbd17b26..000000000 --- a/scripts/deepcopytest.py +++ /dev/null @@ -1,31 +0,0 @@ -from compas_fea2.model import Model -from compas_fea2.model import Part -from compas_fea2.model import Node -from compas_fea2.model import BeamElement -from compas_fea2.model import RectangularSection -from compas_fea2.model import Steel - - -n1 = Node(xyz=[0, 0, 0]) -n2 = Node(xyz=[1, 0, 0]) -p1 = Part() -mdl1 = Model() - -mat = Steel.S355() -sec = RectangularSection(w=1, h=2, material=mat) -beam = BeamElement(nodes=[n1, n2], section=sec, frame=[0, 0, 1]) - -# print(beam.__data__()) - -p1.add_element(beam) - -mdl1.add_part(p1) -p1.add_node(n1) -p2 = p1.copy() -# print(mdl.__data__) - -mdl2 = mdl1.copy() -mdl2.show() -# print(list(mdl1.parts)[0].nodes) -# print(list(mdl2.parts)[0].nodes) -# print(mdl2) diff --git a/scripts/graph_structure.py b/scripts/graph_structure.py deleted file mode 100644 index 92f3dd29e..000000000 --- a/scripts/graph_structure.py +++ /dev/null @@ -1,119 +0,0 @@ -import networkx as nx -from matplotlib import pyplot as plt - - -class Model: - """A model that manages parts and nodes using a graph structure.""" - - def __init__(self): - self.graph = nx.DiGraph() - self.graph.add_node(self, type="model") - - def add_part(self, part): - """Adds a part to the model and registers its nodes if any.""" - self.graph.add_node(part, type="part") - self.graph.add_edge(self, part, relation="contains") - part._model = self # Store reference to the model in the part - - # Register any nodes that were added before the part was in the model - for node in part._pending_nodes: - self.add_node(part, node) - part._pending_nodes.clear() # Clear the pending nodes list - - def add_node(self, part, node): - """Adds a node to the graph under the given part.""" - self.graph.add_node(node, type="node") - self.graph.add_edge(part, node, relation="contains") - - def get_part_of_node(self, node): - """Retrieves the part where a node is registered.""" - for predecessor in self.graph.predecessors(node): - if self.graph.nodes[predecessor]["type"] == "part": - return predecessor - return None - - def visualize_graph(self): - """Visualizes the graph structure.""" - plt.figure(figsize=(8, 6)) - pos = nx.spring_layout(self.graph) # Positioning - - # Get node types - node_types = nx.get_node_attributes(self.graph, "type") - colors = {"model": "red", "part": "blue", "node": "green"} - - # Draw nodes with different colors - node_colors = [colors.get(node_types.get(n, "node"), "gray") for n in self.graph.nodes] - nx.draw(self.graph, pos, with_labels=True, node_size=2000, node_color=node_colors, font_size=10, edge_color="gray") - - # Draw edge labels - edge_labels = nx.get_edge_attributes(self.graph, "relation") - nx.draw_networkx_edge_labels(self.graph, pos, edge_labels=edge_labels, font_size=8) - - plt.title("Model Graph Visualization") - plt.show() - - def __repr__(self): - return "Model()" - - -class Part: - def __init__(self, name): - self.name = name - self._model = None # Model is assigned when added to a Model - self._pending_nodes = [] # Store nodes before the part is added to a Model - - def add_node(self, node): - """Registers a node to this part, even if the part is not yet in a model.""" - if self._model: - self._model.add_node(self, node) - else: - self._pending_nodes.append(node) # Store node until part is added to model - - def add_nodes(self, nodes): - """Registers multiple nodes to this part.""" - for node in nodes: - self.add_node(node) - - def __repr__(self): - return f"Part({self.name})" - - -class Node: - def __init__(self, name): - self.name = name - - @property - def part(self): - """Retrieves the part where this node is registered.""" - model = next((m for m in self.__dict__.values() if isinstance(m, Model)), None) - return model.get_part_of_node(self) if model else None - - def __repr__(self): - return f"Node({self.name})" - - -# Example usage -model = Model() -p1 = Part("P1") -p2 = Part("P1") -n1 = Node("N1") -n2 = Node("N2") -n3 = Node("N3") - -nodes = [Node(f"N{i}") for i in range(100)] -model.add_part(p1) # Now part is registered in the model -p1.add_node(n1) # Uses part.add_node() which delegates to model.add_node() -p1.add_nodes(nodes) # Uses part.add_node() which delegates to model.add_node() - -p2.add_node(n2) # Part is not yet in the model, so node is stored in pending list -p2.add_node(n3) # Part is not yet in the model, so node is stored in pending list - -model.add_part(p2) # Now part is registered in the model, pending nodes are added - -print(f"{n1} is in {n1.part}") # Outputs: Node(N1) is in Part(P1) -print(f"{n3} is in {n3.part}") # Outputs: Node(N3) is in Part(P2) -# print(model.graph.nodes) -# print(model.graph.edges) - -# Visualize the graph -# model.visualize_graph() diff --git a/scripts/groupstest.py b/scripts/groupstest.py deleted file mode 100644 index 86596a627..000000000 --- a/scripts/groupstest.py +++ /dev/null @@ -1,18 +0,0 @@ -from compas_fea2.model import Model, Part, Steel, SolidSection, Node -from compas_fea2.model import PartsGroup, ElementsGroup, NodesGroup - - -mdl = Model(name="multibay") -prt = mdl.add_part(name="part1") - -nodes = [Node(xyz=[i, x, 0]) for i, x in enumerate(range(10))] -ng = NodesGroup(nodes=nodes, name="test") -print(ng) - -ng_sg = ng.create_subgroup(name="x1", condition=lambda n: n.x == 1) -print(ng_sg) - -new_group = ng-ng_sg -print(new_group) -# prt.add_nodes(nodes) -# print(mdl) diff --git a/scripts/hdf5serialization.py b/scripts/hdf5serialization.py deleted file mode 100644 index 463c7f1e2..000000000 --- a/scripts/hdf5serialization.py +++ /dev/null @@ -1,27 +0,0 @@ -import h5py -from compas_fea2.model import Model, Part, Node - -output_path = "/Users/francesco/code/fea2/compas_fea2/scripts/node.hdf5" -# output_path = "/Users/francesco/code/fea2/compas_fea2/scripts/node.json" - -# Create and save the node -node = Node([0.0, 0.0, 0.0], mass=1.0, temperature=20.0) - -mdl = Model(name="test_hdf5") -prt = mdl.add_new_part(name="part") - -# prt.to_hdf5(output_path, group_name="parts", mode="w") - -n = prt.add_node(node) -# prt.to_json(filepath=output_path) -prt.to_hdf5(output_path, group_name="parts", mode="w") -# # n.save_to_hdf5(output_path, group_name="nodes", erase_data=True) -# mdl.save_to_hdf5(output_path, group_name="models", erase_data=True) -# # n_new = Node.load_from_hdf5(output_path, "nodes", n.uid) - -# # Load the node from the file -# new_mdl = Model.load_from_hdf5(output_path, "models", mdl.uid) -# print(new_mdl.uid) -# # print(n_new._registration) - -# # print(f"Loaded Node: {loaded_node.xyz}, Mass: {loaded_node.mass}, Temperature: {loaded_node.temperature}") diff --git a/scripts/mdl2.json b/scripts/mdl2.json deleted file mode 100644 index 90cf062b4..000000000 --- a/scripts/mdl2.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "dtype": "compas_fea2.model/Model", - "data": { - "description": null, - "author": null, - "parts": [ - { - "name": "DP_5271063680", - "nodes": [], - "elements": [], - "sections": [], - "materials": [], - "releases": [], - "nodesgroups": [], - "elementsgroups": [], - "facesgroups": [] - } - ], - "bcs": {}, - "ics": {}, - "constraints": [], - "partgroups": [], - "materials": [], - "sections": [], - "problems": [], - "path": null - }, - "name": "M_5269925536", - "guid": "932b3ee0-7cec-4b6c-a8e8-b37a573c2676" -} \ No newline at end of file diff --git a/scripts/node.hdf5 b/scripts/node.hdf5 deleted file mode 100644 index 8a3816b50bb4d017feda283c21cd6f28bd684488..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7296 zcmeHM&2G~`5MD!|q5P;wJ@9vkd}$9jvK==~5kgcXB?r`2zyYbM(zU%-D-tKN6N!S5 zdMvj*29LsHaOB8=JM8Sv(8?$Zv|J#&kvu!IGqby2#YC;TC z+*L4c$-WrJ6Wq+a*kW@Eb2`Ts@EmimYH0QlA)N=<#B*%sft+ywig|$E%Q2o01P{HR z>d+(MIcPBtEYE3YKj%SyP*}$~ru;8W>AfG{)!X}uyS&C8tDeO!vqhP0qb|?k9&+2V zRHlIYipE-hzNt@1KCdN0sl}rt}I3U-hk(wL~y?v@?2CmTI)?d zB8~rhj?Q*Nx=Fe?$4sJ6p|UKd9K#=d|xvY`A%KUW-F%;YxON z5QD8>3s(|R6-*Kzbm>#<@(vv`{ArFQo zJe1~8^adj1!%-%rfv>#S{j?MIM`_G6SXnyicBRjQypxGUWFn0~HR0V{yx+yGD{^$> zG-vt-0fT@+z#w1{FbEg~3<3rLgMdN6AYc&qy9m(#W7~y#%t?Krr0NT4+*1=zt1rOs jda5r#P2g8Q$shZH8p4SmUGGTyvG|>O2h "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 @@ -445,9 +494,10 @@ def __data__(self): @classmethod def __from_data__(cls, data): - from compas_fea2.model import Node 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"]) @@ -472,11 +522,11 @@ def element(self) -> Optional["_Element"]: return self._registration @property - def part(self) -> "_Part": + def part(self) -> "_Part": # noqa: F821 return self.element.part @property - def model(self) -> "Model": + def model(self) -> "Model": # noqa: F821 return self.element.model @property @@ -506,7 +556,7 @@ def points(self) -> List["Point"]: @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 @@ -587,6 +637,18 @@ def _construct_faces(self, face_indices: Dict[str, Tuple[int]]) -> List[Face]: 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) @@ -730,9 +792,6 @@ def outermesh(self) -> Mesh: return Polyhedron(self.points, list(self._face_indices.values())).to_mesh() -from typing import List, Optional - - class TetrahedronElement(_Element3D): """A Solid element with 4 or 10 nodes. @@ -762,7 +821,13 @@ class TetrahedronElement(_Element3D): The list of nodes defining the element. """ - def __init__(self, *, nodes: List["Node"], section: "_Section", implementation: Optional[str] = None, **kwargs): + 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.") diff --git a/src/compas_fea2/model/groups.py b/src/compas_fea2/model/groups.py index bfcd5b829..04d923070 100644 --- a/src/compas_fea2/model/groups.py +++ b/src/compas_fea2/model/groups.py @@ -1,7 +1,14 @@ -from typing import Callable, Iterable, TypeVar, Set, Dict, Any, List +import logging from importlib import import_module from itertools import groupby -import logging +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 # Define a generic type for members diff --git a/src/compas_fea2/model/ics.py b/src/compas_fea2/model/ics.py index 535de2d31..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 diff --git a/src/compas_fea2/model/interactions.py b/src/compas_fea2/model/interactions.py index 61708335b..ce665cf80 100644 --- a/src/compas_fea2/model/interactions.py +++ b/src/compas_fea2/model/interactions.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/model/materials/concrete.py b/src/compas_fea2/model/materials/concrete.py index df6dacf71..87545ffb3 100644 --- a/src/compas_fea2/model/materials/concrete.py +++ b/src/compas_fea2/model/materials/concrete.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import log from compas_fea2.units import UnitRegistry diff --git a/src/compas_fea2/model/materials/steel.py b/src/compas_fea2/model/materials/steel.py index c6be3d48a..d92fb0088 100644 --- a/src/compas_fea2/model/materials/steel.py +++ b/src/compas_fea2/model/materials/steel.py @@ -1,7 +1,3 @@ -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 diff --git a/src/compas_fea2/model/materials/timber.py b/src/compas_fea2/model/materials/timber.py index 7e05cc39d..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 diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index eb91f9522..790ddb54e 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -2,39 +2,37 @@ import importlib import os import pathlib -from pathlib import Path import pickle from itertools import chain from itertools import groupby - -from compas.datastructures import Graph - +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 compas.geometry import Transformation from pint import UnitRegistry import compas_fea2 from compas_fea2.base import FEAData from compas_fea2.model.bcs import _BoundaryCondition from compas_fea2.model.connectors import Connector -from compas_fea2.model.interfaces import Interface 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 InterfacesGroup 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 diff --git a/src/compas_fea2/model/nodes.py b/src/compas_fea2/model/nodes.py index d978a061c..751eb157c 100644 --- a/src/compas_fea2/model/nodes.py +++ b/src/compas_fea2/model/nodes.py @@ -3,11 +3,11 @@ from typing import Optional from compas.geometry import Point +from compas.geometry import transform_points from compas.tolerance import TOL import compas_fea2 from compas_fea2.base import FEAData -from compas.geometry import transform_points class Node(FEAData): @@ -264,18 +264,62 @@ def connected_elements(self) -> List: # 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) diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index 11ce70f3e..e2f9fdff2 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -1,5 +1,6 @@ +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 @@ -8,16 +9,11 @@ from typing import Tuple from typing import Union -import networkx as nx +import compas import matplotlib.pyplot as plt +import networkx as nx import numpy as np -from compas.topology import connected_components -from collections import defaultdict -from itertools import groupby -import h5py -import json - -import compas +from compas.datastructures import Mesh from compas.geometry import Box from compas.geometry import Frame from compas.geometry import Plane @@ -26,12 +22,12 @@ from compas.geometry import Transformation from compas.geometry import Vector from compas.geometry import bounding_box -from compas.geometry import centroid_points, centroid_points_weighted -from compas.geometry import distance_point_point_sqrd +from compas.geometry import centroid_points +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.datastructures import Mesh from compas.tolerance import TOL +from compas.topology import connected_components from scipy.spatial import KDTree import compas_fea2 @@ -45,8 +41,12 @@ from .elements import _Element1D from .elements import _Element2D from .elements import _Element3D -from .groups import ElementsGroup, FacesGroup, NodesGroup, MaterialsGroup, SectionsGroup, _Group - +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 @@ -133,7 +133,7 @@ def __data__(self): } 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 = hdf5_file.require_group(f"model/{'parts'}/{self.uid}") # Create a group for this object group.attrs["class"] = str(self.__data__["class"]) @classmethod @@ -204,8 +204,11 @@ def __from_data__(cls, data): part.add_element(element, checks=False) part._boundary_mesh = Mesh.__from_data__(data.get("boundary_mesh")) if data.get("boundary_mesh") else None - part._discretized_boundary_mesh = Mesh.__from_data__(data.get("discretized_boundary_mesh")) if data.get("discretized_boundary_mesh") else None - if rp:= data.get("reference_point"): + 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 @@ -256,13 +259,13 @@ def elements_grouped(self) -> Dict[int, List[_Element]]: return {key: group.members for key, group in sub_groups} @property - def elements_faces(self) -> List[List[List["Face"]]]: + 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"]]]: + 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 @@ -493,7 +496,7 @@ def all_interfaces(self): @property def bounding_box(self) -> Optional[Box]: - # FIXME: add bounding box for lienar elements (bb of the section outer boundary) + # 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 @@ -505,7 +508,9 @@ def center(self) -> Point: def centroid(self) -> Point: """The geometric center of the part.""" self.compute_nodal_masses() - return centroid_points_weighted([node.point for node in self.nodes], [sum(node.mass) / len(node.mass) for node in self.nodes]) + 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: @@ -583,6 +588,17 @@ def transformed(self, transformation: Transformation) -> "_Part": 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: @@ -737,7 +753,8 @@ def from_gmsh(cls, gmshModel, section: Optional[Union[SolidSection, ShellSection # 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) @@ -750,17 +767,31 @@ def from_gmsh(cls, gmshModel, section: Optional[Union[SolidSection, ShellSection element_nodes = fea2_nodes[ntags] if ntags.size == 3: - 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): - 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: part.add_element(TetrahedronElement(nodes=element_nodes, section=section, rigid=rigid)) - part.ndf = 3 # FIXME: try to move outside the loop + 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)) # Automatically supports C3D10 + part.add_element(TetrahedronElement(nodes=element_nodes, section=section, rigid=rigid)) part.ndf = 3 elif ntags.size == 8: @@ -773,7 +804,7 @@ def from_gmsh(cls, gmshModel, section: Optional[Union[SolidSection, ShellSection 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: @@ -909,6 +940,26 @@ def from_step_file(cls, step_file: str, name: Optional[str] = None, **kwargs) -> @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) @@ -1499,6 +1550,19 @@ def visualize_node_connectivity(self): 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.""" @@ -1704,7 +1768,9 @@ def is_element_on_boundary(self, element: _Element) -> bool: 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 @@ -1714,7 +1780,7 @@ def is_element_on_boundary(self, element: _Element) -> bool: # Faces methods # ========================================================================= - def find_faces_on_plane(self, plane: Plane, tol: float=1) -> List["compas_fea2.model.Face"]: + 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 @@ -1827,12 +1893,13 @@ def find_group_by_name(self, name: str) -> List[Union[NodesGroup, ElementsGroup, print(f"No groups found with name {name}") return None - def add_group(self, group: Union[NodesGroup, ElementsGroup, FacesGroup]) -> Union[NodesGroup, ElementsGroup, FacesGroup]: + 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.NodesGroup` | :class:`compas_fea2.model.ElementsGroup` | :class:`compas_fea2.model.FacesGroup` + group : :class:`compas_fea2.model.NodesGroup` | :class:`compas_fea2.model.ElementsGroup` | + :class:`compas_fea2.model.FacesGroup` Returns ------- @@ -1852,7 +1919,7 @@ def add_group(self, group: Union[NodesGroup, ElementsGroup, FacesGroup]) -> Unio self._groups.add(group) return group - def add_groups(self, groups: List[Union[NodesGroup, ElementsGroup, FacesGroup]]) -> List[Union[NodesGroup, ElementsGroup, FacesGroup]]: + def add_groups(self, groups: List[Union[NodesGroup, ElementsGroup, FacesGroup]]) -> List[Union[_Group]]: """Add multiple groups to the part. Parameters @@ -1888,7 +1955,12 @@ def sorted_nodes_by_displacement(self, step: "_Step", component: str = "length") """ return self.nodes.sorted_by(lambda n: getattr(Vector(*n.results[step].get("U", None)), component)) - def get_max_displacement(self, problem: "Problem", step: Optional["_Step"] = None, component: str = "length") -> Tuple[Node, float]: # noqa: F821 + 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 @@ -1912,7 +1984,12 @@ def get_max_displacement(self, problem: "Problem", step: Optional["_Step"] = Non displacement = getattr(Vector(*node.results[problem][step].get("U", None)), component) return node, displacement - def get_min_displacement(self, problem: "Problem", step: Optional["_Step"] = None, component: str = "length") -> Tuple[Node, float]: # noqa: F821 + 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 @@ -2227,7 +2304,7 @@ def from_gmsh(cls, gmshModel: object, name: Optional[str] = None, **kwargs) -> " return super().from_gmsh(gmshModel, name=name, **kwargs) @classmethod - def from_boundary_mesh(cls, boundary_mesh: "compas.datastructures.Mesh", name: Optional[str] = None, **kwargs) -> "_Part": + 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. @@ -2249,7 +2326,7 @@ def from_boundary_mesh(cls, boundary_mesh: "compas.datastructures.Mesh", name: O # ========================================================================= # 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: _Element) -> _Element: # type: (_Element) -> _Element diff --git a/src/compas_fea2/model/releases.py b/src/compas_fea2/model/releases.py index beeeb7098..feaabb86b 100644 --- a/src/compas_fea2/model/releases.py +++ b/src/compas_fea2/model/releases.py @@ -1,6 +1,3 @@ -from __future__ import annotations - -import compas_fea2.model from compas_fea2.base import FEAData @@ -45,7 +42,7 @@ class _BeamEndRelease(FEAData): 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: compas_fea2.model.BeamElement | None = None + self._element: "BeamElement | None" # type: ignore self._location: str | None = None self.n: bool = n self.v1: bool = v1 @@ -55,12 +52,12 @@ def __init__(self, n: bool = False, v1: bool = False, v2: bool = False, m1: bool self.t: bool = t @property - def element(self) -> compas_fea2.model.BeamElement | None: + def element(self) -> "BeamElement | None": # type: ignore return self._element @element.setter - def element(self, value: compas_fea2.model.BeamElement): - if not isinstance(value, compas_fea2.model.BeamElement): + 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 diff --git a/src/compas_fea2/model/sections.py b/src/compas_fea2/model/sections.py index dff0a58bd..acb3c1c63 100644 --- a/src/compas_fea2/model/sections.py +++ b/src/compas_fea2/model/sections.py @@ -75,7 +75,7 @@ def __from_data__(cls, data): def __str__(self) -> str: return f""" Section {self.name} -{'-' * len(self.name)} +{"-" * len(self.name)} model : {self.model!r} key : {self.key} material : {self.material!r} @@ -130,7 +130,7 @@ def __init__(self, mass: float, **kwargs): def __str__(self) -> str: return f""" Mass Section {self.name} -{'-' * len(self.name)} +{"-" * len(self.name)} model : {self.model!r} mass : {self.mass} """ @@ -347,7 +347,7 @@ def __from_data__(cls, data): def __str__(self) -> str: return f""" {self.__class__.__name__} -{'-' * len(self.__class__.__name__)} +{"-" * len(self.__class__.__name__)} name : {self.name} material : {self.material!r} diff --git a/src/compas_fea2/problem/__init__.py b/src/compas_fea2/problem/__init__.py index 75ab5b893..6cb54fa54 100644 --- a/src/compas_fea2/problem/__init__.py +++ b/src/compas_fea2/problem/__init__.py @@ -19,9 +19,6 @@ PointLoadField, _PrescribedField, PrescribedTemperatureField, - # LineLoadField, - # PressureLoadField, - # VolumeLoadField, ) from .combinations import LoadCombination diff --git a/src/compas_fea2/problem/_outputs.py b/src/compas_fea2/problem/_outputs.py deleted file mode 100644 index 3569036cf..000000000 --- a/src/compas_fea2/problem/_outputs.py +++ /dev/null @@ -1,159 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from compas_fea2.base import FEAData -from compas_fea2.results.database import ResultsDatabase -from compas_fea2.results.results import AccelerationResult -from compas_fea2.results.results import DisplacementResult -from compas_fea2.results.results import ReactionResult -from compas_fea2.results.results import SectionForcesResult -from compas_fea2.results.results import ShellStressResult -from compas_fea2.results.results import VelocityResult - - -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_results_cls, **kwargs): - super(_Output, self).__init__(**kwargs) - self._field_results_cls = field_results_cls - - @property - def field_results_cls(self): - return self._field_results_cls - - @property - def sqltable_schema(self): - return self._field_results_cls.sqltable_schema() - - @property - def results_func(self): - return self._field_results_cls._results_func - - @property - def results_func_output(self): - return self._field_results_cls._results_func_output - - @property - def field_name(self): - return self.field_results_cls._field_name - - @property - def components_names(self): - return self.field_results_cls._components_names - - @property - def invariants_names(self): - return self.field_results_cls._invariants_names - - @property - def step(self): - return self._registration - - @property - def problem(self): - return self.step.problem - - @property - def model(self): - return self.problem.model - - def create_sql_table(self, connection, results): - """ - Delegate the table creation to the ResultsDatabase class. - """ - ResultsDatabase.create_table_for_output_class(self, connection, results) - - -class _NodeFieldOutput(_Output): - """NodeFieldOutput object for requesting the fields at the nodes from the analysis.""" - - def __init__(self, results_cls, **kwargs): - super().__init__(field_results_cls=results_cls, **kwargs) - - -class DisplacementFieldOutput(_NodeFieldOutput): - """DisplacmentFieldOutput object for requesting the displacements at the nodes - from the analysis.""" - - def __init__(self, **kwargs): - super(DisplacementFieldOutput, self).__init__(DisplacementResult, **kwargs) - - -class AccelerationFieldOutput(_NodeFieldOutput): - """AccelerationFieldOutput object for requesting the accelerations at the nodes - from the analysis.""" - - def __init__(self, **kwargs): - super(AccelerationFieldOutput, self).__init__(AccelerationResult, **kwargs) - - -class VelocityFieldOutput(_NodeFieldOutput): - """VelocityFieldOutput object for requesting the velocities at the nodes - from the analysis.""" - - def __init__(self, **kwargs): - super(VelocityFieldOutput, self).__init__(VelocityResult, **kwargs) - - -class ReactionFieldOutput(_NodeFieldOutput): - """ReactionFieldOutput object for requesting the reaction forces at the nodes - from the analysis.""" - - def __init__(self, **kwargs): - super(ReactionFieldOutput, self).__init__(ReactionResult, **kwargs) - - -class _ElementFieldOutput(_Output): - """ElementFieldOutput object for requesting the fields at the elements from the analysis.""" - - def __init__(self, results_cls, **kwargs): - super().__init__(field_results_cls=results_cls, **kwargs) - - -class StressFieldOutput(_ElementFieldOutput): - """StressFieldOutput object for requesting the stresses at the elements from the analysis.""" - - def __init__(self, **kwargs): - super(StressFieldOutput, self).__init__(ShellStressResult, **kwargs) - - -class SectionForcesFieldOutput(_ElementFieldOutput): - """SectionForcesFieldOutput object for requesting the section forces at the elements from the analysis.""" - - def __init__(self, **kwargs): - super(SectionForcesFieldOutput, self).__init__(SectionForcesResult, **kwargs) - - -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/combinations.py b/src/compas_fea2/problem/combinations.py index 413f0c81d..283b38707 100644 --- a/src/compas_fea2/problem/combinations.py +++ b/src/compas_fea2/problem/combinations.py @@ -45,13 +45,13 @@ def Fire(cls): def __data__(self): return { - 'factors': self.factors, - 'name': self.name, + "factors": self.factors, + "name": self.name, } @classmethod def __from_data__(cls, data): - return cls(factors=data['factors'], name=data.get('name')) + return cls(factors=data["factors"], name=data.get("name")) # BUG: Rewrite. this is not general and does not account for different loads types @property diff --git a/src/compas_fea2/problem/displacements.py b/src/compas_fea2/problem/displacements.py index cb8db33fb..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 @@ -78,23 +74,15 @@ def components(self): 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, + "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'] - ) + 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 6dfd38896..dbadea21d 100644 --- a/src/compas_fea2/problem/fields.py +++ b/src/compas_fea2/problem/fields.py @@ -1,9 +1,6 @@ -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 PressureLoad from compas_fea2.problem.loads import GravityLoad # TODO implement __*__ magic method for combination @@ -97,6 +94,7 @@ def model(self): # name=self.name or other.name, # ) + class DisplacementField(LoadField): """A distribution of a set of displacements over a set of nodes. @@ -128,6 +126,7 @@ 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. @@ -190,128 +189,6 @@ def nodes(self): return self._distribution -# class LineLoadField(LoadField): -# """A distribution of a concentrated load over a given polyline. - -# 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, loads, polyline, load_case=None, tolerance=1, discretization=10, **kwargs): -# if not isinstance(loads, ConcentratedLoad): -# raise TypeError("LineLoadPattern only supports ConcentratedLoad") -# super(LineLoadField, self).__init__(loads, 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.loads] * self.nodes) - - -# class PressureLoadField(LoadField): -# """A distribution of a pressure load over a region defined by a polygon. -# The loads are distributed over the nodes within the region using their tributary area. - -# Parameters -# ---------- -# load : object -# The load to be applied. -# polygon : object -# The polygon defining the area where the load is distributed. -# planar : bool, optional -# If True, only the nodes in the same plane of the polygon are considered. Default is False. -# 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, pressure, polygon, planar=False, load_case=None, tolerance=1.05, **kwargs): -# if not isinstance(pressure, PressureLoad): -# raise TypeError("For the moment PressureLoadField only supports PressureLoad") - -# super().__init__(loads=pressure, 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.loads] * self.nodes) - - -# class VolumeLoadField(LoadField): -# """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(VolumeLoadField, 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.loads.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())) - - class GravityLoadField(LoadField): """Volume distribution of a gravity load case. diff --git a/src/compas_fea2/problem/problem.py b/src/compas_fea2/problem/problem.py index 39b3932e8..f6229cb34 100644 --- a/src/compas_fea2/problem/problem.py +++ b/src/compas_fea2/problem/problem.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import os import shutil from pathlib import Path @@ -386,21 +382,21 @@ def _delete_folder_contents(folder_path: 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() + 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.") + 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): ") + 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() ) @@ -408,7 +404,7 @@ def _delete_folder_contents(folder_path: Path): _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.") + 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) diff --git a/src/compas_fea2/problem/steps/perturbations.py b/src/compas_fea2/problem/steps/perturbations.py index d14b27d1b..d920d8420 100644 --- a/src/compas_fea2/problem/steps/perturbations.py +++ b/src/compas_fea2/problem/steps/perturbations.py @@ -1,11 +1,6 @@ -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 DisplacementResult from compas_fea2.results import ModalAnalysisResult from compas_fea2.UI import FEA2Viewer diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index 6ca4213b3..f7507403e 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -8,16 +8,14 @@ from compas_fea2.base import FEAData from compas_fea2.problem.displacements import GeneralDisplacement 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 -from compas_fea2.problem.fields import NodeLoadField -from compas_fea2.problem.fields import PointLoadField - # ============================================================================== # Base Steps # ============================================================================== @@ -505,9 +503,7 @@ def add_line_load(self, polyline, load_case=None, discretization=10, x=None, y=N local axes are not supported yet """ - return self.add_load_field( - LineLoadField(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) - ) + 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 @@ -605,13 +601,27 @@ def add_gravity_load(self, parts=None, g=9.81, x=0.0, y=0.0, z=-1.0, load_case=N 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 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/database.py b/src/compas_fea2/results/database.py index 1fb62eba8..2d4031639 100644 --- a/src/compas_fea2/results/database.py +++ b/src/compas_fea2/results/database.py @@ -1,12 +1,13 @@ +import json import sqlite3 + import h5py import numpy as np -import json + from compas_fea2.base import FEAData class ResultsDatabase(FEAData): - def __init__(self, problem, **kwargs): super().__init__(**kwargs) self._registration = problem @@ -37,6 +38,7 @@ 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] @@ -58,7 +60,7 @@ def save_to_hdf5(self, key, data): 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) + # 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): @@ -66,7 +68,7 @@ def save_to_hdf5(self, key, data): elif isinstance(v, str): group.attrs[k] = v else: - print(f"⚠️ Warning: Skipping {k} (Unsupported type {type(v)})") + print(f"Warning: Skipping {k} (Unsupported type {type(v)})") def load_from_hdf5(self, key): """Load data from the HDF5 database.""" diff --git a/src/compas_fea2/results/fields.py b/src/compas_fea2/results/fields.py index ce70c7b02..3ff098617 100644 --- a/src/compas_fea2/results/fields.py +++ b/src/compas_fea2/results/fields.py @@ -1,3 +1,4 @@ +from itertools import groupby from typing import Iterable import numpy as np @@ -7,6 +8,7 @@ from compas_fea2.base import FEAData +from .database import ResultsDatabase # noqa: F401 from .results import AccelerationResult # noqa: F401 from .results import DisplacementResult # noqa: F401 from .results import ReactionResult # noqa: F401 @@ -14,8 +16,6 @@ from .results import ShellStressResult # noqa: F401 from .results import SolidStressResult # noqa: F401 from .results import VelocityResult # noqa: F401 -from .database import ResultsDatabase # noqa: F401 -from itertools import groupby class FieldResults(FEAData): @@ -81,15 +81,15 @@ def sqltable_schema(self): } @property - def step(self) -> "Step": + def step(self) -> "_Step": # noqa: F821 return self._registration @property - def problem(self) -> "Problem": + def problem(self) -> "Problem": # noqa: F821 return self.step.problem @property - def model(self) -> "Model": + def model(self) -> "Model": # noqa: F821 return self.problem.model @property @@ -329,7 +329,9 @@ def compute_resultant(self, sub_set=None): 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`. + The translation resultant as :class:`compas.geometry.Vector`, + moment resultant as :class:`compas.geometry.Vector`, + and location as a :class:`compas.geometry.Point`. """ from compas.geometry import Point from compas.geometry import centroid_points_weighted @@ -679,7 +681,14 @@ def global_stresses(self, plane="mid"): 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)) + 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: @@ -708,7 +717,8 @@ def average_stress_at_nodes(self, component="von_mises_stress"): node_indices = np.array([n.key for e in self.results for n in e.element.nodes]) # Shape (N_total_entries,) # Repeat von Mises stress for each node in the corresponding element - repeated_von_mises = np.repeat(element_von_mises, repeats=[len(e.element.nodes) for e in self.results], axis=0) # Shape (N_total_entries,) + 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,) # Get the number of unique nodes max_node_index = node_indices.max() + 1 @@ -799,18 +809,18 @@ def principal_components(self, plane="mid"): """ stress_tensors = self.global_stresses(plane) # Shape: (N_elements, 3, 3) - # **✅ Ensure symmetry (avoiding numerical instability)** + # Ensure symmetry (avoiding numerical instability)** stress_tensors = 0.5 * (stress_tensors + np.transpose(stress_tensors, (0, 2, 1))) - # **✅ Compute eigenvalues and eigenvectors (batch operation)** + # Compute eigenvalues and eigenvectors (batch operation)** eigvals, eigvecs = np.linalg.eigh(stress_tensors) - # **✅ Sort eigenvalues & corresponding eigenvectors (by absolute magnitude)** + # 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** + # 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 diff --git a/src/compas_fea2/results/modal.py b/src/compas_fea2/results/modal.py index d73cdb204..c61cc2582 100644 --- a/src/compas_fea2/results/modal.py +++ b/src/compas_fea2/results/modal.py @@ -129,7 +129,7 @@ def to_csv(self, filepath): 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)" + return f"ModalAnalysisResult(mode={self.mode}, eigenvalue={self.eigenvalue:.4f}, frequency={self.frequency:.4f} Hz, period={self.period:.4f} s)" class ModalShape(NodeFieldResults): diff --git a/src/compas_fea2/results/results.py b/src/compas_fea2/results/results.py index 2fc292889..530fdd70e 100644 --- a/src/compas_fea2/results/results.py +++ b/src/compas_fea2/results/results.py @@ -679,7 +679,7 @@ def __init__(self, element, *, s11, s12, s13, s22, s23, s33, **kwargs): self._local_stress = np.array([[s11, s12, s13], [s12, s22, s23], [s13, s23, s33]]) # 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)} + 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 s11(self): @@ -1113,8 +1113,8 @@ def generate_html_report(self, file_path):

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'}

+

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

@@ -1122,7 +1122,7 @@ def generate_html_report(self, file_path): - {''.join([f"" for key, value in self._components.items()])} + {"".join([f"" for key, value in self._components.items()])}
Component Value
{key}{value:.4f}
{key}{value:.4f}

Principal Stresses

@@ -1131,7 +1131,7 @@ def generate_html_report(self, file_path): Principal Stress Value - {''.join([f"Principal Stress {i+1}{value:.4f}" for i, value in enumerate(self.principal_stresses_values)])} + {"".join([f"Principal Stress {i + 1}{value:.4f}" for i, value in enumerate(self.principal_stresses_values)])}

Von Mises Stress

diff --git a/src/compas_fea2/utilities/interfaces_numpy.py b/src/compas_fea2/utilities/interfaces_numpy.py deleted file mode 100644 index 2b0474a75..000000000 --- a/src/compas_fea2/utilities/interfaces_numpy.py +++ /dev/null @@ -1,214 +0,0 @@ -import numpy as np -from shapely.geometry import Polygon -from scipy.spatial import KDTree -from numpy.linalg import solve -from compas.geometry import Frame, centroid_points, cross_vectors, local_to_world_coordinates_numpy -from compas_fea2.model.interfaces import Interface - - -def face_bounding_sphere(mesh, face): - """ - Compute a bounding sphere for a given face: - center = average of face vertices - radius = max distance from center to any vertex - """ - coords = mesh.face_coordinates(face) - if not coords: - return None, 0.0 - center = np.mean(coords, axis=0) - radius = max(np.linalg.norm(c - center) for c in coords) - return center, radius - - -def mesh_mesh_interfaces(a, b, tmax=1e-6, amin=1e-1): - """ - Face-face contact detection between two meshes, using - broad-phase bounding spheres + narrow-phase 2D polygon intersection. - - Parameters - ---------- - a : Mesh (compas.datastructures.Mesh) - b : Mesh (compas.datastructures.Mesh) - tmax : float - Maximum allowable Z-deviation in the local frame. - amin : float - Minimum area for a valid intersection polygon. - - Returns - ------- - List[Interface] - A list of face-face intersection interfaces. - """ - - # --------------------------------------------------------------------- - # 1. Precompute B’s data once - # --------------------------------------------------------------------- - b_xyz = np.array(b.vertices_attributes("xyz"), dtype=float).T - k_i = {key: index for index, key in enumerate(b.vertices())} - - # We also store face center for each face in B (for the KDTree) - faces_b = list(b.faces()) - face_centers_b = [] - face_radii_b = [] - face_vertex_indices_b = [] - - for fb in faces_b: - centerB, radiusB = face_bounding_sphere(b, fb) - face_centers_b.append(centerB) # bounding sphere center - face_radii_b.append(radiusB) - - # Store the vertex indices for this face - face_vs = b.face_vertices(fb) - face_vertex_indices_b.append([k_i[vk] for vk in face_vs]) - - face_centers_b = np.array(face_centers_b) - face_radii_b = np.array(face_radii_b) - - # Build a KDTree for B’s face centers - if len(face_centers_b) == 0: - print("No faces in mesh B. Exiting.") - return [] - - # --------------------------------------------------------------------- - # 2. Precompute A’s bounding spheres & KDTree - # --------------------------------------------------------------------- - faces_a = list(a.faces()) - face_centers_a = [] - face_radii_a = [] - frames_a = {} # local 2D frames for each face in A (for narrow-phase) - - for fa in faces_a: - centerA, radiusA = face_bounding_sphere(a, fa) - face_centers_a.append(centerA) - face_radii_a.append(radiusA) - - # Precompute stable local frame for face A - coordsA = np.array(a.face_coordinates(fa)) - if coordsA.shape[0] < 2: - continue - w = np.array(a.face_normal(fa)) - edge_vecs = coordsA[1:] - coordsA[:-1] - if len(edge_vecs) == 0: - continue - longest_edge = max(edge_vecs, key=lambda e: np.linalg.norm(e)) - - u = longest_edge - v = cross_vectors(w, u) - - frames_a[fa] = Frame(centerA, u, v) - - face_centers_a = np.array(face_centers_a) - face_radii_a = np.array(face_radii_a) - - # KDTree for A’s face centers - tree_a = KDTree(face_centers_a) - - # --------------------------------------------------------------------- - # 3. Helper: 2D polygon from face in local frame - # --------------------------------------------------------------------- - def face_polygon_in_frame(mesh, face, frame): - """ - Project a face into `frame`'s local XY, returning a shapely Polygon. - """ - coords_3d = np.array(mesh.face_coordinates(face)).T - A = np.array([frame.xaxis, frame.yaxis, frame.zaxis], dtype=float).T - o = np.array(frame.point, dtype=float).reshape(-1, 1) - - try: - rst = solve(A, coords_3d - o).T # shape: (n,3), but z ~ 0 - except np.linalg.LinAlgError: - return None - # If the Z-values are large, it might fail tmax - return Polygon(rst[:, :2]) # polygon in local 2D plane - - # --------------------------------------------------------------------- - # 4. Narrow-phase intersection - # --------------------------------------------------------------------- - def intersect_faces(fa, fb): - """ - Return an Interface if face fa intersects face fb, else None. - """ - # bounding sphere overlap is already assumed; we do a final check for planarity + polygon intersection - - # local frame of face A - fA_center, fA_radius = face_bounding_sphere(a, fa) - frameA = frames_a.get(fa) - if not frameA: - return None - - # Build polygon for face A - pA = face_polygon_in_frame(a, fa, frameA) - if pA is None or pA.is_empty or pA.area < amin: - return None - - # Transform all B vertices once for the frame of A: - # But we only need face fb’s vertices - # Instead, let's do a minimal local transform of face fb - coords_3d_b = np.array(b.face_coordinates(fb)).T - A_mat = np.array([frameA.xaxis, frameA.yaxis, frameA.zaxis], dtype=float).T - o_mat = np.array(frameA.point, dtype=float).reshape(-1, 1) - - try: - rst_b = solve(A_mat, coords_3d_b - o_mat).T - except np.linalg.LinAlgError: - return None - - # Check planarity threshold - if any(abs(z) > tmax for x, y, z in rst_b): - return None - - pB = Polygon(rst_b[:, :2]) - if pB.is_empty or pB.area < amin: - return None - - if not pA.intersects(pB): - return None - - intersection = pA.intersection(pB) - if intersection.is_empty: - return None - area = intersection.area - if area < amin: - return None - - # Re-project intersection to 3D - coords_2d = list(intersection.exterior.coords)[:-1] # exclude closing point - coords_2d_3 = [[x, y, 0.0] for x, y in coords_2d] - - coords_3d = local_to_world_coordinates_numpy(Frame(o_mat.ravel(), A_mat[:, 0], A_mat[:, 1]), coords_2d_3).tolist() - - return Interface( - size=area, - points=coords_3d, - frame=Frame(centroid_points(coords_3d), frameA.xaxis, frameA.yaxis), - ) - - # --------------------------------------------------------------------- - # 5. Broad-Phase + Narrow-Phase - # --------------------------------------------------------------------- - interfaces = [] - - # A. For each face in B, find overlapping faces in A - for idxB, fb in enumerate(faces_b): - centerB = face_centers_b[idxB] - radiusB = face_radii_b[idxB] - - # Search in A’s KDTree - candidate_indices = tree_a.query_ball_point(centerB, r=radiusB + np.max(face_radii_a)) - - for idxA in candidate_indices: - centerA = face_centers_a[idxA] - radiusA = face_radii_a[idxA] - - # Check actual bounding sphere overlap - dist_centers = np.linalg.norm(centerB - centerA) - if dist_centers > (radiusA + radiusB): - continue # No overlap in bounding sphere - - # Now do narrow-phase - fa = faces_a[idxA] - interface = intersect_faces(fa, fb) - if interface: - interfaces.append(interface) - - return interfaces diff --git a/tests/test_parts.py b/tests/test_parts.py index e0b36d408..24aeff783 100644 --- a/tests/test_parts.py +++ b/tests/test_parts.py @@ -23,8 +23,6 @@ def test_add_element(self): part.add_element(element) self.assertIn(element, part.elements) - -class TestPart(unittest.TestCase): def test_add_material(self): part = Part() material = Steel.S355() From f6723eb089f8d4e7b7372444e661ae1748da2b31 Mon Sep 17 00:00:00 2001 From: ines Date: Fri, 9 May 2025 10:44:02 +0200 Subject: [PATCH 37/39] Fixes #10 : Unicode Error Debug --- src/compas_fea2/utilities/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compas_fea2/utilities/_utils.py b/src/compas_fea2/utilities/_utils.py index 880cf48c6..84782cd64 100644 --- a/src/compas_fea2/utilities/_utils.py +++ b/src/compas_fea2/utilities/_utils.py @@ -101,7 +101,7 @@ def launch_process(cmd_args: list[str], cwd: Optional[str] = None, verbose: bool 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().strip() + yield line.decode(errors="replace").strip() process.wait() if process.returncode != 0: From 7e1ef03a43f5998dc178ce5387f83209fdf94f9f Mon Sep 17 00:00:00 2001 From: ines Date: Fri, 9 May 2025 13:36:52 +0200 Subject: [PATCH 38/39] Fixes #9 : LoadCombination debug --- src/compas_fea2/problem/combinations.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/compas_fea2/problem/combinations.py b/src/compas_fea2/problem/combinations.py index 283b38707..11d37465f 100644 --- a/src/compas_fea2/problem/combinations.py +++ b/src/compas_fea2/problem/combinations.py @@ -1,5 +1,5 @@ from compas_fea2.base import FEAData - +import compas_fea2 class LoadCombination(FEAData): """Load combination used to combine load fields together at each step. @@ -56,7 +56,7 @@ def __from_data__(cls, data): # 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 @@ -66,10 +66,12 @@ def node_load(self): """ nodes_loads = {} for load_field in self.step.load_fields: - if load_field.load_case in self.factors: - for node, load in load_field.node_load: - 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] + 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())) From 97d5ec4ad27a416a47fed4b08c0e7f4e4bc754ef Mon Sep 17 00:00:00 2001 From: ines Date: Thu, 22 May 2025 17:18:05 +0200 Subject: [PATCH 39/39] show_deformed debug --- src/compas_fea2/UI/viewer/scene.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/compas_fea2/UI/viewer/scene.py b/src/compas_fea2/UI/viewer/scene.py index 8971b583e..b31c6e7be 100644 --- a/src/compas_fea2/UI/viewer/scene.py +++ b/src/compas_fea2/UI/viewer/scene.py @@ -210,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) { @@ -227,16 +227,16 @@ 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 FEA2Stress2DFieldResultsObject(GroupObject):