diff --git a/Pynite/Analysis.py b/New Pynite Folder/Analysis.py similarity index 100% rename from Pynite/Analysis.py rename to New Pynite Folder/Analysis.py diff --git a/PyNite/BeamSegY.py b/New Pynite Folder/BeamSegY.py similarity index 100% rename from PyNite/BeamSegY.py rename to New Pynite Folder/BeamSegY.py diff --git a/PyNite/BeamSegZ.py b/New Pynite Folder/BeamSegZ.py similarity index 100% rename from PyNite/BeamSegZ.py rename to New Pynite Folder/BeamSegZ.py diff --git a/PyNite/FEModel3D.py b/New Pynite Folder/FEModel3D.py similarity index 100% rename from PyNite/FEModel3D.py rename to New Pynite Folder/FEModel3D.py diff --git a/PyNite/FixedEndReactions.py b/New Pynite Folder/FixedEndReactions.py similarity index 100% rename from PyNite/FixedEndReactions.py rename to New Pynite Folder/FixedEndReactions.py diff --git a/PyNite/LoadCombo.py b/New Pynite Folder/LoadCombo.py similarity index 100% rename from PyNite/LoadCombo.py rename to New Pynite Folder/LoadCombo.py diff --git a/PyNite/MainStyleSheet.css b/New Pynite Folder/MainStyleSheet.css similarity index 100% rename from PyNite/MainStyleSheet.css rename to New Pynite Folder/MainStyleSheet.css diff --git a/PyNite/Material.py b/New Pynite Folder/Material.py similarity index 100% rename from PyNite/Material.py rename to New Pynite Folder/Material.py diff --git a/PyNite/Member3D.py b/New Pynite Folder/Member3D.py similarity index 100% rename from PyNite/Member3D.py rename to New Pynite Folder/Member3D.py diff --git a/PyNite/Mesh.py b/New Pynite Folder/Mesh.py similarity index 100% rename from PyNite/Mesh.py rename to New Pynite Folder/Mesh.py diff --git a/PyNite/Node3D.py b/New Pynite Folder/Node3D.py similarity index 100% rename from PyNite/Node3D.py rename to New Pynite Folder/Node3D.py diff --git a/PyNite/PhysMember.py b/New Pynite Folder/PhysMember.py similarity index 100% rename from PyNite/PhysMember.py rename to New Pynite Folder/PhysMember.py diff --git a/PyNite/Plastic Beam.py b/New Pynite Folder/Plastic Beam.py similarity index 100% rename from PyNite/Plastic Beam.py rename to New Pynite Folder/Plastic Beam.py diff --git a/PyNite/Plate3D.py b/New Pynite Folder/Plate3D.py similarity index 100% rename from PyNite/Plate3D.py rename to New Pynite Folder/Plate3D.py diff --git a/PyNite/Quad3D.py b/New Pynite Folder/Quad3D.py similarity index 100% rename from PyNite/Quad3D.py rename to New Pynite Folder/Quad3D.py diff --git a/PyNite/Rendering.py b/New Pynite Folder/Rendering.py similarity index 100% rename from PyNite/Rendering.py rename to New Pynite Folder/Rendering.py diff --git a/PyNite/Report_Template.html b/New Pynite Folder/Report_Template.html similarity index 100% rename from PyNite/Report_Template.html rename to New Pynite Folder/Report_Template.html diff --git a/PyNite/Reporting.py b/New Pynite Folder/Reporting.py similarity index 100% rename from PyNite/Reporting.py rename to New Pynite Folder/Reporting.py diff --git a/PyNite/Section.py b/New Pynite Folder/Section.py similarity index 100% rename from PyNite/Section.py rename to New Pynite Folder/Section.py diff --git a/PyNite/ShearWall.py b/New Pynite Folder/ShearWall.py similarity index 100% rename from PyNite/ShearWall.py rename to New Pynite Folder/ShearWall.py diff --git a/PyNite/Spring3D.py b/New Pynite Folder/Spring3D.py similarity index 100% rename from PyNite/Spring3D.py rename to New Pynite Folder/Spring3D.py diff --git a/PyNite/Visualization.py b/New Pynite Folder/Visualization.py similarity index 100% rename from PyNite/Visualization.py rename to New Pynite Folder/Visualization.py diff --git a/PyNite/__init__.py b/New Pynite Folder/__init__.py similarity index 100% rename from PyNite/__init__.py rename to New Pynite Folder/__init__.py diff --git a/Old Pynite Folder/Analysis.py b/Old Pynite Folder/Analysis.py new file mode 100644 index 00000000..96f69df0 --- /dev/null +++ b/Old Pynite Folder/Analysis.py @@ -0,0 +1,1089 @@ +from math import isclose +from Pynite.LoadCombo import LoadCombo +from numpy import array, atleast_2d, zeros, subtract, matmul, divide, seterr, nanmax +from numpy.linalg import solve + +def _prepare_model(model): + """Prepares a model for analysis by ensuring at least one load combination is defined, generating all meshes that have not already been generated, activating all non-linear members, and internally numbering all nodes and elements. + + :param model: The model being prepared for analysis. + :type model: FEModel3D + """ + + # Reset any nodal displacements + model._D = {} + for node in model.nodes.values(): + node.DX = {} + node.DY = {} + node.DZ = {} + node.RX = {} + node.RY = {} + node.RZ = {} + + # Ensure there is at least 1 load combination to solve if the user didn't define any + if model.load_combos == {}: + # Create and add a default load combination to the dictionary of load combinations + model.load_combos['Combo 1'] = LoadCombo('Combo 1', factors={'Case 1':1.0}) + + # Generate all meshes + for mesh in model.meshes.values(): + if mesh.is_generated == False: + mesh.generate() + + # Activate all springs and members for all load combinations + for spring in model.springs.values(): + for combo_name in model.load_combos.keys(): + spring.active[combo_name] = True + + # Activate all physical members for all load combinations + for phys_member in model.members.values(): + for combo_name in model.load_combos.keys(): + phys_member.active[combo_name] = True + + # Assign an internal ID to all nodes and elements in the model. This number is different from the name used by the user to identify nodes and elements. + _renumber(model) + +def _identify_combos(model, combo_tags=None): + """Returns a list of load combinations that are to be run based on tags given by the user. + + :param model: The model being analyzed. + :type model: FEModel3D + :param combo_tags: A list of tags used for the load combinations to be evaluated. Defaults to `None` in which case all load combinations will be added to the list of load combinations to be run. + :type combo_tags: list, optional + :return: A list containing the load combinations to be analyzed. + :rtype: list + """ + + # Identify which load combinations to evaluate + if combo_tags is None: + # Evaluate all load combinations if not tags have been provided + combo_list = model.load_combos.values() + else: + # Initialize the list of load combinations to be evaluated + combo_list = [] + # Step through each load combination in the model + for combo in model.load_combos.values(): + # Check if this load combination is tagged with any of the tags we're looking for + if combo.combo_tags is not None and any(tag in combo.combo_tags for tag in combo_tags): + # Add the load combination to the list of load combinations to be evaluated + combo_list.append(combo) + + # Return the list of load combinations to be evaluated + return combo_list + +def _check_stability(model, K): + """ + Identifies nodal instabilities in a model's stiffness matrix. + """ + + # Initialize the `unstable` flag to `False` + unstable = False + + # Step through each diagonal term in the stiffness matrix + for i in range(K.shape[0]): + + # Determine which node this term belongs to + node = [node for node in model.nodes.values() if node.ID == int(i/6)][0] + + # Determine which degree of freedom this term belongs to + dof = i%6 + + # Check to see if this degree of freedom is supported + if dof == 0: + supported = node.support_DX + elif dof == 1: + supported = node.support_DY + elif dof == 2: + supported = node.support_DZ + elif dof == 3: + supported = node.support_RX + elif dof == 4: + supported = node.support_RY + elif dof == 5: + supported = node.support_RZ + + # Check if the degree of freedom on this diagonal is unstable + if isclose(K[i, i], 0) and not supported: + + # Flag the model as unstable + unstable = True + + # Identify which direction this instability affects + if i%6 == 0: + direction = 'for translation in the global X direction.' + if i%6 == 1: + direction = 'for translation in the global Y direction.' + if i%6 == 2: + direction = 'for translation in the global Z direction.' + if i%6 == 3: + direction = 'for rotation about the global X axis.' + if i%6 == 4: + direction = 'for rotation about the global Y axis.' + if i%6 == 5: + direction = 'for rotation about the global Z axis.' + + # Print a message to the console + print('* Nodal instability detected: node ' + node.name + ' is unstable ' + direction) + + if unstable: + raise Exception('Unstable node(s). See console output for details.') + + return + +def _PDelta_step(model, combo_name, P1, FER1, D1_indices, D2_indices, D2, log=True, sparse=True, check_stability=False, max_iter=30, first_step=True): + """Performs second order (P-Delta) analysis. This type of analysis is appropriate for most models using beams, columns and braces. Second order analysis is usually required by material-specific codes. The analysis is iterative and takes longer to solve. Models with slender members and/or members with combined bending and axial loads will generally have more significant P-Delta effects. P-Delta effects in plates/quads are not considered. + + :param combo_name: The name of the load combination to evaluate P-Delta effects for. + :type combo_name: string + :param log: Prints updates to the console if set to True. Default is False. + :type log: bool, optional + :param check_stability: When set to True, checks the stiffness matrix for any unstable degrees of freedom and reports them back to the console. This does add to the solution time. Defaults to True. + :type check_stability: bool, optional + :param max_iter: The maximum number of iterations permitted. If this value is exceeded the program will report divergence. Defaults to 30. + :type max_iter: int, optional + :param sparse: Indicates whether the sparse matrix solver should be used. A matrix can be considered sparse or dense depening on how many zero terms there are. Structural stiffness matrices often contain many zero terms. The sparse solver can offer faster solutions for such matrices. Using the sparse solver on dense matrices may lead to slower solution times. Be sure ``scipy`` is installed to use the sparse solver. Default is True. + :type sparse: bool, optional + :param check_stability: Indicates whether nodal stability should be checked. This slows down the analysis considerably, but can be useful for small models or for debugging. Default is `False`. + :type check_stability: bool, optional + :param first_step: Indicates whether this P-Delta analysis is the first load step. Usually this should be set to `True`, unless this is a subsequent step of an analysis using multiple load steps. Default is True. + :type first_step: bool, optional + :raises ValueError: Occurs when there is a singularity in the stiffness matrix, which indicates an unstable structure. + :raises Exception: Occurs when a model fails to converge. + """ + + # Import `scipy` features if the sparse solver is being used + if sparse == True: + from scipy.sparse.linalg import spsolve + + iter_count_TC = 1 # Tracks tension/compression-only iterations + iter_count_PD = 1 # Tracks P-Delta iterations + + convergence_TC = False # Tracks tension/compression-only convergence + divergence_TC = False # Tracks tension/compression-only divergence + + # Iterate until either T/C convergence or divergence occurs. Perform at least 2 iterations for the P-Delta analysis. + while (convergence_TC == False and divergence_TC == False) or iter_count_PD <= 2: + + # Inform the user which iteration we're on + if log: + print('- Beginning tension/compression-only iteration #' + str(iter_count_TC)) + + # Calculate the partitioned global stiffness matrices + if sparse == True: + + # Calculate the initial stiffness matrix + K11, K12, K21, K22 = _partition(model, model.K(combo_name, log, check_stability, sparse).tolil(), D1_indices, D2_indices) + + # Calculate the geometric stiffness matrix + if iter_count_PD == 1 and first_step: + # For the first iteration of the first load step P=0 + Kg11, Kg12, Kg21, Kg22 = _partition(model, model.Kg(combo_name, log, sparse, True), D1_indices, D2_indices) + else: + # For subsequent iterations P will be calculated based on member end displacements + Kg11, Kg12, Kg21, Kg22 = _partition(model, model.Kg(combo_name, log, sparse, False), D1_indices, D2_indices) + + # The stiffness matrices are currently `lil` format which is great for + # memory, but slow for mathematical operations. They will be converted to + # `csr` format. The `+` operator performs matrix addition on `csr` + # matrices. + K11 = K11.tocsr() + Kg11.tocsr() + K12 = K12.tocsr() + Kg12.tocsr() + K21 = K21.tocsr() + Kg21.tocsr() + K22 = K22.tocsr() + Kg22.tocsr() + + else: + + # Initial stiffness matrix + K11, K12, K21, K22 = _partition(model, model.K(combo_name, log, check_stability, sparse), D1_indices, D2_indices) + + # Geometric stiffness matrix + if iter_count_PD == 1 and first_step: + # For the first iteration of the first load step P=0 + Kg11, Kg12, Kg21, Kg22 = _partition(model, model.Kg(combo_name, log, sparse, True), D1_indices, D2_indices) + else: + # For subsequent iterations P will be calculated based on member end displacements + Kg11, Kg12, Kg21, Kg22 = _partition(model.Kg(combo_name, log, sparse, False), D1_indices, D2_indices) + + K11 = K11 + Kg11 + K12 = K12 + Kg12 + K21 = K21 + Kg21 + K22 = K22 + Kg22 + + # Calculate the changes to the global displacement vector + if log: print('- Calculating changes to the global displacement vector') + if K11.shape == (0, 0): + # All displacements are known, so D1 is an empty vector + Delta_D1 = [] + else: + try: + # Calculate the change in the displacements Delta_D1 + if sparse == True: + # The partitioned stiffness matrix is already in `csr` format. The `@` + # operator performs matrix multiplication on sparse matrices. + Delta_D1 = spsolve(K11.tocsr(), subtract(subtract(P1, FER1), K12.tocsr() @ D2)) + Delta_D1 = Delta_D1.reshape(len(Delta_D1), 1) + else: + # The partitioned stiffness matrix is in `csr` format. It will be + # converted to a 2D dense array for mathematical operations. + Delta_D1 = solve(K11, subtract(subtract(P1, FER1), matmul(K12, D2))) + + except: + # Return out of the method if 'K' is singular and provide an error message + raise ValueError('The stiffness matrix is singular, which indicates that the structure is unstable.') + + # Sum the calculated displacements + if first_step: + _store_displacements(model, Delta_D1, D2, D1_indices, D2_indices, model.load_combos[combo_name]) + else: + _sum_displacements(model, Delta_D1, D2, D1_indices, D2_indices, model.load_combos[combo_name]) + + # Check whether the tension/compression-only analysis has converged and deactivate any members that are showing forces they can't hold + convergence_TC = _check_TC_convergence(model, combo_name, log) + + # Report on convergence of tension/compression only analysis + if convergence_TC == False: + + if log: + print('- Tension/compression-only analysis did not converge on this iteration') + print('- Stiffness matrix will be adjusted') + print('- P-Delta analysis will be restarted') + + # Increment the tension/compression-only iteration count + iter_count_TC += 1 + + # Undo the last iteration of the analysis since the T/C analysis didn't converge + _sum_displacements(model, -Delta_D1, D2, D1_indices, D2_indices, model.load_combos[combo_name]) + iter_count_PD = 0 + + else: + if log: print('- Tension/compression-only analysis converged after ' + str(iter_count_TC) + ' iteration(s)') + + # Check for divergence in the tension/compression-only analysis + if iter_count_TC > max_iter: + divergence_TC = True + raise Exception('- Model diverged during tension/compression-only analysis') + + # Increment the P-Delta iteration count + iter_count_PD += 1 + + # Flag the model as solved + model.solution = 'P-Delta' + +def _pushover_step(model, combo_name, push_combo, step_num, P1, FER1, D1_indices, D2_indices, D2, log=True, sparse=True, check_stability=False): + + # Run at least one iteration + run_step = True + + # Run/rerun the load step until convergence occurs + while run_step == True: + + # Calculate the partitioned global stiffness matrices + # Sparse solver + if sparse == True: + + from scipy.sparse.linalg import spsolve + + # Calculate the initial stiffness matrix + K11, K12, K21, K22 = _partition(model, model.K(combo_name, log, check_stability, sparse).tolil(), D1_indices, D2_indices) + + # Calculate the geometric stiffness matrix + # The `combo_name` variable in the code below is not the name of the pushover load combination. Rather it is the name of the primary combination that the pushover load will be added to. Axial loads used to develop Kg are calculated from the displacements stored in `combo_name`. + Kg11, Kg12, Kg21, Kg22 = _partition(model, model.Kg(combo_name, log, sparse, False).tolil(), D1_indices, D2_indices) + + # Calculate the stiffness reduction matrix + Km11, Km12, Km21, Km22 = _partition(model, model.Km(combo_name, push_combo, step_num, log, sparse).tolil(), D1_indices, D2_indices) + + # The stiffness matrices are currently `lil` format which is great for + # memory, but slow for mathematical operations. They will be converted to + # `csr` format. The `+` operator performs matrix addition on `csr` + # matrices. + K11 = K11.tocsr() + Kg11.tocsr() + Km11.tocsr() + K12 = K12.tocsr() + Kg12.tocsr() + Km12.tocsr() + K21 = K21.tocsr() + Kg21.tocsr() + Km21.tocsr() + K22 = K22.tocsr() + Kg22.tocsr() + Km22.tocsr() + + # Dense solver + else: + + # Initial stiffness matrix + K11, K12, K21, K22 = _partition(model, model.K(combo_name, log, check_stability, sparse), D1_indices, D2_indices) + + # Geometric stiffness matrix + # The `combo_name` variable in the code below is not the name of the pushover load combination. Rather it is the name of the primary combination that the pushover load will be added to. Axial loads used to develop Kg are calculated from the displacements stored in `combo_name`. + Kg11, Kg12, Kg21, Kg22 = _partition(model.Kg(combo_name, log, sparse, False), D1_indices, D2_indices) + + # Calculate the stiffness reduction matrix + Km11, Km12, Km21, Km22 = _partition(model, model.Km(combo_name, push_combo, step_num, log, sparse), D1_indices, D2_indices) + + K11 = K11 + Kg11 + Km11 + K12 = K12 + Kg12 + Km12 + K21 = K21 + Kg21 + Km21 + K22 = K22 + Kg22 + Km22 + + # Calculate the changes to the global displacement vector + if log: print('- Calculating changes to the global displacement vector') + if K11.shape == (0, 0): + # All displacements are known, so D1 is an empty vector + Delta_D1 = [] + else: + try: + # Calculate the change in the displacements Delta_D1 + if sparse == True: + # The partitioned stiffness matrix is already in `csr` format. The `@` + # operator performs matrix multiplication on sparse matrices. + Delta_D1 = spsolve(K11.tocsr(), subtract(subtract(P1, FER1), K12.tocsr() @ D2)) + Delta_D1 = Delta_D1.reshape(len(Delta_D1), 1) + else: + # The partitioned stiffness matrix is in `csr` format. It will be + # converted to a 2D dense array for mathematical operations. + Delta_D1 = solve(K11, subtract(subtract(P1, FER1), matmul(K12, D2))) + + except: + # Return out of the method if 'K' is singular and provide an error message + raise ValueError('The structure is unstable. Unable to proceed any further with analysis.') + + # Unpartition the displacement results from the analysis step + Delta_D = _unpartition_disp(model, Delta_D1, D2, D1_indices, D2_indices) + + # Step through each member in the model + for member in model.members.values(): + + # Check for plastic load reversal at the i-node in this load step + if member.i_reversal == False and member.lamb(Delta_D, combo_name, push_combo, step_num)[0, 1] < 0: + + # Flag the member as having plastic load reversal at the i-node + i_reversal = True + + # Flag the load step for reanalysis + run_step = True + + # Check for plastic load reversal at the j-node in this load step + if member.j_reversal == False and member.lamb(Delta_D, combo_name, push_combo, step_num)[1, 1] < 0: + + # Flag the member as having plastic load reversal at the j-node + j_reversal = True + + # Flag the load step for reanalysis + run_step = True + + # Undo the last loadstep if plastic load reversal was discovered. We'll rerun it with the corresponding gradients set to zero vectors. + if run_step == True: + _sum_displacements(model, -Delta_D1, D2, D1_indices, D2_indices, model.load_combos[combo_name]) + + # Sum the calculated displacements + _sum_displacements(model, Delta_D1, D2, D1_indices, D2_indices, model.load_combos[combo_name]) + + # Flag the model as solved + model.solution = 'Pushover' + +def _unpartition_disp(model, D1, D2, D1_indices, D2_indices): + """Unpartitions displacements from the solver and returns them as a global displacement vector + + :param model: The finite element model being evaluated + :type model: FEModel3D + :param D1: An array of calculated displacements + :type D1: array + :param D2: An array of enforced displacements + :type D2: array + :param D1_indices: A list of the degree of freedom indices for each displacement in D1 + :type D1_indices: list + :param D2_indices: A list of the degree of freedom indices for each displacement in D2 + :type D2_indices: list + :return: Global displacement matrix + :rtype: array + """ + + D = zeros((len(model.nodes)*6, 1)) + + # Step through each node in the model + for node in model.nodes.values(): + + # Step through each degree of freedom at the node + for i in range(6): + + # Check if the dof is in the list of enforced displacements + if node.ID*6 + i in D2_indices: + # Get the enforced displacement + D[(node.ID*6 + i, 0)] = D2[D2_indices.index(node.ID*6 + i), 0] + else: + # Get the calculated displacement + D[(node.ID*6 + i, 0)] = D1[D1_indices.index(node.ID*6 + i), 0] + + # Return the displacement vector + return D + +def _store_displacements(model, D1, D2, D1_indices, D2_indices, combo): + """Stores calculated displacements from the solver into the model's displacement vector `_D` and into each node object in the model + + :param model: The finite element model being evaluated. + :type model: FEModel3D + :param D1: An array of calculated displacements + :type D1: array + :param D2: An array of enforced displacements + :type D2: array + :param D1_indices: A list of the degree of freedom indices for each displacement in D1 + :type D1_indices: list + :param D2_indices: A list of the degree of freedom indices for each displacement in D2 + :type D2_indices: list + :param combo: The load combination to store the displacements for + :type combo: LoadCombo + """ + + # The raw results from the solver are partitioned. Unpartition them. + D = _unpartition_disp(model, D1, D2, D1_indices, D2_indices) + + # Store the displacements in the model's global displacement vector + model._D[combo.name] = D + + # Store the calculated global nodal displacements into each node object + for node in model.nodes.values(): + + node.DX[combo.name] = D[node.ID*6 + 0, 0] + node.DY[combo.name] = D[node.ID*6 + 1, 0] + node.DZ[combo.name] = D[node.ID*6 + 2, 0] + node.RX[combo.name] = D[node.ID*6 + 3, 0] + node.RY[combo.name] = D[node.ID*6 + 4, 0] + node.RZ[combo.name] = D[node.ID*6 + 5, 0] + +def _sum_displacements(model, Delta_D1, Delta_D2, D1_indices, D2_indices, combo): + """Sums calculated displacements for a load step from the solver into the model's displacement vector `_D` and into each node object in the model. + + :param model: The finite element model being evaluated. + :type model: FEModel3D + :param Delta_D1: An array of calculated displacements for a load step + :type Delta_D1: array + :param Delta_D2: An array of enforced displacements for a load step + :type Delta_D2: array + :param D1_indices: A list of the degree of freedom indices for each displacement in D1 + :type D1_indices: list + :param D2_indices: A list of the degree of freedom indices for each displacement in D2 + :type D2_indices: list + :param combo: The load combination to store the displacements for + :type combo: LoadCombo + """ + + # The raw results from the solver are partitioned. Unpartition them. + Delta_D = _unpartition_disp(model, Delta_D1, Delta_D2, D1_indices, D2_indices) + + # Sum the load step's global displacement vector with the model's global displacement vector + model._D[combo.name] += Delta_D + + # Sum the load step's calculated global nodal displacements to each node object's global displacement + for node in model.nodes.values(): + + node.DX[combo.name] += Delta_D[node.ID*6 + 0, 0] + node.DY[combo.name] += Delta_D[node.ID*6 + 1, 0] + node.DZ[combo.name] += Delta_D[node.ID*6 + 2, 0] + node.RX[combo.name] += Delta_D[node.ID*6 + 3, 0] + node.RY[combo.name] += Delta_D[node.ID*6 + 4, 0] + node.RZ[combo.name] += Delta_D[node.ID*6 + 5, 0] + +def _check_TC_convergence(model, combo_name="Combo 1", log=True, spring_tolerance=0, member_tolerance=0): + + # Assume the model has converged until we find out otherwise + convergence = True + + # Provide an update to the console if requested by the user + if log: + print("- Checking for tension/compression-only support spring convergence") + # Loop through each node and each directional spring to check and update their active status + for node in model.nodes.values(): + + for direction in ["DX", "DY", "DZ", "RX", "RY", "RZ"]: + spring = getattr(node, f"spring_{direction}") + displacement = getattr(node, direction)[combo_name] + + if spring[1] is not None: + # Determine if the spring should be active based on its designation ('+' or '-') and the displacement + should_be_active = ( + spring[1] == "-" and displacement <= -spring_tolerance + ) or (spring[1] == "+" and displacement >= spring_tolerance) + + # Check if there's a need to switch the active state of the spring + if spring[2] != should_be_active: + spring[2] = should_be_active + convergence = False + + # TODO: Adjust the code below to allow elements to reactivate on subsequent iterations if deformations at element nodes indicate the member goes back into an active state. This will lead to a less conservative and more realistic analysis. Nodal springs (above) already do this. + + # Check tension/compression-only springs + if log: print('- Checking for tension/compression-only spring convergence') + for spring in model.springs.values(): + + if spring.active[combo_name] == True: + # Check if tension-only conditions exist + if ( + spring.tension_only == True + and spring.axial(combo_name) > spring_tolerance + ): + spring.active[combo_name] = False + convergence = False + + # Check if compression-only conditions exist + elif ( + spring.comp_only == True + and spring.axial(combo_name) < -spring_tolerance + ): + spring.active[combo_name] = False + convergence = False + + # Check tension/compression only members + if log: print('- Checking for tension/compression-only member convergence') + for phys_member in model.members.values(): + + # Only run the tension/compression only check if the member is still active + if phys_member.active[combo_name] == True: + + # Check if a tension-only conditions exist + if ( + phys_member.tension_only == True + and phys_member.max_axial(combo_name) > member_tolerance + ): + # Deactivate the physical member + phys_member.active[combo_name] = False + + # Deactivate all the sub-members + for sub_member in phys_member.sub_members.values(): + sub_member.active[combo_name] = False + + # Flag the analysis as not converged + convergence = False + + # Check if a compression-only conditions exist + elif ( + phys_member.comp_only == True + and phys_member.min_axial(combo_name) < -member_tolerance + ): + # Deactivate the physical member + phys_member.active[combo_name] = False + + # Deactivate all the sub-members + for sub_member in phys_member.sub_members.values(): + sub_member.active[combo_name] = False + + # Flag the analysis as not converged + convergence = False + + # Reset the sub-member's flag to unsolved. This will allow it to resolve for the same load combination after subsequent iterations have made further changes. + for sub_member in phys_member.sub_members.values(): + sub_member._solved_combo = None + + # Return whether the TC analysis has converged + return convergence + +def _calc_reactions(model, log=False, combo_tags=None): + """ + Calculates reactions internally once the model is solved. + + Parameters + ---------- + model : FEModel3D + The finite element model to calculate reactions for. + log : bool, optional + Prints updates to the console if set to True. Default is False. + combo_tags : string, optional + A list of tags that will be used to identify which load combinations need their reactions calculated. If set to `None` then all load combinations will have their reactions calculated. Default is `None`. + """ + + # Print a status update to the console + if log: print('- Calculating reactions') + + # Identify which load combinations to evaluate + combo_list = _identify_combos(model, combo_tags) + + # Calculate the reactions node by node + for node in model.nodes.values(): + + # Step through each load combination + for combo in combo_list: + + # Initialize reactions for this node and load combination + node.RxnFX[combo.name] = 0.0 + node.RxnFY[combo.name] = 0.0 + node.RxnFZ[combo.name] = 0.0 + node.RxnMX[combo.name] = 0.0 + node.RxnMY[combo.name] = 0.0 + node.RxnMZ[combo.name] = 0.0 + + # Determine if the node has any supports + if (node.support_DX or node.support_DY or node.support_DZ + or node.support_RX or node.support_RY or node.support_RZ): + + # Sum the spring end forces at the node + for spring in model.springs.values(): + + if spring.i_node == node and spring.active[combo.name] == True: + + # Get the spring's global force matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + spring_F = spring.F(combo.name) + + if node.support_DX: node.RxnFX[combo.name] += spring_F[0, 0] + if node.support_DY: node.RxnFY[combo.name] += spring_F[1, 0] + if node.support_DZ: node.RxnFZ[combo.name] += spring_F[2, 0] + if node.support_RX: node.RxnMX[combo.name] += spring_F[3, 0] + if node.support_RY: node.RxnMY[combo.name] += spring_F[4, 0] + if node.support_RZ: node.RxnMZ[combo.name] += spring_F[5, 0] + + elif spring.j_node == node and spring.active[combo.name] == True: + + # Get the spring's global force matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + spring_F = spring.F(combo.name) + + if node.support_DX: node.RxnFX[combo.name] += spring_F[6, 0] + if node.support_DY: node.RxnFY[combo.name] += spring_F[7, 0] + if node.support_DZ: node.RxnFZ[combo.name] += spring_F[8, 0] + if node.support_RX: node.RxnMX[combo.name] += spring_F[9, 0] + if node.support_RY: node.RxnMY[combo.name] += spring_F[10, 0] + if node.support_RZ: node.RxnMZ[combo.name] += spring_F[11, 0] + + # Step through each physical member in the model + for phys_member in model.members.values(): + + # Sum the sub-member end forces at the node + for member in phys_member.sub_members.values(): + + if member.i_node == node and phys_member.active[combo.name] == True: + + # Get the member's global force matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + member_F = member.F(combo.name) + + if node.support_DX: node.RxnFX[combo.name] += member_F[0, 0] + if node.support_DY: node.RxnFY[combo.name] += member_F[1, 0] + if node.support_DZ: node.RxnFZ[combo.name] += member_F[2, 0] + if node.support_RX: node.RxnMX[combo.name] += member_F[3, 0] + if node.support_RY: node.RxnMY[combo.name] += member_F[4, 0] + if node.support_RZ: node.RxnMZ[combo.name] += member_F[5, 0] + + elif member.j_node == node and phys_member.active[combo.name] == True: + + # Get the member's global force matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + member_F = member.F(combo.name) + + if node.support_DX: node.RxnFX[combo.name] += member_F[6, 0] + if node.support_DY: node.RxnFY[combo.name] += member_F[7, 0] + if node.support_DZ: node.RxnFZ[combo.name] += member_F[8, 0] + if node.support_RX: node.RxnMX[combo.name] += member_F[9, 0] + if node.support_RY: node.RxnMY[combo.name] += member_F[10, 0] + if node.support_RZ: node.RxnMZ[combo.name] += member_F[11, 0] + + # Sum the plate forces at the node + for plate in model.plates.values(): + + if plate.i_node == node: + + # Get the plate's global force matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + plate_F = plate.F(combo.name) + + if node.support_DX: node.RxnFX[combo.name] += plate_F[0, 0] + if node.support_DY: node.RxnFY[combo.name] += plate_F[1, 0] + if node.support_DZ: node.RxnFZ[combo.name] += plate_F[2, 0] + if node.support_RX: node.RxnMX[combo.name] += plate_F[3, 0] + if node.support_RY: node.RxnMY[combo.name] += plate_F[4, 0] + if node.support_RZ: node.RxnMZ[combo.name] += plate_F[5, 0] + + elif plate.j_node == node: + + # Get the plate's global force matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + plate_F = plate.F(combo.name) + + if node.support_DX: node.RxnFX[combo.name] += plate_F[6, 0] + if node.support_DY: node.RxnFY[combo.name] += plate_F[7, 0] + if node.support_DZ: node.RxnFZ[combo.name] += plate_F[8, 0] + if node.support_RX: node.RxnMX[combo.name] += plate_F[9, 0] + if node.support_RY: node.RxnMY[combo.name] += plate_F[10, 0] + if node.support_RZ: node.RxnMZ[combo.name] += plate_F[11, 0] + + elif plate.m_node == node: + + # Get the plate's global force matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + plate_F = plate.F(combo.name) + + if node.support_DX: node.RxnFX[combo.name] += plate_F[12, 0] + if node.support_DY: node.RxnFY[combo.name] += plate_F[13, 0] + if node.support_DZ: node.RxnFZ[combo.name] += plate_F[14, 0] + if node.support_RX: node.RxnMX[combo.name] += plate_F[15, 0] + if node.support_RY: node.RxnMY[combo.name] += plate_F[16, 0] + if node.support_RZ: node.RxnMZ[combo.name] += plate_F[17, 0] + + elif plate.n_node == node: + + # Get the plate's global force matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + plate_F = plate.F(combo.name) + + if node.support_DX: node.RxnFX[combo.name] += plate_F[18, 0] + if node.support_DY: node.RxnFY[combo.name] += plate_F[19, 0] + if node.support_DZ: node.RxnFZ[combo.name] += plate_F[20, 0] + if node.support_RX: node.RxnMX[combo.name] += plate_F[21, 0] + if node.support_RY: node.RxnMY[combo.name] += plate_F[22, 0] + if node.support_RZ: node.RxnMZ[combo.name] += plate_F[23, 0] + + # Sum the quad forces at the node + for quad in model.quads.values(): + + if quad.i_node == node: + + # Get the quad's global force matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + quad_F = quad.F(combo.name) + + if node.support_DX: node.RxnFX[combo.name] += quad_F[0, 0] + if node.support_DY: node.RxnFY[combo.name] += quad_F[1, 0] + if node.support_DZ: node.RxnFZ[combo.name] += quad_F[2, 0] + if node.support_RX: node.RxnMX[combo.name] += quad_F[3, 0] + if node.support_RY: node.RxnMY[combo.name] += quad_F[4, 0] + if node.support_RZ: node.RxnMZ[combo.name] += quad_F[5, 0] + + elif quad.j_node == node: + + # Get the quad's global force matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + quad_F = quad.F(combo.name) + + if node.support_DX: node.RxnFX[combo.name] += quad_F[6, 0] + if node.support_DY: node.RxnFY[combo.name] += quad_F[7, 0] + if node.support_DZ: node.RxnFZ[combo.name] += quad_F[8, 0] + if node.support_RX: node.RxnMX[combo.name] += quad_F[9, 0] + if node.support_RY: node.RxnMY[combo.name] += quad_F[10, 0] + if node.support_RZ: node.RxnMZ[combo.name] += quad_F[11, 0] + + elif quad.m_node == node: + + # Get the quad's global force matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + quad_F = quad.F(combo.name) + + if node.support_DX: node.RxnFX[combo.name] += quad_F[12, 0] + if node.support_DY: node.RxnFY[combo.name] += quad_F[13, 0] + if node.support_DZ: node.RxnFZ[combo.name] += quad_F[14, 0] + if node.support_RX: node.RxnMX[combo.name] += quad_F[15, 0] + if node.support_RY: node.RxnMY[combo.name] += quad_F[16, 0] + if node.support_RZ: node.RxnMZ[combo.name] += quad_F[17, 0] + + elif quad.n_node == node: + + # Get the quad's global force matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + quad_F = quad.F(combo.name) + + if node.support_DX: node.RxnFX[combo.name] += quad_F[18, 0] + if node.support_DY: node.RxnFY[combo.name] += quad_F[19, 0] + if node.support_DZ: node.RxnFZ[combo.name] += quad_F[20, 0] + if node.support_RX: node.RxnMX[combo.name] += quad_F[21, 0] + if node.support_RY: node.RxnMY[combo.name] += quad_F[22, 0] + if node.support_RZ: node.RxnMZ[combo.name] += quad_F[23, 0] + + # Sum the joint loads applied to the node + for load in node.NodeLoads: + + for case, factor in combo.factors.items(): + + if load[2] == case: + + if load[0] == 'FX' and node.support_DX: + node.RxnFX[combo.name] -= load[1]*factor + elif load[0] == 'FY' and node.support_DY: + node.RxnFY[combo.name] -= load[1]*factor + elif load[0] == 'FZ' and node.support_DZ: + node.RxnFZ[combo.name] -= load[1]*factor + elif load[0] == 'MX' and node.support_RX: + node.RxnMX[combo.name] -= load[1]*factor + elif load[0] == 'MY' and node.support_RY: + node.RxnMY[combo.name] -= load[1]*factor + elif load[0] == 'MZ' and node.support_RZ: + node.RxnMZ[combo.name] -= load[1]*factor + + # Calculate any reactions due to active spring supports at the node + if node.spring_DX[0] != None and node.spring_DX[2] == True: + sign = node.spring_DX[1] + k = node.spring_DX[0] + if sign != None: k = float(sign + str(k)) + DX = node.DX[combo.name] + node.RxnFX[combo.name] += k*DX + if node.spring_DY[0] != None and node.spring_DY[2] == True: + sign = node.spring_DY[1] + k = node.spring_DY[0] + if sign != None: k = float(sign + str(k)) + DY = node.DY[combo.name] + node.RxnFY[combo.name] += k*DY + if node.spring_DZ[0] != None and node.spring_DZ[2] == True: + sign = node.spring_DZ[1] + k = node.spring_DZ[0] + if sign != None: k = float(sign + str(k)) + DZ = node.DZ[combo.name] + node.RxnFZ[combo.name] += k*DZ + if node.spring_RX[0] != None and node.spring_RX[2] == True: + sign = node.spring_RX[1] + k = node.spring_RX[0] + if sign != None: k = float(sign + str(k)) + RX = node.RX[combo.name] + node.RxnMX[combo.name] += k*RX + if node.spring_RY[0] != None and node.spring_RY[2] == True: + sign = node.spring_RY[1] + k = node.spring_RY[0] + if sign != None: k = float(sign + str(k)) + RY = node.RY[combo.name] + node.RxnMY[combo.name] += k*RY + if node.spring_RZ[0] != None and node.spring_RZ[2] == True: + sign = node.spring_RZ[1] + k = node.spring_RZ[0] + if sign != None: k = float(sign + str(k)) + RZ = node.RZ[combo.name] + node.RxnMZ[combo.name] += k*RZ + +def _check_statics(model, combo_tags=None): + ''' + Checks static equilibrium and prints results to the console. + + Parameters + ---------- + precision : number + The number of decimal places to carry the results to. + ''' + + print('+----------------+') + print('| Statics Check: |') + print('+----------------+') + print('') + + from prettytable import PrettyTable + + # Start a blank table and create a header row + statics_table = PrettyTable() + statics_table.field_names = ['Load Combination', 'Sum FX', 'Sum RX', 'Sum FY', 'Sum RY', 'Sum FZ', 'Sum RZ', 'Sum MX', 'Sum RMX', 'Sum MY', 'Sum RMY', 'Sum MZ', 'Sum RMZ'] + + # Identify which load combinations to evaluate + if combo_tags is None: + combo_list = model.load_combos.values() + else: + combo_list = [] + for combo in model.load_combos.values(): + if any(tag in combo.combo_tags for tag in combo_tags): + combo_list.append(combo) + + # Step through each load combination + for combo in combo_list: + + # Initialize force and moment summations to zero + SumFX, SumFY, SumFZ = 0.0, 0.0, 0.0 + SumMX, SumMY, SumMZ = 0.0, 0.0, 0.0 + SumRFX, SumRFY, SumRFZ = 0.0, 0.0, 0.0 + SumRMX, SumRMY, SumRMZ = 0.0, 0.0, 0.0 + + # Get the global force vector and the global fixed end reaction vector + P = model.P(combo.name) + FER = model.FER(combo.name) + + # Step through each node and sum its forces + for node in model.nodes.values(): + + # Get the node's coordinates + X = node.X + Y = node.Y + Z = node.Z + + # Get the nodal forces + FX = P[node.ID*6+0][0] - FER[node.ID*6+0][0] + FY = P[node.ID*6+1][0] - FER[node.ID*6+1][0] + FZ = P[node.ID*6+2][0] - FER[node.ID*6+2][0] + MX = P[node.ID*6+3][0] - FER[node.ID*6+3][0] + MY = P[node.ID*6+4][0] - FER[node.ID*6+4][0] + MZ = P[node.ID*6+5][0] - FER[node.ID*6+5][0] + + # Get the nodal reactions + RFX = node.RxnFX[combo.name] + RFY = node.RxnFY[combo.name] + RFZ = node.RxnFZ[combo.name] + RMX = node.RxnMX[combo.name] + RMY = node.RxnMY[combo.name] + RMZ = node.RxnMZ[combo.name] + + # Sum the global forces + SumFX += FX + SumFY += FY + SumFZ += FZ + SumMX += MX - FY*Z + FZ*Y + SumMY += MY + FX*Z - FZ*X + SumMZ += MZ - FX*Y + FY*X + + # Sum the global reactions + SumRFX += RFX + SumRFY += RFY + SumRFZ += RFZ + SumRMX += RMX - RFY*Z + RFZ*Y + SumRMY += RMY + RFX*Z - RFZ*X + SumRMZ += RMZ - RFX*Y + RFY*X + + # Add the results to the table + statics_table.add_row([combo.name, '{:.3g}'.format(SumFX), '{:.3g}'.format(SumRFX), + '{:.3g}'.format(SumFY), '{:.3g}'.format(SumRFY), + '{:.3g}'.format(SumFZ), '{:.3g}'.format(SumRFZ), + '{:.3g}'.format(SumMX), '{:.3g}'.format(SumRMX), + '{:.3g}'.format(SumMY), '{:.3g}'.format(SumRMY), + '{:.3g}'.format(SumMZ), '{:.3g}'.format(SumRMZ)]) + + # Print the static check table + print(statics_table) + print('') + +def _partition_D(model): + """Builds a list with known nodal displacements and with the positions in global stiffness matrix of known and unknown nodal displacements + + :return: A list of the global matrix indices for the unknown nodal displacements (D1_indices). A list of the global matrix indices for the known nodal displacements (D2_indices). A list of the known nodal displacements (D2). + :rtype: list, list, list + """ + + D1_indices = [] # A list of the indices for the unknown nodal displacements + D2_indices = [] # A list of the indices for the known nodal displacements + D2 = [] # A list of the values of the known nodal displacements + + # Create the auxiliary table + for node in model.nodes.values(): + + # Unknown displacement DX + if node.support_DX == False and node.EnforcedDX == None: + D1_indices.append(node.ID*6 + 0) + # Known displacement DX + elif node.EnforcedDX != None: + D2_indices.append(node.ID*6 + 0) + D2.append(node.EnforcedDX) + # Support at DX + else: + D2_indices.append(node.ID*6 + 0) + D2.append(0.0) + + # Unknown displacement DY + if node.support_DY == False and node.EnforcedDY == None: + D1_indices.append(node.ID*6 + 1) + # Known displacement DY + elif node.EnforcedDY != None: + D2_indices.append(node.ID*6 + 1) + D2.append(node.EnforcedDY) + # Support at DY + else: + D2_indices.append(node.ID*6 + 1) + D2.append(0.0) + + # Unknown displacement DZ + if node.support_DZ == False and node.EnforcedDZ == None: + D1_indices.append(node.ID*6 + 2) + # Known displacement DZ + elif node.EnforcedDZ != None: + D2_indices.append(node.ID*6 + 2) + D2.append(node.EnforcedDZ) + # Support at DZ + else: + D2_indices.append(node.ID*6 + 2) + D2.append(0.0) + + # Unknown displacement RX + if node.support_RX == False and node.EnforcedRX == None: + D1_indices.append(node.ID*6 + 3) + # Known displacement RX + elif node.EnforcedRX != None: + D2_indices.append(node.ID*6 + 3) + D2.append(node.EnforcedRX) + # Support at RX + else: + D2_indices.append(node.ID*6 + 3) + D2.append(0.0) + + # Unknown displacement RY + if node.support_RY == False and node.EnforcedRY == None: + D1_indices.append(node.ID*6 + 4) + # Known displacement RY + elif node.EnforcedRY != None: + D2_indices.append(node.ID*6 + 4) + D2.append(node.EnforcedRY) + # Support at RY + else: + D2_indices.append(node.ID*6 + 4) + D2.append(0.0) + + # Unknown displacement RZ + if node.support_RZ == False and node.EnforcedRZ == None: + D1_indices.append(node.ID*6 + 5) + # Known displacement RZ + elif node.EnforcedRZ != None: + D2_indices.append(node.ID*6 + 5) + D2.append(node.EnforcedRZ) + # Support at RZ + else: + D2_indices.append(node.ID*6 + 5) + D2.append(0.0) + + # Legacy code on the next line. I will leave it here until the line that follows has been proven over time. + # D2 = atleast_2d(D2) + + # Convert D2 from a list to a matrix + D2 = array(D2, ndmin=2).T + + # Return the indices and the known displacements + return D1_indices, D2_indices, D2 + +def _partition(model, unp_matrix, D1_indices, D2_indices): + """Partitions a matrix (or vector) into submatrices (or subvectors) based on degree of freedom boundary conditions. + + :param unp_matrix: The unpartitioned matrix (or vector) to be partitioned. + :type unp_matrix: ndarray or lil_matrix + :param D1_indices: A list of the indices for degrees of freedom that have unknown displacements. + :type D1_indices: list + :param D2_indices: A list of the indices for degrees of freedom that have known displacements. + :type D2_indices: list + :return: Partitioned submatrices (or subvectors) based on degree of freedom boundary conditions. + :rtype: array, array, array, array + """ + + # Determine if this is a 1D vector or a 2D matrix + + # 1D vectors + if unp_matrix.shape[1] == 1: + # Partition the vector into 2 subvectors + m1 = unp_matrix[D1_indices, :] + m2 = unp_matrix[D2_indices, :] + return m1, m2 + # 2D matrices + else: + # Partition the matrix into 4 submatrices + m11 = unp_matrix[D1_indices, :][:, D1_indices] + m12 = unp_matrix[D1_indices, :][:, D2_indices] + m21 = unp_matrix[D2_indices, :][:, D1_indices] + m22 = unp_matrix[D2_indices, :][:, D2_indices] + return m11, m12, m21, m22 + +def _renumber(model): + """ + Assigns node and element ID numbers to be used internally by the program. Numbers are + assigned according to the order in which they occur in each dictionary. + """ + + # Number each node in the model + for id, node in enumerate(model.nodes.values()): + node.ID = id + + # Number each spring in the model + for id, spring in enumerate(model.springs.values()): + spring.ID = id + + # Descritize all the physical members and number each member in the model + id = 0 + for phys_member in model.members.values(): + phys_member.descritize() + for member in phys_member.sub_members.values(): + member.ID = id + id += 1 + + # Number each plate in the model + for id, plate in enumerate(model.plates.values()): + plate.ID = id + + # Number each quadrilateral in the model + for id, quad in enumerate(model.quads.values()): + quad.ID = id diff --git a/Old Pynite Folder/BeamSegY.py b/Old Pynite Folder/BeamSegY.py new file mode 100644 index 00000000..e75515cd --- /dev/null +++ b/Old Pynite Folder/BeamSegY.py @@ -0,0 +1,201 @@ +from Pynite.BeamSegZ import BeamSegZ + +# %% +class BeamSegY(BeamSegZ): + +#%% + # Returns the moment at a location on the segment + def moment(self, x, P_delta=False): + ''' + Returns the moment at a location on the segment. + + Parameters + ---------- + x : number + Location (relative to start of segment) where moment is to be calculated + ''' + + V1 = self.V1 + M1 = self.M1 + P1 = self.P1 + w1 = self.w1 + w2 = self.w2 + L = self.Length() + + # M = M1 + V1*x + w1*x**2/2 + x**3*(-w1 + w2)/(6*L) + M = -M1 - V1*x - w1*x**2/2 - x**3*(-w1 + w2)/(6*L) + + if P_delta == True: + delta1 = self.delta1 + delta = self.deflection(x) + M += P1*(delta - delta1) + + return M + + def slope(self, x, P_delta=False): + """Returns the slope of the elastic curve at any point `x` along the segment. + + :param x: Location (relative to start of segment) where slope is to be calculated. + :type x: float + :param P_delta: Indicates whether P-little-delta effects should be included in the calculation. Generally only used when a P-Delta analysis has been run. Defaults to False. + :type P_delta: bool, optional + :return: The slope of the elastic curve (radians) at location `x`. + :rtype: float + """ + + V1 = self.V1 + M1 = self.M1 + P1 = self.P1 + w1 = self.w1 + w2 = self.w2 + theta_1 = self.theta1 + + L = self.Length() + EI = self.EI + + if P_delta == True: + delta_x = self.deflection(x, P_delta) + delta_1 = self.delta1 + return theta_1 + (-V1*x**2/2 - w1*x**3/6 + x*(-M1 - P1*delta_1 + P1*delta_x) + x**4*(w1 - w2)/(24*L))/EI + else: + return theta_1 + (-V1*x**2/2 - w1*x**3/6 + x*(-M1) + x**4*(w1 - w2)/(24*L))/EI + +#%% + # Returns the deflection at a location on the segment + def deflection(self, x, P_delta=False): + + V1 = self.V1 + M1 = self.M1 + P1 = self.P1 + w1 = self.w1 + w2 = self.w2 + theta_1 = self.theta1 + delta_1 = self.delta1 + d_delta = 1 + L = self.Length() + EI = self.EI + + # Iteration is required to calculate P-little-delta effects + if P_delta == True: + + # Initialize the deflection at `x` to match the deflection at the start of the segment + delta_x = delta_1 + + # Iterate until we reach a deflection convergence of 1% + while d_delta > 0.01: + + # Save the deflection value from the last iteration + delta_last = delta_x + + # Compute the deflection + delta_x = delta_1 - theta_1*x + V1*x**3/(6*EI) + w1*x**4/(24*EI) - x**2*(-M1 - P1*delta_1 + P1*delta_x)/(2*EI) - x**5*(w1 - w2)/(120*EI*L) + + # Check the change in deflection between iterations + if delta_last != 0: + d_delta = abs(delta_x/delta_last - 1) + else: + # Members with no relative deflection after at least one iteration need no further iterations + if delta_1 - delta_x == 0: + break + + # Return the calculated deflection + return delta_x + + # Non-P-delta solutions are not iterative + else: + + # Return the calcuated deflection + return delta_1 - theta_1*x + V1*x**3/(6*EI) + w1*x**4/(24*EI) - x**2*(-M1)/(2*EI) - x**5*(w1 - w2)/(120*EI*L) + +#%% + # Returns the maximum moment in the segment + def max_moment(self, P_delta=False): + + w1 = self.w1 + w2 = self.w2 + V1 = self.V1 + L = self.Length() + + # Find the quadratic equation parameters + a = (w2-w1)/(2*L) + b = w1 + c = V1 + + # Determine possible locations of maximum moment + if a == 0: + if b != 0: + x1 = -c/b + else: + x1 = 0 + x2 = 0 + elif b**2-4*a*c < 0: + x1 = 0 + x2 = 0 + else: + x1 = (-b+(b**2-4*a*c)**0.5)/(2*a) + x2 = (-b-(b**2-4*a*c)**0.5)/(2*a) + + x3 = 0 + x4 = L + + if round(x1, 10) < 0 or round(x1, 10) > round(L, 10): + x1 = 0 + + if round(x2, 10) < 0 or round(x2, 10) > round(L, 10): + x2 = 0 + + # Find the moment at each location of interest + M1 = self.moment(x1) + M2 = self.moment(x2) + M3 = self.moment(x3) + M4 = self.moment(x4) + + # Return the maximum moment + return max(M1, M2, M3, M4) + +#%% + # Returns the minimum moment in the segment + def min_moment(self, P_delta=False): + + w1 = self.w1 + w2 = self.w2 + V1 = self.V1 + L = self.Length() + + # Find the quadratic equation parameters + a = (w2-w1)/(2*L) + b = w1 + c = V1 + + # Determine possible locations of minimum moment + if a == 0: + if b != 0: + x1 = -c/b + else: + x1 = 0 + x2 = 0 + elif b**2-4*a*c < 0: + x1 = 0 + x2 = 0 + else: + x1 = (-b+(b**2-4*a*c)**0.5)/(2*a) + x2 = (-b-(b**2-4*a*c)**0.5)/(2*a) + + x3 = 0 + x4 = L + + if round(x1, 10) < 0 or round(x1, 10) > round(L, 10): + x1 = 0 + + if round(x2, 10) < 0 or round(x2, 10) > round(L, 10): + x2 = 0 + + # Find the moment at each location of interest + M1 = self.moment(x1, P_delta) + M2 = self.moment(x2, P_delta) + M3 = self.moment(x3, P_delta) + M4 = self.moment(x4, P_delta) + + # Return the minimum moment + return min(M1, M2, M3, M4) + \ No newline at end of file diff --git a/Old Pynite Folder/BeamSegZ.py b/Old Pynite Folder/BeamSegZ.py new file mode 100644 index 00000000..dda58692 --- /dev/null +++ b/Old Pynite Folder/BeamSegZ.py @@ -0,0 +1,457 @@ +# -*- coding: utf-8 -*- +""" +Created on Mon Nov 6 20:52:31 2017 + +@author: D. Craig Brinck, SE +""" +from numpy import full, array + +# %% +# A mathematically continuous beam segment +class BeamSegZ(): + """ + A mathematically continuous beam segment + + Properties + ---------- + x1 : number + The starting location of the segment relative to the start of the beam + x2 : number + The ending location of the segment relative to the start of the beam + w1 : number + The distributed load magnitude at the start of the segment + w2 : number + The distributed load magnitude at the end of the segment + p1 : number + The distributed axial load magnitude at the start of the segment + p2 : number + The distributed axial load magnitude at the end of the segment + V1 : number + The internal shear force at the start of the segment + M1 : number + The internal moment at the start of the segment + P1 : number + The internal axial force at the start of the segment + T1 : number + Torsional moment at start of segment + theta1: number + The slope (radians) at the start of the segment + delta1: number + The transverse displacement at the start of the segment + delta_x1 : number + The axial displacement at the start of the segment + EI : number + The flexural stiffness of the segment + EA : number + The axial stiffness of the segment + + Methods + ------- + __init__() + Constructor + Length() + Returns the length of the segment + Shear(x) + Returns the shear force at a location on the segment + + Notes + ----- + Any unit system may be used as long as the units are consistent with each other + + """ + + def __init__(self): + """ + Constructor + """ + + self.x1 = None # Start location of beam segment (relative to start of beam) + self.x2 = None # End location of beam segment (relative to start of beam) + self.w1 = None # Linear distributed transverse load at start of segment + self.w2 = None # Linear distributed transverse load at end of segment + self.p1 = None # Linear distributed axial load at start of segment + self.p2 = None # Linear distributed axial load at end of segment + self.V1 = None # Internal shear force at start of segment + self.M1 = None # Internal moment at start of segment + self.P1 = None # Internal axial force at start of segment + self.T1 = None # Torsional moment at start of segment + self.theta1 = None # Slope at start of beam segment + self.delta1 = None # Displacement at start of beam segment + self.delta_x1 = None # Axial displacement at start of beam segment + self.EI = None # Flexural stiffness of the beam segment + self.EA = None # Axial stiffness of the beam segment + + # Returns the length of the segment + def Length(self): + """ + Returns the length of the segment + """ + + return self.x2 - self.x1 + + # Returns the shear force at a location 'x' on the segment + def Shear(self, x): + + V1 = self.V1 + w1 = self.w1 + w2 = self.w2 + L = self.Length() + + return V1 + w1*x + x**2*(-w1 + w2)/(2*L) + + # Returns the moment at a location on the segment + def moment(self, x, P_delta=False): + + V1 = self.V1 + M1 = self.M1 + P1 = self.P1 + w1 = self.w1 + w2 = self.w2 + L = self.Length() + + M = M1 - V1*x - w1*x**2/2 - x**3*(-w1 + w2)/(6*L) + + # Include the P-little-delta moment if a P-Delta analysis was run + if P_delta == True: + delta_1 = self.delta1 + delta_x = self.deflection(x) + M += P1*(delta_x - delta_1) + + # Return the computed moment + return M + + # Returns the axial force at a location on the segment + def axial(self, x): + + P1 = self.P1 + p1 = self.p1 + p2 = self.p2 + L = self.Length() + + return P1 + (p2 - p1)/(2*L)*x**2 + p1*x + + def Torsion(self, x = 0): + """ + Returns the torsional moment in the segment. + """ + + # The torsional moment is constant across the segment + # This can be updated in the future for distributed torsional forces + + #As the return value is not calculated as a function of x (for now), we need to check + #whether x is an array, and if so, return a results array of the same length + if isinstance(x, (int, float)): + return self.T1 + else: + return full(len(x), self.T1) + + def slope(self, x, P_delta=False): + """Returns the slope of the elastic curve at any point `x` along the segment. + + :param x: Location (relative to start of segment) where slope is to be calculated. + :type x: float + :param P_delta: Indicates whether P-little-delta effects should be included in the calculation. Generally only used when a P-Delta analysis has been run. Defaults to False. + :type P_delta: bool, optional + :return: The slope of the elastic curve (radians) at location `x`. + :rtype: float + """ + + V1 = self.V1 + M1 = self.M1 + P1 = self.P1 + w1 = self.w1 + w2 = self.w2 + theta_1 = self.theta1 + L = self.Length() + EI = self.EI + + theta_x = theta_1 - (-V1*x**2/2 - w1*x**3/6 + x*M1 + x**4*(w1 - w2)/(24*L))/EI + theta_x = theta_1 - (-V1*x**2/2 - w1*x**3/6 + x*M1 + x**4*(w1 - w2)/(24*L))/EI + + if P_delta == True: + delta_1 = self.delta1 + delta_x = self.deflection(x, P_delta) + theta_x -= x*(-P1*delta_1 + P1*delta_x)/EI + + # TODO: This is an old equation left for reference. Delete it after the new equation has been proved over time. + # return theta_1 - (M1*x - V1*x**2/2 - w1*x**3/6 + x**4*(w1 - w2)/(24*L))/(EI) + + # Return the calculated slope + return theta_x + + # Returns the deflection at a location on the segment + def deflection(self, x, P_delta=False): + + V1 = self.V1 + M1 = self.M1 + P1 = self.P1 + w1 = self.w1 + w2 = self.w2 + theta_1 = self.theta1 + delta_1 = self.delta1 + d_delta = 1 + L = self.Length() + EI = self.EI + + # Iteration is required to calculate P-little-delta effects + if P_delta == True: + + # Initialize the deflection at `x` to match the deflection at the start of the segment + delta_x = delta_1 + + # Iterate until we reach a deflection convergence of 1% + while d_delta > 0.01: + + # Save the deflection value from the last iteration + delta_last = delta_x + + # Compute the deflection + # delta_x = delta1 + theta1*x + V1*x**3/(6*EI) + w1*x**4/(24*EI) + x**2*(-M1 + P1*delta1 - P1*delta_x)/(2*EI) + x**5*(-w1 + w2)/(120*EI*L) + delta_x = delta_1 + theta_1*x + V1*x**3/(6*EI) + w1*x**4/(24*EI) + x**2*(-M1 + P1*delta_1 - P1*delta_x)/(2*EI) + x**5*(-w1 + w2)/(120*EI*L) + + # Check the change in deflection between iterations + if delta_last != 0: + d_delta = abs(delta_x/delta_last - 1) + else: + # Members with no relative deflection after at least one iteration need no further iterations + if delta_1 - delta_x == 0: + break + + # Return the calculated deflection + return delta_x + + # Non-P-delta solutions are not iterative + else: + + # Return the calcuated deflection + # return delta1 + theta1*x - M1*x**2/(2*EI) + V1*x**3/(6*EI) + w1*x**4/(24*EI) + x**5*(-w1 + w2)/(120*EI*L) + return delta_1 + theta_1*x + V1*x**3/(6*EI) + w1*x**4/(24*EI) + x**2*(-M1)/(2*EI) + x**5*(-w1 + w2)/(120*EI*L) + + def AxialDeflection(self, x): + + delta_x1 = self.delta_x1 + P1 = self.P1 + p1 = self.p1 + p2 = self.p2 + L = self.Length() + EA = self.EA + + return delta_x1 - 1/EA*(P1*x + p1*x**2/2 + (p2 - p1)*x**3/(6*L)) + + # Returns the maximum shear in the segment + def max_shear(self): + + w1 = self.w1 + w2 = self.w2 + L = self.Length() + + # Determine possible locations of maximum shear + if w1-w2 == 0: + x1 = 0 + else: + x1 = w1*L/(w1-w2) + + if round(x1, 10) < 0 or round(x1, 10) > round(L, 10): + x1 = 0 + + x2 = 0 + x3 = L + + # Find the shear at each location of interest + V1 = self.Shear(x1) + V2 = self.Shear(x2) + V3 = self.Shear(x3) + + # Return the maximum shear + return max(V1, V2, V3) + + # Returns the minimum shear in the segment + def min_shear(self): + + w1 = self.w1 + w2 = self.w2 + L = self.Length() + + # Determine possible locations of minimum shear + if w1-w2 == 0: + x1 = 0 + else: + x1 = w1*L/(w1-w2) + + if round(x1, 10) < 0 or round(x1, 10) > round(L, 10): + x1 = 0 + + x2 = 0 + x3 = L + + # Find the shear at each location of interest + V1 = self.Shear(x1) + V2 = self.Shear(x2) + V3 = self.Shear(x3) + + # Return the minimum shear + return min(V1, V2, V3) + + # Returns the maximum moment in the segment + def max_moment(self, P_delta=False): + + w1 = self.w1 + w2 = self.w2 + V1 = self.V1 + L = self.Length() + + # Find the quadratic equation parameters + a = -(w2-w1)/(2*L) + b = -w1 + c = -V1 + + # Determine possible locations of maximum moment + if a == 0: + if b != 0: + x1 = -c/b + else: + x1 = 0 + x2 = 0 + elif b**2-4*a*c < 0: + x1 = 0 + x2 = 0 + else: + x1 = (-b+(b**2-4*a*c)**0.5)/(2*a) + x2 = (-b-(b**2-4*a*c)**0.5)/(2*a) + + x3 = 0 + x4 = L + + if round(x1, 10) < 0 or round(x1, 10) > round(L, 10): + x1 = 0 + + if round(x2, 10) < 0 or round(x2, 10) > round(L, 10): + x2 = 0 + + # Find the moment at each location of interest + M1 = self.moment(x1, P_delta) + M2 = self.moment(x2, P_delta) + M3 = self.moment(x3, P_delta) + M4 = self.moment(x4, P_delta) + + # Return the maximum moment + return max(M1, M2, M3, M4) + + # Returns the minimum moment in the segment + def min_moment(self, P_delta=False): + + w1 = self.w1 + w2 = self.w2 + V1 = self.V1 + L = self.Length() + + # Find the quadratic equation parameters + a = -(w2-w1)/(2*L) + b = -w1 + c = -V1 + + # Determine possible locations of minimum moment + if a == 0: + if b != 0: + x1 = -c/b + else: + x1 = 0 + x2 = 0 + elif b**2-4*a*c < 0: + x1 = 0 + x2 = 0 + else: + x1 = (-b+(b**2-4*a*c)**0.5)/(2*a) + x2 = (-b-(b**2-4*a*c)**0.5)/(2*a) + + x3 = 0 + x4 = L + + if round(x1, 10) < 0 or round(x1, 10) > round(L, 10): + x1 = 0 + + if round(x2, 10) < 0 or round(x2, 10) > round(L, 10): + x2 = 0 + + # Find the moment at each location of interest + M1 = self.moment(x1, P_delta) + M2 = self.moment(x2, P_delta) + M3 = self.moment(x3, P_delta) + M4 = self.moment(x4, P_delta) + + # Return the minimum moment + return min(M1, M2, M3, M4) + + # Returns the maximum axial force in the segment + def max_axial(self): + + p1 = self.p1 + p2 = self.p2 + L = self.Length() + + # Determine possible locations of maximum axial force + if p1-p2 != 0: + x1 = L*p1/(p1-p2) + else: + x1 = 0 + + if round(x1, 10) < 0 or round(x1, 10) > round(L, 10): + x1 = 0 + + x2 = 0 + x3 = L + + # Find the axial force at each location of interest + P1 = self.axial(x1) + P2 = self.axial(x2) + P3 = self.axial(x3) + + # Return the maximum axial force + return max(P1, P2, P3) + + # Returns the minimum axial force in the segment + def min_axial(self): + + p1 = self.p1 + p2 = self. p2 + L = self.Length() + + # Determine possible locations of minimum axial force + if p1-p2 != 0: + x1 = L*p1/(p1-p2) + else: + x1 = 0 + + if round(x1, 10) < 0 or round(x1, 10) > round(L, 10): + x1 = 0 + + x2 = 0 + x3 = L + + # Find the axial force at each location of interest + P1 = self.axial(x1) + P2 = self.axial(x2) + P3 = self.axial(x3) + + # Return the minimum axial force + return min(P1, P2, P3) + + def MaxTorsion(self): + """ + Returns the maximum torsional moment in the segment. + """ + + # Return the maximum torsional moment + # Since the torsional moment is constant on the segment, the maximum torsional moment is T1 + # This can be updated in the future for distributed torsional forces + return self.T1 + + def MinTorsion(self): + """ + Returns the minimum torsional moment in the segment. + """ + + # Return the minimum torsional moment + # Since the torsional moment is constant on the segment, the minimum torsional moment is T1 + # This can be updated in the future for distributed torsional forces + return self.T1 diff --git a/Old Pynite Folder/FEModel3D.py b/Old Pynite Folder/FEModel3D.py new file mode 100644 index 00000000..286bddf4 --- /dev/null +++ b/Old Pynite Folder/FEModel3D.py @@ -0,0 +1,2467 @@ +# %% +#futures import required to use bar operators for optional type annotations +from __future__ import annotations + +from os import rename +import warnings +from math import isclose + +from numpy import array, zeros, matmul, divide, subtract, atleast_2d, all +from numpy.linalg import solve + +from Pynite.Node3D import Node3D +from Pynite.Material import Material +from Pynite.Section import Section, SteelSection +from Pynite.PhysMember import PhysMember +from Pynite.Spring3D import Spring3D +from Pynite.Member3D import Member3D +from Pynite.Quad3D import Quad3D +from Pynite.Plate3D import Plate3D +from Pynite.LoadCombo import LoadCombo +from Pynite.Mesh import Mesh, RectangleMesh, AnnulusMesh, FrustrumMesh, CylinderMesh +from Pynite import Analysis + + +# %% +class FEModel3D(): + """A 3D finite element model object. This object has methods and dictionaries to create, store, + and retrieve results from a finite element model. + """ + + def __init__(self): + """Creates a new 3D finite element model. + """ + + # Initialize the model's various dictionaries. The dictionaries will be prepopulated with + # the data types they store, and then those types will be removed. This will give us the + # ability to get type-based hints when using the dictionaries. + + self.nodes = {str:Node3D} # A dictionary of the model's nodes + self.nodes.pop(str) + self.materials = {str:Material} # A dictionary of the model's materials + self.materials.pop(str) + self.sections = {str:Section} # A dictonary of the model's cross-sections + self.sections.pop(str) + self.springs = {str:Spring3D} # A dictionary of the model's springs + self.springs.pop(str) + self.members = {str:PhysMember} # A dictionary of the model's physical members + self.members.pop(str) + self.quads = {str:Quad3D} # A dictionary of the model's quadiralterals + self.quads.pop(str) + self.plates = {str:Plate3D} # A dictionary of the model's rectangular plates + self.plates.pop(str) + self.meshes = {str:Mesh} # A dictionary of the model's meshes + self.meshes.pop(str) + self.load_combos = {str:LoadCombo} # A dictionary of the model's load combinations + self.load_combos.pop(str) + self._D = {str:[]} # A dictionary of the model's nodal displacements by load combination + self._D.pop(str) + + self.solution = None # Indicates the solution type for the latest run of the model + + @property + def load_cases(self): + """Returns a list of all the load cases in the model (in alphabetical order). + """ + + # Create an empty list of load cases + cases = [] + + # Step through each node + for node in self.nodes.values(): + # Step through each nodal load + for load in node.NodeLoads: + # Get the load case for each nodal laod + cases.append(load[2]) + + # Step through each member + for member in self.members.values(): + # Step through each member point load + for load in member.PtLoads: + # Get the load case for each member point load + cases.append(load[3]) + # Step through each member distributed load + for load in member.DistLoads: + # Get the load case for each member distributed load + cases.append(load[5]) + + # Step through each plate/quad + for plate in list(self.plates.values()) + list(self.quads.values()): + # Step through each surface load + for load in plate.pressures: + # Get the load case for each plate/quad pressure + cases.append(load[1]) + + # Remove duplicates and return the list (sorted ascending) + return sorted(list(dict.fromkeys(cases))) + + def add_node(self, name:str, X:float, Y:float, Z:float) -> str: + """Adds a new node to the model. + + :param name: A unique user-defined name for the node. If set to None or "" a name will be + automatically assigned. + :type name: str + :param X: The node's global X-coordinate. + :type X: number + :param Y: The node's global Y-coordinate. + :type Y: number + :param Z: The node's global Z-coordinate. + :type Z: number + :raises NameError: Occurs when the specified name already exists in the model. + :return: The name of the node added to the model. + :rtype: str + """ + + # Name the node or check it doesn't already exist + if name: + if name in self.nodes: + raise NameError(f"Node name '{name}' already exists") + else: + # As a guess, start with the length of the dictionary + name = "N" + str(len(self.nodes)) + count = 1 + while name in self.nodes: + name = "N" + str(len(self.nodes) + count) + count += 1 + + # Create a new node + new_node = Node3D(name, X, Y, Z) + + # Add the new node to the model + self.nodes[name] = new_node + + # Flag the model as unsolved + self.solution = None + + #Return the node name + return name + + def add_material(self, name: str, E:float, G:float, nu:float, rho: float, fy:float | None = None) -> str: + """Adds a new material to the model. + + :param name: A unique user-defined name for the material. + :type name: str + :param E: The modulus of elasticity of the material. + :type E: number + :param G: The shear modulus of elasticity of the material. + :type G: number + :param nu: Poisson's ratio of the material. + :type nu: number + :param rho: The density of the material + :type rho: number + :raises NameError: Occurs when the specified name already exists in the model. + """ + + # Name the material or check it doesn't already exist + if name: + if name in self.materials: + raise NameError(f"Material name '{name}' already exists") + else: + # As a guess, start with the length of the dictionary + name = "M" + str(len(self.materials)) + count = 1 + while name in self.materials: + name = "M" + str(len(self.materials) + count) + count += 1 + + # Create a new material + new_material = Material(self, name, E, G, nu, rho, fy) + + # Add the new material to the model + self.materials[name] = new_material + + # Flag the model as unsolved + self.solution = None + + #Return the materal name + return name + + def add_section(self, name:str, A:float, Iy:float, Iz:float, J:float) -> str: + """Adds a cross-section to the model. + + :param name: A unique name for the cross-section. + :type name: string + :param name: Name of the section + :type name: str + :param A: Cross-sectional area of the section + :type A: float + :param Iy: The second moment of area the section about the Y (minor) axis + :type Iy: float + :param Iz: The second moment of area the section about the Z (major) axis + :type Iz: float + :param J: The torsion constant of the section + :type J: float + """ + + # Name the section or check it doesn't already exist + if name: + if name in self.sections: + raise NameError(f"Section name '{name}' already exists") + else: + # As a guess, start with the length of the dictionary + name = "SC" + str(len(self.sections)) + count = 1 + while name in self.sections: + name = "SC" + str(len(self.sections) + count) + count += 1 + + # Add the new section to the model + self.sections[name] = Section(self, name, A, Iy, Iz, J) + + #Return the section name + return name + + def add_steel_section(self, name:str, A:float, Iy:float, Iz:float, J:float, + Zy:float, Zz:float, material_name:str) -> str: + """Adds a cross-section to the model. + + :param name: A unique name for the cross-section. + :type name: string + :param name: Name of the section + :type name: str + :param A: Cross-sectional area of the section + :type A: float + :param Iy: The second moment of area the section about the Y (minor) axis + :type Iy: float + :param Iz: The second moment of area the section about the Z (major) axis + :type Iz: float + :param J: The torsion constant of the section + :type J: float + :param Zy: The section modulus about the Y (minor) axis + :type Zy: float + :param Zz: The section modulus about the Z (major) axis + :type Zz: float + :param material_name: The name of the steel material + :type material_name: str + """ + + # Name the section or check it doesn't already exist + if name: + if name in self.sections: + raise NameError(f"Section name '{name}' already exists") + else: + # As a guess, start with the length of the dictionary + name = "SC" + str(len(self.sections)) + count = 1 + while name in self.sections: + name = "SC" + str(len(self.sections) + count) + count += 1 + + # Add the new section to the model + self.sections[name] = SteelSection(self, name, A, Iy, Iz, J, Zy, Zz, material_name) + + #Return the section name + return name + + def add_spring(self, name:str, i_node:str, j_node:str, ks:float, tension_only: bool=False, comp_only: bool=False) -> str: + """Adds a new spring to the model. + + :param name: A unique user-defined name for the member. If None or "", a name will be + automatically assigned + :type name: str + :param i_node: The name of the i-node (start node). + :type i_node: str + :param j_node: The name of the j-node (end node). + :type j_node: str + :param ks: The spring constant (force/displacement). + :type ks: number + :param tension_only: Indicates if the member is tension-only, defaults to False + :type tension_only: bool, optional + :param comp_only: Indicates if the member is compression-only, defaults to False + :type comp_only: bool, optional + :raises NameError: Occurs when the specified name already exists in the model. + :return: The name of the spring that was added to the model. + :rtype: str + """ + + # Name the spring or check it doesn't already exist + if name: + if name in self.springs: + raise NameError(f"Spring name '{name}' already exists") + else: + # As a guess, start with the length of the dictionary + name = "S" + str(len(self.springs)) + count = 1 + while name in self.springs: + name = "S" + str(len(self.springs) + count) + count += 1 + + #Lookup node names and safely handle exceptions + try: + pn_nodes = [self.nodes[node_name] for node_name in (i_node, j_node)] + except KeyError as e: + raise NameError(f"Node '{e.args[0]}' does not exist in the model") + + # Create a new spring + new_spring = Spring3D(name, pn_nodes[0], pn_nodes[1], + ks, self.load_combos, tension_only=tension_only, + comp_only=comp_only) + + # Add the new spring to the model + self.springs[name] = new_spring + + # Flag the model as unsolved + self.solution = None + + # Return the spring name + return name + + def add_member(self, name:str, i_node:str, j_node:str, material_name:str, section_name:str, + rotation: float = 0.0, tension_only: bool = False, comp_only:bool = False) -> str: + """Adds a new physical member to the model. + + :param name: A unique user-defined name for the member. If ``None`` or ``""``, a name will be automatically assigned + :type name: str + :param i_node: The name of the i-node (start node). + :type i_node: str + :param j_node: The name of the j-node (end node). + :type j_node: str + :param material_name: The name of the material of the member. + :type material_name: str + :param section_name: The name of the cross section to use for section properties. + :type section_name: string + :param rotation: The angle of rotation (degrees) of the member cross-section about its longitudinal (local x) axis. Default is 0. + :type rotation: float, optional + :param tension_only: Indicates if the member is tension-only, defaults to False + :type tension_only: bool, optional + :param comp_only: Indicates if the member is compression-only, defaults to False + :type comp_only: bool, optional + :raises NameError: Occurs if the specified name already exists. + :return: The name of the member added to the model. + :rtype: str + """ + + # Name the member or check it doesn't already exist + if name: + if name in self.members: raise NameError(f"Member name '{name}' already exists") + else: + # As a guess, start with the length of the dictionary + name = "M" + str(len(self.members)) + count = 1 + while name in self.members: + name = "M" + str(len(self.members)+count) + count += 1 + + #Lookup node names and safely handle exceptions + try: + pn_nodes = [self.nodes[node_name] for node_name in (i_node, j_node)] + except KeyError as e: + raise NameError(f"Node '{e.args[0]}' does not exist in the model") + + # Create a new member + new_member = PhysMember(self, name, pn_nodes[0], pn_nodes[1], material_name, section_name, rotation=rotation, tension_only=tension_only, comp_only=comp_only) + + # Add the new member to the model + self.members[name] = new_member + + # Flag the model as unsolved + self.solution = None + + # Return the member name + return name + + def add_plate(self, name:str, i_node:str, j_node:str, m_node:str, n_node:str, + t:float, material_name:str, kx_mod:float = 1.0, ky_mod:float = 1.0) -> str: + """Adds a new rectangular plate to the model. The plate formulation for in-plane (membrane) + stiffness is based on an isoparametric formulation. For bending, it is based on a 12-term + polynomial formulation. This element must be rectangular, and must not be used where a + thick plate formulation is needed. For a more versatile plate element that can handle + distortion and thick plate conditions, consider using the `add_quad` method instead. + + :param name: A unique user-defined name for the plate. If None or "", a name will be + automatically assigned. + :type name: str + :param i_node: The name of the i-node. + :type i_node: str + :param j_node: The name of the j-node. + :type j_node: str + :param m_node: The name of the m-node. + :type m_node: str + :param n_node: The name of the n-node. + :type n_node: str + :param t: The thickness of the element. + :type t: number + :param material_name: The name of the material for the element. + :type material_name: str + :param kx_mod: Stiffness modification factor for in-plane stiffness in the element's local + x-direction, defaults to 1 (no modification). + :type kx_mod: number, optional + :param ky_mod: Stiffness modification factor for in-plane stiffness in the element's local + y-direction, defaults to 1 (no modification). + :type ky_mod: number, optional + :raises NameError: Occurs when the specified name already exists in the model. + :return: The name of the element added to the model. + :rtype: str + """ + + # Name the plate or check it doesn't already exist + if name: + if name in self.plates: raise NameError(f"Plate name '{name}' already exists") + else: + # As a guess, start with the length of the dictionary + name = "P" + str(len(self.plates)) + count = 1 + while name in self.plates: + name = "P" + str(len(self.plates)+count) + count += 1 + + #Lookup node names and safely handle exceptions + try: + pn_nodes = [self.nodes[node_name] for node_name in (i_node, j_node, m_node, n_node)] + except KeyError as e: + raise NameError(f"Node '{e.args[0]}' does not exist in the model") + + # Create a new plate + new_plate = Plate3D(name, pn_nodes[0], pn_nodes[1], pn_nodes[2], pn_nodes[3], + t, material_name, self, kx_mod, ky_mod) + + # Add the new plate to the model + self.plates[name] = new_plate + + # Flag the model as unsolved + self.solution = None + + # Return the plate name + return name + + def add_quad(self, name:str, i_node:str, j_node:str, m_node:str, n_node:str, + t:float, material_name:str, kx_mod:float = 1.0, ky_mod:float = 1.0) -> str: + """Adds a new quadrilateral to the model. The quad formulation for in-plane (membrane) + stiffness is based on an isoparametric formulation. For bending, it is based on an MITC4 + formulation. This element handles distortion relatively well, and is appropriate for thick + and thin plates. One limitation with this element is that it does a poor job of reporting + corner stresses. Corner forces, however are very accurate. Center stresses are very + accurate as well. For cases where corner stress results are important, consider using the + `add_plate` method instead. + + :param name: A unique user-defined name for the quadrilateral. If None or "", a name will + be automatically assigned. + :type name: str + :param i_node: The name of the i-node. + :type i_node: str + :param j_node: The name of the j-node. + :type j_node: str + :param m_node: The name of the m-node. + :type m_node: str + :param n_node: The name of the n-node. + :type n_node: str + :param t: The thickness of the element. + :type t: number + :param material_name: The name of the material for the element. + :type material_name: str + :param kx_mod: Stiffness modification factor for in-plane stiffness in the element's local + x-direction, defaults to 1 (no modification). + :type kx_mod: number, optional + :param ky_mod: Stiffness modification factor for in-plane stiffness in the element's local + y-direction, defaults to 1 (no modification). + :type ky_mod: number, optional + :raises NameError: Occurs when the specified name already exists in the model. + :return: The name of the element added to the model. + :rtype: str + """ + + # Name the quad or check it doesn't already exist + if name: + if name in self.quads: raise NameError(f"Quad name '{name}' already exists") + else: + # As a guess, start with the length of the dictionary + name = "Q" + str(len(self.quads)) + count = 1 + while name in self.quads: + name = "Q" + str(len(self.quads) + count) + count += 1 + + #Lookup node names and safely handle exceptions + try: + pn_nodes = [self.nodes[node_name] for node_name in (i_node, j_node, m_node, n_node)] + except KeyError as e: + raise NameError(f"Node '{e.args[0]}' does not exist in the model") + + # Create a new member + new_quad = Quad3D(name, pn_nodes[0], pn_nodes[1], pn_nodes[2], pn_nodes[3], + t, material_name, self, kx_mod, ky_mod) + + # Add the new member to the model + self.quads[name] = new_quad + + # Flag the model as unsolved + self.solution = None + + #Return the quad name + return name + + def add_rectangle_mesh(self, name:str, mesh_size:float, width:float, height:float, + thickness:float, material_name:str, kx_mod:float = 1.0, + ky_mod:float = 1.0, origin: list | tuple = (0, 0, 0), + plane:str = 'XY', x_control:list | None = None, + y_control: list | None = None, start_node: str | None = None, + start_element:str | None = None, element_type:str = 'Quad') -> str: + """Adds a rectangular mesh of elements to the model. + + :param name: A unique name for the mesh. + :type name: str + :param mesh_size: The desired mesh size. + :type mesh_size: number + :param width: The overall width of the rectangular mesh measured along its local x-axis. + :type width: number + :param height: The overall height of the rectangular mesh measured along its local y-axis. + :type height: number + :param thickness: The thickness of each element in the mesh. + :type thickness: number + :param material_name: The name of the material for elements in the mesh. + :type material_name: str + :param kx_mod: Stiffness modification factor for in-plane stiffness in the element's local x-direction. Defaults to 1.0 (no modification). + :type kx_mod: float, optional + :param ky_mod: Stiffness modification factor for in-plane stiffness in the element's local y-direction. Defaults to 1.0 (no modification). + :type ky_mod: float, optional + :param origin: The origin of the regtangular mesh's local coordinate system. Defaults to [0, 0, 0] + :type origin: list, optional + :param plane: The plane the mesh will be parallel to. Options are 'XY', 'YZ', and 'XZ'. Defaults to 'XY'. + :type plane: str, optional + :param x_control: A list of control points along the mesh's local x-axis to work into the mesh. Defaults to `None`. + :type x_control: list, optional + :param y_control: A list of control points along the mesh's local y-axis to work into the mesh. Defaults to None. + :type y_control: list, optional + :param start_node: The name of the first node in the mesh. If set to `None` the program will use the next available node name. Default is `None`. + :type start_node: str, optional + :param start_element: The name of the first element in the mesh. If set to `None` the program will use the next available element name. Default is `None`. + :type start_element: str, optional + :param element_type: They type of element to make the mesh out of. Either 'Quad' or 'Rect'. Defaults to 'Quad'. + :type element_type: str, optional + :raises NameError: Occurs when the specified name already exists in the model. + :return: The name of the mesh added to the model. + :rtype: str + """ + + # Check if a mesh name has been provided + if name: + # Check that the mesh name isn't already being used + if name in self.meshes: raise NameError(f"Mesh name '{name}' already exists") + # Rename the mesh if necessary + else: + name = self.unique_name(self.meshes, 'MSH') + + # Identify the starting node and element + if start_node is None: + start_node = self.unique_name(self.nodes, 'N') + if element_type == 'Rect' and start_element is None: + start_element = self.unique_name(self.plates, 'R') + elif element_type == 'Quad' and start_element is None: + start_element = self.unique_name(self.quads, 'Q') + + # Create the mesh + new_mesh = RectangleMesh(mesh_size, width, height, thickness, material_name, self, kx_mod, + ky_mod, origin, plane, x_control, y_control, start_node, + start_element, element_type=element_type) + + # Add the new mesh to the `Meshes` dictionary + self.meshes[name] = new_mesh + + # Flag the model as unsolved + self.solution = None + + #Return the mesh's name + return name + + def add_annulus_mesh(self, name:str, mesh_size:float, outer_radius:float, inner_radius:float, + thickness:float, material_name:str, kx_mod:float = 1.0, ky_mod:float = 1.0, + origin:list | tuple = (0, 0, 0), axis:str = 'Y', + start_node:str | None = None, start_element:str | None = None) -> str: + """Adds a mesh of quadrilaterals forming an annulus (a donut). + + :param name: A unique name for the mesh. + :type name: str + :param mesh_size: The target mesh size. + :type mesh_size: float + :param outer_radius: The radius to the outside of the annulus. + :type outer_radius: float + :param inner_radius: The radius to the inside of the annulus. + :type inner_radius: float + :param thickness: Element thickness. + :type thickness: float + :param material_name: The name of the element material. + :type material_name: str + :param kx_mod: Stiffness modification factor for radial stiffness in the element's local + x-direction. Default is 1.0 (no modification). + :type kx_mod: float, optional + :param ky_mod: Stiffness modification factor for meridional stiffness in the element's + local y-direction. Default is 1.0 (no modification). + :type ky_mod: float, optional + :param origin: The origin of the mesh. The default is [0, 0, 0]. + :type origin: list, optional + :param axis: The global axis about which the mesh will be generated. The default is 'Y'. + :type axis: str, optional + :param start_node: The name of the first node in the mesh. If set to `None` the program + will use the next available node name. Default is `None`. + :type start_node: str, optional + :param start_element: The name of the first element in the mesh. If set to `None` the + program will use the next available element name. Default is `None`. + :type start_element: str, optional + :raises NameError: Occurs if the specified name already exists in the model. + :return: The name of the mesh added to the model. + :rtype: str + """ + + # Check if a mesh name has been provided + if name: + # Check that the mesh name doesn't already exist + if name in self.meshes: raise NameError(f"Mesh name '{name}' already exists") + # Give the mesh a new name if necessary + else: + name = self.unique_name(self.meshes, 'MSH') + + # Identify the starting node and element + if start_node is None: + start_node = self.unique_name(self.nodes, 'N') + if start_element is None: + start_element = self.unique_name(self.quads, 'Q') + + # Create a new mesh + new_mesh = AnnulusMesh(mesh_size, outer_radius, inner_radius, thickness, material_name, self, + kx_mod, ky_mod, origin, axis, start_node, start_element) + + # Add the new mesh to the `Meshes` dictionary + self.meshes[name] = new_mesh + + # Flag the model as unsolved + self.solution = None + + #Return the mesh's name + return name + + def add_frustrum_mesh(self, name:str, mesh_size:float, large_radius:float, + small_radius:float, height:float, thickness:float, + material_name:str, kx_mod:float = 1.0, ky_mod:float = 1.0, + origin: list | tuple = (0, 0, 0), axis:str = 'Y', + start_node:str | None = None, start_element:str | None = None) -> str: + """Adds a mesh of quadrilaterals forming a frustrum (a cone intersected by a horizontal plane). + + :param name: A unique name for the mesh. + :type name: str + :param mesh_size: The target mesh size + :type mesh_size: number + :param large_radius: The larger of the two end radii. + :type large_radius: number + :param small_radius: The smaller of the two end radii. + :type small_radius: number + :param height: The height of the frustrum. + :type height: number + :param thickness: The thickness of the elements. + :type thickness: number + :param material_name: The name of the element material. + :type material_name: str + :param kx_mod: Stiffness modification factor for radial stiffness in each element's local x-direction, defaults to 1 (no modification). + :type kx_mod: number, optional + :param ky_mod: Stiffness modification factor for meridional stiffness in each element's local y-direction, defaults to 1 (no modification). + :type ky_mod: number, optional + :param origin: The origin of the mesh, defaults to [0, 0, 0]. + :type origin: list, optional + :param axis: The global axis about which the mesh will be generated, defaults to 'Y'. + :type axis: str, optional + :param start_node: The name of the first node in the mesh. If set to None the program will use the next available node name, defaults to None. + :type start_node: str, optional + :param start_element: The name of the first element in the mesh. If set to `None` the + program will use the next available element name, defaults to None + :type start_element: str, optional + :raises NameError: Occurs if the specified name already exists. + :return: The name of the mesh added to the model. + :rtype: str + """ + + # Check if a name has been provided + if name: + # Check that the mesh name doesn't already exist + if name in self.meshes: raise NameError(f"Mesh name '{name}' already exists") + # Give the mesh a new name if necessary + else: + name = self.unique_name(self.meshes, 'MSH') + + # Identify the starting node and element + if start_node is None: + start_node = self.unique_name(self.nodes, 'N') + if start_element is None: + start_element = self.unique_name(self.quads, 'Q') + + # Create a new mesh + new_mesh = FrustrumMesh(mesh_size, large_radius, small_radius, height, thickness, material_name, + self, kx_mod, ky_mod, origin, axis, start_node, start_element) + + # Add the new mesh to the `Meshes` dictionary + self.meshes[name] = new_mesh + + # Flag the model as unsolved + self.solution = None + + #Return the mesh's name + return name + + def add_cylinder_mesh(self, name:str, mesh_size:float, radius:float, height:float, + thickness:float, material_name:str, kx_mod:float = 1, + ky_mod:float = 1, origin:list | tuple = (0, 0, 0), + axis:str = 'Y', num_elements:int | None = None, + start_node: str | None = None, start_element:str | None = None, + element_type:str = 'Quad') -> str: + """Adds a mesh of elements forming a cylinder. + + :param name: A unique name for the mesh. + :type name: str + :param mesh_size: The target mesh size. + :type mesh_size: float + :param radius: The radius of the cylinder. + :type radius: float + :param height: The height of the cylinder. + :type height: float + :param thickness: Element thickness. + :type thickness: float + :param material_name: The name of the element material. + :type material_name: str + :param kx_mod: Stiffness modification factor for hoop stiffness in each element's local + x-direction. Defaults to 1.0 (no modification). + :type kx_mod: int, optional + :param ky_mod: Stiffness modification factor for meridional stiffness in each element's + local y-direction. Defaults to 1.0 (no modification). + :type ky_mod: int, optional + :param origin: The origin [X, Y, Z] of the mesh. Defaults to [0, 0, 0]. + :type origin: list, optional + :param axis: The global axis about which the mesh will be generated. Defaults to 'Y'. + :type axis: str, optional + :param num_elements: The number of elements to use to form each course of elements. This + is typically only used if you are trying to match the nodes to another + mesh's nodes. If set to `None` the program will automatically + calculate the number of elements to use based on the mesh size. + Defaults to None. + :type num_elements: int, optional + :param start_node: The name of the first node in the mesh. If set to `None` the program + will use the next available node name. Defaults to `None`. + :type start_node: str, optional + :param start_element: The name of the first element in the mesh. If set to `None` the + program will use the next available element name. Defaults to `None`. + :type start_element: str, optional + :param element_type: The type of element to make the mesh out of. Either 'Quad' or 'Rect'. + Defaults to 'Quad'. + :type element_type: str, optional + :raises NameError: Occurs when the specified mesh name is already being used in the model. + :return: The name of the mesh added to the model + :rtype: str + """ + + # Check if a name has been provided + if name: + # Check that the mesh name doesn't already exist + if name in self.meshes: raise NameError(f"Mesh name '{name}' already exists") + # Give the mesh a new name if necessary + else: + name = self.unique_name(self.meshes, 'MSH') + + # Identify the starting node and element + if start_node is None: + start_node = self.unique_name(self.nodes, 'N') + if element_type == 'Rect' and start_element is None: + start_element = self.unique_name(self.plates, 'R') + elif element_type == 'Quad' and start_element is None: + start_element = self.unique_name(self.quads, 'Q') + + # Create a new mesh + new_mesh = CylinderMesh(mesh_size, radius, height, thickness, material_name, self, + kx_mod, ky_mod, origin, axis, start_node, start_element, + num_elements, element_type) + + # Add the new mesh to the `Meshes` dictionary + self.meshes[name] = new_mesh + + # Flag the model as unsolved + self.solution = None + + #Return the mesh's name + return name + + def merge_duplicate_nodes(self, tolerance:float = 0.001) -> list: + """Removes duplicate nodes from the model and returns a list of the removed node names. + + :param tolerance: The maximum distance between two nodes in order to consider them duplicates. Defaults to 0.001. + :type tolerance: float, optional + :return: A list of the names of the nodes that were removed from the model. + """ + + # Initialize a dictionary marking where each node is used + node_lookup = {node_name: [] for node_name in self.nodes.keys()} + element_dicts = ('springs', 'members', 'plates', 'quads') + node_types = ('i_node', 'j_node', 'm_node', 'n_node') + + # Step through each dictionary of elements in the model (springs, members, plates, quads) + for element_dict in element_dicts: + + # Step through each element in the dictionary + for element in getattr(self, element_dict).values(): + + # Step through each possible node type in the element (i-node, j-node, m-node, n-node) + for node_type in node_types: + + # Get the current element's node having the current type + # Return `None` if the element doesn't have this node type + node = getattr(element, node_type, None) + + # Determine if the node exists on the element + if node is not None: + # Add the element to the list of elements attached to the node + node_lookup[node.name].append((element, node_type)) + + # Make a list of the names of each node in the model + node_names = list(self.nodes.keys()) + + # Make a list of nodes to be removed from the model + remove_list = [] + + # Step through each node in the copy of the `Nodes` dictionary + for i, node_1_name in enumerate(node_names): + + # Skip iteration if `node_1` has already been removed + if node_lookup[node_1_name] is None: + continue + + # There is no need to check `node_1` against itself + for node_2_name in node_names[i + 1:]: + + # Skip iteration if node_2 has already been removed + if node_lookup[node_2_name] is None: + continue + + # Calculate the distance between nodes + if self.nodes[node_1_name].distance(self.nodes[node_2_name]) > tolerance: + continue + + # Replace references to `node_2` in each element with references to `node_1` + for element, node_type in node_lookup[node_2_name]: + setattr(element, node_type, self.nodes[node_1_name]) + + # Flag `node_2` as no longer used + node_lookup[node_2_name] = None + + # Merge any boundary conditions + support_cond = ('support_DX', 'support_DY', 'support_DZ', 'support_RX', 'support_RY', 'support_RZ') + for dof in support_cond: + if getattr(self.nodes[node_2_name], dof) == True: + setattr(self.nodes[node_1_name], dof, True) + + # Merge any spring supports + spring_cond = ('spring_DX', 'spring_DY', 'spring_DZ', 'spring_RX', 'spring_RY', 'spring_RZ') + for dof in spring_cond: + value = getattr(self.nodes[node_2_name], dof) + if value != [None, None, None]: + setattr(self.nodes[node_1_name], dof, value) + + # Fix the mesh labels + for mesh in self.meshes.values(): + + # Fix the nodes in the mesh + if node_2_name in mesh.nodes.keys(): + + # Attach the correct node to the mesh + mesh.nodes[node_2_name] = self.nodes[node_1_name] + + # Fix the dictionary key + mesh.nodes[node_1_name] = mesh.nodes.pop(node_2_name) + + # Fix the elements in the mesh + for element in mesh.elements.values(): + if node_2_name == element.i_node.name: element.i_node = self.nodes[node_1_name] + if node_2_name == element.j_node.name: element.j_node = self.nodes[node_1_name] + if node_2_name == element.m_node.name: element.m_node = self.nodes[node_1_name] + if node_2_name == element.n_node.name: element.n_node = self.nodes[node_1_name] + + # Add the node to the `remove` list + remove_list.append(node_2_name) + + # Remove `node_2` from the model's `Nodes` dictionary + for node_name in remove_list: + self.nodes.pop(node_name) + + # Flag the model as unsolved + self.solution = None + + # Return the list of removed nodes + return remove_list + + def delete_node(self, node_name:str): + """Removes a node from the model. All nodal loads associated with the node and elements attached to the node will also be removed. + + :param node_name: The name of the node to be removed. + :type node_name: str + """ + + # Remove the node. Nodal loads are stored within the node, so they + # will be deleted automatically when the node is deleted. + self.nodes.pop(node_name) + + # Find any elements attached to the node and remove them + self.members = {name: member for name, member in self.members.items() if member.i_node.name != node_name and member.j_node.name != node_name} + self.plates = {name: plate for name, plate in self.plates.items() if plate.i_node.name != node_name and plate.j_node.name != node_name and plate.m_node.name != node_name and plate.n_node.name != node_name} + self.quads = {name: quad for name, quad in self.quads.items() if quad.i_node.name != node_name and quad.j_node.name != node_name and quad.m_node.name != node_name and quad.n_node.name != node_name} + + # Flag the model as unsolved + self.solution = None + + def delete_spring(self, spring_name:str): + """Removes a spring from the model. + + :param spring_name: The name of the spring to be removed. + :type spring_name: str + """ + + # Remove the spring + self.springs.pop(spring_name) + + # Flag the model as unsolved + self.solution = None + + def delete_member(self, member_name:str): + """Removes a member from the model. All member loads associated with the member will also + be removed. + + :param member_name: The name of the member to be removed. + :type member_name: str + """ + + # Remove the member. Member loads are stored within the member, so they + # will be deleted automatically when the member is deleted. + self.members.pop(member_name) + + # Flag the model as unsolved + self.solution = None + + def def_support(self, node_name:str, support_DX:bool=False, support_DY:bool=False, + support_DZ:bool=False, support_RX:bool=False, support_RY:bool=False, + support_RZ:bool=False): + """Defines the support conditions at a node. Nodes will default to fully unsupported + unless specified otherwise. + + :param node_name: The name of the node where the support is being defined. + :type node_name: str + :param support_DX: Indicates whether the node is supported against translation in the + global X-direction. Defaults to False. + :type support_DX: bool, optional + :param support_DY: Indicates whether the node is supported against translation in the + global Y-direction. Defaults to False. + :type support_DY: bool, optional + :param support_DZ: Indicates whether the node is supported against translation in the + global Z-direction. Defaults to False. + :type support_DZ: bool, optional + :param support_RX: Indicates whether the node is supported against rotation about the + global X-axis. Defaults to False. + :type support_RX: bool, optional + :param support_RY: Indicates whether the node is supported against rotation about the + global Y-axis. Defaults to False. + :type support_RY: bool, optional + :param support_RZ: Indicates whether the node is supported against rotation about the + global Z-axis. Defaults to False. + :type support_RZ: bool, optional + """ + + # Get the node to be supported + try: + node = self.nodes[node_name] + except KeyError: + raise NameError(f"Node '{node_name}' does not exist in the model") + + # Set the node's support conditions + node.support_DX = support_DX + node.support_DY = support_DY + node.support_DZ = support_DZ + node.support_RX = support_RX + node.support_RY = support_RY + node.support_RZ = support_RZ + + # Flag the model as unsolved + self.solution = None + + def def_support_spring(self, node_name:str, dof:str, stiffness:float, direction:str | None = None): + """Defines a spring support at a node. + + :param node_name: The name of the node to apply the spring support to. + :type node_name: str + :param dof: The degree of freedom to apply the spring support to. + :type dof: str ('DX', 'DY', 'DZ', 'RX', 'RY', or 'RZ') + :param stiffness: The translational or rotational stiffness of the spring support. + :type stiffness: float + :param direction: The direction in which the spring can act. '+' allows the spring to resist positive displacements. '-' allows the spring to resist negative displacements. None allows the spring to act in both directions. Default is None. + :type direction: str or None ('+', '-', None), optional + :raises ValueError: Occurs when an invalid support spring direction has been specified. + :raises ValueError: Occurs when an invalid support spring degree of freedom has been specified. + """ + + if dof in ('DX', 'DY', 'DZ', 'RX', 'RY', 'RZ'): + if direction in ('+', '-', None): + try: + if dof == 'DX': + self.nodes[node_name].spring_DX = [stiffness, direction, True] + elif dof == 'DY': + self.nodes[node_name].spring_DY = [stiffness, direction, True] + elif dof == 'DZ': + self.nodes[node_name].spring_DZ = [stiffness, direction, True] + elif dof == 'RX': + self.nodes[node_name].spring_RX = [stiffness, direction, True] + elif dof == 'RY': + self.nodes[node_name].spring_RY = [stiffness, direction, True] + elif dof == 'RZ': + self.nodes[node_name].spring_RZ = [stiffness, direction, True] + except KeyError: + raise NameError(f"Node '{node_name}' does not exist in the model") + else: + raise ValueError('Invalid support spring direction. Specify \'+\', \'-\', or None.') + else: + raise ValueError('Invalid support spring degree of freedom. Specify \'DX\', \'DY\', \'DZ\', \'RX\', \'RY\', or \'RZ\'') + + # Flag the model as unsolved + self.solution = None + + def def_node_disp(self, node_name:str, direction:str, magnitude:float): + """Defines a nodal displacement at a node. + + :param node_name: The name of the node where the nodal displacement is being applied. + :type node_name: str + :param direction: The global direction the nodal displacement is being applied in. Displacements are 'DX', 'DY', and 'DZ'. Rotations are 'RX', 'RY', and 'RZ'. + :type direction: str + :param magnitude: The magnitude of the displacement. + :type magnitude: float + :raises ValueError: _description_ + """ + + # Validate the value of direction + if direction not in ('DX', 'DY', 'DZ', 'RX', 'RY', 'RZ'): + raise ValueError(f"direction must be 'DX', 'DY', 'DZ', 'RX', 'RY', or 'RZ'. {direction} was given.") + + # Get the node + try: + node = self.nodes[node_name] + except KeyError: + raise NameError(f"Node '{node_name}' does not exist in the model") + + if direction == 'DX': + node.EnforcedDX = magnitude + if direction == 'DY': + node.EnforcedDY = magnitude + if direction == 'DZ': + node.EnforcedDZ = magnitude + if direction == 'RX': + node.EnforcedRX = magnitude + if direction == 'RY': + node.EnforcedRY = magnitude + if direction == 'RZ': + node.EnforcedRZ = magnitude + + # Flag the model as unsolved + self.solution = None + + def def_releases(self, member_name:str, Dxi:bool=False, Dyi:bool=False, Dzi:bool=False, + Rxi:bool=False, Ryi:bool=False, Rzi:bool=False, + Dxj:bool=False, Dyj:bool=False, Dzj:bool=False, + Rxj:bool=False, Ryj:bool=False, Rzj:bool=False): + """Defines member end realeses for a member. All member end releases will default to unreleased unless specified otherwise. + + :param member_name: The name of the member to have its releases modified. + :type member_name: str + :param Dxi: Indicates whether the member is released axially at its start. Defaults to False. + :type Dxi: bool, optional + :param Dyi: Indicates whether the member is released for shear in the local y-axis at its start. Defaults to False. + :type Dyi: bool, optional + :param Dzi: Indicates whether the member is released for shear in the local z-axis at its start. Defaults to False. + :type Dzi: bool, optional + :param Rxi: Indicates whether the member is released for torsion at its start. Defaults to False. + :type Rxi: bool, optional + :param Ryi: Indicates whether the member is released for moment about the local y-axis at its start. Defaults to False. + :type Ryi: bool, optional + :param Rzi: Indicates whether the member is released for moment about the local z-axis at its start. Defaults to False. + :type Rzi: bool, optional + :param Dxj: Indicates whether the member is released axially at its end. Defaults to False. + :type Dxj: bool, optional + :param Dyj: Indicates whether the member is released for shear in the local y-axis at its end. Defaults to False. + :type Dyj: bool, optional + :param Dzj: Indicates whether the member is released for shear in the local z-axis. Defaults to False. + :type Dzj: bool, optional + :param Rxj: Indicates whether the member is released for torsion at its end. Defaults to False. + :type Rxj: bool, optional + :param Ryj: Indicates whether the member is released for moment about the local y-axis at its end. Defaults to False. + :type Ryj: bool, optional + :param Rzj: Indicates whether the member is released for moment about the local z-axis at its end. Defaults to False. + :type Rzj: bool, optional + """ + + # Apply the end releases to the member + try: + self.members[member_name].Releases = [Dxi, Dyi, Dzi, Rxi, Ryi, Rzi, Dxj, Dyj, Dzj, Rxj, Ryj, Rzj] + except KeyError: + raise NameError(f"Member '{member_name}' does not exist in the model") + + # Flag the model as unsolved + self.solution = None + + def add_load_combo(self, name:str, factors:dict, combo_tags:list | None = None): + """Adds a load combination to the model. + + :param name: A unique name for the load combination (e.g. '1.2D+1.6L+0.5S' or 'Gravity Combo'). + :type name: str + :param factors: A dictionary containing load cases and their corresponding factors (e.g. {'D':1.2, 'L':1.6, 'S':0.5}). + :type factors: dict + :param combo_tags: A list of tags used to categorize load combinations. Default is `None`. This can be useful for filtering results later on, or for limiting analysis to only those combinations with certain tags. This feature is provided for convenience. It is not necessary to use tags. + :type combo_tags: list, optional + """ + + # Create a new load combination object + new_combo = LoadCombo(name, combo_tags, factors) + + # Add the load combination to the dictionary of load combinations + self.load_combos[name] = new_combo + + # Flag the model as solved + self.solution = None + + def add_node_load(self, node_name:str, direction:str, P:float, case:str = 'Case 1'): + """Adds a nodal load to the model. + + :param node_name: The name of the node where the load is being applied. + :type node_name: str + :param direction: The global direction the load is being applied in. Forces are `'FX'`, `'FY'`, and `'FZ'`. Moments are `'MX'`, `'MY'`, and `'MZ'`. + :type direction: str + :param P: The numeric value (magnitude) of the load. + :type P: float + :param case: The name of the load case the load belongs to. Defaults to 'Case 1'. + :type case: str, optional + :raises ValueError: Occurs when an invalid load direction was specified. + """ + + # Validate the value of direction + if direction not in ('FX', 'FY', 'FZ', 'MX', 'MY', 'MZ'): + raise ValueError(f"direction must be 'FX', 'FY', 'FZ', 'MX', 'MY', or 'MZ'. {direction} was given.") + + # Add the node load to the model + try: + self.nodes[node_name].NodeLoads.append((direction, P, case)) + except KeyError: + raise NameError(f"Node '{node_name}' does not exist in the model") + + # Flag the model as unsolved + self.solution = None + + def add_member_pt_load(self, member_name:str, direction:str, P:float, x:float, case:str = 'Case 1'): + """Adds a member point load to the model. + + :param member_name: The name of the member the load is being applied to. + :type member_name: str + :param direction: The direction in which the load is to be applied. Valid values are `'Fx'`, + `'Fy'`, `'Fz'`, `'Mx'`, `'My'`, `'Mz'`, `'FX'`, `'FY'`, `'FZ'`, `'MX'`, `'MY'`, or `'MZ'`. + Note that lower-case notation indicates use of the beam's local + coordinate system, while upper-case indicates use of the model's globl + coordinate system. + :type direction: str + :param P: The numeric value (magnitude) of the load. + :type P: float + :param x: The load's location along the member's local x-axis. + :type x: float + :param case: The load case to categorize the load under. Defaults to 'Case 1'. + :type case: str, optional + :raises ValueError: Occurs when an invalid load direction has been specified. + """ + + # Validate the value of direction + if direction not in ('Fx', 'Fy', 'Fz', 'FX', 'FY', 'FZ', 'Mx', 'My', 'Mz', 'MX', 'MY', 'MZ'): + raise ValueError(f"direction must be 'Fx', 'Fy', 'Fz', 'FX', 'FY', FZ', 'Mx', 'My', 'Mz', 'MX', 'MY', or 'MZ'. {direction} was given.") + + # Add the point load to the member + try: + self.members[member_name].PtLoads.append((direction, P, x, case)) + except KeyError: + raise NameError(f"Member '{member_name}' does not exist in the model") + + # Flag the model as unsolved + self.solution = None + + def add_member_dist_load(self, member_name:str, direction:str, w1:float, w2:float, + x1:float | None = None, x2:float | None = None, + case:str = 'Case 1'): + """Adds a member distributed load to the model. + + :param member_name: The name of the member the load is being appied to. + :type member_name: str + :param direction: The direction in which the load is to be applied. Valid values are `'Fx'`, + `'Fy'`, `'Fz'`, `'FX'`, `'FY'`, or `'FZ'`. + Note that lower-case notation indicates use of the beam's local + coordinate system, while upper-case indicates use of the model's globl + coordinate system. + :type direction: str + :param w1: The starting value (magnitude) of the load. + :type w1: float + :param w2: The ending value (magnitude) of the load. + :type w2: float + :param x1: The load's start location along the member's local x-axis. If this argument is + not specified, the start of the member will be used. Defaults to `None` + :type x1: float, optional + :param x2: The load's end location along the member's local x-axis. If this argument is not + specified, the end of the member will be used. Defaults to `None`. + :type x2: float, optional + :param case: _description_, defaults to 'Case 1' + :type case: str, optional + :raises ValueError: Occurs when an invalid load direction has been specified. + """ + + # Validate the value of direction + if direction not in ('Fx', 'Fy', 'Fz', 'FX', 'FY', 'FZ'): + raise ValueError(f"direction must be 'Fx', 'Fy', 'Fz', 'FX', 'FY', or 'FZ'. {direction} was given.") + # Determine if a starting and ending points for the load have been specified. + # If not, use the member start and end as defaults + if x1 == None: + start = 0 + else: + start = x1 + + if x2 == None: + end = self.members[member_name].L() + else: + end = x2 + + # Add the distributed load to the member + try: + self.members[member_name].DistLoads.append((direction, w1, w2, start, end, case)) + except KeyError: + raise NameError(f"Member '{member_name}' does not exist in the model") + + # Flag the model as unsolved + self.solution = None + + def add_member_self_weight(self, global_direction:str, factor:float, case:str = 'Case 1'): + """Adds self weight to all members in the model. Note that this only works for members. Plate and Quad elements will be ignored by this command. + + :param global_direction: The global direction to apply the member load in: 'FX', 'FY', or 'FZ'. + :type global_direction: string + :param factor: A factor to apply to the member self-weight. Can be used to account for items like connections, or to switch the direction of the self-weight load. + :type factor: float + :param case: The load case to apply the self-weight to. Defaults to 'Case 1' + :type case: str, optional + """ + + # Step through each member in the model + for member in self.members.values(): + + # Calculate the self weight of the member + self_weight = factor*member.material.rho*member.section.A + + # Add the self-weight load to the member + self.add_member_dist_load(member.name, global_direction, self_weight, self_weight, case=case) + + # No need to flag the model as unsolved. That has already been taken care of by our call to `add_member_dist_load` + + def add_plate_surface_pressure(self, plate_name:str, pressure:float, case:str = 'Case 1'): + """Adds a surface pressure to the rectangular plate element. + + + :param plate_name: The name for the rectangular plate to add the surface pressure to. + :type plate_name: str + :param pressure: The value (magnitude) for the surface pressure. + :type pressure: float + :param case: The load case to add the surface pressure to. Defaults to 'Case 1'. + :type case: str, optional + :raises Exception: Occurs when an invalid plate name has been specified. + """ + + # Add the surface pressure to the rectangle + try: + self.plates[plate_name].pressures.append([pressure, case]) + except KeyError: + raise NameError(f"Plate '{plate_name}' does not exist in the model") + + # Flag the model as unsolved + self.solution = None + + def add_quad_surface_pressure(self, quad_name:str, pressure:float, case:str = 'Case 1'): + """Adds a surface pressure to the quadrilateral element. + + :param quad_name: The name for the quad to add the surface pressure to. + :type quad_name: str + :param pressure: The value (magnitude) for the surface pressure. + :type pressure: float + :param case: The load case to add the surface pressure to. Defaults to 'Case 1'. + :type case: str, optional + :raises Exception: Occurs when an invalid quad name has been specified. + """ + + # Add the surface pressure to the quadrilateral + try: + self.quads[quad_name].pressures.append([pressure, case]) + except KeyError: + raise NameError(f"Quad '{quad_name}' does not exist in the model") + + # Flag the model as unsolved + self.solution = None + + def delete_loads(self): + """Deletes all loads from the model along with any results based on the loads. + """ + + # Delete the member loads and the calculated internal forces + for member in self.members.values(): + member.DistLoads = [] + member.PtLoads = [] + member.SegmentsZ = [] + member.SegmentsY = [] + member.SegmentsX = [] + + # Delete the plate loads + for plate in self.plates.values(): + plate.pressures = [] + + # Delete the quadrilateral loads + for quad in self.quads.values(): + quad.pressures = [] + + # Delete the nodal loads, calculated displacements, and calculated reactions + for node in self.nodes.values(): + + node.NodeLoads = [] + + node.DX = {} + node.DY = {} + node.DZ = {} + node.RX = {} + node.RY = {} + node.RZ = {} + + node.RxnFX = {} + node.RxnFY = {} + node.RxnFZ = {} + node.RxnMX = {} + node.RxnMY = {} + node.RxnMZ = {} + + # Flag the model as unsolved + self.solution = None + + def K(self, combo_name='Combo 1', log=False, check_stability=True, sparse=True): + """Returns the model's global stiffness matrix. The stiffness matrix will be returned in + scipy's sparse lil format, which reduces memory usage and can be easily converted to + other formats. + + :param combo_name: The load combination to get the stiffness matrix for. Defaults to 'Combo 1'. + :type combo_name: str, optional + :param log: Prints updates to the console if set to True. Defaults to False. + :type log: bool, optional + :param check_stability: Causes Pynite to check for instabilities if set to True. Defaults + to True. Set to False if you want the model to run faster. + :type check_stability: bool, optional + :param sparse: Returns a sparse matrix if set to True, and a dense matrix otherwise. + Defaults to True. + :type sparse: bool, optional + :return: The global stiffness matrix for the structure. + :rtype: ndarray or coo_matrix + """ + + # Determine if a sparse matrix has been requested + if sparse == True: + # The stiffness matrix will be stored as a scipy `coo_matrix`. Scipy's + # documentation states that this type of matrix is ideal for efficient + # construction of finite element matrices. When converted to another + # format, the `coo_matrix` sums values at the same (i, j) index. We'll + # build the matrix from three lists. + row = [] + col = [] + data = [] + else: + # Initialize a dense matrix of zeros + K = zeros((len(self.nodes)*6, len(self.nodes)*6)) + + # Add stiffness terms for each nodal spring in the model + if log: print('- Adding nodal spring support stiffness terms to global stiffness matrix') + for node in self.nodes.values(): + + # Determine if the node has any spring supports + if node.spring_DX[0] != None: + + # Check for an active spring support + if node.spring_DX[2] == True: + m, n = node.ID*6, node.ID*6 + if sparse == True: + data.append(float(node.spring_DX[0])) + row.append(m) + col.append(n) + else: + K[m, n] += float(node.spring_DX[0]) + + if node.spring_DY[0] != None: + + # Check for an active spring support + if node.spring_DY[2] == True: + m, n = node.ID*6 + 1, node.ID*6 + 1 + if sparse == True: + data.append(float(node.spring_DY[0])) + row.append(m) + col.append(n) + else: + K[m, n] += float(node.spring_DY[0]) + + if node.spring_DZ[0] != None: + + # Check for an active spring support + if node.spring_DZ[2] == True: + m, n = node.ID*6 + 2, node.ID*6 + 2 + if sparse == True: + data.append(float(node.spring_DZ[0])) + row.append(m) + col.append(n) + else: + K[m, n] += float(node.spring_DZ[0]) + + if node.spring_RX[0] != None: + + # Check for an active spring support + if node.spring_RX[2] == True: + m, n = node.ID*6 + 3, node.ID*6 + 3 + if sparse == True: + data.append(float(node.spring_RX[0])) + row.append(m) + col.append(n) + else: + K[m, n] += float(node.spring_RX[0]) + + if node.spring_RY[0] != None: + + # Check for an active spring support + if node.spring_RY[2] == True: + m, n = node.ID*6 + 4, node.ID*6 + 4 + if sparse == True: + data.append(float(node.spring_RY[0])) + row.append(m) + col.append(n) + else: + K[m, n] += float(node.spring_RY[0]) + + if node.spring_RZ[0] != None: + + # Check for an active spring support + if node.spring_RZ[2] == True: + m, n = node.ID*6 + 5, node.ID*6 + 5 + if sparse == True: + data.append(float(node.spring_RZ[0])) + row.append(m) + col.append(n) + else: + K[m, n] += float(node.spring_RZ[0]) + + # Add stiffness terms for each spring in the model + if log: print('- Adding spring stiffness terms to global stiffness matrix') + for spring in self.springs.values(): + + if spring.active[combo_name] == True: + + # Get the spring's global stiffness matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + spring_K = spring.K() + + # Step through each term in the spring's stiffness matrix + # 'a' & 'b' below are row/column indices in the spring's stiffness matrix + # 'm' & 'n' are corresponding row/column indices in the global stiffness matrix + for a in range(12): + + # Determine if index 'a' is related to the i-node or j-node + if a < 6: + # Find the corresponding index 'm' in the global stiffness matrix + m = spring.i_node.ID*6 + a + else: + # Find the corresponding index 'm' in the global stiffness matrix + m = spring.j_node.ID*6 + (a-6) + + for b in range(12): + + # Determine if index 'b' is related to the i-node or j-node + if b < 6: + # Find the corresponding index 'n' in the global stiffness matrix + n = spring.i_node.ID*6 + b + else: + # Find the corresponding index 'n' in the global stiffness matrix + n = spring.j_node.ID*6 + (b-6) + + # Now that 'm' and 'n' are known, place the term in the global stiffness matrix + if sparse == True: + row.append(m) + col.append(n) + data.append(spring_K[a, b]) + else: + K[m, n] += spring_K[a, b] + + # Add stiffness terms for each physical member in the model + if log: print('- Adding member stiffness terms to global stiffness matrix') + for phys_member in self.members.values(): + + # Check to see if the physical member is active for the given load combination + if phys_member.active[combo_name] == True: + + # Step through each sub-member in the physical member and add terms + for member in phys_member.sub_members.values(): + + # Get the member's global stiffness matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + member_K = member.K() + + # Step through each term in the member's stiffness matrix + # 'a' & 'b' below are row/column indices in the member's stiffness matrix + # 'm' & 'n' are corresponding row/column indices in the global stiffness matrix + for a in range(12): + + # Determine if index 'a' is related to the i-node or j-node + if a < 6: + # Find the corresponding index 'm' in the global stiffness matrix + m = member.i_node.ID*6 + a + else: + # Find the corresponding index 'm' in the global stiffness matrix + m = member.j_node.ID*6 + (a-6) + + for b in range(12): + + # Determine if index 'b' is related to the i-node or j-node + if b < 6: + # Find the corresponding index 'n' in the global stiffness matrix + n = member.i_node.ID*6 + b + else: + # Find the corresponding index 'n' in the global stiffness matrix + n = member.j_node.ID*6 + (b-6) + + # Now that 'm' and 'n' are known, place the term in the global stiffness matrix + if sparse == True: + row.append(m) + col.append(n) + data.append(member_K[a, b]) + else: + K[m, n] += member_K[a, b] + + # Add stiffness terms for each quadrilateral in the model + if log: print('- Adding quadrilateral stiffness terms to global stiffness matrix') + for quad in self.quads.values(): + + # Get the quadrilateral's global stiffness matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + quad_K = quad.K() + + # Step through each term in the quadrilateral's stiffness matrix + # 'a' & 'b' below are row/column indices in the quadrilateral's stiffness matrix + # 'm' & 'n' are corresponding row/column indices in the global stiffness matrix + for a in range(24): + + # Determine which node the index 'a' is related to + if a < 6: + # Find the corresponding index 'm' in the global stiffness matrix + m = quad.i_node.ID*6 + a + elif a < 12: + # Find the corresponding index 'm' in the global stiffness matrix + m = quad.j_node.ID*6 + (a - 6) + elif a < 18: + # Find the corresponding index 'm' in the global stiffness matrix + m = quad.m_node.ID*6 + (a - 12) + else: + # Find the corresponding index 'm' in the global stiffness matrix + m = quad.n_node.ID*6 + (a - 18) + + for b in range(24): + + # Determine which node the index 'b' is related to + if b < 6: + # Find the corresponding index 'n' in the global stiffness matrix + n = quad.i_node.ID*6 + b + elif b < 12: + # Find the corresponding index 'n' in the global stiffness matrix + n = quad.j_node.ID*6 + (b - 6) + elif b < 18: + # Find the corresponding index 'n' in the global stiffness matrix + n = quad.m_node.ID*6 + (b - 12) + else: + # Find the corresponding index 'n' in the global stiffness matrix + n = quad.n_node.ID*6 + (b - 18) + + # Now that 'm' and 'n' are known, place the term in the global stiffness matrix + if sparse == True: + row.append(m) + col.append(n) + data.append(quad_K[a, b]) + else: + K[m, n] += quad_K[a, b] + + # Add stiffness terms for each plate in the model + if log: print('- Adding plate stiffness terms to global stiffness matrix') + for plate in self.plates.values(): + + # Get the plate's global stiffness matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + plate_K = plate.K() + + # Step through each term in the plate's stiffness matrix + # 'a' & 'b' below are row/column indices in the plate's stiffness matrix + # 'm' & 'n' are corresponding row/column indices in the global stiffness matrix + for a in range(24): + + # Determine which node the index 'a' is related to + if a < 6: + # Find the corresponding index 'm' in the global stiffness matrix + m = plate.i_node.ID*6 + a + elif a < 12: + # Find the corresponding index 'm' in the global stiffness matrix + m = plate.j_node.ID*6 + (a - 6) + elif a < 18: + # Find the corresponding index 'm' in the global stiffness matrix + m = plate.m_node.ID*6 + (a - 12) + else: + # Find the corresponding index 'm' in the global stiffness matrix + m = plate.n_node.ID*6 + (a - 18) + + for b in range(24): + + # Determine which node the index 'b' is related to + if b < 6: + # Find the corresponding index 'n' in the global stiffness matrix + n = plate.i_node.ID*6 + b + elif b < 12: + # Find the corresponding index 'n' in the global stiffness matrix + n = plate.j_node.ID*6 + (b - 6) + elif b < 18: + # Find the corresponding index 'n' in the global stiffness matrix + n = plate.m_node.ID*6 + (b - 12) + else: + # Find the corresponding index 'n' in the global stiffness matrix + n = plate.n_node.ID*6 + (b - 18) + + # Now that 'm' and 'n' are known, place the term in the global stiffness matrix + if sparse == True: + row.append(m) + col.append(n) + data.append(plate_K[a, b]) + else: + K[m, n] += plate_K[a, b] + + if sparse: + # The stiffness matrix will be stored as a scipy `coo_matrix`. Scipy's + # documentation states that this type of matrix is ideal for efficient + # construction of finite element matrices. When converted to another + # format, the `coo_matrix` sums values at the same (i, j) index. + from scipy.sparse import coo_matrix + row = array(row) + col = array(col) + data = array(data) + K = coo_matrix((data, (row, col)), shape=(len(self.nodes)*6, len(self.nodes)*6)) + + # Check that there are no nodal instabilities + if check_stability: + if log: print('- Checking nodal stability') + if sparse: Analysis._check_stability(self, K.tocsr()) + else: Analysis._check_stability(self, K) + + # Return the global stiffness matrix + return K + + def Kg(self, combo_name='Combo 1', log=False, sparse=True, first_step=True): + """Returns the model's global geometric stiffness matrix. Geometric stiffness of plates is not considered. + + :param combo_name: The name of the load combination to derive the matrix for. Defaults to 'Combo 1'. + :type combo_name: str, optional + :param log: Prints updates to the console if set to `True`. Defaults to `False`. + :type log: bool, optional + :param sparse: Returns a sparse matrix if set to `True`, and a dense matrix otherwise. Defaults to `True`. + :type sparse: bool, optional + :param first_step: Used to indicate if the analysis is occuring at the first load step. Used in nonlinear analysis where the load is broken into multiple steps. Default is `True`. + :type first_step: book, optional + :return: The global geometric stiffness matrix for the structure. + :rtype: ndarray or coo_matrix + """ + + if sparse == True: + # Initialize a zero matrix to hold all the stiffness terms. The matrix will be stored as a scipy sparse `lil_matrix`. This matrix format has several advantages. It uses less memory if the matrix is sparse, supports slicing, and can be converted to other formats (sparse or dense) later on for mathematical operations. + from scipy.sparse import lil_matrix + Kg = lil_matrix((len(self.nodes)*6, len(self.nodes)*6)) + else: + Kg = zeros(len(self.nodes)*6, len(self.nodes)*6) + + # Add stiffness terms for each physical member in the model + if log: print('- Adding member geometric stiffness terms to global geometric stiffness matrix') + for phys_member in self.members.values(): + + # Check to see if the physical member is active for the given load combination + if phys_member.active[combo_name] == True: + + # Step through each sub-member in the physical member and add terms + for member in phys_member.sub_members.values(): + + # Calculate the axial force in the member + E = member.material.E + A = member.section.A + L = member.L() + + # Calculate the axial force acting on the member + if first_step: + # For the first load step take P = 0 + P = 0 + else: + # Calculate the member axial force due to axial strain + d = member.d(combo_name) + P = E*A/L*(d[6, 0] - d[0, 0]) + + # Get the member's global stiffness matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + member_Kg = member.Kg(P) + + # Step through each term in the member's stiffness matrix + # 'a' & 'b' below are row/column indices in the member's stiffness matrix + # 'm' & 'n' are corresponding row/column indices in the global stiffness matrix + for a in range(12): + + # Determine if index 'a' is related to the i-node or j-node + if a < 6: + # Find the corresponding index 'm' in the global stiffness matrix + m = member.i_node.ID*6 + a + else: + # Find the corresponding index 'm' in the global stiffness matrix + m = member.j_node.ID*6 + (a-6) + + for b in range(12): + + # Determine if index 'b' is related to the i-node or j-node + if b < 6: + # Find the corresponding index 'n' in the global stiffness matrix + n = member.i_node.ID*6 + b + else: + # Find the corresponding index 'n' in the global stiffness matrix + n = member.j_node.ID*6 + (b-6) + + # Now that 'm' and 'n' are known, place the term in the global stiffness matrix + Kg[m, n] += member_Kg[(a, b)] + + # Return the global geometric stiffness matrix + return Kg + + def Km(self, combo_name='Combo 1', push_combo='Push', step_num=1, log=False, sparse=True): + """Calculates the structure's global plastic reduction matrix, which is used for nonlinear inelastic analysis. + + :param combo_name: The name of the load combination to get the plastic reduction matrix for. Defaults to 'Combo 1'. + :type combo_name: str, optional + :param push_combo: The name of the load combination that contains the pushover load definition. Defaults to 'Push'. + :type push_combo: str, optional + :param step_num: The load step used to generate the plastic reduction matrix. Defaults to 1. + :type step_num: int, optional + :param log: Determines whether this method writes output to the console as it runs. Defaults to False. + :type log: bool, optional + :param sparse: Indicates whether the sparse solver should be used. Defaults to True. + :type sparse: bool, optional + :return: The gloabl plastic reduction matrix. + :rtype: array + """ + + # Determine if a sparse matrix has been requested + if sparse == True: + # The plastic reduction matrix will be stored as a scipy `coo_matrix`. Scipy's documentation states that this type of matrix is ideal for efficient construction of finite element matrices. When converted to another format, the `coo_matrix` sums values at the same (i, j) index. We'll build the matrix from three lists. + row = [] + col = [] + data = [] + else: + # Initialize a dense matrix of zeros + Km = zeros((len(self.nodes)*6, len(self.nodes)*6)) + + # Add stiffness terms for each physical member in the model + if log: print('- Calculating the plastic reduction matrix') + for phys_member in self.members.values(): + + # Check to see if the physical member is active for the given load combination + if phys_member.active[combo_name] == True: + + # Step through each sub-member in the physical member and add terms + for member in phys_member.sub_members.values(): + + # Get the member's global plastic reduction matrix + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + member_Km = member.Km(combo_name, push_combo, step_num) + + # Step through each term in the member's plastic reduction matrix + # 'a' & 'b' below are row/column indices in the member's matrix + # 'm' & 'n' are corresponding row/column indices in the structure's global matrix + for a in range(12): + + # Determine if index 'a' is related to the i-node or j-node + if a < 6: + # Find the corresponding index 'm' in the global plastic reduction matrix + m = member.i_node.ID*6 + a + else: + # Find the corresponding index 'm' in the global plastic reduction matrix + m = member.j_node.ID*6 + (a-6) + + for b in range(12): + + # Determine if index 'b' is related to the i-node or j-node + if b < 6: + # Find the corresponding index 'n' in the global plastic reduction matrix + n = member.i_node.ID*6 + b + else: + # Find the corresponding index 'n' in the global plastic reduction matrix + n = member.j_node.ID*6 + (b-6) + + # Now that 'm' and 'n' are known, place the term in the global plastic reduction matrix + if sparse == True: + row.append(m) + col.append(n) + data.append(member_Km[a, b]) + else: + Km[m, n] += member_Km[a, b] + + if sparse: + # The plastic reduction matrix will be stored as a scipy `coo_matrix`. Scipy's documentation states that this type of matrix is ideal for efficient construction of finite element matrices. When converted to another format, the `coo_matrix` sums values at the same (i, j) index. + from scipy.sparse import coo_matrix + row = array(row) + col = array(col) + data = array(data) + Km = coo_matrix((data, (row, col)), shape=(len(self.nodes)*6, len(self.nodes)*6)) + + # Check that there are no nodal instabilities + # if check_stability: + # if log: print('- Checking nodal stability') + # if sparse: Analysis._check_stability(self, Km.tocsr()) + # else: Analysis._check_stability(self, Km) + + # Return the global plastic reduction matrix + return Km + + def FER(self, combo_name='Combo 1'): + """Assembles and returns the global fixed end reaction vector for any given load combo. + + :param combo_name: The name of the load combination to get the fixed end reaction vector + for. Defaults to 'Combo 1'. + :type combo_name: str, optional + :return: The fixed end reaction vector + :rtype: array + """ + + # Initialize a zero vector to hold all the terms + FER = zeros((len(self.nodes) * 6, 1)) + + # Step through each physical member in the model + for phys_member in self.members.values(): + + # Step through each sub-member and add terms + for member in phys_member.sub_members.values(): + + # Get the member's global fixed end reaction vector + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + member_FER = member.FER(combo_name) + + # Step through each term in the member's fixed end reaction vector + # 'a' below is the row index in the member's fixed end reaction vector + # 'm' below is the corresponding row index in the global fixed end reaction vector + for a in range(12): + + # Determine if index 'a' is related to the i-node or j-node + if a < 6: + # Find the corresponding index 'm' in the global fixed end reaction vector + m = member.i_node.ID * 6 + a + else: + # Find the corresponding index 'm' in the global fixed end reaction vector + m = member.j_node.ID * 6 + (a - 6) + + # Now that 'm' is known, place the term in the global fixed end reaction vector + FER[m, 0] += member_FER[a, 0] + + # Add terms for each rectangle in the model + for plate in self.plates.values(): + + # Get the quadrilateral's global fixed end reaction vector + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + plate_FER = plate.FER(combo_name) + + # Step through each term in the quadrilateral's fixed end reaction vector + # 'a' below is the row index in the quadrilateral's fixed end reaction vector + # 'm' below is the corresponding row index in the global fixed end reaction vector + for a in range(24): + + # Determine if index 'a' is related to the i-node, j-node, m-node, or n-node + if a < 6: + # Find the corresponding index 'm' in the global fixed end reaction vector + m = plate.i_node.ID*6 + a + elif a < 12: + # Find the corresponding index 'm' in the global fixed end reaction vector + m = plate.j_node.ID*6 + (a - 6) + elif a < 18: + # Find the corresponding index 'm' in the global fixed end reaction vector + m = plate.m_node.ID*6 + (a - 12) + else: + # Find the corresponding index 'm' in the global fixed end reaction vector + m = plate.n_node.ID*6 + (a - 18) + + # Now that 'm' is known, place the term in the global fixed end reaction vector + FER[m, 0] += plate_FER[a, 0] + + # Add terms for each quadrilateral in the model + for quad in self.quads.values(): + + # Get the quadrilateral's global fixed end reaction vector + # Storing it as a local variable eliminates the need to rebuild it every time a term is needed + quad_FER = quad.FER(combo_name) + + # Step through each term in the quadrilateral's fixed end reaction vector + # 'a' below is the row index in the quadrilateral's fixed end reaction vector + # 'm' below is the corresponding row index in the global fixed end reaction vector + for a in range(24): + + # Determine if index 'a' is related to the i-node, j-node, m-node, or n-node + if a < 6: + # Find the corresponding index 'm' in the global fixed end reaction vector + m = quad.i_node.ID*6 + a + elif a < 12: + # Find the corresponding index 'm' in the global fixed end reaction vector + m = quad.j_node.ID*6 + (a - 6) + elif a < 18: + # Find the corresponding index 'm' in the global fixed end reaction vector + m = quad.m_node.ID*6 + (a - 12) + else: + # Find the corresponding index 'm' in the global fixed end reaction vector + m = quad.n_node.ID*6 + (a - 18) + + # Now that 'm' is known, place the term in the global fixed end reaction vector + FER[m, 0] += quad_FER[a, 0] + + # Return the global fixed end reaction vector + return FER + + def P(self, combo_name='Combo 1'): + """Assembles and returns the global nodal force vector. + + :param combo_name: The name of the load combination to get the force vector for. Defaults + to 'Combo 1'. + :type combo_name: str, optional + :return: The global nodal force vector. + :rtype: array + """ + + # Initialize a zero vector to hold all the terms + P = zeros((len(self.nodes)*6, 1)) + + # Get the load combination for the given 'combo_name' + combo = self.load_combos[combo_name] + + # Add terms for each node in the model + for node in self.nodes.values(): + + # Get the node's ID + ID = node.ID + + # Step through each load factor in the load combination + for case, factor in combo.factors.items(): + + # Add the node's loads to the global nodal load vector + for load in node.NodeLoads: + + if load[2] == case: + + if load[0] == 'FX': + P[ID*6 + 0, 0] += factor*load[1] + elif load[0] == 'FY': + P[ID*6 + 1, 0] += factor*load[1] + elif load[0] == 'FZ': + P[ID*6 + 2, 0] += factor*load[1] + elif load[0] == 'MX': + P[ID*6 + 3, 0] += factor*load[1] + elif load[0] == 'MY': + P[ID*6 + 4, 0] += factor*load[1] + elif load[0] == 'MZ': + P[ID*6 + 5, 0] += factor*load[1] + + # Return the global nodal force vector + return P + + def D(self, combo_name='Combo 1'): + """Returns the global displacement vector for the model. + + :param combo_name: The name of the load combination to get the results for. Defaults to + 'Combo 1'. + :type combo_name: str, optional + :return: The global displacement vector for the model + :rtype: array + """ + + # Return the global displacement vector + return self._D[combo_name] + + def analyze(self, log=False, check_stability=True, check_statics=False, max_iter=30, sparse=True, combo_tags=None, spring_tolerance=0, member_tolerance=0): + """Performs first-order static analysis. Iterations are performed if tension-only members or compression-only members are present. + + :param log: Prints the analysis log to the console if set to True. Default is False. + :type log: bool, optional + :param check_stability: When set to `True`, checks for nodal instabilities. This slows down analysis a little. Default is `True`. + :type check_stability: bool, optional + :param check_statics: When set to `True`, causes a statics check to be performed + :type check_statics: bool, optional + :param max_iter: The maximum number of iterations to try to get convergence for tension/compression-only analysis. Defaults to 30. + :type max_iter: int, optional + :param sparse: Indicates whether the sparse matrix solver should be used. A matrix can be considered sparse or dense depening on how many zero terms there are. Structural stiffness matrices often contain many zero terms. The sparse solver can offer faster solutions for such matrices. Using the sparse solver on dense matrices may lead to slower solution times. + :type sparse: bool, optional + :raises Exception: _description_ + :raises Exception: _description_ + """ + + if log: + print('+-----------+') + print('| Analyzing |') + print('+-----------+') + + # Import `scipy` features if the sparse solver is being used + if sparse == True: + from scipy.sparse.linalg import spsolve + + # Prepare the model for analysis + Analysis._prepare_model(self) + + # Get the auxiliary list used to determine how the matrices will be partitioned + D1_indices, D2_indices, D2 = Analysis._partition_D(self) + + # Identify which load combinations have the tags the user has given + combo_list = Analysis._identify_combos(self, combo_tags) + + # Step through each load combination + for combo in combo_list: + + if log: + print('') + print('- Analyzing load combination ' + combo.name) + + # Keep track of the number of iterations + iter_count = 1 + convergence = False + divergence = False + + # Iterate until convergence or divergence occurs + while convergence == False and divergence == False: + + # Check for tension/compression-only divergence + if iter_count > max_iter: + divergence = True + raise Exception('Model diverged during tension/compression-only analysis') + + # Get the partitioned global stiffness matrix K11, K12, K21, K22 + if sparse == True: + K11, K12, K21, K22 = Analysis._partition(self, self.K(combo.name, log, check_stability, sparse).tolil(), D1_indices, D2_indices) + else: + K11, K12, K21, K22 = Analysis._partition(self, self.K(combo.name, log, check_stability, sparse), D1_indices, D2_indices) + + # Get the partitioned global fixed end reaction vector + FER1, FER2 = Analysis._partition(self, self.FER(combo.name), D1_indices, D2_indices) + + # Get the partitioned global nodal force vector + P1, P2 = Analysis._partition(self, self.P(combo.name), D1_indices, D2_indices) + + # Calculate the global displacement vector + if log: print('- Calculating global displacement vector') + if K11.shape == (0, 0): + # All displacements are known, so D1 is an empty vector + D1 = [] + else: + try: + # Calculate the unknown displacements D1 + if sparse == True: + # The partitioned stiffness matrix is in `lil` format, which is great for memory, but slow for mathematical operations. The stiffness matrix will be converted to `csr` format for mathematical operations. The `@` operator performs matrix multiplication on sparse matrices. + D1 = spsolve(K11.tocsr(), subtract(subtract(P1, FER1), K12.tocsr() @ D2)) + D1 = D1.reshape(len(D1), 1) + else: + D1 = solve(K11, subtract(subtract(P1, FER1), matmul(K12, D2))) + except: + # Return out of the method if 'K' is singular and provide an error message + raise Exception('The stiffness matrix is singular, which implies rigid body motion. The structure is unstable. Aborting analysis.') + + # Store the calculated displacements to the model and the nodes in the model + Analysis._store_displacements(self, D1, D2, D1_indices, D2_indices, combo) + + # Check for tension/compression-only convergence + convergence = Analysis._check_TC_convergence(self, combo.name, log=log, spring_tolerance=spring_tolerance, member_tolerance=member_tolerance) + + if convergence == False: + + if log: print('- Tension/compression-only analysis did not converge. Adjusting stiffness matrix and reanalyzing.') + else: + if log: print('- Tension/compression-only analysis converged after ' + str(iter_count) + ' iteration(s)') + + # Keep track of the number of tension/compression only iterations + iter_count += 1 + + # Calculate reactions + Analysis._calc_reactions(self, log, combo_tags) + + if log: + print('') + print('- Analysis complete') + print('') + + # Check statics if requested + if check_statics == True: + Analysis._check_statics(self, combo_tags) + + # Flag the model as solved + self.solution = 'Linear TC' + + def analyze_linear(self, log=False, check_stability=True, check_statics=False, sparse=True, combo_tags=None): + """Performs first-order static analysis. This analysis procedure is much faster since it only assembles the global stiffness matrix once, rather than once for each load combination. It is not appropriate when non-linear behavior such as tension/compression only analysis or P-Delta analysis are required. + + :param log: Prints the analysis log to the console if set to True. Default is False. + :type log: bool, optional + :param check_stability: When set to True, checks the stiffness matrix for any unstable degrees of freedom and reports them back to the console. This does add to the solution time. Defaults to True. + :type check_stability: bool, optional + :param check_statics: When set to True, causes a statics check to be performed. Defaults to False. + :type check_statics: bool, optional + :param sparse: Indicates whether the sparse matrix solver should be used. A matrix can be considered sparse or dense depening on how many zero terms there are. Structural stiffness matrices often contain many zero terms. The sparse solver can offer faster solutions for such matrices. Using the sparse solver on dense matrices may lead to slower solution times. Be sure ``scipy`` is installed to use the sparse solver. Default is True. + :type sparse: bool, optional + :raises Exception: Occurs when a singular stiffness matrix is found. This indicates an unstable structure has been modeled. + """ + + if log: + print('+-------------------+') + print('| Analyzing: Linear |') + print('+-------------------+') + + # Import `scipy` features if the sparse solver is being used + if sparse == True: + from scipy.sparse.linalg import spsolve + + # Prepare the model for analysis + Analysis._prepare_model(self) + + # Get the auxiliary list used to determine how the matrices will be partitioned + D1_indices, D2_indices, D2 = Analysis._partition_D(self) + + # Get the partitioned global stiffness matrix K11, K12, K21, K22 + # Note that for linear analysis the stiffness matrix can be obtained for any load combination, as it's the same for all of them + combo_name = list(self.load_combos.keys())[0] + if sparse == True: + K11, K12, K21, K22 = Analysis._partition(self, self.K(combo_name, log, check_stability, sparse).tolil(), D1_indices, D2_indices) + else: + K11, K12, K21, K22 = Analysis._partition(self, self.K(combo_name, log, check_stability, sparse), D1_indices, D2_indices) + + # Identify which load combinations have the tags the user has given + combo_list = Analysis._identify_combos(self, combo_tags) + + # Step through each load combination + for combo in combo_list: + + if log: + print('') + print('- Analyzing load combination ' + combo.name) + + # Get the partitioned global fixed end reaction vector + FER1, FER2 = Analysis._partition(self, self.FER(combo.name), D1_indices, D2_indices) + + # Get the partitioned global nodal force vector + P1, P2 = Analysis._partition(self, self.P(combo.name), D1_indices, D2_indices) + + # Calculate the global displacement vector + if log: print('- Calculating global displacement vector') + if K11.shape == (0, 0): + # All displacements are known, so D1 is an empty vector + D1 = [] + else: + try: + # Calculate the unknown displacements D1 + if sparse == True: + # The partitioned stiffness matrix is in `lil` format, which is great + # for memory, but slow for mathematical operations. The stiffness + # matrix will be converted to `csr` format for mathematical operations. + # The `@` operator performs matrix multiplication on sparse matrices. + D1 = spsolve(K11.tocsr(), subtract(subtract(P1, FER1), K12.tocsr() @ D2)) + D1 = D1.reshape(len(D1), 1) + else: + D1 = solve(K11, subtract(subtract(P1, FER1), matmul(K12, D2))) + except: + # Return out of the method if 'K' is singular and provide an error message + raise Exception('The stiffness matrix is singular, which implies rigid body motion. The structure is unstable. Aborting analysis.') + + # Store the calculated displacements to the model and the nodes in the model + Analysis._store_displacements(self, D1, D2, D1_indices, D2_indices, combo) + + # Calculate reactions + Analysis._calc_reactions(self, log, combo_tags) + + if log: + print('') + print('- Analysis complete') + print('') + + # Check statics if requested + if check_statics == True: + Analysis._check_statics(self, combo_tags) + + # Flag the model as solved + self.solution = 'Linear' + + def analyze_PDelta(self, log=False, check_stability=True, max_iter=30, sparse=True, combo_tags=None): + """Performs second order (P-Delta) analysis. This type of analysis is appropriate for most models using beams, columns and braces. Second order analysis is usually required by material specific codes. The analysis is iterative and takes longer to solve. Models with slender members and/or members with combined bending and axial loads will generally have more significant P-Delta effects. P-Delta effects in plates/quads are not considered. + + :param log: Prints updates to the console if set to True. Default is False. + :type log: bool, optional + :param check_stability: When set to True, checks the stiffness matrix for any unstable degrees of freedom and reports them back to the console. This does add to the solution time. Defaults to True. + :type check_stability: bool, optional + :param max_iter: The maximum number of iterations permitted. If this value is exceeded the program will report divergence. Defaults to 30. + :type max_iter: int, optional + :param sparse: Indicates whether the sparse matrix solver should be used. A matrix can be considered sparse or dense depening on how many zero terms there are. Structural stiffness matrices often contain many zero terms. The sparse solver can offer faster solutions for such matrices. Using the sparse solver on dense matrices may lead to slower solution times. Be sure ``scipy`` is installed to use the sparse solver. Default is True. + :type sparse: bool, optional + :raises ValueError: Occurs when there is a singularity in the stiffness matrix, which indicates an unstable structure. + :raises Exception: Occurs when a model fails to converge. + """ + + if log: + print('+--------------------+') + print('| Analyzing: P-Delta |') + print('+--------------------+') + + # Import `scipy` features if the sparse solver is being used + if sparse == True: + from scipy.sparse.linalg import spsolve + + # Prepare the model for analysis + Analysis._prepare_model(self) + + # Get the auxiliary list used to determine how the matrices will be partitioned + D1_indices, D2_indices, D2 = Analysis._partition_D(self) + + # Identify which load combinations have the tags the user has given + combo_list = Analysis._identify_combos(self, combo_tags) + + # Step through each load combination + for combo in combo_list: + + # Get the partitioned global fixed end reaction vector + FER1, FER2 = Analysis._partition(self, self.FER(combo.name), D1_indices, D2_indices) + + # Get the partitioned global nodal force vector + P1, P2 = Analysis._partition(self, self.P(combo.name), D1_indices, D2_indices) + + # Run the P-Delta analysis for this load combination + Analysis._PDelta_step(self, combo.name, P1, FER1, D1_indices, D2_indices, D2, log, sparse, check_stability, max_iter, True) + + # Calculate reactions + Analysis._calc_reactions(self, log, combo_tags) + + if log: + print('') + print('- Analysis complete') + print('') + + # Flag the model as solved + self.solution = 'P-Delta' + + def _not_ready_yet_analyze_pushover(self, log=False, check_stability=True, push_combo='Push', max_iter=30, tol=0.01, sparse=True, combo_tags=None): + + if log: + print('+---------------------+') + print('| Analyzing: Pushover |') + print('+---------------------+') + + # Import `scipy` features if the sparse solver is being used + if sparse == True: + from scipy.sparse.linalg import spsolve + + # Prepare the model for analysis + Analysis._prepare_model(self) + + # Get the auxiliary list used to determine how the matrices will be partitioned + D1_indices, D2_indices, D2 = Analysis._partition_D(self) + + # Identify and tag the primary load combinations the pushover load will be added to + for combo in self.load_combos.values(): + + # No need to tag the pushover combo + if combo.name != push_combo: + + # Add 'primary' to the combo's tags if it's not already there + if combo.combo_tags is None: + combo.combo_tags = ['primary'] + elif 'primary' not in combo.combo_tags: + combo.combo_tags.append('primary') + + # Identify which load combinations have the tags the user has given + # TODO: Remove the pushover combo istelf from `combo_list` + combo_list = Analysis._identify_combos(self, combo_tags) + combo_list = [combo for combo in combo_list if combo.name != push_combo] + + # Step through each load combination + for combo in combo_list: + + # Skip the pushover combo + if combo.name == push_combo: + continue + + if log: + print('') + print('- Analyzing load combination ' + combo.name) + + # Reset nonlinear material member end forces to zero + for member in self.members.values(): + member._fxi, member._myi, member._mzi = 0, 0, 0 + member._fxj, member._myj, member._mzj = 0, 0, 0 + + # Get the pushover load step and initialize the load factor + load_step = list(self.load_combos[push_combo].factors.values())[0] + load_factor = load_step + step_num = 1 + + # Get the partitioned global fixed end reaction vector + FER1, FER2 = Analysis._partition(self, self.FER(combo.name), D1_indices, D2_indices) + + # Get the partitioned global nodal force vector + P1, P2 = Analysis._partition(self, self.P(combo.name), D1_indices, D2_indices) + + # Get the partitioned global fixed end reaction vector for a pushover load increment + FER1_push, FER2_push = Analysis._partition(self, self.FER(push_combo), D1_indices, D2_indices) + + # Get the partitioned global nodal force vector for a pushover load increment + P1_push, P2_push = Analysis._partition(self, self.P(push_combo), D1_indices, D2_indices) + + # Solve the current load combination without the pushover load applied + Analysis._PDelta_step(self, combo.name, P1, FER1, D1_indices, D2_indices, D2, log, sparse, check_stability, max_iter, first_step=True) + + # Since a P-Delta analysis was just run, we'll need to correct the solution to flag it + # as 'pushover' instead of 'PDelta' + self.solution = 'Pushover' + + # Apply the pushover load in steps, summing deformations as we go, until the full + # pushover load has been analyzed + while load_factor <= 1: + + # Inform the user which pushover load step we're on + if log: + print('- Beginning pushover load step #' + str(step_num)) + + # Reset all member plastic load reversal flags to be `False` + for member in self.members.values(): + member.pl_reverse = False + + # Run/rerun this load step until no new unloaded member flags exist + run_step = True + while run_step == True: + + # Assume this iteration will converge + run_step = False + + # Store the model's current displacements in case we need to revert + D_temp = self._D + + # Run or rerun the next pushover load step + d_Delta = Analysis._pushover_step(self, combo.name, push_combo, step_num, P1_push, FER1_push, D1_indices, D2_indices, D2, log, sparse, check_stability) + + # Update nonlinear material member end forces for each member + for member in self.members.values(): + member._fxi = member.f(combo.name, push_combo, step_num)[0, 0] + member._myi = member.f(combo.name, push_combo, step_num)[4, 0] + member._mzi = member.f(combo.name, push_combo, step_num)[5, 0] + member._fxj = member.f(combo.name, push_combo, step_num)[6, 0] + member._myj = member.f(combo.name, push_combo, step_num)[10, 0] + member._mzj = member.f(combo.name, push_combo, step_num)[11, 0] + + # Move on to the next load step + load_factor += load_step + step_num += 1 + + # Calculate reactions for every primary load combination + Analysis._calc_reactions(self, log, combo_tags=['primary']) + + if log: + print('') + print('- Analysis complete') + print('') + + # Flag the model as solved + self.solution = 'Pushover' + + def unique_name(self, dictionary, prefix): + """Returns the next available unique name for a dictionary of objects. + + :param dictionary: The dictionary to get a unique name for. + :type dictionary: dict + :param prefix: The prefix to use for the unique name. + :type prefix: str + :return: A unique name for the dictionary. + :rtype: str + """ + + # Select a trial value for the next available name + name = prefix + str(len(dictionary) + 1) + i = 2 + while name in dictionary.keys(): + name = prefix + str(len(dictionary) + i) + i += 1 + + # Return the next available name + return name + + def rename(self): + """ + Renames all the nodes and elements in the model. + """ + + # Rename each node in the model + temp = self.nodes.copy() + id = 1 + for old_key in temp.keys(): + new_key = 'N' + str(id) + self.nodes[new_key] = self.nodes.pop(old_key) + self.nodes[new_key].name = new_key + id += 1 + + # Rename each spring in the model + temp = self.springs.copy() + id = 1 + for old_key in temp.keys(): + new_key = 'S' + str(id) + self.springs[new_key] = self.springs.pop(old_key) + self.springs[new_key].name = new_key + id += 1 + + # Rename each member in the model + temp = self.members.copy() + id = 1 + for old_key in temp.keys(): + new_key = 'M' + str(id) + self.members[new_key] = self.members.pop(old_key) + self.members[new_key].name = new_key + id += 1 + + # Rename each plate in the model + temp = self.plates.copy() + id = 1 + for old_key in temp.keys(): + new_key = 'P' + str(id) + self.plates[new_key] = self.plates.pop(old_key) + self.plates[new_key].name = new_key + id += 1 + + # Rename each quad in the model + temp = self.quads.copy() + id = 1 + for old_key in temp.keys(): + new_key = 'Q' + str(id) + self.quads[new_key] = self.quads.pop(old_key) + self.quads[new_key].name = new_key + id += 1 + + def orphaned_nodes(self): + """ + Returns a list of the names of nodes that are not attached to any elements. + """ + + # Initialize a list of orphaned nodes + orphans = [] + + # Step through each node in the model + for node in self.nodes.values(): + + orphaned = False + + # Check to see if the node is attached to any elements + quads = [quad.name for quad in self.quads.values() if quad.i_node == node or quad.j_node == node or quad.m_node == node or quad.n_node == node] + plates = [plate.name for plate in self.plates.values() if plate.i_node == node or plate.j_node == node or plate.m_node == node or plate.n_node == node] + members = [member.name for member in self.members.values() if member.i_node == node or member.j_node == node] + springs = [spring.name for spring in self.springs.values() if spring.i_node == node or spring.j_node == node] + + # Determine if the node is orphaned + if quads == [] and plates == [] and members == [] and springs == []: + orphaned = True + + # Add the orphaned nodes to the list of orphaned nodes + if orphaned == True: + orphans.append(node.name) + + return orphans + diff --git a/Old Pynite Folder/FixedEndReactions.py b/Old Pynite Folder/FixedEndReactions.py new file mode 100644 index 00000000..237cd69b --- /dev/null +++ b/Old Pynite Folder/FixedEndReactions.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +""" +Created on Fri Nov 3 20:58:03 2017 + +@author: D. Craig Brinck, SE +""" +# %% +from numpy import zeros + +# %% +def FER_PtLoad(P, x, L, Direction): + """ + Returns the fixed end reaction vector for a point load + + Parameters: + ----------- + P : number + The magnitude of the point load + x : number + The location of the point load relative to the start of the member + L : number + The length of the member + Direction : string + The direction of the point load. Must be one of the following: + "Fy" = Force on the member's local y-axis + "Fz" = Force on the member's local z-axis + """ + # Define variables + b = L - x + + # Create the fixed end reaction vector + FER = zeros((12, 1)) + + # Populate the fixed end reaction vector + if Direction == "Fy": + FER[1, 0] = -P*b**2*(L+2*x)/L**3 + FER[5, 0] = -P*x*b**2/L**2 + FER[7, 0] = -P*x**2*(L+2*b)/L**3 + FER[11, 0] = P*x**2*b/L**2 + elif Direction == "Fz": + FER[2, 0] = -P*b**2*(L+2*x)/L**3 + FER[4, 0] = P*x*b**2/L**2 + FER[8, 0] = -P*x**2*(L+2*b)/L**3 + FER[10, 0] = -P*x**2*b/L**2 + + return FER + +# %% +def FER_Moment(M, x, L, Direction): + """ + Returns the fixed end reaction vector for a concentrated moment + + Parameters + ---------- + M : number + The magnitude of the moment + x : number + The location of the moment relative to the start of the member + Direction : string + The direction of the moment. Must be one of the following: + "My" = Moment applied about the local y-axis + "Mz" = Moment applied about the local z-axis + """ + + # Define variables + b = L - x + + # Create the fixed end reaction vector + FER = zeros((12, 1)) + + # Populate the fixed end reaction vector + if Direction == "Mz": + FER[1, 0] = 6*M*x*b/L**3 + FER[5, 0] = M*b*(2*x-b)/L**2 + FER[7, 0] = -6*M*x*b/L**3 + FER[11, 0] = M*x*(2*b-x)/L**2 + elif Direction == "My": + FER[2, 0] = -6*M*x*b/L**3 + FER[4, 0] = M*b*(2*x-b)/L**2 + FER[8, 0] = 6*M*x*b/L**3 + FER[10, 0] = M*x*(2*b-x)/L**2 + return FER + +# %% +# Returns the fixed end reaction vector for a linear distributed load +def FER_LinLoad(w1, w2, x1, x2, L, Direction): + + # Create the fixed end reaction vector + FER = zeros((12, 1)) + + # Populate the fixed end reaction vector + if Direction == 'Fy': + FER[1, 0] = (x1 - x2)*(10*L**3*w1 + 10*L**3*w2 - 15*L*w1*x1**2 - 10*L*w1*x1*x2 - 5*L*w1*x2**2 - 5*L*w2*x1**2 - 10*L*w2*x1*x2 - 15*L*w2*x2**2 + 8*w1*x1**3 + 6*w1*x1**2*x2 + 4*w1*x1*x2**2 + 2*w1*x2**3 + 2*w2*x1**3 + 4*w2*x1**2*x2 + 6*w2*x1*x2**2 + 8*w2*x2**3)/(20*L**3) + FER[5, 0] = (x1 - x2)*(20*L**2*w1*x1 + 10*L**2*w1*x2 + 10*L**2*w2*x1 + 20*L**2*w2*x2 - 30*L*w1*x1**2 - 20*L*w1*x1*x2 - 10*L*w1*x2**2 - 10*L*w2*x1**2 - 20*L*w2*x1*x2 - 30*L*w2*x2**2 + 12*w1*x1**3 + 9*w1*x1**2*x2 + 6*w1*x1*x2**2 + 3*w1*x2**3 + 3*w2*x1**3 + 6*w2*x1**2*x2 + 9*w2*x1*x2**2 + 12*w2*x2**3)/(60*L**2) + FER[7, 0] = -(x1 - x2)*(-15*L*w1*x1**2 - 10*L*w1*x1*x2 - 5*L*w1*x2**2 - 5*L*w2*x1**2 - 10*L*w2*x1*x2 - 15*L*w2*x2**2 + 8*w1*x1**3 + 6*w1*x1**2*x2 + 4*w1*x1*x2**2 + 2*w1*x2**3 + 2*w2*x1**3 + 4*w2*x1**2*x2 + 6*w2*x1*x2**2 + 8*w2*x2**3)/(20*L**3) + FER[11, 0] = (x1 - x2)*(-15*L*w1*x1**2 - 10*L*w1*x1*x2 - 5*L*w1*x2**2 - 5*L*w2*x1**2 - 10*L*w2*x1*x2 - 15*L*w2*x2**2 + 12*w1*x1**3 + 9*w1*x1**2*x2 + 6*w1*x1*x2**2 + 3*w1*x2**3 + 3*w2*x1**3 + 6*w2*x1**2*x2 + 9*w2*x1*x2**2 + 12*w2*x2**3)/(60*L**2) + elif Direction == 'Fz': + FER[2, 0] = (x1 - x2)*(10*L**3*w1 + 10*L**3*w2 - 15*L*w1*x1**2 - 10*L*w1*x1*x2 - 5*L*w1*x2**2 - 5*L*w2*x1**2 - 10*L*w2*x1*x2 - 15*L*w2*x2**2 + 8*w1*x1**3 + 6*w1*x1**2*x2 + 4*w1*x1*x2**2 + 2*w1*x2**3 + 2*w2*x1**3 + 4*w2*x1**2*x2 + 6*w2*x1*x2**2 + 8*w2*x2**3)/(20*L**3) + FER[4, 0] = -(x1 - x2)*(20*L**2*w1*x1 + 10*L**2*w1*x2 + 10*L**2*w2*x1 + 20*L**2*w2*x2 - 30*L*w1*x1**2 - 20*L*w1*x1*x2 - 10*L*w1*x2**2 - 10*L*w2*x1**2 - 20*L*w2*x1*x2 - 30*L*w2*x2**2 + 12*w1*x1**3 + 9*w1*x1**2*x2 + 6*w1*x1*x2**2 + 3*w1*x2**3 + 3*w2*x1**3 + 6*w2*x1**2*x2 + 9*w2*x1*x2**2 + 12*w2*x2**3)/(60*L**2) + FER[8, 0] = -(x1 - x2)*(-15*L*w1*x1**2 - 10*L*w1*x1*x2 - 5*L*w1*x2**2 - 5*L*w2*x1**2 - 10*L*w2*x1*x2 - 15*L*w2*x2**2 + 8*w1*x1**3 + 6*w1*x1**2*x2 + 4*w1*x1*x2**2 + 2*w1*x2**3 + 2*w2*x1**3 + 4*w2*x1**2*x2 + 6*w2*x1*x2**2 + 8*w2*x2**3)/(20*L**3) + FER[10, 0] = -(x1 - x2)*(-15*L*w1*x1**2 - 10*L*w1*x1*x2 - 5*L*w1*x2**2 - 5*L*w2*x1**2 - 10*L*w2*x1*x2 - 15*L*w2*x2**2 + 12*w1*x1**3 + 9*w1*x1**2*x2 + 6*w1*x1*x2**2 + 3*w1*x2**3 + 3*w2*x1**3 + 6*w2*x1**2*x2 + 9*w2*x1*x2**2 + 12*w2*x2**3)/(60*L**2) + + return FER + +# %% +# Returns the fixed end reaction vector for an axial point load +def FER_AxialPtLoad(P, x, L): + + # Create the fixed end reaction vector + FER = zeros((12, 1)) + + # Populate the fixed end reaction vector + FER[0, 0] = -P*(L-x)/L + FER[6, 0] = -P*x/L + + return FER + +# %% +# Returns the fixed end reaction vector for a distributed axial load +def FER_AxialLinLoad(p1, p2, x1, x2, L): + + # Create the fixed end reaction vector + FER = zeros((12, 1)) + + # Populate the fixed end reaction vector + FER[0, 0] = 1/(6*L)*(x1-x2)*(3*L*p1+3*L*p2-2*p1*x1-p1*x2-p2*x1-2*p2*x2) + FER[6, 0] = 1/(6*L)*(x1-x2)*(2*p1*x1+p1*x2+p2*x1+2*p2*x2) + + return FER + +def FER_Torque(T, x, L): + """ + Returns the fixed end reaction vector for a concentrated torque + + Parameters + ---------- + T : number + The magnitude of the torque + x : number + The location of the torque relative to the start of the member + """ + + # Create the fixed end reaction vector + FER = zeros((12, 1)) + + # Populate the fixed end reaction vector + FER[3, 0] = -T*(L - x)/L + FER[9, 0] = -T*x/L + + return FER diff --git a/Old Pynite Folder/LoadCombo.py b/Old Pynite Folder/LoadCombo.py new file mode 100644 index 00000000..2e2d0ae3 --- /dev/null +++ b/Old Pynite Folder/LoadCombo.py @@ -0,0 +1,32 @@ +class LoadCombo(): + """A class that stores all the information necessary to define a load combination. + """ + + def __init__(self, name, combo_tags=None, factors={}): + """Initializes a new load combination. + + :param name: A unique name for the load combination. + :type name: str + :param combo_tags: A list of tags for the load combination. This is a list of any strings you would like to use to categorize your load combinations. It is useful for separating load combinations into strength, service, or overstrength combinations as often required by building codes. This parameter has no effect on the analysis, but it can be used to restrict analysis to only the load combinations with the tags you specify. + :type combo_tags: list, optional + :param factors: A dictionary of load case names (`keys`) followed by their load factors (`items`). For example, the load combination 1.2D+1.6L would be represented as follows: `{'D': 1.2, 'L': 1.6}`. Defaults to {}. + :type factors: dict, optional + """ + + self.name = name # A unique user-defined name for the load combination + self.combo_tags = combo_tags # Used to categorize the load combination (e.g. strength or serviceability) + self.factors = factors # A dictionary containing each load case name and associated load factor + + def AddLoadCase(self, case_name, factor): + ''' + Adds a load case with its associated load factor + ''' + + self.factors[case_name] = factor + + def DeleteLoadCase(self, case_name): + ''' + Deletes a load case with its associated load factor + ''' + + del self.factors[case_name] diff --git a/Old Pynite Folder/MainStyleSheet.css b/Old Pynite Folder/MainStyleSheet.css new file mode 100644 index 00000000..ea0f5733 --- /dev/null +++ b/Old Pynite Folder/MainStyleSheet.css @@ -0,0 +1,120 @@ +* { + box-sizing: border-box; +} + +html, body { + width: 100vw; + height: 100vh; + margin: 0; + padding: 0; +} + +body { + background-color: white; + margin: 0; + font-family: Arial, Helvetica, sans-serif; + display: grid; + grid-template-rows: auto 1fr auto; + grid-template-columns: auto 1fr auto; +} + +canvas { + border: 1px solid black; +} + +header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-content: center; + background-color: #314C53; + padding: 0; + grid-column: 1 / 4; +} + +header img { + width: 300px; + border: none; +} + +header h1 { + width: auto; + height: auto; + right: 20px; + margin: auto 20px auto 0; +} + +img +{ + background-color: #94BBC5; + color: #314C53; +} + +main { + overflow: scroll; + padding: 12px; +} + +h1 { + margin: 0; + width: 100%; +} + +h2 { + width: 100%; + margin: 0; + padding: 3px; + font-size: 16px; +} + +img { + margin: 6px 0px; + border: 1px solid black; +} + +/* Table Formatting */ +table { + padding: 6px 0; + border: 1px solid black; + border-collapse: collapse; + overflow: scroll; +} + +/* Table headers will be dark with light text */ +th { + background-color: #314C53; + color: #DAE9EE; +} + +/* For 2-row table headers, apply a light line to separate the two rows */ +tr:last-child th { + border-top: 1px solid #DAE9EE; +} + +/* Table data borders will match the table header background */ +td { + border-bottom: 1px solid #314C53 +} + +th, td { + margin: 0px; + padding: 5px; + text-align: center; + vertical-align: center; + font-size: 14px; +} + +/* Every odd row of table data will have a white background */ +tr { + background-color: white; +} + +/* Every even row of table data will be off-white */ +tr:nth-child(even) { + background-color: #f2f2f2; +} + +/* Make the upper border of the first data row blend in with the table header */ +tr:first-child td { + border-top: 1px solid #314C53; +} \ No newline at end of file diff --git a/Old Pynite Folder/Material.py b/Old Pynite Folder/Material.py new file mode 100644 index 00000000..ec1885d9 --- /dev/null +++ b/Old Pynite Folder/Material.py @@ -0,0 +1,17 @@ +from typing import Optional + +class Material(): + """ + A class representing a material assigned to a Member3D, Plate or Quad in a finite element model. + + This class stores all properties related to the physical material of the element + """ + def __init__(self, model, name:str, E:float, G:float, nu:float, rho:float, fy:Optional[float] = None): + + self.model = model + self.name = name + self.E = E + self.G = G + self.nu = nu + self.rho = rho + self.fy = fy \ No newline at end of file diff --git a/Old Pynite Folder/Member3D.py b/Old Pynite Folder/Member3D.py new file mode 100644 index 00000000..3af19d35 --- /dev/null +++ b/Old Pynite Folder/Member3D.py @@ -0,0 +1,2207 @@ +# %% +from numpy import array, zeros, add, subtract, matmul, insert, cross, divide, linspace, vstack, hstack, allclose, where, radians, sin, cos +from numpy.linalg import inv, pinv +from math import isclose +from Pynite.BeamSegZ import BeamSegZ +from Pynite.BeamSegY import BeamSegY +import Pynite.FixedEndReactions +import warnings + +#%% +class Member3D(): + """ + A class representing a 3D frame element in a finite element model. + + Most users will not need to interface with this class directly. Rather, the physical member + class, which inherits from this class and stitches together a seires of colinear `Member3D` + objects will be more useful. + """ + + # '__plt' is used to store the 'pyplot' from matplotlib once it gets imported. Setting it to 'None' for now allows us to defer importing it until it's actually needed. + __plt = None + +#%% + def __init__(self, model, name, i_node, j_node, material_name, section_name, rotation=0.0, + tension_only=False, comp_only=False): + """ + Initializes a new member. + """ + self.name = name # A unique name for the member given by the user + self.ID = None # Unique index number for the member assigned by the program + self.i_node = i_node # The element's i-node + self.j_node = j_node # The element's j-node + + try: + self.material = model.materials[material_name] # The element's material + except KeyError: + raise NameError(f"No material named '{material_name}'") + + try: + self.section = model.sections[section_name] # The element's section + except KeyError: + raise NameError(f"No section names '{section_name}'") + + # Variables used to track nonlinear material member end forces + self._fxi = 0 + self._myi = 0 + self._mzi= 0 + self._fxj = 0 + self._myj = 0 + self._mzj = 0 + + # Variable used to track plastic load reveral + self.i_reversal = False + self.j_reversal = False + + self.rotation = rotation # Member rotation (degrees) about its local x-axis + self.PtLoads = [] # A list of point loads & moments applied to the element (Direction, P, x, case='Case 1') or (Direction, M, x, case='Case 1') + self.DistLoads = [] # A list of linear distributed loads applied to the element (Direction, w1, w2, x1, x2, case='Case 1') + self.SegmentsZ = [] # A list of mathematically continuous beam segments for z-bending + self.SegmentsY = [] # A list of mathematically continuous beam segments for y-bending + self.SegmentsX = [] # A list of mathematically continuous beam segments for torsion + self.Releases = [False, False, False, False, False, False, False, False, False, False, False, False] + self.tension_only = tension_only # Indicates whether the member is tension-only + self.comp_only = comp_only # Indicates whether the member is compression-only + + # Members need to track whether they are active or not for any given load combination. They may become inactive for a load combination during a tension/compression-only analysis. This dictionary will be used when the model is solved. + self.active = {} # Key = load combo name, Value = True or False + + # The 'Member3D' object will store results for one load combination at a time. To reduce repetative calculations the '_solved_combo' variable will be used to track whether the member needs to be resegmented before running calculations for any given load combination. + self._solved_combo = None # The current solved load combination + + # Members need a link to the model they belong to + self.model = model + +#%% + def L(self): + """ + Returns the length of the member. + """ + + # Return the distance between the two nodes + return self.i_node.distance(self.j_node) + +#%% + def _partition_D(self): + """ + Builds lists of unreleased and released degree of freedom indices for the member. + + Returns + ------- + R1_indices : list + A list of the indices for the unreleased DOFs + R2_indices : list + A list of the indices for the released DOFs + """ + + R1_indices = [] + R2_indices = [] + for i in range(12): + if self.Releases[i] == False: + R1_indices.append(i) + else: + R2_indices.append(i) + + return R1_indices, R2_indices + +#%% + def k(self): + """ + Returns the condensed (and expanded) local stiffness matrix for the member. + """ + + # Partition the local stiffness matrix as 4 submatrices in + # preparation for static condensation + k11, k12, k21, k22 = self._partition(self._k_unc()) + + # Calculate the condensed local stiffness matrix + k_Condensed = subtract(k11, matmul(matmul(k12, inv(k22)), k21)) + + # Expand the condensed local stiffness matrix + i=0 + for DOF in self.Releases: + + if DOF == True: + k_Condensed = insert(k_Condensed, i, 0, axis = 0) + k_Condensed = insert(k_Condensed, i, 0, axis = 1) + + i += 1 + + # Return the local stiffness matrix, with end releases applied + return k_Condensed + +#%% + def _k_unc(self): + """ + Returns the uncondensed local stiffness matrix for the member. + """ + + # Get the properties needed to form the local stiffness matrix + E = self.material.E + G = self.material.G + Iy = self.section.Iy + Iz = self.section.Iz + J = self.section.J + A = self.section.A + L = self.L() + + # Create the uncondensed local stiffness matrix + k = array([[A*E/L, 0, 0, 0, 0, 0, -A*E/L, 0, 0, 0, 0, 0], + [0, 12*E*Iz/L**3, 0, 0, 0, 6*E*Iz/L**2, 0, -12*E*Iz/L**3, 0, 0, 0, 6*E*Iz/L**2], + [0, 0, 12*E*Iy/L**3, 0, -6*E*Iy/L**2, 0, 0, 0, -12*E*Iy/L**3, 0, -6*E*Iy/L**2, 0], + [0, 0, 0, G*J/L, 0, 0, 0, 0, 0, -G*J/L, 0, 0], + [0, 0, -6*E*Iy/L**2, 0, 4*E*Iy/L, 0, 0, 0, 6*E*Iy/L**2, 0, 2*E*Iy/L, 0], + [0, 6*E*Iz/L**2, 0, 0, 0, 4*E*Iz/L, 0, -6*E*Iz/L**2, 0, 0, 0, 2*E*Iz/L], + [-A*E/L, 0, 0, 0, 0, 0, A*E/L, 0, 0, 0, 0, 0], + [0, -12*E*Iz/L**3, 0, 0, 0, -6*E*Iz/L**2, 0, 12*E*Iz/L**3, 0, 0, 0, -6*E*Iz/L**2], + [0, 0, -12*E*Iy/L**3, 0, 6*E*Iy/L**2, 0, 0, 0, 12*E*Iy/L**3, 0, 6*E*Iy/L**2, 0], + [0, 0, 0, -G*J/L, 0, 0, 0, 0, 0, G*J/L, 0, 0], + [0, 0, -6*E*Iy/L**2, 0, 2*E*Iy/L, 0, 0, 0, 6*E*Iy/L**2, 0, 4*E*Iy/L, 0], + [0, 6*E*Iz/L**2, 0, 0, 0, 2*E*Iz/L, 0, -6*E*Iz/L**2, 0, 0, 0, 4*E*Iz/L]]) + + # Return the uncondensed local stiffness matrix + return k + +#%% + def kg(self, P=0): + """ + Returns the condensed (expanded) local geometric stiffness matrix for the member. + + Parameters + ---------- + P : number, optional + The axial force acting on the member (compression = +, tension = -) + """ + + # Get the properties needed to form the local geometric stiffness matrix + Ip = self.section.Iy + self.section.Iz + A = self.section.A + L = self.L() + + # Create the uncondensed local geometric stiffness matrix + kg = array([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 6/5, 0, 0, 0, L/10, 0, -6/5, 0, 0, 0, L/10], + [0, 0, 6/5, 0, -L/10, 0, 0, 0, -6/5, 0, -L/10, 0], + [0, 0, 0, Ip/A, 0, 0, 0, 0, 0, -Ip/A, 0, 0], + [0, 0, -L/10, 0, 2*L**2/15, 0, 0, 0, L/10, 0, -L**2/30, 0], + [0, L/10, 0, 0, 0, 2*L**2/15, 0, -L/10, 0, 0, 0, -L**2/30], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, -6/5, 0, 0, 0, -L/10, 0, 6/5, 0, 0, 0, -L/10], + [0, 0, -6/5, 0, L/10, 0, 0, 0, 6/5, 0, L/10, 0], + [0, 0, 0, -Ip/A, 0, 0, 0, 0, 0, Ip/A, 0, 0], + [0, 0, -L/10, 0, -L**2/30, 0, 0, 0, L/10, 0, 2*L**2/15, 0], + [0, L/10, 0, 0, 0, -L**2/30, 0, -L/10, 0, 0, 0, 2*L**2/15]]) + + kg = kg*P/L + + # Partition the geometric stiffness matrix as 4 submatrices in + # preparation for static condensation + kg11, kg12, kg21, kg22 = self._partition(kg) + + # Calculate the condensed local geometric stiffness matrix + # Note that a matrix of zeros cannot be inverted, so if P is 0 an error will occur + if isclose(P, 0.0): + kg_Condensed = zeros(kg11.shape) + else: + kg_Condensed = subtract(kg11, matmul(matmul(kg12, inv(kg22)), kg21)) + + # Expand the condensed local geometric stiffness matrix + i=0 + for DOF in self.Releases: + + if DOF == True: + kg_Condensed = insert(kg_Condensed, i, 0, axis = 0) + kg_Condensed = insert(kg_Condensed, i, 0, axis = 1) + + i += 1 + + # Return the local geomtric stiffness matrix, with end releases applied + return kg_Condensed + + def km(self, combo_name='Combo 1', push_combo='Push', step_num=1): + """Returns the local plastic reduction matrix for the element. + + :param combo_name: The name of the load combination to get the plastic reduction matrix for. Defaults to 'Combo 1'. + :type combo_name: str, optional + :param push_combo: The name of the load combination that defines the pushover load. Defaults to 'Push'. + :type push_combo: str, optional + :param step_num: The pushover load step to consider for calculating the plastic reduciton matrix. Default is 1. + :type step_num: int, optional + :return: The plastic reduction matrix for the element + :rtype: array + """ + + # Get the elastic local stiffness matrix + ke = self.k() + + # Get the member's axial force + P = self._fxj - self._fxi + + # Get the geometric local stiffness matrix + kg = self.kg(P) + + # Get the total elastic local stiffness matrix + ke = add(ke, kg) + + # Get the gradient to the failure surface at at each end of the element + if self.section is None: + raise Exception('Nonlinear material analysis requires member sections to be defined. A section definition is missing for element ' + self.name + '.') + else: + Gi = self.section.G(self._fxi, self._myi, self._mzi) + Gj = self.section.G(self._fxj, self._myj, self._mzj) + + # Expand the gradients for a 12 degree of freedom element + zeros_array = zeros((6, 1)) + Gi = vstack((Gi, zeros_array)) + Gj = vstack((zeros_array, Gj)) + G = hstack((Gi, Gj)) + + # Calculate the plastic reduction matrix for each end of the element + # TODO: Note that `ke` below already accounts for P-Delta effects and any member end releases which should spill into `km`. I believe end releases will resolve themselves because of this. We'll see how this tests when we get to testing. If it causes problems when end releases are applied we may need to adjust our calculation of G when end releases are present. + # Check that G is not a zero matrix, which indicates no plastic behavior + if allclose(G, 0, atol=1e-14): + return zeros((12, 12)) + else: + return -ke @ G @ pinv(G.T @ ke @ G) @ G.T @ ke + + def lamb(self, model_Delta_D, combo_name='Combo 1', push_combo='Push', step_num=1): + + # Obtain the change in the member's end displacements from the calculated displacement change vector + Delta_D = array([model_Delta_D[self.i_node.ID*6 + 0], + model_Delta_D[self.i_node.ID*6 + 1], + model_Delta_D[self.i_node.ID*6 + 2], + model_Delta_D[self.i_node.ID*6 + 3], + model_Delta_D[self.i_node.ID*6 + 4], + model_Delta_D[self.i_node.ID*6 + 5], + model_Delta_D[self.j_node.ID*6 + 0], + model_Delta_D[self.j_node.ID*6 + 1], + model_Delta_D[self.j_node.ID*6 + 2], + model_Delta_D[self.j_node.ID*6 + 3], + model_Delta_D[self.j_node.ID*6 + 4], + model_Delta_D[self.j_node.ID*6 + 5]]).reshape(12, 1) + + # Convert the gloabl changes in displacement to local coordinates + Delta_d = self.T() @ Delta_D + + # Get the elastic local stiffness matrix + ke = self.k() + + # Get the total end forces applied to the element + f = self.f(combo_name, push_combo) - self.fer(combo_name) - self.fer(push_combo)*step_num + + # Get the gradient to the failure surface at at each end of the element + if self.section is None: + raise Exception('Nonlinear material analysis requires member sections to be defined. A section definition is missing for element ' + self.name + '.') + else: + if self.i_reversal == False: + Gi = self.section.G(f[0], f[4], f[5]) + else: + Gi = [[0], [0], [0]] + + if self.j_reversal == False: + Gj = self.section.G(f[6], f[10], f[11]) + else: + Gj = [[0], [0], [0]] + + # Expand the gradients for a 12 degree of freedom element + zeros_array = zeros((6, 1)) + Gi = vstack((Gi, zeros_array)) + Gj = vstack((zeros_array, Gj)) + G = hstack((Gi, Gj)) + + return inv(G.T @ ke @ G) @ G.T @ ke @ Delta_d + +#%% + def fer(self, combo_name='Combo 1'): + """ + Returns the condensed (and expanded) local fixed end reaction vector for the member for the given load combination. + + Parameters + ---------- + combo : LoadCombo + The load combination to construct the fixed end reaction vector for. + """ + + # Get the lists of unreleased and released degree of freedom indices + R1_indices, R2_indices = self._partition_D() + + # Partition the local stiffness matrix and local fixed end reaction vector + k11, k12, k21, k22 = self._partition(self._k_unc()) + fer1, fer2 = self._partition(self._fer_unc(combo_name)) + + # Calculate the condensed fixed end reaction vector + ferCondensed = subtract(fer1, matmul(matmul(k12, inv(k22)), fer2)) + + # Expand the condensed fixed end reaction vector + i=0 + for DOF in self.Releases: + + if DOF == True: + ferCondensed = insert(ferCondensed, i, 0, axis = 0) + + i += 1 + + # Return the fixed end reaction vector + return ferCondensed + +#%% + def _fer_unc(self, combo_name='Combo 1'): + """ + Returns the member's local fixed end reaction vector, ignoring the effects of end releases. + Needed to apply the slope-deflection equation properly. + """ + + # Initialize the fixed end reaction vector + fer = zeros((12,1)) + + # Get the requested load combination + combo = self.model.load_combos[combo_name] + + # Loop through each load case and factor in the load combination + for case, factor in combo.factors.items(): + + # Sum the fixed end reactions for the point loads & moments + for ptLoad in self.PtLoads: + + # Check if the current point load corresponds to the current load case + if ptLoad[3] == case: + + if ptLoad[0] == 'Fx': + fer = add(fer, Pynite.FixedEndReactions.FER_AxialPtLoad(factor*ptLoad[1], ptLoad[2], self.L())) + elif ptLoad[0] == 'Fy': + fer = add(fer, Pynite.FixedEndReactions.FER_PtLoad(factor*ptLoad[1], ptLoad[2], self.L(), 'Fy')) + elif ptLoad[0] == 'Fz': + fer = add(fer, Pynite.FixedEndReactions.FER_PtLoad(factor*ptLoad[1], ptLoad[2], self.L(), 'Fz')) + elif ptLoad[0] == 'Mx': + fer = add(fer, Pynite.FixedEndReactions.FER_Torque(factor*ptLoad[1], ptLoad[2], self.L())) + elif ptLoad[0] == 'My': + fer = add(fer, Pynite.FixedEndReactions.FER_Moment(factor*ptLoad[1], ptLoad[2], self.L(), 'My')) + elif ptLoad[0] == 'Mz': + fer = add(fer, Pynite.FixedEndReactions.FER_Moment(factor*ptLoad[1], ptLoad[2], self.L(), 'Mz')) + elif ptLoad[0] == 'FX' or ptLoad[0] == 'FY' or ptLoad[0] == 'FZ': + FX, FY, FZ = 0, 0, 0 + if ptLoad[0] == 'FX': FX = 1 + if ptLoad[0] == 'FY': FY = 1 + if ptLoad[0] == 'FZ': FZ = 1 + f = self.T()[:3, :][:, :3] @ array([FX*ptLoad[1], FY*ptLoad[1], FZ*ptLoad[1]]) + fer = add(fer, Pynite.FixedEndReactions.FER_AxialPtLoad(factor*f[0], ptLoad[2], self.L())) + fer = add(fer, Pynite.FixedEndReactions.FER_PtLoad(factor*f[1], ptLoad[2], self.L(), 'Fy')) + fer = add(fer, Pynite.FixedEndReactions.FER_PtLoad(factor*f[2], ptLoad[2], self.L(), 'Fz')) + elif ptLoad[0] == 'MX' or ptLoad[0] == 'MY' or ptLoad[0] == 'MZ': + MX, MY, MZ = 0, 0, 0 + if ptLoad[0] == 'MX': MX = 1 + if ptLoad[0] == 'MY': MY = 1 + if ptLoad[0] == 'MZ': MZ = 1 + f = self.T()[:3, :][:, :3] @ array([MX*ptLoad[1], MY*ptLoad[1], MZ*ptLoad[1]]) + fer = add(fer, Pynite.FixedEndReactions.FER_Torque(factor*f[0], ptLoad[2], self.L())) + fer = add(fer, Pynite.FixedEndReactions.FER_Moment(factor*f[1], ptLoad[2], self.L(), 'My')) + fer = add(fer, Pynite.FixedEndReactions.FER_Moment(factor*f[2], ptLoad[2], self.L(), 'Mz')) + else: + raise Exception('Invalid member point load direction specified.') + + # Sum the fixed end reactions for the distributed loads + for distLoad in self.DistLoads: + + # Check if the current distributed load corresponds to the current load case + if distLoad[5] == case: + + if distLoad[0] == 'Fx': + fer = add(fer, Pynite.FixedEndReactions.FER_AxialLinLoad(factor*distLoad[1], factor*distLoad[2], distLoad[3], distLoad[4], self.L())) + elif distLoad[0] == 'Fy' or distLoad[0] == 'Fz': + fer = add(fer, Pynite.FixedEndReactions.FER_LinLoad(factor*distLoad[1], factor*distLoad[2], distLoad[3], distLoad[4], self.L(), distLoad[0])) + elif distLoad[0] == 'FX' or distLoad[0] == 'FY' or distLoad[0] == 'FZ': + FX, FY, FZ = 0, 0, 0 + if distLoad[0] =='FX': FX = 1 + if distLoad[0] =='FY': FY = 1 + if distLoad[0] =='FZ': FZ = 1 + w1 = self.T()[:3, :][:, :3] @ array([FX*distLoad[1], FY*distLoad[1], FZ*distLoad[1]]) + w2 = self.T()[:3, :][:, :3] @ array([FX*distLoad[2], FY*distLoad[2], FZ*distLoad[2]]) + fer = add(fer, Pynite.FixedEndReactions.FER_AxialLinLoad(factor*w1[0], factor*w2[0], distLoad[3], distLoad[4], self.L())) + fer = add(fer, Pynite.FixedEndReactions.FER_LinLoad(factor*w1[1], factor*w2[1], distLoad[3], distLoad[4], self.L(), 'Fy')) + fer = add(fer, Pynite.FixedEndReactions.FER_LinLoad(factor*w1[2], factor*w2[2], distLoad[3], distLoad[4], self.L(), 'Fz')) + + # Return the fixed end reaction vector, uncondensed + return fer + +#%% + def _partition(self, unp_matrix): + """ + Partitions a matrix into sub-matrices based on unreleased and released degree of freedom indices. + """ + + # Create auxiliary lists of released/unreleased DOFs + R1_indices, R2_indices = self._partition_D() + + # Partition the matrix by slicing + if unp_matrix.shape[1] == 1: + m1 = unp_matrix[R1_indices, :] + m2 = unp_matrix[R2_indices, :] + return m1, m2 + else: + m11 = unp_matrix[R1_indices, :][:, R1_indices] + m12 = unp_matrix[R1_indices, :][:, R2_indices] + m21 = unp_matrix[R2_indices, :][:, R1_indices] + m22 = unp_matrix[R2_indices, :][:, R2_indices] + return m11, m12, m21, m22 + +#%% + def f(self, combo_name='Combo 1', push_combo='Push', step_num=1): + """Returns the member's elastic local end force vector for the given load combination. + + :param combo_name: The load combination to get the local end for vector for. Defaults to 'Combo 1'. + :type combo_name: str, optional + :return: The member's local end force vector for the given load combination. + :rtype: array + """ + + # Calculate and return the member's local end force vector + if self.model.solution == 'P-Delta': + # Back-calculate the axial force on the member from the axial strain + P = (self.d(combo_name)[6, 0] - self.d(combo_name)[0, 0])*self.section.A*self.material.E/self.L() + return add(matmul(add(self.k(), self.kg(P)), self.d(combo_name)), self.fer(combo_name)) + elif self.model.solution == 'Pushover': + P = self._fxj - self._fxi + return add(matmul(add(self.k(), self.kg(P), self.km(combo_name, push_combo, step_num)), self.d(combo_name)), self.fer(combo_name)) + else: + return add(matmul(self.k(), self.d(combo_name)), self.fer(combo_name)) + +#%% + def d(self, combo_name='Combo 1'): + """ + Returns the member's local displacement vector. + + Parameters + ---------- + combo_name : string + The name of the load combination to construct the displacement vector for (not the load combination itself). + """ + + # Calculate and return the local displacement vector + return self.T() @ self.D(combo_name) + +#%% + # Transformation matrix + def T(self): + """ + Returns the transformation matrix for the member. + """ + + x1 = self.i_node.X + x2 = self.j_node.X + y1 = self.i_node.Y + y2 = self.j_node.Y + z1 = self.i_node.Z + z2 = self.j_node.Z + L = self.L() + + # Calculate the direction cosines for the local x-axis + x = [(x2-x1)/L, (y2-y1)/L, (z2-z1)/L] + + # Calculate the remaining direction cosines. + # For now, the local z-axis will be kept parallel to the global XZ plane in all cases. It will be adjusted later if a rotation has been applied to the member. + # Vertical members + if isclose(x1, x2) and isclose(z1, z2): + + # For vertical members, keep the local y-axis in the XY plane to make 2D problems easier to solve in the XY plane + if y2 > y1: + y = [-1, 0, 0] + z = [0, 0, 1] + else: + y = [1, 0, 0] + z = [0, 0, 1] + + # Horizontal members + elif isclose(y1, y2): + + # Find a vector in the direction of the local z-axis by taking the cross-product + # of the local x-axis and the local y-axis. This vector will be perpendicular to + # both the local x-axis and the local y-axis. + y = [0, 1, 0] + z = cross(x, y) + + # Divide the z-vector by its magnitude to produce a unit vector of direction cosines + z = divide(z, (z[0]**2 + z[1]**2 + z[2]**2)**0.5) + + # Members neither vertical or horizontal + else: + + # Find the projection of x on the global XZ plane + proj = [x2-x1, 0, z2-z1] + + # Find a vector in the direction of the local z-axis by taking the cross-product + # of the local x-axis and its projection on a plane parallel to the XZ plane. This + # produces a vector perpendicular to both the local x-axis and its projection. This + # vector will always be horizontal since it's parallel to the XZ plane. The order + # in which the vectors are 'crossed' has been selected to ensure the y-axis always + # has an upward component (i.e. the top of the beam is always on top). + if y2 > y1: + z = cross(proj, x) + else: + z = cross(x, proj) + + # Divide the z-vector by its magnitude to produce a unit vector of direction cosines + z = divide(z, (z[0]**2 + z[1]**2 + z[2]**2)**0.5) + + # Find the direction cosines for the local y-axis + y = cross(z, x) + y = divide(y, (y[0]**2 + y[1]**2 + y[2]**2)**0.5) + + # Check if the member is rotated + if self.rotation != 0.0: + + # Get the member rotation angle + theta = radians(self.rotation) + + # Define the rotation matrix for rotation about the x-axis + R = array([[1, 0, 0], + [0, cos(theta), -sin(theta)], + [0, sin(theta), cos(theta)]]) + + # Rotate the y and z axes about the x axis + y = R @ y + z = R @ z + + # Create the direction cosines matrix + dirCos = array([x, y, z]) + + # Build the transformation matrix + transMatrix = zeros((12, 12)) + transMatrix[0:3, 0:3] = dirCos + transMatrix[3:6, 3:6] = dirCos + transMatrix[6:9, 6:9] = dirCos + transMatrix[9:12, 9:12] = dirCos + + return transMatrix + +#%% + # Member global stiffness matrix + def K(self): + """Returns the global elastic stiffness matrix for the member. + + :return: The global elastic stiffness matrix for the member. + :rtype: array + """ + + # Calculate and return the stiffness matrix in global coordinates + return matmul(matmul(inv(self.T()), self.k()), self.T()) + + def Kg(self, P=0.0): + """Returns the global geometric stiffness matrix for the member. Used for P-Delta analysis. + + :param P: Member axial load. Defaults to 0. + :type P: float, optional + :return: The global geometric stiffness matrix for the member. + :rtype: array + """ + + # Calculate and return the geometric stiffness matrix in global coordinates + return matmul(matmul(inv(self.T()), self.kg(P)), self.T()) + + def Km(self, combo_name, push_combo, step_num): + """Returns the global plastic reduction matrix for the member. Used to modify member behavior for plastic hinges at the ends. + + :param combo_name: The name of the load combination to get the plastic reduction matrix for. + :type combo_name: string + :param push_combo: The name of the load combination used to define the pushover load. + :type push_combo: string + :param step_num: The load step (1, 2, 3, ...etc) to use to determine the current load from the pushover combo. + :type step_num: int + :return: The global plastic reduction matrix for the member. + :rtype: array + """ + + # Calculate and return the plastic reduction matrix in global coordinates + return matmul(matmul(inv(self.T()), self.km(combo_name, push_combo, step_num)), self.T()) + + def F(self, combo_name='Combo 1'): + """ + Returns the member's global end force vector for the given load combination. + """ + + # Calculate and return the global force vector + return matmul(inv(self.T()), self.f(combo_name)) + + def FER(self, combo_name='Combo 1'): + """ + Returns the global fixed end reaction vector + + Parameters + ---------- + combo_name : string + The name of the load combination to calculate the fixed end reaction vector for (not the load combination itself). + """ + + # Calculate and return the fixed end reaction vector + return matmul(inv(self.T()), self.fer(combo_name)) + +#%% + def D(self, combo_name='Combo 1'): + """ + Returns the member's global displacement vector. + + Parameters + ---------- + combo_name : string + The name of the load combination to construct the global + displacement vector for (not the load combination itelf). + """ + + # Initialize the displacement vector + D = zeros((12, 1)) + + # TODO: I'm not sure this next block is the best way to handle inactive members - need to review + # Read in the global displacements from the nodes + # Apply axial displacements only if the member is active + if self.active[combo_name] == True: + D[0, 0] = self.i_node.DX[combo_name] + D[6, 0] = self.j_node.DX[combo_name] + + # Apply the remaining displacements + D[1, 0] = self.i_node.DY[combo_name] + D[2, 0] = self.i_node.DZ[combo_name] + D[3, 0] = self.i_node.RX[combo_name] + D[4, 0] = self.i_node.RY[combo_name] + D[5, 0] = self.i_node.RZ[combo_name] + D[7, 0] = self.j_node.DY[combo_name] + D[8, 0] = self.j_node.DZ[combo_name] + D[9, 0] = self.j_node.RX[combo_name] + D[10, 0] = self.j_node.RY[combo_name] + D[11, 0] = self.j_node.RZ[combo_name] + + # Return the global displacement vector + return D + +#%% + def shear(self, Direction, x, combo_name='Combo 1'): + """ + Returns the shear at a point along the member's length. + + Parameters + ---------- + Direction : string + The direction in which to find the shear. Must be one of the following: + 'Fy' = Shear acting on the local y-axis. + 'Fz' = Shear acting on the local z-axis. + x : number + The location at which to find the shear. + combo_name : string + The name of the load combination to get the results for (not the combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Check which direction is of interest + if Direction == 'Fy': + + # Check which segment 'x' falls on + for segment in self.SegmentsZ: + if round(x, 10) >= round(segment.x1, 10) and round(x, 10) < round(segment.x2, 10): + return segment.Shear(x - segment.x1) + + if isclose(x, self.L()): + lastIndex = len(self.SegmentsZ) - 1 + return self.SegmentsZ[lastIndex].Shear(x - self.SegmentsZ[lastIndex].x1) + + elif Direction == 'Fz': + + for segment in self.SegmentsY: + + if round(x,10) >= round(segment.x1,10) and round(x,10) < round(segment.x2,10): + + return segment.Shear(x - segment.x1) + + if isclose(x, self.L()): + + lastIndex = len(self.SegmentsY) - 1 + return self.SegmentsY[lastIndex].Shear(x - self.SegmentsY[lastIndex].x1) + + else: + + return 0 + +#%% + def max_shear(self, Direction, combo_name='Combo 1'): + """ + Returns the maximum shear in the member for the given direction + + Parameters + ---------- + Direction : string + The direction in which to find the maximum shear. Must be one of the following: + 'Fy' = Shear acting on the local y-axis + 'Fz' = Shear acting on the local z-axis + combo_name : string + The name of the load combination to get the results for (not the combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + if Direction == 'Fy': + + Vmax = self.SegmentsZ[0].Shear(0) + + for segment in self.SegmentsZ: + + if segment.max_shear() > Vmax: + + Vmax = segment.max_shear() + + if Direction == 'Fz': + + Vmax = self.SegmentsY[0].Shear(0) + + for segment in self.SegmentsY: + + if segment.max_shear() > Vmax: + + Vmax = segment.max_shear() + + return Vmax + + else: + + return 0 + +#%% + def min_shear(self, Direction, combo_name='Combo 1'): + """ + Returns the minimum shear in the member for the given direction + + Parameters + ---------- + Direction : string + The direction in which to find the minimum shear. Must be one of the following: + 'Fy' = Shear acting on the local y-axis + 'Fz' = Shear acting on the local z-axis + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + if Direction == 'Fy': + + Vmin = self.SegmentsZ[0].Shear(0) + + for segment in self.SegmentsZ: + + if segment.min_shear() < Vmin: + + Vmin = segment.min_shear() + + if Direction == 'Fz': + + Vmin = self.SegmentsY[0].Shear(0) + + for segment in self.SegmentsY: + + if segment.min_shear() < Vmin: + + Vmin = segment.min_shear() + + return Vmin + + else: + + return 0 + +#%% + def plot_shear(self, Direction, combo_name='Combo 1', n_points=20): + """ + Plots the shear diagram for the member + + Parameters + ---------- + Direction : string + The direction in which to find the moment. Must be one of the following: + 'Fy' = Shear acting on the local y-axis. + 'Fz' = Shear acting on the local z-axis. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + n_points: int + The number of points used to generate the plot + """ + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Import 'pyplot' if not already done + if Member3D.__plt is None: + from matplotlib import pyplot as plt + Member3D.__plt = plt + + fig, ax = Member3D.__plt.subplots() + ax.axhline(0, color='black', lw=1) + ax.grid() + + x, V = self.shear_array(Direction, n_points, combo_name) + + Member3D.__plt.plot(x, V) + Member3D.__plt.ylabel('Shear') + Member3D.__plt.xlabel('Location') + Member3D.__plt.title('Member ' + self.name + '\n' + combo_name) + Member3D.__plt.show() + + + def shear_array(self, Direction, n_points: int, combo_name='Combo 1', x_array=None): + """ + Returns the array of the shear in the member for the given direction + + Parameters + ---------- + Direction : string + The direction to plot the shear for. Must be one of the following: + 'Fy' = Shear acting on the local y-axis. + 'Fz' = Shear acting on the local z-axis. + n_points: int + The number of points in the array to generate over the full length of the member. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + x_array : array = None + A custom array of x values that may be provided by the user, otherwise an array is generated. + Values must be provided in local member coordinates (between 0 and L) and be in ascending order + """ + + # Segment the member into segments with mathematically continuous loads if not already done + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + L = self.L() + if x_array is None: + x_array = linspace(0, L, n_points) + else: + if any(x_array<0) or any(x_array>L): + raise ValueError(f"All x values must be in the range 0 to {L}") + + # Check which axis is of interest + if Direction == 'Fz': + return self._extract_vector_results(self.SegmentsY, x_array, 'shear') + + elif Direction == 'Fy': + return self._extract_vector_results(self.SegmentsZ, x_array, 'shear') + + else: + raise ValueError(f"Direction must be 'Fy' or 'Fz'. {Direction} was given.") + +#%% + def moment(self, Direction, x, combo_name='Combo 1'): + """ + Returns the moment at a point along the member's length + + Parameters + ---------- + Direction : string + The direction in which to find the moment. Must be one of the following: + 'My' = Moment about the local y-axis. + 'Mz' = moment about the local z-axis. + x : number + The location at which to find the moment. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Determine if a P-Delta analysis has been run + if self.model.solution == 'P-Delta' or self.model.solution == 'Pushover': + # Include P-little-delta effects in the moment results + P_delta = True + else: + # Do not include P-little delta effects in the moment results + P_delta = False + + # Check which axis is of interest + if Direction == 'My': + + # Check which segment 'x' falls on + for segment in self.SegmentsY: + + if round(x,10) >= round(segment.x1,10) and round(x,10) < round(segment.x2,10): + + return segment.moment(x - segment.x1, P_delta) + + if isclose(x, self.L()): + + return self.SegmentsY[-1].moment(x - self.SegmentsY[-1].x1, P_delta) + + elif Direction == 'Mz': + + for segment in self.SegmentsZ: + + if round(x,10) >= round(segment.x1,10) and round(x,10) < round(segment.x2,10): + + return segment.moment(x - segment.x1, P_delta) + + if isclose(x, self.L()): + + return self.SegmentsZ[-1].moment(x - self.SegmentsZ[-1].x1, P_delta) + + else: + raise ValueError(f"Direction must be 'My' or 'Mz'. {Direction} was given.") + + else: + + return 0 + +#%% + def max_moment(self, Direction, combo_name='Combo 1'): + """ + Returns the maximum moment in the member for the given direction. + + Parameters + ---------- + Direction : string + The direction in which to find the maximum moment. Must be one of the following: + 'My' = Moment about the local y-axis. + 'Mz' = Moment about the local z-axis. + combo_name : string + The name of the load combination to get the results for (not the combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Determine if a P-Delta analysis has been run + if self.model.solution == 'P-Delta' or self.model.solution == 'Pushover': + # Include P-little-delta effects in the moment results + P_delta = True + else: + # Do not include P-little delta effects in the moment results + P_delta = False + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + if Direction == 'Mz': + + Mmax = self.SegmentsZ[0].moment(0, P_delta) + + for segment in self.SegmentsZ: + + if segment.max_moment() > Mmax: + + Mmax = segment.max_moment() + + if Direction == 'My': + + Mmax = self.SegmentsY[0].moment(0, P_delta) + + for segment in self.SegmentsY: + + if segment.max_moment() > Mmax: + + Mmax = segment.max_moment() + + return Mmax + + else: + + return 0 + +#%% + def min_moment(self, Direction, combo_name='Combo 1'): + """ + Returns the minimum moment in the member for the given direction + + Parameters + ---------- + Direction : string + The direction in which to find the minimum moment. Must be one of the following: + 'My' = Moment about the local y-axis. + 'Mz' = Moment about the local z-axis. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Determine if a P-Delta analysis has been run + if self.model.solution == 'P-Delta' or self.model.solution == 'Pushover': + # Include P-little-delta effects in the moment results + P_delta = True + else: + # Do not include P-little delta effects in the moment results + P_delta = False + + if Direction == 'Mz': + + Mmin = self.SegmentsZ[0].moment(0, P_delta) + + for segment in self.SegmentsZ: + + if segment.min_moment(P_delta) < Mmin: Mmin = segment.min_moment(P_delta) + + if Direction == 'My': + + Mmin = self.SegmentsY[0].moment(0, P_delta) + + for segment in self.SegmentsY: + + if segment.min_moment(P_delta) < Mmin: Mmin = segment.min_moment(P_delta) + + return Mmin + + else: + + return 0 + +#%% + def plot_moment(self, Direction, combo_name='Combo 1', n_points=20): + """ + Plots the moment diagram for the member + + Parameters + ---------- + Direction : string + The direction in which to plot the moment. Must be one of the following: + 'My' = Moment about the local y-axis. + 'Mz' = moment about the local z-axis. + combo_name : string + The name of the load combination to get the results for (not the combination itself). + n_points: int + The number of points used to generate the plot + """ + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Import 'pyplot' if not already done + if Member3D.__plt is None: + from matplotlib import pyplot as plt + Member3D.__plt = plt + + fig, ax = Member3D.__plt.subplots() + ax.axhline(0, color='black', lw=1) + ax.grid() + + # Generate the moment diagram coordinates + x, M = self.moment_array(Direction, n_points, combo_name) + + Member3D.__plt.plot(x, M) + Member3D.__plt.ylabel('Moment') + Member3D.__plt.xlabel('Location') + Member3D.__plt.title('Member ' + self.name + '\n' + combo_name) + Member3D.__plt.show() + + def moment_array(self, Direction, n_points, combo_name='Combo 1', x_array = None): + """ + Returns the array of the moment in the member for the given direction + + Parameters + ---------- + Direction : string + The direction in which to find the moment. Must be one of the following: + 'My' = Moment about the local y-axis. + 'Mz' = moment about the local z-axis. + n_points: int + The number of points in the array to generate over the full length of the member. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + x_array : array = None + A custom array of x values that may be provided by the user, otherwise an array is generated. + Values must be provided in local member coordinates (between 0 and L) and be in ascending order. + """ + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Determine if a P-Delta analysis has been run + if self.model.solution == 'P-Delta' or self.model.solution == 'Pushover': + # Include P-little-delta effects in the moment results + P_delta = True + else: + # Do not include P-little delta effects in the moment results + P_delta = False + + L = self.L() + + if x_array is None: + x_array = linspace(0, L, n_points) + else: + if any(x_array<0) or any(x_array>L): + raise ValueError(f"All x values must be in the range 0 to {L}") + + if P_delta: + #P-delta analysis is not vectorised yet, do it element-wise + y_arr = array([self.moment(Direction, x, combo_name) for x in x_array]) + return array([x_array, y_arr]) + + else: + # Check which axis is of interest + if Direction == 'My': + return self._extract_vector_results(self.SegmentsY, x_array, 'moment', P_delta) + + elif Direction == 'Mz': + return self._extract_vector_results(self.SegmentsZ, x_array, 'moment', P_delta) + + else: + raise ValueError(f"Direction must be 'My' or 'Mz'. {Direction} was given.") + +#%% + def torque(self, x, combo_name='Combo 1'): + """ + Returns the torsional moment at a point along the member's length + + Parameters + ---------- + x : number + The location at which to find the torque + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Check which segment 'x' falls on + for segment in self.SegmentsX: + if round(x, 10) >= round(segment.x1, 10) and round(x, 10) < round(segment.x2, 10): + return segment.Torsion() + + if isclose(x, self.L()): + lastIndex = len(self.SegmentsX) - 1 + return self.SegmentsX[lastIndex].Torsion() + + else: + + return 0 + +#%% + def max_torque(self, combo_name='Combo 1'): + """ + Returns the maximum torsional moment in the member. + + Parameters + ---------- + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + Tmax = self.SegmentsX[0].Torsion() + + for segment in self.SegmentsX: + + if segment.MaxTorsion() > Tmax: + + Tmax = segment.MaxTorsion() + + return Tmax + + else: + + return 0 + +#%% + def min_torque(self, combo_name='Combo 1'): + """ + Returns the minimum torsional moment in the member. + + Parameters + ---------- + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + Tmin = self.SegmentsX[0].Torsion() + + for segment in self.SegmentsX: + + if segment.MinTorsion() < Tmin: + + Tmin = segment.MinTorsion() + + return Tmin + + else: + + return 0 + +#%% + def plot_torque(self, combo_name='Combo 1', n_points=20): + """ + Plots the torque diagram for the member. + + Paramters + --------- + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + n_points: int + The number of points used to generate the plot + """ + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Import 'pyplot' if not already done + if Member3D.__plt is None: + from matplotlib import pyplot as plt + Member3D.__plt = plt + + fig, ax = Member3D.__plt.subplots() + ax.axhline(0, color='black', lw=1) + ax.grid() + + x, T = self.torque_array(n_points, combo_name) + + Member3D.__plt.plot(x, T) + Member3D.__plt.ylabel('Torsional Moment (Warping Torsion Not Included)') # Torsion results are for pure torsion. Torsional warping has not been considered + Member3D.__plt.xlabel('Location') + Member3D.__plt.title('Member ' + self.name + '\n' + combo_name) + Member3D.__plt.show() + + def torque_array(self, n_points, combo_name='Combo 1', x_array = None): + """ + Returns the array of the torque in the member + + Parameters + ---------- + n_points: int + The number of points in the array to generate over the full length of the member. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + x_array : array = None + A custom array of x values that may be provided by the user, otherwise an array is generated. + Values must be provided in local member coordinates (between 0 and L) and be in ascending order. + """ + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + L = self.L() + + if x_array is None: + x_array = linspace(0, L, n_points) + else: + if any(x_array<0) or any(x_array>L): + raise ValueError(f"All x values must be in the range 0 to {L}") + + return self._extract_vector_results(self.SegmentsX, x_array, 'torque') + + + def axial(self, x, combo_name='Combo 1'): + """ + Returns the axial force at a point along the member's length. + + Parameters + ---------- + x : number + The location at which to find the axial force. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Check which segment 'x' falls on + for segment in self.SegmentsZ: + if round(x, 10) >= round(segment.x1, 10) and round(x, 10) < round(segment.x2, 10): + return segment.axial(x - segment.x1) + + if isclose(x, self.L()): + lastIndex = len(self.SegmentsZ) - 1 + return self.SegmentsZ[lastIndex].axial(x - self.SegmentsZ[lastIndex].x1) + + else: + + return 0 + + def max_axial(self, combo_name='Combo 1'): + """ + Returns the maximum axial force in the member + + Parameters + ---------- + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + Pmax = self.SegmentsZ[0].axial(0) + + for segment in self.SegmentsZ: + + if segment.max_axial() > Pmax: + + Pmax = segment.max_axial() + + return Pmax + + else: + + return 0 + + def min_axial(self, combo_name='Combo 1'): + """ + Returns the minimum axial force in the member. + + Paramters + --------- + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + Pmin = self.SegmentsZ[0].axial(0) + + for segment in self.SegmentsZ: + + if segment.min_axial() < Pmin: + + Pmin = segment.min_axial() + + return Pmin + + else: + + return 0 + + def plot_axial(self, combo_name='Combo 1', n_points=20): + """ + Plots the axial force diagram for the member. + + Parameters + ---------- + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + n_points: int + The number of points used to generate the plot + """ + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Import 'pyplot' if not already done + if Member3D.__plt is None: + from matplotlib import pyplot as plt + Member3D.__plt = plt + + fig, ax = Member3D.__plt.subplots() + ax.axhline(0, color='black', lw=1) + ax.grid() + + x, P = self.axial_array(n_points, combo_name) + + Member3D.__plt.plot(x, P) + Member3D.__plt.ylabel('Axial Force') + Member3D.__plt.xlabel('Location') + Member3D.__plt.title('Member ' + self.name + '\n' + combo_name) + Member3D.__plt.show() + + def axial_array(self, n_points, combo_name='Combo 1', x_array=None): + """ + Returns the array of the axial force in the member for the given direction + + Parameters + ---------- + n_points: int + The number of points in the array to generate over the full length of the member. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + x_array : array = None + A custom array of x values that may be provided by the user, otherwise an array is generated. + Values must be provided in local member coordinates (between 0 and L) and be in ascending order. + """ + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + L = self.L() + if x_array is None: + x_array = linspace(0, L, n_points) + else: + if any(x_array<0) or any(x_array>L): + raise ValueError(f"All x values must be in the range 0 to {L}") + + return self._extract_vector_results(self.SegmentsZ, x_array, 'axial') + + + def deflection(self, Direction, x, combo_name='Combo 1'): + """ + Returns the deflection at a point along the member's length. + + Parameters + ---------- + Direction : string + The direction in which to find the deflection. Must be one of the following: + 'dx' = Deflection in the local x-axis. + 'dy' = Deflection in the local y-axis. + 'dz' = Deflection in the local z-axis. + x : number + The location at which to find the deflection. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + if self.model.solution == 'P-Delta' or self.model.solution == 'Pushover': + P_delta = True + else: + P_delta = False + + # Check which axis is of interest + if Direction == 'dx': + + # Check which segment 'x' falls on + for segment in self.SegmentsZ: + + if round(x, 10) >= round(segment.x1, 10) and round(x, 10) < round(segment.x2, 10): + return segment.AxialDeflection(x - segment.x1) + + if isclose(x, self.L()): + + lastIndex = len(self.SegmentsZ) - 1 + return self.SegmentsZ[lastIndex].AxialDeflection(x - self.SegmentsZ[lastIndex].x1) + + elif Direction == 'dy': + + # Check which segment 'x' falls on + for segment in self.SegmentsZ: + + if round(x,10) >= round(segment.x1,10) and round(x,10) < round(segment.x2,10): + + return segment.deflection(x - segment.x1, P_delta) + + if isclose(x, self.L()): + + lastIndex = len(self.SegmentsZ) - 1 + return self.SegmentsZ[lastIndex].deflection(x - self.SegmentsZ[lastIndex].x1, P_delta) + + elif Direction == 'dz': + + for segment in self.SegmentsY: + + if round(x,10) >= round(segment.x1,10) and round(x,10) < round(segment.x2,10): + + return segment.deflection(x - segment.x1) + + if isclose(x, self.L()): + + lastIndex = len(self.SegmentsY) - 1 + return self.SegmentsY[lastIndex].deflection(x - self.SegmentsY[lastIndex].x1) + + else: + + return 0 + + def max_deflection(self, Direction, combo_name='Combo 1'): + """ + Returns the maximum deflection in the member. + + Parameters + ---------- + Direction : {'dy', 'dz'} + The direction in which to find the maximum deflection. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Initialize the maximum deflection + dmax = self.deflection(Direction, 0, combo_name) + + # Check the deflection at 100 locations along the member and find the largest value + for i in range(100): + d = self.deflection(Direction, self.L()*i/99, combo_name) + if d > dmax: + dmax = d + + # Return the largest value + return dmax + + else: + + return 0 + + def min_deflection(self, Direction, combo_name='Combo 1'): + """ + Returns the minimum deflection in the member. + + Parameters + ---------- + Direction : {'dy', 'dz'} + The direction in which to find the minimum deflection. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Initialize the minimum deflection + dmin = self.deflection(Direction, 0, combo_name) + + # Check the deflection at 100 locations along the member and find the smallest value + for i in range(100): + d = self.deflection(Direction, self.L()*i/99, combo_name) + if d < dmin: + dmin = d + + # Return the smallest value + return dmin + + else: + + return 0 + + def plot_deflection(self, Direction, combo_name='Combo 1', n_points=20): + """ + Plots the deflection diagram for the member + + Parameters + ---------- + Direction : {'dy', 'dz'} + The direction in which to plot the deflection. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + n_points: int + The number of points used to generate the plot + """ + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Import 'pyplot' if not already done + if Member3D.__plt is None: + from matplotlib import pyplot as plt + Member3D.__plt = plt + + fig, ax = Member3D.__plt.subplots() + ax.axhline(0, color='black', lw=1) + ax.grid() + + x, d = self.deflection_array(Direction, n_points, combo_name) + + Member3D.__plt.plot(x, d) + Member3D.__plt.ylabel('Deflection') + Member3D.__plt.xlabel('Location') + Member3D.__plt.title('Member ' + self.name + '\n' + combo_name) + Member3D.__plt.show() + + def deflection_array(self, Direction, n_points, combo_name='Combo 1', x_array=None): + """ + Returns the array of the deflection in the member for the given direction + + Parameters + ---------- + Direction : string + The direction in which to find the deflection. Must be one of the following: + 'dx' = Deflection in the local x-axis. + 'dy' = Deflection in the local y-axis. + 'dz' = Deflection in the local z-axis. + n_points: int + The number of points in the array to generate over the full length of the member. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + x_array : array = None + A custom array of x values that may be provided by the user, otherwise an array is generated. + Values must be provided in local member coordinates (between 0 and L) and be in ascending order. + """ + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Determine if a P-Delta analysis has been run + if self.model.solution == 'P-Delta' or self.model.solution == 'Pushover': + # Include P-little-delta effects in the moment results + P_delta = True + else: + # Do not include P-little delta effects in the moment results + P_delta = False + + L = self.L() + + if x_array is None: + x_array = linspace(0, L, n_points) + else: + if any(x_array<0) or any(x_array>L): + raise ValueError(f"All x values must be in the range 0 to {L}") + + if P_delta: + #P-delta analysis is not vectorised yet, do it element-wise + y_arr = array([self.deflection(Direction, x, combo_name) for x in x_array]) + return array([x_array, y_arr]) + + else: + # Check which axis is of interest + if Direction == 'dz': + return self._extract_vector_results(self.SegmentsY, x_array, 'deflection', P_delta) + + elif Direction == 'dy': + return self._extract_vector_results(self.SegmentsZ, x_array, 'deflection', P_delta) + + elif Direction == 'dx': + return self._extract_vector_results(self.SegmentsZ, x_array, 'axial_deflection', P_delta) + + else: + raise ValueError(f"Direction must be 'My' or 'Mz'. {Direction} was given.") + + def rel_deflection(self, Direction, x, combo_name='Combo 1'): + """ + Returns the relative deflection at a point along the member's length + + Parameters + ---------- + Direction : string + The direction in which to find the relative deflection. Must be one of the following: + 'dy' = Deflection in the local y-axis + 'dz' = Deflection in the local z-axis + x : number + The location at which to find the relative deflection + combo_name : string + The name of the load combination to get the results for (not the combination itself). + """ + + # Only calculate results if the member is currently active + if self.active[combo_name]: + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + d = self.d(combo_name) + dyi = d[1,0] + dyj = d[7,0] + dzi = d[2,0] + dzj = d[8,0] + L = self.L() + + # Check which axis is of interest + if Direction == 'dy': + + # Check which segment 'x' falls on + for segment in self.SegmentsZ: + + if round(x,10) >= round(segment.x1,10) and round(x,10) < round(segment.x2,10): + + return (segment.deflection(x - segment.x1)) - (dyi + (dyj-dyi)/L*x) + + if isclose(x, self.L()): + + lastIndex = len(self.SegmentsZ) - 1 + return (self.SegmentsZ[lastIndex].deflection(x - self.SegmentsZ[lastIndex].x1))-dyj + + elif Direction == 'dz': + + for segment in self.SegmentsY: + + if round(x,10) >= round(segment.x1,10) and round(x,10) < round(segment.x2,10): + + return (segment.deflection(x - segment.x1)) - (dzi + (dzj-dzi)/L*x) + + if isclose(x, self.L()): + + lastIndex = len(self.SegmentsY) - 1 + return (self.SegmentsY[lastIndex].deflection(x - self.SegmentsY[lastIndex].x1)) - dzj + + else: + + return 0 + + def plot_rel_deflection(self, Direction, combo_name='Combo 1', n_points=20): + """ + Plots the deflection diagram for the member + + Parameters + ---------- + Direction : {'dy', 'dz'} + The direction in which to plot the deflection. + combo_name : string + The name of the load combination to get the results for (not the combination itself). + """ + + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + # Import 'pyplot' if not already done + if Member3D.__plt is None: + from matplotlib import pyplot as plt + Member3D.__plt = plt + + fig, ax = Member3D.__plt.subplots() + ax.axhline(0, color='black', lw=1) + ax.grid() + + x, d_relative = self.rel_deflection_array(Direction, n_points, combo_name) + + Member3D.__plt.plot(x, d_relative) + Member3D.__plt.ylabel('Relative Deflection') + Member3D.__plt.xlabel('Location') + Member3D.__plt.title('Member ' + self.name + '\n' + combo_name) + Member3D.__plt.show() + + def rel_deflection_array(self, Direction, n_points, combo_name='Combo 1', x_array=None): + """ + Returns the array of the relative deflection in the member for the given direction + + Parameters + ---------- + Direction : string + The direction in which to find the relative deflection. Must be one of the following: + 'dy' = Deflection in the local y-axis + 'dz' = Deflection in the local z-axis + n_points: int + The number of points in the array to generate over the full length of the member. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + x_array : array = None + A custom array of x values that may be provided by the user, otherwise an array is generated. + Values must be provided in local member coordinates (between 0 and L) and be in ascending order. + """ + # Segment the member if necessary + if self._solved_combo is None or combo_name != self._solved_combo.name: + self._segment_member(combo_name) + self._solved_combo = self.model.load_combos[combo_name] + + d = self.d(combo_name) + dyi = d[1,0] + dyj = d[7,0] + dzi = d[2,0] + dzj = d[8,0] + + L = self.L() + if x_array is None: + x_array = linspace(0, L, n_points) + else: + if any(x_array<0) or any(x_array>L): + raise ValueError(f"All x values must be in the range 0 to {L}") + + # Check which axis is of interest + if Direction == 'dy': + deflections = self._extract_vector_results(self.SegmentsZ, x_array, 'deflection')[1] + return vstack((x_array, deflections - (dyi + (dyj-dyi)/L*x_array))) + + elif Direction == 'dz': + deflections = self._extract_vector_results(self.SegmentsY, x_array, 'deflection')[1] + return vstack((x_array, deflections - (dzi + (dzj-dzi)/L*x_array))) + + def _segment_member(self, combo_name='Combo 1'): + """ + Divides the element up into mathematically continuous segments along each axis + """ + + # Get the member's length and stiffness properties + L = self.L() + E = self.material.E + A = self.section.A + Iz = self.section.Iz + Iy = self.section.Iy + SegmentsZ = self.SegmentsZ + SegmentsY = self.SegmentsY + SegmentsX = self.SegmentsX + + # Get the load combination to segment the member for + combo = self.model.load_combos[combo_name] + + # Create a list of discontinuity locations + disconts = [0, L] # Member ends + + for load in self.PtLoads: + disconts.append(load[2]) # Point load locations + + for load in self.DistLoads: + disconts.append(load[3]) # Distributed load start locations + disconts.append(load[4]) # Distributed load end locations + + # Sort the list and eliminate duplicate values + disconts = sorted(set(disconts)) + + # Clear out old data from any previous analyses + SegmentsZ.clear() + SegmentsY.clear() + SegmentsX.clear() + + # Create a list of mathematically continuous segments for each direction + for index in range(len(disconts) - 1): + + # z-direction segments (bending about local z-axis) + newSeg = BeamSegZ() # Create the new segment + newSeg.x1 = disconts[index] # Segment start location + newSeg.x2 = disconts[index+1] # Segment end location + newSeg.EI = E*Iz # Segment flexural stiffness + newSeg.EA = E*A # Segment axial stiffness + SegmentsZ.append(newSeg) # Add the segment to the list + + # y-direction segments (bending about local y-axis) + newSeg = BeamSegY() # Create the new segment + newSeg.x1 = disconts[index] # Segment start location + newSeg.x2 = disconts[index+1] # Segment end location + newSeg.EI = E*Iy # Segment flexural stiffness + newSeg.EA = E*A # Segment axial stiffness + SegmentsY.append(newSeg) # Add the segment to the list + + # x-direction segments (for torsional moment) + newSeg = BeamSegZ() # Create the new segment + newSeg.x1 = disconts[index] # Segment start location + newSeg.x2 = disconts[index+1] # Segment end location + newSeg.EA = E*A # Segment axial stiffness + SegmentsX.append(newSeg) # Add the segment to the list + + # Get the element local end forces, local fixed end reactions, and local displacements + f = self.f(combo_name) # Member local end force vector + fer = self._fer_unc(combo_name) # Member local fixed end reaction vector + d = self.d(combo_name) # Member local displacement vector + + # Get the local deflections and calculate the slope at the start of the member + # Note 1: The slope may not be available directly from the local displacement vector if member end releases have been used, so slope-deflection has been applied to solve for it. + # Note 2: The traditional slope-deflection equations assume a sign convention opposite of what Pynite uses for moments about the local y-axis, so a negative value has been applied to those values specifically. + m1z = f[5, 0] # local z-axis moment at start of member + m2z = f[11, 0] # local z-axis moment at end of member + m1y = -f[4, 0] # local y-axis moment at start of member + m2y = -f[10, 0] # local y-axis moment at end of member + fem1z = fer[5, 0] # local z-axis fixed end moment at start of member + fem2z = fer[11, 0] # local z-axis fixed end moment at end of member + fem1y = -fer[4, 0] # local y-axis fixed end moment at start of member + fem2y = -fer[10, 0] # local y-axis fixed end moment at end of member + delta1y = d[1, 0] # local y displacement at start of member + delta2y = d[7, 0] # local y displacement at end of member + delta1z = d[2, 0] # local z displacement at start of member + delta2z = d[8, 0] # local z displacement at end of member + SegmentsZ[0].delta1 = delta1y + SegmentsY[0].delta1 = delta1z + SegmentsZ[0].theta1 = 1/3*((m1z - fem1z)*L/(E*Iz) - (m2z - fem2z)*L/(2*E*Iz) + 3*(delta2y - delta1y)/L) + SegmentsY[0].theta1 = -1/3*((m1y - fem1y)*L/(E*Iy) - (m2y - fem2y)*L/(2*E*Iy) + 3*(delta2z - delta1z)/L) + + # Add the axial deflection at the start of the member + SegmentsZ[0].delta_x1 = d[0, 0] + SegmentsY[0].delta_x1 = d[0, 0] + SegmentsX[0].delta_x1 = d[0, 0] + + # Add loads to each segment + for i in range(len(SegmentsZ)): + + # Get the starting point of the segment + x = SegmentsZ[i].x1 + + # Initialize the distributed loads on the segment to zero + SegmentsZ[i].w1 = 0 + SegmentsZ[i].w2 = 0 + SegmentsZ[i].p1 = 0 + SegmentsZ[i].p2 = 0 + SegmentsY[i].w1 = 0 + SegmentsY[i].w2 = 0 + SegmentsY[i].p1 = 0 + SegmentsY[i].p2 = 0 + + # Initialize the slope and displacement at the start of the segment + if i > 0: # The first segment has already been initialized + SegmentsZ[i].theta1 = SegmentsZ[i-1].slope(SegmentsZ[i-1].Length()) + SegmentsZ[i].delta1 = SegmentsZ[i-1].deflection(SegmentsZ[i-1].Length()) + SegmentsZ[i].delta_x1 = SegmentsZ[i-1].AxialDeflection(SegmentsZ[i-1].Length()) + SegmentsY[i].theta1 = SegmentsY[i-1].slope(SegmentsY[i-1].Length()) + SegmentsY[i].delta1 = SegmentsY[i-1].deflection(SegmentsY[i-1].Length()) + SegmentsY[i].delta_x1 = SegmentsY[i-1].AxialDeflection(SegmentsY[i-1].Length()) + + # Add the effects of the beam end forces to the segment + SegmentsZ[i].P1 = f[0, 0] + SegmentsZ[i].V1 = f[1, 0] + SegmentsZ[i].M1 = f[5, 0] - f[1, 0]*x + SegmentsY[i].P1 = f[0, 0] + SegmentsY[i].V1 = f[2, 0] + SegmentsY[i].M1 = f[4, 0] + f[2, 0]*x + SegmentsX[i].T1 = f[3, 0] + + # Step through each load case in the specified load combination + for case, factor in combo.factors.items(): + + # Add effects of point loads occuring prior to this segment + for ptLoad in self.PtLoads: + + if round(ptLoad[2], 10) <= round(x, 10) and case == ptLoad[3]: + + if ptLoad[0] == 'Fx': + SegmentsZ[i].P1 += factor*ptLoad[1] + elif ptLoad[0] == 'Fy': + SegmentsZ[i].V1 += factor*ptLoad[1] + SegmentsZ[i].M1 -= factor*ptLoad[1]*(x - ptLoad[2]) + elif ptLoad[0] == 'Fz': + SegmentsY[i].V1 += factor*ptLoad[1] + SegmentsY[i].M1 += factor*ptLoad[1]*(x - ptLoad[2]) + elif ptLoad[0] == 'Mx': + SegmentsX[i].T1 += factor*ptLoad[1] + elif ptLoad[0] == 'My': + SegmentsY[i].M1 += factor*ptLoad[1] + elif ptLoad[0] == 'Mz': + SegmentsZ[i].M1 += factor*ptLoad[1] + elif ptLoad[0] == 'FX' or ptLoad[0] == 'FY' or ptLoad[0] == 'FZ': + FX, FY, FZ = 0, 0, 0 + if ptLoad[0] == 'FX': FX = 1 + if ptLoad[0] == 'FY': FY = 1 + if ptLoad[0] == 'FZ': FZ = 1 + force = self.T()[:3, :][:, :3] @ array([FX*ptLoad[1], FY*ptLoad[1], FZ*ptLoad[1]]) + SegmentsZ[i].P1 += factor*force[0] + SegmentsZ[i].V1 += factor*force[1] + SegmentsZ[i].M1 -= factor*force[1]*(x - ptLoad[2]) + SegmentsY[i].V1 += factor*force[2] + SegmentsY[i].M1 += factor*force[2]*(x - ptLoad[2]) + elif ptLoad[0] == 'MX' or ptLoad[0] == 'MY' or ptLoad[0] == 'MZ': + MX, MY, MZ = 0, 0, 0 + if ptLoad[0] == 'MX': MX = 1 + if ptLoad[0] == 'MY': MY = 1 + if ptLoad[0] == 'MZ': MZ = 1 + force = self.T()[:3, :][:, :3] @ array([MX*ptLoad[1], MY*ptLoad[1], MZ*ptLoad[1]]) + SegmentsX[i].T1 += factor*force[0] + SegmentsY[i].M1 += factor*force[1] + SegmentsZ[i].M1 += factor*force[2] + + # Add distributed loads to the segment + for distLoad in self.DistLoads: + + if case == distLoad[5]: + + # Get the parameters for the distributed load + Direction = distLoad[0] + w1 = factor*distLoad[1] + w2 = factor*distLoad[2] + x1 = distLoad[3] + x2 = distLoad[4] + + # Determine if the load affects the segment + if round(x1, 10) <= round(x, 10): + + if Direction == 'Fx': + + # Determine if the load is on this segment + if round(x2,10) > round(x,10): + + # Break up the load and place it on the segment + # Note that 'w1' and 'w2' are really the axial loads 'p1' and 'p2' here + SegmentsZ[i].p1 += (w2 - w1)/(x2 - x1)*(x - x1) + w1 + SegmentsZ[i].p2 += (w2 - w1)/(x2 - x1)*(SegmentsZ[i].x2 - x1) + w1 + SegmentsY[i].p1 += (w2 - w1)/(x2 - x1)*(x - x1) + w1 + SegmentsY[i].p2 += (w2 - w1)/(x2 - x1)*(SegmentsY[i].x2 - x1) + w1 + + # Calculate the magnitude of the load at the start of the segment + w2 = w1+(w2-w1)/(x2-x1)*(x-x1) + x2 = x + + # Calculate the axial force at the start of the segment + SegmentsZ[i].P1 += (w1 + w2)/2*(x2 - x1) + SegmentsY[i].P1 += (w1 + w2)/2*(x2 - x1) + + elif Direction == 'Fy': + + # Determine if the load is on this segment + if round(x2,10) > round(x,10): + + # Break up the load and place it on the segment + SegmentsZ[i].w1 += (w2 - w1)/(x2 - x1)*(x - x1) + w1 + SegmentsZ[i].w2 += (w2 - w1)/(x2 - x1)*(SegmentsZ[i].x2 - x1) + w1 + + # Calculate the magnitude of the load at the start of the segment + # This will be used as the 'x2' value for the load prior to the start of the segment + w2 = w1 + (w2 - w1)/(x2 - x1)*(x - x1) + x2 = x + + # Calculate the shear and moment at the start of the segment due to the load + SegmentsZ[i].V1 += (w1 + w2)/2*(x2 - x1) + SegmentsZ[i].M1 -= (x1 - x2)*(2*w1*x1 - 3*w1*x + w1*x2 + w2*x1 - 3*w2*x + 2*w2*x2)/6 + + elif Direction == 'Fz': + + # Determine if the load is on this segment + if round(x2,10) > round(x,10): + + # Break up the load and place it on the segment + SegmentsY[i].w1 += (w2 - w1)/(x2 - x1)*(SegmentsY[i].x1 - x1) + w1 + SegmentsY[i].w2 += (w2 - w1)/(x2 - x1)*(SegmentsY[i].x2 - x1) + w1 + + # Calculate the magnitude of the load at the start of the segment + w2 = w1 + (w2 - w1)/(x2 - x1)*(x - x1) + x2 = x + + # Calculate the shear and moment at the start of the segment due to the load + SegmentsY[i].V1 += (w1 + w2)/2*(x2 - x1) + SegmentsY[i].M1 += (x1 - x2)*(2*w1*x1 - 3*w1*x + w1*x2 + w2*x1 - 3*w2*x + 2*w2*x2)/6 + + elif Direction == 'FX' or Direction == 'FY' or Direction == 'FZ': + + FX, FY, FZ = 0, 0, 0 + if Direction == 'FX': FX = 1 + if Direction == 'FY': FY = 1 + if Direction == 'FZ': FZ = 1 + T = self.T()[:3, :][:, :3] + f1 = T @ array([FX*w1, FY*w1, FZ*w1]) + f2 = T @ array([FX*w2, FY*w2, FZ*w2]) + + # Determine if the load is on this segment + if round(x2, 10) > round(x, 10): + + # Break up the load and place it on the segment + SegmentsZ[i].p1 += (f2[0] - f1[0])/(x2 - x1)*(x - x1) + f1[0] + SegmentsZ[i].p2 += (f2[0] - f1[0])/(x2 - x1)*(SegmentsZ[i].x2 - x1) + f1[0] + SegmentsY[i].p1 += (f2[0] - f1[0])/(x2 - x1)*(x - x1) + f1[0] + SegmentsY[i].p2 += (f2[0] - f1[0])/(x2 - x1)*(SegmentsY[i].x2 - x1) + f1[0] + + SegmentsZ[i].w1 += (f2[1] - f1[1])/(x2 - x1)*(x - x1) + f1[1] + SegmentsZ[i].w2 += (f2[1] - f1[1])/(x2 - x1)*(SegmentsZ[i].x2 - x1) + f1[1] + + SegmentsY[i].w1 += (f2[2] - f1[2])/(x2 - x1)*(SegmentsY[i].x1 - x1) + f1[2] + SegmentsY[i].w2 += (f2[2] - f1[2])/(x2 - x1)*(SegmentsY[i].x2 - x1) + f1[2] + + # Calculate the magnitude of the load at the start of the segment + w2 = w1 + (w2 - w1)/(x2 - x1)*(x - x1) + f2 = T @ array([FX*w2, FY*w2, FZ*w2]) + x2 = x + + # Calculate the axial force, shear and moment at the start of the segment + SegmentsZ[i].P1 += (f1[0] + f2[0])/2*(x2 - x1) + SegmentsY[i].P1 += (f1[0] + f2[0])/2*(x2 - x1) + + SegmentsZ[i].V1 += (f1[1] + f2[1])/2*(x2 - x1) + SegmentsZ[i].M1 -= (x1 - x2)*(2*f1[1]*x1 - 3*f1[1]*x + f1[1]*x2 + f2[1]*x1 - 3*f2[1]*x + 2*f2[1]*x2)/6 + + SegmentsY[i].V1 += (f1[2] + f2[2])/2*(x2 - x1) + SegmentsY[i].M1 += (x1 - x2)*(2*f1[2]*x1 - 3*f1[2]*x + f1[2]*x2 + f2[2]*x1 - 3*f2[2]*x + 2*f2[2]*x2)/6 + + def _extract_vector_results(self, segments, x_array, result_name, P_delta=False): + """Extract results from the given segments using vectorised numpy functions""" + + # Initialize variables + segment_results = [] + last_index = 0 + + # Step through each segment in the member + for i, segment in enumerate(segments): + + # Determine if the current index `i` is at the end of the member + if i == len(segments) - 1: + index2 = len(x_array) + else: + try: + index2 = where(x_array > segment.x2)[0][0] + except IndexError: + index2 = len(x_array) + + if last_index == index2: continue + + thisseg_x_array = x_array[last_index:index2] + + if result_name == "moment": + thisseg_y_array = segment.moment(thisseg_x_array - segment.x1, P_delta) + elif result_name == "shear": + thisseg_y_array = segment.Shear(thisseg_x_array - segment.x1) + elif result_name == "axial": + thisseg_y_array = segment.axial(thisseg_x_array - segment.x1) + elif result_name == "torque": + thisseg_y_array = segment.Torsion(thisseg_x_array - segment.x1) + elif result_name == "deflection": + thisseg_y_array = segment.deflection(thisseg_x_array - segment.x1, P_delta) + elif result_name == "axial_deflection": + thisseg_y_array = segment.AxialDeflection(thisseg_x_array - segment.x1) + + segment_results.append(thisseg_y_array) + + last_index = index2 + + return vstack((x_array, hstack(segment_results))) \ No newline at end of file diff --git a/Old Pynite Folder/Mesh.py b/Old Pynite Folder/Mesh.py new file mode 100644 index 00000000..9b590b1a --- /dev/null +++ b/Old Pynite Folder/Mesh.py @@ -0,0 +1,1676 @@ +from Pynite.Node3D import Node3D +from Pynite.Quad3D import Quad3D +from Pynite.Plate3D import Plate3D +from math import pi, sin, cos, ceil, isclose +from bisect import bisect + +#%% +class Mesh(): + """ + A parent class for meshes to inherit from. + """ + + def __init__(self, thickness, material_name, model, kx_mod=1, ky_mod=1, start_node='N1', start_element='Q1'): + + self.thickness = thickness # Thickness + self.material_name = material_name # The name of the element material + self.model = model # Meshes need a link to the model they belong to + self.kx_mod = kx_mod # Local x stiffness modification factor for elements in the mesh + self.ky_mod = ky_mod # Local y stiffness modification factor for elements in the mesh + self.start_node = start_node # The name of the first node in the mesh + self.last_node = None # The name of the last node in the mesh + self.start_element = start_element # The name of the first element in the mesh + self.last_element = None # The name of the last element in the mesh + self.nodes = {} # A dictionary containing the nodes in the mesh + self.elements = {} # A dictionary containing the elements in the mesh + self.element_type = 'Quad' # The type of element used in the mesh + self._is_generated = False # A flag indicating whether the mesh has been generated + + def is_generated(self): + return self._is_generated + + def _rename_duplicates(self): + """Renames any nodes or elements in the mesh that are already in the model + """ + + # Initialize lists to track node and element name changes + revised_nodes = {} + revised_elements = {} + + # Step through each node in the mesh + for node in self.nodes.values(): + + # Check if this node name is already being used in the model's `Nodes` dictionary + if node.name in self.model.nodes.keys(): + + # Come up with a new node name + node.name = self.model.unique_name(self.model.nodes, 'N') + + # Save the node to the model + self.model.nodes[node.name] = node + + # Add this node to the mesh's new/replacement `nodes` dictionary + revised_nodes[node.name] = node + + # Step through each element in the mesh + for element in self.elements.values(): + + # Check which type of element this is + if element.type == 'Rect': + + # Check if this element name is already being used in the model's `Plates` dictionary + if element.name in self.model.plates.keys(): + + # Come up with a new element name + element.name = self.model.unique_name(self.model.plates, 'R') + + # Save the element to the model + self.model.plates[element.name] = element + + elif element.type == 'Quad': + + # Check if this element name is already being used in the model's `Quads` dictionary + if element.name in self.model.quads.keys(): + + # Come up with a new element name + element.name = self.model.unique_name(self.model.quads, prefix='Q') + + # Save the element to the model + self.model.quads[element.name] = element + + # Add this element to the mesh's new/replacement `elements` dictionary + revised_elements[element.name] = element + + # Replace the old dictionaries of nodes and elements with the revised dictionaries + self.nodes = revised_nodes + self.elements = revised_elements + + def generate(self): + """ + A placeholder to be overwritten by subclasses inheriting from this class + """ + pass + + def max_shear(self, direction='Qx', combo=None): + """ + Returns the maximum shear in the mesh. + + Checks corner and center shears in all the elements in the mesh. The mesh must be part of + a solved model prior to using this function. + + Parameters + ---------- + direction : string, optional + The direction to ge the maximum shear for. Options are 'Qx' or 'Qy'. Default + is 'Qx'. + combo : string, optional + The name of the load combination to get the maximum shear for. If omitted, all load + combinations will be evaluated. + """ + + if direction in ['QX', 'QY']: + local = False + else: + local = True + + if direction.upper() == 'QX': + i = 0 + elif direction.upper() == 'QY': + i = 1 + else: + raise Exception('Invalid direction specified for mesh shear results. Valid values are \'Qx\', \'Qy\', \'QX\', or \'QY\'') + + # Initialize the maximum value to None + Q_max = None + + # Step through each element in the mesh + for element in self.elements.values(): + + # Determine whether the element is a rectangle or a quadrilateral + if element.type == 'Rect': + # Use the rectangle's local (x, y) coordinate system + xi, yi = 0, 0 + xj, yj = element.width(), 0 + xm, ym = element.width(), element.height() + xn, yn, = 0, element.height() + elif element.type == 'Quad': + # Use the quad's natural (r, s) coordinate system + xi, yi = -1, -1 + xj, yj = 1, -1 + xm, ym = 1, 1 + xn, yn = -1, 1 + + # Step through each load combination in the model + for load_combo in self.model.load_combos.values(): + + # Determine if this load combination should be evaluated + if combo is None or load_combo.name == combo: + + # Find the maximum shear in the element, checking each corner and the center + # of the element + Q_element = max([element.shear(xi, yi, local, load_combo.name)[i, 0], + element.shear(xj, yj, local, load_combo.name)[i, 0], + element.shear(xm, ym, local, load_combo.name)[i, 0], + element.shear(xn, yn, local, load_combo.name)[i, 0], + element.shear((xi + xj)/2, (yi + yn)/2, local, load_combo.name)[i, 0]]) + + # Determine if the maximum shear calculated is the largest encountered so far + if Q_max == None or Q_max < Q_element: + # Save this value if it's the largest + Q_max = Q_element + + # Return the largest value encountered from all the elements + return Q_max + + def min_shear(self, direction='Qx', combo=None): + """ + Returns the minimum shear in the mesh. + + Checks corner and center shears in all the elements in the mesh. The mesh must be part of + a solved model prior to using this function. + + Parameters + ---------- + direction : string, optional + The direction to ge the minimum shear for. Options are 'Qx' or 'Qy'. Default + is 'Qx'. + combo : string, optional + The name of the load combination to get the minimum shear for. If omitted, all load + combinations will be evaluated. + """ + + if direction in ['QX', 'QY']: + local = False + else: + local = True + + if direction.upper() == 'QX': + i = 0 + elif direction.upper() == 'QY': + i = 1 + else: + raise Exception('Invalid direction specified for mesh shear results. Valid values are \'Qx\', \'Qy\', \'QX\', or \'QY\'') + + # Initialize the minimum value to None + Q_min = None + + # Step through each element in the mesh + for element in self.elements.values(): + + # Determine whether the element is a rectangle or a quadrilateral + if element.type == 'Rect': + # Use the rectangle's local (x, y) coordinate system + xi, yi = 0, 0 + xj, yj = element.width(), 0 + xm, ym = element.width(), element.height() + xn, yn, = 0, element.height() + elif element.type == 'Quad': + # Use the quad's natural (r, s) coordinate system + xi, yi = -1, -1 + xj, yj = 1, -1 + xm, ym = 1, 1 + xn, yn = -1, 1 + + # Step through each load combination the element utilizes + for load_combo in self.model.load_combos.values(): + + # Determine if this load combination should be evaluated + if combo is None or load_combo.name == combo: + + # Find the minimum shear in the element, checking each corner and the center + # of the element + Q_element = min([element.shear(xi, yi, local, load_combo.name)[i, 0], + element.shear(xj, yj, local, load_combo.name)[i, 0], + element.shear(xm, ym, local, load_combo.name)[i, 0], + element.shear(xn, yn, local, load_combo.name)[i, 0], + element.shear((xi + xj)/2, (yi + yn)/2, local, load_combo.name)[i, 0]]) + + # Determine if the minimum shear calculated is the smallest encountered so far + if Q_min == None or Q_min > Q_element: + # Save this value if it's the smallest + Q_min = Q_element + + # Return the smallest value encountered from all the elements + return Q_min + + def max_moment(self, direction='Mx', combo=None): + """ + Returns the maximum moment in the mesh. + + Checks corner and center moments in all the elements in the mesh. The mesh must be part of + a solved model prior to using this function. + + Parameters + ---------- + direction : string, optional + The direction to ge the maximum moment for. Options are 'Mx', 'My', or 'Mxy'. Default + is 'Mx'. + combo : string, optional + The name of the load combination to get the maximum moment for. If omitted, all load + combinations will be evaluated. + """ + + if direction in ['MX', 'MY', 'MZ']: + local = False + else: + local = True + + if direction.upper() == 'MX': + i = 0 + elif direction.upper() == 'MY': + i = 1 + elif direction == 'Mxy' or direction == 'MZ': + i = 2 + else: + raise Exception('Invalid direction specified for mesh moment results. Valid values are \'Mx\', \'My\', \'Mxy\', \'MX\', \'MY\', or \'MZ\'') + + # Initialize the maximum value to None + M_max = None + + # Step through each element in the mesh + for element in self.elements.values(): + + # Determine whether the element is a rectangle or a quadrilateral + if element.type == 'Rect': + # Use the rectangle's local (x, y) coordinate system + xi, yi = 0, 0 + xj, yj = element.width(), 0 + xm, ym = element.width(), element.height() + xn, yn, = 0, element.height() + elif element.type == 'Quad': + # Use the quad's natural (xi, eta) coordinate system + xi, yi = -1, -1 + xj, yj = 1, -1 + xm, ym = 1, 1 + xn, yn = -1, 1 + + # Step through each load combination the element utilizes + for load_combo in self.model.load_combos.values(): + + # Determine if this load combination should be evaluated + if combo is None or load_combo.name == combo: + + # Find the maximum moment in the element, checking each corner and the center + # of the element + M_element = max([element.moment(xi, yi, local, load_combo.name)[i, 0], + element.moment(xj, yj, local, load_combo.name)[i, 0], + element.moment(xm, ym, local, load_combo.name)[i, 0], + element.moment(xn, yn, local, load_combo.name)[i, 0], + element.moment((xi + xj)/2, (yi + yn)/2, local, load_combo.name)[i, 0]]) + + # Determine if the maximum moment calculated is the largest encountered so far + if M_max == None or M_max < M_element: + # Save this value if it's the largest + M_max = M_element + + # Return the largest value encountered from all the elements + return M_max + + def min_moment(self, direction='Mx', combo=None): + """ + Returns the minimum moment in the mesh. + + Checks corner and center moments in all the elements in the mesh. The mesh must be part of + a solved model prior to using this function. + + Parameters + ---------- + direction : string, optional + The direction to ge the minimum moment for. Options are 'Mx', 'My', or 'Mxy'. Default + is 'Mx'. + combo : string, optional + The name of the load combination to get the minimum moment for. If omitted, all load + combinations will be evaluated. + """ + + if direction in ['MX', 'MY', 'MZ']: + local = False + else: + local = True + + if direction.upper() == 'MX': + i = 0 + elif direction.upper() == 'MY': + i = 1 + elif direction == 'Mxy' or direction == 'MZ': + i = 2 + else: + raise Exception('Invalid direction specified for mesh moment results. Valid values are \'Mx\', \'My\', \'Mxy\', \'MX\', \'MY\', or \'MZ\'') + + # Initialize the minimum value to None + M_min = None + + # Step through each element in the mesh + for element in self.elements.values(): + + # Determine whether the element is a rectangle or a quadrilateral + if element.type == 'Rect': + # Use the rectangle's local (x, y) coordinate system + xi, yi = 0, 0 + xj, yj = element.width(), 0 + xm, ym = element.width(), element.height() + xn, yn, = 0, element.height() + elif element.type == 'Quad': + # Use the quad's natural (r, s) coordinate system + xi, yi = -1, -1 + xj, yj = 1, -1 + xm, ym = 1, 1 + xn, yn = -1, 1 + + # Step through each load combination the element utilizes + for load_combo in self.model.load_combos.values(): + + # Determine if this load combination should be evaluated + if combo is None or load_combo.name == combo: + + # Find the minimum moment in the element, checking each corner and the center + # of the element + M_element = min([element.moment(xi, yi, local, load_combo.name)[i, 0], + element.moment(xj, yj, local, load_combo.name)[i, 0], + element.moment(xm, ym, local, load_combo.name)[i, 0], + element.moment(xn, yn, local, load_combo.name)[i, 0], + element.moment((xi + xj)/2, (yi + yn)/2, local, load_combo.name)[i, 0]]) + + # Determine if the minimum moment calculated is the smallest encountered so far + if M_min == None or M_min > M_element: + # Save this value if it's the smallest + M_min = M_element + + # Return the smallest value encountered from all the elements + return M_min + +#%% +class RectangleMesh(Mesh): + + def __init__(self, mesh_size, width, height, thickness, material_name, model, kx_mod=1, ky_mod=1, origin=[0, 0, 0], plane='XY', x_control=None, y_control=None, start_node='N1', start_element='Q1', element_type='Quad'): + """ + A rectangular mesh of elements. + + Parameters + ---------- + mesh_size : number + Desired mesh size. + width : number + The overall width of the mesh measured along its local x-axis. + height : number + The overall height of the mesh measured along its local y-axis. + thickness : number + Element thickness. + material_name : string + The name of the element material. + model : FEModel3D + The model the mesh belongs to. + kx_mod : number + Stiffness modification factor for in-plane stiffness in the element's local + x-direction. Default value is 1.0 (no modification). + ky_mod : number + Stiffness modification factor for in-plane stiffness in the element's local + y-direction. Default value is 1.0 (no modification). + origin : list, optional + The origin of the rectangular mesh's local coordinate system. The default is [0, 0, 0]. + plane : string, optional + The plane the mesh will be parallel to. Options are 'XY', 'YZ', and 'XZ'. The default + is 'XY'. + x_control : list, optional + A list of control points along the mesh's local x-axis work into the mesh. + y_control : list, optional + A list of control points along the mesh's local y-axis work into the mesh. + start_node : string, optional + A unique name for the first node in the mesh. The default is 'N1'. + start_element : string, optional + A unique name for the first element in the mesh. The default is 'Q1' or 'R1' depending + on the type of element selected. + element_type : string, optional + The type of element to make the mesh out of. Either 'Quad' or 'Rect'. The default is + 'Quad'. + + Returns + ------- + A new rectangular mesh object. + + """ + + super().__init__(thickness, material_name, model, kx_mod, ky_mod, start_node, start_element) + self.mesh_size = mesh_size + self.width = width + self.height = height + self.origin = origin + self.plane = plane + + if x_control is None: self.x_control = [] + else: self.x_control = x_control + + if y_control is None: self.y_control = [] + else: self.y_control = y_control + + self.element_type = element_type + self.openings = {} + + def generate(self): + + mesh_size = self.mesh_size + width = self.width + height = self.height + Xo = self.origin[0] + Yo = self.origin[1] + Zo = self.origin[2] + plane = self.plane + x_control = self.x_control + y_control = self.y_control + + element_type = self.element_type + + # Add the mesh's boundaries to the list of control points + x_control.append(0) + x_control.append(width) + y_control.append(0) + y_control.append(height) + + # Sort the control points in ascending order + x_control = sorted(x_control) + y_control = sorted(y_control) + + # Remove any values that are duplicates or near duplicates from `x_control` + unique_list = [] + for i in range(len(x_control) - 1): + # Only keep the value at `i` if it's not a duplicate or near duplicate of the next value + if not isclose(x_control[i], x_control[i+1]): + unique_list.append(x_control[i]) + unique_list.append(x_control[-1]) + x_control = unique_list + + # Remove any values that are duplicates or near duplicates from `y_control` + unique_list = [] + for i in range(len(y_control) - 1): + # Only keep the value at `i` if it's not a duplicate or near duplicate of the next value + if not isclose(y_control[i], y_control[i+1]): + unique_list.append(y_control[i]) + unique_list.append(y_control[-1]) + y_control = unique_list + + # Each node number will be increased by the offset calculated below + node_offset = int(self.start_node[1:]) - 1 + + # Each element number will be increased by the offset calculated below + element_offset = int(self.start_element[1:]) - 1 + + # Determine which prefix to assign to new elements + if element_type == 'Quad': + element_prefix = 'Q' + elif element_type == 'Rect': + element_prefix = 'R' + else: + raise Exception('Invalid element type specified for RectangleMesh. Select \'Quad\' or \'Rect\'.') + + # Initialize node numbering + node_num = 1 + + # Step through each y control point (except the first one which is always zero) + num_rows = 0 + num_cols = 0 + y, h = 0, None + for j in range(1, len(y_control), 1): + + # If this is not the first iteration 'y' will be too high at this point. + if j != 1: + y -= h + + # Determine the mesh size between this y control point and the previous one + ny = max(1, (y_control[j] - y_control[j - 1])/mesh_size) + h = (y_control[j] - y_control[j - 1])/ceil(ny) + + # Adjust 'y' if this is not the first iteration. + if j != 1: + y += h + + # Generate nodes between the y control points + while round(y, 10) <= round(y_control[j], 10): + + # Count the number of rows of plates as we go + num_rows += 1 + + # Step through each x control point (except the first one which is always zero) + x, b = 0, None + for i in range(1, len(x_control), 1): + + # 'x' needs to be adjusted for the same reasons 'y' needed to be adjusted + if i != 1: + x -= b + + # Determine the mesh size between this x control point and the previous one + nx = max(1, (x_control[i] - x_control[i - 1])/mesh_size) + b = (x_control[i] - x_control[i - 1])/ceil(nx) + + if i != 1: + x += b + + # Generate nodes between the x control points + while round(x, 10) <= round(x_control[i], 10): + + # Count the number of columns of plates as we go + if y == 0: + num_cols += 1 + + # Assign the node a name + node_name = 'N' + str(node_num + node_offset) + + # Calculate the node's coordinates + if plane == 'XY': + X = Xo + x + Y = Yo + y + Z = Zo + 0 + elif plane == 'YZ': + X = Xo + 0 + Y = Yo + y + Z = Zo + x + elif plane == 'XZ': + X = Xo + x + Y = Yo + 0 + Z = Zo + y + else: + raise Exception('Invalid plane selected for RectangleMesh.') + + # Add the node to the mesh + self.nodes[node_name] = Node3D(node_name, X, Y, Z) + + # Move to the next x coordinate + x += b + + # Move to the next node number + node_num += 1 + + # Move to the next y coordinate + y += h + + # At this point `num_cols` and `num_rows` represent the number of columns and rows of + # nodes. We'll adjust these variables to be the number of columns and rows of elements + # instead. + num_cols -= 1 + num_rows -= 1 + + # Create the elements + r = 1 + n = 1 + for i in range(1, num_cols*num_rows + 1, 1): + + # Assign the element a name + element_name = element_prefix + str(i + element_offset) + + # Find the attached nodes + i_node = n + (r - 1) + j_node = i_node + 1 + m_node = j_node + (num_cols + 1) + n_node = m_node - 1 + + if i % num_cols == 0: + r += 1 + + n += 1 + + if element_type == 'Quad': + self.elements[element_name] = Quad3D(element_name, self.nodes['N' + str(i_node + node_offset)], + self.nodes['N' + str(j_node + node_offset)], + self.nodes['N' + str(m_node + node_offset)], + self.nodes['N' + str(n_node + node_offset)], + self.thickness, self.material_name, self.model, self.kx_mod, self.ky_mod) + else: + self.elements[element_name] = Plate3D(element_name, self.nodes['N' + str(i_node + node_offset)], + self.nodes['N' + str(j_node + node_offset)], + self.nodes['N' + str(m_node + node_offset)], + self.nodes['N' + str(n_node + node_offset)], + self.thickness, self.material_name, self.model, self.kx_mod, self.ky_mod) + + # Initialize a list of nodes and associated elements that fall within opening boundaries + # that will be deleted + node_del_list = [] + element_del_list = [] + + # Go back through the mesh and delete any nodes that are in the openings + for node in self.nodes.values(): + + # Get the node's position in the mesh's local coordinate sytem. + x, y = self.node_local_coords(node) + + # Step through each opening in the mesh + for opng in self.openings.values(): + + # Determine if the node falls within the boundaries of the opening + if (round(x, 10) > round(opng.x_left, 10) + and round(x, 10) < round(opng.x_left + opng.width, 10) + and round(y, 10) > round(opng.y_bott, 10) + and round(y, 10) < round(opng.y_bott + opng.height, 10)): + + # Mark the node for deletion if it's not already marked + if node.name not in node_del_list: + node_del_list.append(node.name) + + # Go back through the mesh and delete any elements that are in the openings + for element in self.elements.values(): + + # Find the top, bottom, left side and right side of the element in local coordinates + left, top = self.node_local_coords(element.n_node) + right, bott = self.node_local_coords(element.j_node) + + for opng in self.openings.values(): + + # Determine if the element falls within the boundaries of the opening + if ((round(opng.y_bott + opng.height, 10) >= round(top, 10)) + and (round(opng.y_bott, 10) <= round(bott, 10)) + and (round(opng.x_left, 10) <= round(left, 10)) + and (round(opng.x_left + opng.width, 10) >= round(right, 10))): + + # Mark the element for deletion if it's not already marked + if element.name not in element_del_list: + element_del_list.append(element.name) + + # Delete the elements marked for deletion + for element_name in element_del_list: + del self.elements[element_name] + + # Delete the nodes marked for deletion + for node_name in node_del_list: + del self.nodes[node_name] + + # Find any remaining orphaned nodes around the perimeter of the mesh + node_del_list = [] + for node in self.nodes.values(): + if (node not in [element.i_node for element in self.elements.values()] + and node not in [element.j_node for element in self.elements.values()] + and node not in [element.m_node for element in self.elements.values()] + and node not in [element.n_node for element in self.elements.values()]): + node_del_list.append(node.name) + + # Delete the orphaned nodes + for node_name in node_del_list: + del self.nodes[node_name] + + # Identify the last node and last element in the mesh + self.last_node = list(self.nodes.values())[-1] + self.last_element = list(self.elements.values())[-1] + + # At this point we have a mesh, but some of the element or node names may already be + # being used in the model. Rename any names that are already being used. + self._rename_duplicates() + + # Add the nodes to the model + for key, node in self.nodes.items(): + self.model.nodes[key] = node + + # Add the elements to the model + for key, element in self.elements.items(): + if element.type == 'Quad': + self.model.quads[key] = element + elif element.type == 'Rect': + self.model.plates[key] = element + + # Flag the mesh as generated + self._is_generated = True + + def node_local_coords(self, node): + """ + Calculates a node's position in the mesh's local coordinate system + """ + + if self.plane == 'XY': + x = node.X - self.origin[0] + y = node.Y - self.origin[1] + elif self.plane == 'YZ': + x = node.Z - self.origin[2] + y = node.Y - self.origin[1] + elif self.plane == 'XZ': + x = node.X - self.origin[0] + y = node.Z - self.origin[2] + + return x, y + + def add_rect_opening(self, name, x_left, y_bott, width, height): + """ + Adds a rectangular opening to the mesh. + + Parameters + ---------- + name : string + A unique name for the opening that can be used to access it later + on. + x_left : number + The x-coordinate for the left side of the opening in the mesh's + local coordinate system. + y_bott : number + The y-coordinate for the bottom of the opening in the mesh's local + coordinate system + width : number + The width of the opening. + height : number + The height of the opening. + """ + + self.openings[name] = RectOpening(x_left, y_bott, width, height) + self.x_control.append(x_left) + self.y_control.append(y_bott) + self.x_control.append(x_left + width) + self.y_control.append(y_bott + height) + + # Flag the mesh as not generated yet + self._is_generated = False + +#%% +class RectOpening(): + """ + Represents a rectangular opening in a rectangular mesh. + """ + + def __init__(self, x_left, y_bott, width, height): + """ + Parameters + ---------- + x_left : number + The x-coordinate for the left side of the opening in the mesh's + local coordinate system. + y_bott : number + The y-coordinate for the bottom of the opening in the mesh's local + coordinate system + width : number + The width of the opening. + height : number + The height of the opening. + """ + + self.x_left = x_left + self.y_bott = y_bott + self.width = width + self.height = height + +#%% +class AnnulusMesh(Mesh): + """ + A mesh of quadrilaterals forming an annulus (a donut). + """ + + def __init__(self, mesh_size, outer_radius, inner_radius, thickness, material_name, model, kx_mod=1, + ky_mod=1, origin=[0, 0, 0], axis='Y', start_node='N1', start_element='Q1'): + + super().__init__(thickness, material_name, model, kx_mod, ky_mod, start_node, start_element) + + self.inner_radius = inner_radius + self.outer_radius = outer_radius + self.mesh_size = mesh_size + self.origin = origin + self.axis = axis + + self.num_quads_inner = None + self.num_quads_outer = None + + # self.generate() + + def generate(self): + + mesh_size = self.mesh_size + r_outer = self.outer_radius + r_inner = self.inner_radius + n = int(self.start_node[1:]) + q = int(self.start_element[1:]) + + circumf = 2*pi*r_inner # Circumference of the ring at the inner radius + n_circ = int(circumf/mesh_size) # Number of times `mesh_size` fits in the circumference + self.num_quads_outer = n_circ + + # Mesh the annulus from the inside toward the outside + while round(r_inner, 10) < round(r_outer, 10): + + radial = r_outer - r_inner # Remaining length in the radial direction to be meshed + circumf = 2*pi*r_inner # Circumference of the ring at the inner radius + b_circ = circumf/n_circ # Element width in the circumferential direction + n_rad = int(radial/min(mesh_size, 3*b_circ)) # Number of times the plate width fits in the remaining unmeshed radial direction + h_rad = radial/n_rad # Element height in the radial direction + + # Determine if the mesh is getting too big. If so the mesh will need to transition to a + # finer mesh. + if b_circ > 3*mesh_size: + transition = True + else: + transition = False + + # Create a mesh of nodes for the ring + if transition == True: + ring = AnnulusTransRingMesh(r_inner + h_rad, r_inner, n_circ, self.thickness, self.material_name, self.model, self.kx_mod, self.ky_mod, + self.origin, self.axis, 'N' + str(n), 'Q' + str(q)) + n += 3*n_circ + q += 4*n_circ + n_circ *= 3 + self.num_quads_outer = n_circ + else: + ring = AnnulusRingMesh(r_inner + h_rad, r_inner, n_circ, self.thickness, self.material_name, self.model, self.kx_mod, self.ky_mod, self.origin, + self.axis, 'N' + str(n), 'Q' + str(q)) + n += n_circ + q += n_circ + + # Add the newly generated nodes and elements to the overall mesh. Note that if duplicate + # keys exist, the `.update()` method will overwrite them with the newly generated key value + # pairs. This works in our favor by automatically eliminating duplicate nodes from the + # dictionary. + self.nodes.update(ring.nodes) + self.elements.update(ring.elements) + + # Prepare to move to the next ring + r_inner += h_rad + + # After calling the `.update()` method some elements are still attached to the duplicate + # nodes that are no longer in the dictionary. Attach these plates to the nodes that are + # still in the dictionary instead. + for element in self.elements.values(): + element.i_node = self.nodes[element.i_node.name] + element.j_node = self.nodes[element.j_node.name] + element.m_node = self.nodes[element.m_node.name] + element.n_node = self.nodes[element.n_node.name] + + # Add the nodes to the model + for node in self.nodes.values(): + self.model.nodes[node.name] = node + + # Add the elements to the model + for element in self.elements.values(): + if element.type.upper() == 'QUAD': + self.model.quads[element.name] = element + elif element.type.upper() == 'RECT': + self.model.plates[element.name] = element + + # Flag the mesh as generated + self._is_generated = True + +#%% +class AnnulusRingMesh(Mesh): + """ + A mesh of quadrilaterals forming an annular ring (a donut). + """ + + def __init__(self, outer_radius, inner_radius, num_quads, thickness, material_name, model, kx_mod=1, ky_mod=1, + origin=[0, 0, 0], axis='Y', start_node='N1', start_element='Q1'): + + super().__init__(thickness, material_name, model, kx_mod, ky_mod, start_node=start_node, + start_element=start_element) + + self.inner_radius = inner_radius + self.outer_radius = outer_radius + self.n = num_quads + self.Xo = origin[0] + self.Yo = origin[1] + self.Zo = origin[2] + + self.axis = axis + + # Generate the nodes and elements + self.generate() + + def generate(self): + + n = self.n # Number of plates in the initial ring + + inner_radius = self.inner_radius # The inner radius of the ring + outer_radius = self.outer_radius # The outer radius of the ring + + Xo = self.Xo # Global X-coordinate of the center of the ring + Yo = self.Yo # Global Y-coordinate of the center of the ring + Zo = self.Zo # Global Z-coordinate of the center of the ring + + axis = self.axis + + theta = 2*pi/self.n # Angle between nodes in the ring + + # Each node number will be increased by the offset calculated below + node_offset = int(self.start_node[1:]) - 1 + + # Each element number will be increased by the offset calculated below + element_offset = int(self.start_element[1:]) - 1 + + # Generate the nodes that make up the ring, working from the inside to the outside + angle = 0 + for i in range(1, 2*n + 1, 1): + + # Assign the node a name + node_name = 'N' + str(i + node_offset) + + # Generate the inner radius of nodes + if i <= n: + angle = theta*(i - 1) + if axis == 'Y': + x = Xo + inner_radius*cos(angle) + y = Yo + z = Zo + inner_radius*sin(angle) + elif axis == 'X': + x = Xo + y = Yo + inner_radius*sin(angle) + z = Zo + inner_radius*cos(angle) + elif axis == 'Z': + x = Xo + inner_radius*sin(angle) + y = Yo + inner_radius*cos(angle) + z = Zo + else: + raise Exception('Invalid axis specified for AnnulusRingMesh.') + + # Generate the outer radius of nodes + else: + angle = theta*((i - n) - 1) + if axis == 'Y': + x = Xo + outer_radius*cos(angle) + y = Yo + z = Zo + outer_radius*sin(angle) + elif axis == 'X': + x = Xo + y = Yo + outer_radius*sin(angle) + z = Zo + outer_radius*cos(angle) + elif axis == 'Z': + x = Xo + outer_radius*sin(angle) + y = Yo + outer_radius*cos(angle) + z = Zo + else: + raise Exception('Invalid axis specified for AnnulusRingMesh.') + + self.nodes[node_name] = Node3D(node_name, x, y, z) + + # Generate the elements that make up the ring + for i in range(1, n + 1, 1): + + # Assign the element a name + element_name = 'Q' + str(i + element_offset) + + n_node = i + i_node = i + n + if i != n: + m_node = i + 1 + j_node = i + 1 + n + else: + m_node = 1 + j_node = 1 + n + + self.elements[element_name] = Quad3D(element_name, self.nodes['N' + str(i_node + node_offset)], + self.nodes['N' + str(j_node + node_offset)], + self.nodes['N' + str(m_node + node_offset)], + self.nodes['N' + str(n_node + node_offset)], + self.thickness, self.material_name, self.model, self.kx_mod, self.ky_mod) + + # Add the nodes and elements to the model + for node in self.nodes.values(): + self.model.nodes[node.name] = node + + # Add the elements to the model + for element in self.elements.values(): + if element.type.upper() == 'QUAD': + self.model.quads[element.name] = element + if element.type.upper() == 'RECT': + self.model.plates[element.name] = element + + # Flag the mesh as generated + self._is_generated = True + +#%% +class AnnulusTransRingMesh(Mesh): + """ + A mesh of quadrilaterals forming an annular ring (a donut) with the mesh getting finer on the outer + edge. + """ + + def __init__(self, outer_radius, inner_radius, num_inner_quads, thickness, material_name, model, + kx_mod=1, ky_mod=1, origin=[0, 0, 0], axis='Y', start_node='N1', + start_element='Q1'): + """ + Parameters + ---------- + direction : array + A vector indicating the direction normal to the ring. + """ + + super().__init__(thickness, material_name, model, kx_mod, ky_mod, start_node=start_node, + start_element=start_element) + + self.inner_radius = inner_radius + self.outer_radius = (inner_radius + outer_radius)/2 + self.r3 = outer_radius + self.n = num_inner_quads + self.Xo = origin[0] + self.Yo = origin[1] + self.Zo = origin[2] + self.axis = axis + + # Create the mesh + self.generate() + + def generate(self): + + n = self.n # Number of plates in the outside of the ring (coarse mesh) + + inner_radius = self.inner_radius # The inner radius of the ring + outer_radius = self.outer_radius # The center radius of the ring + r3 = self.r3 # The outer radius of the ring + + Xo = self.Xo # Global X-coordinate of the center of the ring + Yo = self.Yo # Global Y-coordinate of the center of the ring + Zo = self.Zo # Global Z-coordinate of the center of the ring + + axis = self.axis + + theta1 = 2*pi/self.n # Angle between nodes at the inner radius of the ring + theta2 = 2*pi/(self.n*3) # Angle between nodes at the center of the ring + theta3 = 2*pi/(self.n*3) # Angle between nodes at the outer radius of the ring + + # Each node number will be increased by the offset calculated below + node_offset = int(self.start_node[1:]) - 1 + + # Each element number will be increased by the offset calculated below + element_offset = int(self.start_element[1:]) - 1 + + # Generate the nodes that make up the ring, working from the inside to the outside + angle = 0 + for i in range(1, 6*n + 1, 1): + + # Assign the node a name + node_name = 'N' + str(i + node_offset) + + # Generate the inner radius of nodes + if i <= n: + angle = theta1*(i - 1) + if axis == 'Y': + x = Xo + inner_radius*cos(angle) + y = Yo + z = Zo + inner_radius*sin(angle) + elif axis == 'X': + x = Xo + y = Yo + inner_radius*sin(angle) + z = Zo + inner_radius*cos(angle) + elif axis == 'Z': + x = Xo + inner_radius*sin(angle) + y = Yo + inner_radius*cos(angle) + z = Zo + else: + raise Exception('Invalid axis specified for AnnulusTransRingMesh.') + + # Generate the center radius of nodes + elif i <= 3*n: + if (i - n) == 1: + angle = theta2 + elif (i - n) % 2 == 0: + angle += theta2 + else: + angle += 2*theta2 + if axis == 'Y': + x = Xo + outer_radius*cos(angle) + y = Yo + z = Zo + outer_radius*sin(angle) + elif axis == 'X': + x = Xo + y = Yo + outer_radius*sin(angle) + z = Zo + outer_radius*cos(angle) + elif axis == 'Z': + x = Xo + outer_radius*sin(angle) + y = Yo + outer_radius*cos(angle) + z = Zo + # Generate the outer radius of nodes + else: + if (i - 3*n) == 1: + angle = 0 + else: + angle = theta3*((i - 3*n) - 1) + if axis == 'Y': + x = Xo + r3*cos(angle) + y = Yo + z = Zo + r3*sin(angle) + elif axis == 'X': + x = Xo + y = Yo + r3*sin(angle) + z = Zo + r3*cos(angle) + elif axis == 'Z': + x = Xo + r3*sin(angle) + y = Yo + r3*cos(angle) + z = Zo + else: + raise Exception('Invalid axis specified for AnnulusTransRingMesh.') + + self.nodes[node_name] = Node3D(node_name, x, y, z) + + # Generate the elements that make up the ring + for i in range(1, 4*n + 1, 1): + + # Assign the element a name + element_name = 'Q' + str(i + element_offset) + + if i <= n: + n_node = i + j_node = 2*i + n + i_node = 2*i + n - 1 + if i != n: + m_node = i + 1 + else: + m_node = 1 + elif (i - n) % 3 == 1: + n_node = 1 + (i - (n + 1))//3 + m_node = i - (i - (n + 1))//3 + j_node = i + 2*n + 1 + i_node = i + 2*n + elif (i - n) % 3 == 2: + n_node = i - 1 - (i - (n + 1))//3 + m_node = i - (i - (n + 1))//3 + j_node = i + 2*n + 1 + i_node = i + 2*n + else: + n_node = i - 1 - (i - (n + 1))//3 + i_node = i + 2*n + if i != 4*n: + m_node = 2 + (i - (n + 1))//3 + j_node = i + 2*n + 1 + else: + m_node = 1 + j_node = 1 + 3*n + + self.elements[element_name] = Quad3D(element_name, self.nodes['N' + str(i_node + node_offset)], + self.nodes['N' + str(j_node + node_offset)], + self.nodes['N' + str(m_node + node_offset)], + self.nodes['N' + str(n_node + node_offset)], + self.thickness, self.material_name, self.model, self.kx_mod, self.ky_mod) + + # Add the nodes and elements to the model + for node in self.nodes.values(): + self.model.nodes[node.name] = node + + for element in self.elements.values(): + if element.type == 'Quad': + self.model.quads[element.name] = element + else: + self.model.plates[element.name] = element + + # Flag the mesh as generated + self._is_generated = True + +#%% +class FrustrumMesh(AnnulusMesh): + """ + A mesh of quadrilaterals forming a frustrum (a cone intersected by a horizontal plane). + """ + + def __init__(self, mesh_size, large_radius, small_radius, height, thickness, material_name, model, kx_mod=1, ky_mod=1, + origin=[0, 0, 0], axis='Y', start_node='N1', start_element='Q1'): + + # Create an annulus mesh + super().__init__(mesh_size, large_radius, small_radius, thickness, material_name, model, kx_mod, + ky_mod, origin, axis, start_node, start_element) + + self.height = height + + def generate(self): + + super().generate() + + Xo = self.origin[0] + Yo = self.origin[1] # Not used + Zo = self.origin[2] + + # Adjust the cooridnates of each node to make a frustrum + for node in self.nodes.values(): + X = node.X + Y = node.Y + Z = node.Z + r = ((X - Xo)**2 + (Z - Zo)**2)**0.5 + if self.axis == 'Y': + node.Y += (r - self.outer_radius)/(self.outer_radius - self.inner_radius)*self.height + elif self.axis == 'X': + node.X += (r - self.outer_radius)/(self.outer_radius - self.inner_radius)*self.height + elif self.axis == 'Z': + node.Z += (r - self.outer_radius)/(self.outer_radius - self.inner_radius)*self.height + else: + raise Exception('Invalid axis specified for frustrum mesh.') + +#%% +class CylinderMesh(Mesh): + """ + A mesh of quadrilaterals forming a cylinder. + + The mesh is formed with the local y-axis of the elements pointed toward + the base of the cylinder + + Parameters + ---------- + mesh_size : number + The desired mesh element edge size. This value will only be used to mesh vertically if `num_elements` is + specified. Otherwise it will be used to mesh the circumference too. + radius : number + The radius of the cylinder to the element centers + height : number + Total height of the cylinder. + thickness : number + Element thickness. + material_name : string + The name of the element material. + kx_mod : number + Stiffness modification factor for in-plane stiffness in the element's local + x-direction. Default value is 1.0 (no modification). + ky_mod : number + Stiffness modification factor for in-plane stiffness in the element's local + y-direction. Default value is 1.0 (no modification). + start_node : string, optional + The name of the first node in the mesh. The name must be formatted starting with a single + letter followed by a number (e.g. 'N12'). The mesh will begin numbering nodes from this + number. The default is 'N1'. + start_element : string, optional + The name of the first element in the mesh. The name must be formatted starting with a + single letter followed by a number (e.g. 'Q32'). The mesh will begin numbering elements + from this number. The default is 'Q1'. + num_elements : number, optional + The number of quadrilaterals to divide the circumference into. If this value is omitted + `mesh_size` will be used instead to calculate the number of quadrilaterals in the + circumference. The default is `None`. + element_type : string + The type of element to use for the mesh: 'Quad' or 'Rect' + """ + + def __init__(self, mesh_size, radius, height, thickness, material_name, model, kx_mod=1, ky_mod=1,origin=[0, 0, 0], axis='Y', start_node='N1', start_element='Q1', num_elements=None, element_type='Quad'): + + # Inherit properties and methods from the parent `Mesh` class + super().__init__(thickness, material_name, model, kx_mod, ky_mod, start_node, start_element) + + # Define a few new additional class properties related to cylinders + self.radius = radius + self.h = height + self.mesh_size = mesh_size + self.origin = origin + self.axis = axis + + # Check if the user has requested a specific number of elements for each course of plates. This can be useful for ensuring the mesh matches up with other meshes. + if num_elements == None: + # Calculate the number of elements if the user hasn't specified + self.num_elements = int(round(2*pi*radius/mesh_size, 0)) + else: + # Use the user specified number of elements + self.num_elements = num_elements + + # Check which type of element the user has requested (rectangular plate or quad) + self.element_type = element_type + + # Generate the mesh + self.generate() + + def generate(self): + + # Get the mesh thickness and the material name + thickness = self.thickness + material_name = self.material_name + + mesh_size = self.mesh_size # Desired mesh size + num_elements = self.num_elements # Number of quadrilaterals in each course of the ring + n = self.num_elements # Total number of elements in the mesh (initialized for a single ring at the moment) + + radius = self.radius + h = self.h + + # Set the cylinder base's local y-coordinate + if self.axis == 'Y': + y = self.origin[1] + elif self.axis == 'X': + y = self.origin[0] + elif self.axis == 'Z': + y = self.origin[2] + + n = int(self.start_node[1:]) + q = int(self.start_element[1:]) + + element_type = self.element_type + + # Determine the number of quads to mesh the circumference into + if num_elements == None: + num_elements = int(2*pi/mesh_size) + + # Mesh the cylinder from the bottom toward the top + while round(y, 10) < round(h, 10): + + height = h - y #Remaining height to be meshed + # Number of times the plate height fits in the remaining unmeshed height, resulting at least one element + n_vert = max(int(abs(height)/mesh_size),1) + h_y = height/n_vert # Element height in the vertical direction + # Create a mesh of nodes for the ring + if self.axis == 'Y': + ring = CylinderRingMesh(radius, h_y, num_elements, thickness, material_name, self.model, 1, 1, [0, y, 0], self.axis, 'N' + str(n), 'Q' + str(q), element_type) + elif self.axis == 'X': + ring = CylinderRingMesh(radius, h_y, num_elements, thickness, material_name, self.model, 1, 1, [y, 0, 0], self.axis, 'N' + str(n), 'Q' + str(q), element_type) + elif self.axis == 'Z': + ring = CylinderRingMesh(radius, h_y, num_elements, thickness, material_name, self.model, 1, 1, [0, 0, y], self.axis, 'N' + str(n), 'Q' + str(q), element_type) + + n += num_elements + q += num_elements + + # Add the newly generated nodes and elements to the overall mesh. Note that if duplicate keys exist, the `.update()` method will overwrite them with the newly generated key value pairs. This works in our favor by automatically eliminating duplicate nodes at the shared boundaries between rings. + self.nodes.update(ring.nodes) + self.elements.update(ring.elements) + + # Prepare to move to the next ring + y += h_y + + # After calling the `.update()` method some elements are still attached to the duplicate nodes that are no longer in the dictionary. Attach these plates to the nodes that are still in the dictionary instead. + for element in self.elements.values(): + element.i_node = self.nodes[element.i_node.name] + element.j_node = self.nodes[element.j_node.name] + element.m_node = self.nodes[element.m_node.name] + element.n_node = self.nodes[element.n_node.name] + + # Add the nodes and elements to the model + for node in self.nodes.values(): + self.model.nodes[node.name] = node + + for element in self.elements.values(): + if element.type == 'Quad': + self.model.quads[element.name] = element + else: + self.model.plates[element.name] = element + + # Flag the mesh as generated + self._is_generated = True + +#%% +class CylinderRingMesh(Mesh): + """ + A mesh of quadrilaterals forming a cylindrical ring. + + Parameters + ---------- + radius : number + Radius to the center of the plates in the cylindrical ring. + height : number + Height of the cylindrical ring. + num_elements : number + Number of elements used to generate the cylindrical ring. + thickness : number + Element thickness. + material_name : string + The name of the element material. + kx_mod : number + Stiffness modification factor for in-plane stiffness in the element's local + x-direction. Default value is 1.0 (no modification). + ky_mod : number + Stiffness modification factor for in-plane stiffness in the element's local + y-direction. Default value is 1.0 (no modification). + origin : list + The location of the center of the base of the cylindrical ring. Default is [0, 0, 0]. + axis : string + Global axis about which to revolve the ring ('X', 'Y', or 'Z'). Default is 'Y'. + start_node : string, optional + The name of the first node in the mesh. The name must be formatted starting with a single + letter followed by a number (e.g. 'N12'). The mesh will begin numbering nodes from this + number. The default is 'N1'. + start_element : string, optional + The name of the first element in the mesh. The name must be formatted starting with a + single letter followed by a number (e.g. 'Q32'). The mesh will begin numbering elements + from this number. The default is 'Q1'. + num_elements : number + The number of elements to divide the circumference into. + + """ + + def __init__(self, radius, height, num_elements, thickness, material_name, model, kx_mod=1, ky_mod=1, + origin=[0, 0, 0], axis='Y', start_node='N1', start_element='Q1', + element_type='Quad'): + + super().__init__(thickness, material_name, model, kx_mod, ky_mod, start_node=start_node, start_element=start_element) + + self.radius = radius + self.height = height + self.num_elements = num_elements + self.Xo = origin[0] + self.Yo = origin[1] + self.Zo = origin[2] + self.axis = axis + self.element_type = element_type + + # Generate the nodes and elements + self.generate() + + def generate(self): + """ + Generates the nodes and elements in the mesh. + """ + + num_elements = self.num_elements # Number of quadrilaterals in the ring + n = self.num_elements + + radius = self.radius # The radius of the ring + height = self.height # The height of the ring + + Xo = self.Xo # Global X-coordinate of the center of the bottom of the ring + Yo = self.Yo # Global Y-coordinate of the center of the bottom of the ring + Zo = self.Zo # Global Z-coordinate of the center of the bottom of the ring + + axis = self.axis + + # Calculate the angle between nodes in the circumference of the ring + theta = 2*pi/num_elements + + # Each node number will be increased by the offset calculated below + try: + node_offset = int(self.start_node[1:]) - 1 + except: + raise ValueError('Invalid node name. Enter a letter followed by a number (e.g. \'N25\')') + + # Each element number will be increased by the offset calculated below + try: + element_offset = int(self.start_element[1:]) - 1 + except: + raise ValueError('Invalid element ame. Enter a letter followed by a number (e.g. \'Q83\')') + + # Generate the nodes that make up the ring + angle = 0 + for i in range(1, 2*n + 1, 1): + + # Assign the node a name + node_name = 'N' + str(i + node_offset) + + # Generate the bottom nodes of the ring + if i <= n: + angle = theta*(i - 1) + if axis == 'Y': + x = Xo + radius*cos(angle) + y = Yo + z = Zo + radius*sin(angle) + elif axis == 'X': + x = Xo + y = Yo + radius*sin(angle) + z = Zo + radius*cos(angle) + elif axis == 'Z': + x = Xo + radius*sin(angle) + y = Yo + radius*cos(angle) + z = Zo + else: + raise Exception('Invalid axis specified for CylinderRingMesh.') + + # Generate the top nodes of the ring + else: + angle = theta*((i - n) - 1) + if axis == 'Y': + x = Xo + radius*cos(angle) + y = Yo + height + z = Zo + radius*sin(angle) + elif axis == 'X': + x = Xo + height + y = Yo + radius*sin(angle) + z = Zo + radius*cos(angle) + elif axis == 'Z': + x = Xo + radius*sin(angle) + y = Yo + radius*cos(angle) + z = Zo + height + else: + raise Exception('Invalid axis specified for CylinderRingMesh.') + + self.nodes[node_name] = Node3D(node_name, x, y, z) + + # Generate the elements that make up the ring + for i in range(1, n + 1, 1): + + # Assign the element a name + if self.element_type == 'Quad': + element_name = 'Q' + str(i + element_offset) + elif self.element_type == 'Rect': + element_name = 'R' + str(i + element_offset) + else: + raise Exception('Invalid element type specified for cylinder ring mesh.') + + # Assign nodes to the element + n_node = i + i_node = i + n + if i != n: + m_node = i + 1 + j_node = i + 1 + n + else: + m_node = 1 + j_node = 1 + n + + # Create the element and add it to the `elements` dictionary + if self.element_type == 'Quad': + self.elements[element_name] = Quad3D(element_name, self.nodes['N' + str(i_node + node_offset)], + self.nodes['N' + str(j_node + node_offset)], + self.nodes['N' + str(m_node + node_offset)], + self.nodes['N' + str(n_node + node_offset)], + self.thickness, self.material_name, self.model, self.kx_mod, self.ky_mod) + elif self.element_type == 'Rect': + self.elements[element_name] = Plate3D(element_name, self.nodes['N' + str(i_node + node_offset)], + self.nodes['N' + str(j_node + node_offset)], + self.nodes['N' + str(m_node + node_offset)], + self.nodes['N' + str(n_node + node_offset)], + self.thickness, self.material_name, self.model, self.kx_mod, self.ky_mod) + + # Add the nodes and elements to the model + for node in self.nodes.values(): + self.model.nodes[node.name] = node + + for element in self.elements.values(): + if element.type == 'Quad': + self.model.quads[element.name] = element + else: + self.model.plates[element.name] = element + + # Flag the mesh as generated + self._is_generated = True + +def check_mesh_integrity(mesh, console_log=True): + """Runs basic integrity checks to ensure the mesh is in sync with its model. Usually you don't + want to run this check unless the mesh has been generated since generating the mesh is what + syncs it to the model. + + :param mesh: A mesh of finite elements. + :type mesh: Mesh + :param console_log: Determines whether the results will be printed to the console or returned + as a list of strings. Defaults to True. + :type console_log: bool, optional + :return: Any errors discovered by the integrity check + :rtype: list + """ + + # Initialize an empty list for error messages + errors = [] + + # Check that every element in the mesh is attached at all four nodes to nodes that are in the mesh + count = 0 + for element in mesh.elements.values(): + + if (element.i_node not in mesh.nodes.values() or + element.j_node not in mesh.nodes.values() or + element.m_node not in mesh.nodes.values() or + element.n_node not in mesh.nodes.values()): + + count += 1 + + # Prepare the error message + if count != 0: + errors.append(str(count) + ' elements are attached to nodes that are not in the mesh.') + + # Check that every element in the mesh is attached at all four nodes to nodes that are in the model + count = 0 + for element in mesh.elements.values(): + + if (element.i_node not in mesh.model.nodes.values() or + element.j_node not in mesh.model.nodes.values() or + element.m_node not in mesh.model.nodes.values() or + element.n_node not in mesh.model.nodes.values()): + + count += 1 + + # Prepare the error message + if count != 0: + errors.append(str(count) + ' elements are attached to nodes that are not in the model.') + + # Check that each element has 4 unique nodes + count = 0 + for element in mesh.elements.values(): + + if (element.i_node is element.j_node or + element.i_node is element.m_node or + element.i_node is element.n_node or + element.j_node is element.m_node or + element.j_node is element.n_node or + element.m_node is element.n_node): + + count += 1 + + # Prepare the error message + if count != 0: + errors.append(str(count) + ' elements have duplicate nodes in them.') + + # Check that each element's name in the mesh is in the model + count = 0 + for element in mesh.elements.values(): + if element.name not in mesh.model.plates.keys() and element.name not in mesh.model.quads.keys(): + count += 1 + + if count != 0: + errors.append(str(count) + ' element names in the mesh were not found in the model.') + + # Check that each element in the mesh is in the model + count = 0 + for element in mesh.elements.values(): + if element not in mesh.model.plates.values() and element.name not in mesh.model.quads.values(): + count += 1 + + if count != 0: + errors.append(str(count) + ' elements in the mesh were not found in the model.') + + # Check that each element in the mesh matches its corresponding element in the model + count = 0 + for element in mesh.elements.values(): + + if mesh.element_type == 'Rect': + if element.name not in mesh.model.plates.keys() or mesh.elements[element.name] is not mesh.model.plates[element.name]: + count +=1 + elif mesh.element_type == 'Quad': + if element.name not in mesh.model.quads.keys() or mesh.elements[element.name] is not mesh.model.quads[element.name]: + count += 1 + + # Prepare the error message + if count != 0: + errors.append(str(count) + ' elements in the mesh do not match their respective elements in the model.') + + # Check that the mesh's key for each node matches the node's name + count = 0 + for node in mesh.nodes.values(): + if node.name not in mesh.nodes.keys(): + count += 1 + + if count != 0: + errors.append(str(count) + ' node names in the mesh do not match the mesh\'s `nodes` dictionary\'s keys.') + + # # Check that each node in the mesh matches its corresponding node in the model + # count = 0 + # for node in mesh.nodes.values(): + + # if node.name not in mesh.model.nodes.keys() or mesh.nodes[node.name] is not mesh.model.nodes[node.name]: + # count += 1 + + # # Prepare the error message + # if count != 0: + # errors.append(str(count) + ' nodes in the mesh do not match their respective nodes in the model.') + + # TODO: Add more integrity checks and error messages + + # Report if no errors were found + if errors == []: + errors = ['No errors detected.'] + + # Return the error messages + if console_log == True: + for error in errors: + print(error) + return '' + else: + return errors diff --git a/Old Pynite Folder/Node3D.py b/Old Pynite Folder/Node3D.py new file mode 100644 index 00000000..90496197 --- /dev/null +++ b/Old Pynite Folder/Node3D.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +""" +Created on Thu Nov 2 18:04:56 2017 + +@author: D. Craig Brinck, SE +""" +# %% +class Node3D(): + """ + A class representing a node in a 3D finite element model. + """ + + def __init__(self, name, X, Y, Z): + + self.name = name # A unique name for the node assigned by the user + self.ID = None # A unique index number for the node assigned by the program + + self.X = X # Global X coordinate + self.Y = Y # Global Y coordinate + self.Z = Z # Global Z coordinate + + self.NodeLoads = [] # A list of loads applied to the node (Direction, P, case) or (Direction, M, case) + + # Initialize the dictionaries of calculated node displacements + self.DX = {} + self.DY = {} + self.DZ = {} + self.RX = {} + self.RY = {} + self.RZ = {} + + # Initialize the dictionaries of calculated node reactions + self.RxnFX = {} + self.RxnFY = {} + self.RxnFZ = {} + self.RxnMX = {} + self.RxnMY = {} + self.RxnMZ = {} + + # Initialize all support conditions to `False` + self.support_DX = False + self.support_DY = False + self.support_DZ = False + self.support_RX = False + self.support_RY = False + self.support_RZ = False + + # Inititialize all support springs + self.spring_DX = [None, None, None] # [stiffness, direction, active] + self.spring_DY = [None, None, None] + self.spring_DZ = [None, None, None] + self.spring_RX = [None, None, None] + self.spring_RY = [None, None, None] + self.spring_RZ = [None, None, None] + + # Initialize all enforced displacements to `None` + self.EnforcedDX = None + self.EnforcedDY = None + self.EnforcedDZ = None + self.EnforcedRX = None + self.EnforcedRY = None + self.EnforcedRZ = None + + # Initialize the color contour value for the node. This will be used for contour smoothing. + self.contour = [] + + def distance(self, other): + """ + Returns the distance to another node. + + Parameters + ---------- + other : Node3D + A node object to compare coordinates with. + """ + return ((self.X - other.X)**2 + (self.Y - other.Y)**2 + (self.Z - other.Z)**2)**0.5 diff --git a/Old Pynite Folder/PhysMember.py b/Old Pynite Folder/PhysMember.py new file mode 100644 index 00000000..f0feb96e --- /dev/null +++ b/Old Pynite Folder/PhysMember.py @@ -0,0 +1,665 @@ +from numpy import array, dot, cross +from numpy.linalg import norm +from math import isclose, acos + +from Pynite.Member3D import Member3D + +class PhysMember(Member3D): + """ + A physical member. + + Physical members can detect internal nodes and subdivide themselves into sub-members at those + nodes. + """ + + # '__plt' is used to store the 'pyplot' from matplotlib once it gets imported. Setting it to 'None' for now allows us to defer importing it until it's actually needed. + __plt = None + + def __init__(self, model, name, i_node, j_node, material_name, section_name, rotation=0.0, + tension_only=False, comp_only=False): + + super().__init__(model, name, i_node, j_node, material_name, section_name, rotation, tension_only, comp_only) + self.sub_members = {} + + def descritize(self): + """ + Subdivides the physical member into sub-members at each node along the physical member + """ + + # Clear out any old sub_members + self.sub_members = {} + + # Start a new list of nodes along the member + int_nodes = [] + + # Create a vector from the i-node to the j-node + Xi, Yi, Zi = self.i_node.X, self.i_node.Y, self.i_node.Z + Xj, Yj, Zj = self.j_node.X, self.j_node.Y, self.j_node.Z + vector_ij = array([Xj-Xi, Yj-Yi, Zj-Zi]) + + # Add the i-node and j-node to the list + int_nodes.append([self.i_node, 0]) + int_nodes.append([self.j_node, norm(vector_ij)]) + + # Step through each node in the model + for node in self.model.nodes.values(): + + # Check each node in the model (except the i and j-nodes) + if node is not self.i_node and node is not self.j_node: + + # Create a vector from the i-node to the current node + X, Y, Z = node.X, node.Y, node.Z + vector_in = array([X-Xi, Y-Yi, Z-Zi]) + + # Calculate the angle between the two vectors + angle = acos(round(dot(vector_in, vector_ij)/(norm(vector_in)*norm(vector_ij)), 10)) + + # Determine if the node is colinear with the member + if isclose(angle, 0): + + # Determine if the node is on the member + if norm(vector_in) < norm(vector_ij): + + # Add the node to the list of intermediate nodes + int_nodes.append([node, norm(vector_in)]) + + # Create a list of sorted intermediate nodes by distance from the i-node + int_nodes = sorted(int_nodes, key=lambda x: x[1]) + + # Break up the member into sub-members at each intermediate node + for i in range(len(int_nodes) - 1): + + # Generate the sub-member's name (physical member name + a, b, c, etc.) + name = self.name + chr(i+97) + + # Find the i and j nodes for the sub-member, and their positions along the physical + # member's local x-axis + i_node = int_nodes[i][0] + j_node = int_nodes[i+1][0] + xi = int_nodes[i][1] + xj = int_nodes[i+1][1] + + # Create a new sub-member + new_sub_member = Member3D(self.model, name, i_node, j_node, self.material.name, self.section.name, self.rotation, self.tension_only, self.comp_only) + + # Flag the sub-member as active + for combo_name in self.model.load_combos.keys(): + new_sub_member.active[combo_name] = True + + # Apply end releases if applicable + if i == 0: + new_sub_member.Releases[0:6] = self.Releases[0:6] + if i == len(int_nodes) - 2: + new_sub_member.Releases[6:12] = self.Releases[6:12] + + # Add distributed to the sub-member + for dist_load in self.DistLoads: + + # Find the start and end points of the distributed load in the physical member's + # local coordinate system + x1_load = dist_load[3] + x2_load = dist_load[4] + + # Determine if the distributed load should be applied to this segment + if x1_load <= xj and x2_load > xi: + + direction = dist_load[0] + w1 = dist_load[1] + w2 = dist_load[2] + case = dist_load[5] + + # Equation describing the load as a function of x + w = lambda x: (w2 - w1)/(x2_load - x1_load)*(x - x1_load) + w1 + + # Chop up the distributed load for the sub-member + if x1_load > xi: + x1 = x1_load - xi + else: + x1 = 0 + w1 = w(xi) + + if x2_load < xj: + x2 = x2_load - xi + else: + x2 = xj - xi + w2 = w(xj) + + # Add the load to the sub-member + new_sub_member.DistLoads.append([direction, w1, w2, x1, x2, case]) + + # Add point loads to the sub-member + for pt_load in self.PtLoads: + + direction = pt_load[0] + P = pt_load[1] + x = pt_load[2] + case = pt_load[3] + + # Determine if the point load should be applied to this segment + if x >= xi and x < xj or (isclose(x, xj) and isclose(xj, self.L())): + + x = x - xi + + # Add the load to the sub-member + new_sub_member.PtLoads.append([direction, P, x, case]) + + # Add the new sub-member to the sub-member dictionary for this physical member + self.sub_members[name] = new_sub_member + + def shear(self, Direction, x, combo_name='Combo 1'): + """ + Returns the shear at a point along the member's length. + + Parameters + ---------- + Direction : string + The direction in which to find the shear. Must be one of the following: + 'Fy' = Shear acting on the local y-axis. + 'Fz' = Shear acting on the local z-axis. + x : number + The location at which to find the shear. + combo_name : string + The name of the load combination to get the results for (not the combination itself). + """ + + member, x_mod = self.find_member(x) + return member.shear(Direction, x_mod, combo_name) + + def max_shear(self, Direction, combo_name='Combo 1'): + """ + Returns the maximum shear in the member for the given direction + + Parameters + ---------- + Direction : string + The direction in which to find the maximum shear. Must be one of the following: + 'Fy' = Shear acting on the local y-axis + 'Fz' = Shear acting on the local z-axis + combo_name : string + The name of the load combination to get the results for (not the combination itself). + """ + + Vmax = None + for member in self.sub_members.values(): + V = member.max_shear(Direction, combo_name) + if Vmax is None or V > Vmax: + Vmax = V + return Vmax + + def min_shear(self, Direction, combo_name='Combo 1'): + """ + Returns the minimum shear in the member for the given direction + + Parameters + ---------- + Direction : string + The direction in which to find the minimum shear. Must be one of the following: + 'Fy' = Shear acting on the local y-axis + 'Fz' = Shear acting on the local z-axis + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + Vmin = None + for member in self.sub_members.values(): + V = member.min_shear(Direction, combo_name) + if Vmin is None or V < Vmin: + Vmin = V + return Vmin + + def plot_shear(self, Direction, combo_name='Combo 1', n_points=20): + """ + Plots the shear diagram for the member + + Parameters + ---------- + Direction : string + The direction in which to plot the shear force. Must be one of the following: + 'Fy' = Shear in the local y-axis. + 'Fz' = Shear in the local z-axis. + combo_name : string + The name of the load combination to get the results for (not the combination itself). + n_points: int + The number of points used to generate the plot + """ + + # Import 'pyplot' if not already done + if PhysMember.__plt is None: + from matplotlib import pyplot as plt + PhysMember.__plt = plt + + fig, ax = PhysMember.__plt.subplots() + ax.axhline(0, color='black', lw=1) + ax.grid() + + # Initialize the shear force diagram coordinates for each submember + x, V = [], [] + + # Step through each submember in the physical member + x_o = 0 + for submember in self.sub_members.values(): + + # Get moment result for the sub-member + x_submember, V_submember = submember.shear_array(Direction, n_points, combo_name) + x_submember = [x_o + x for x in x_submember] + x.extend(x_submember) + V.extend(V_submember) + x_o += submember.L() + + PhysMember.__plt.plot(x, V) + PhysMember.__plt.ylabel('Shear') + PhysMember.__plt.xlabel('Location') + PhysMember.__plt.title('Member ' + self.name + '\n' + combo_name) + PhysMember.__plt.show() + + def moment(self, Direction, x, combo_name='Combo 1'): + """ + Returns the moment at a point along the member's length + + Parameters + ---------- + Direction : string + The direction in which to find the moment. Must be one of the following: + 'My' = Moment about the local y-axis. + 'Mz' = moment about the local z-axis. + x : number + The location at which to find the moment. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + member, x_mod = self.find_member(x) + return member.moment(Direction, x_mod, combo_name) + + def max_moment(self, Direction, combo_name='Combo 1'): + """ + Returns the maximum moment in the member for the given direction. + + Parameters + ---------- + Direction : string + The direction in which to find the maximum moment. Must be one of the following: + 'My' = Moment about the local y-axis. + 'Mz' = Moment about the local z-axis. + combo_name : string + The name of the load combination to get the results for (not the combination itself). + """ + + Mmax = None + for member in self.sub_members.values(): + M = member.max_moment(Direction, combo_name) + if Mmax is None or M > Mmax: + Mmax = M + return Mmax + + def min_moment(self, Direction, combo_name='Combo 1'): + """ + Returns the minimum moment in the member for the given direction + + Parameters + ---------- + Direction : string + The direction in which to find the minimum moment. Must be one of the following: + 'My' = Moment about the local y-axis. + 'Mz' = Moment about the local z-axis. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + Mmin = None + for member in self.sub_members.values(): + M = member.min_moment(Direction, combo_name) + if Mmin is None or M < Mmin: + Mmin = M + return Mmin + + def plot_moment(self, Direction, combo_name='Combo 1', n_points=20): + """ + Plots the moment diagram for the member + + Parameters + ---------- + Direction : string + The direction in which to plot the moment. Must be one of the following: + 'My' = Moment about the local y-axis. + 'Mz' = moment about the local z-axis. + combo_name : string + The name of the load combination to get the results for (not the combination itself). + n_points: int + The number of points used to generate the plot + """ + + # Import 'pyplot' if not already done + if PhysMember.__plt is None: + from matplotlib import pyplot as plt + PhysMember.__plt = plt + + fig, ax = PhysMember.__plt.subplots() + ax.axhline(0, color='black', lw=1) + ax.grid() + + # Generate the moment diagram coordinates for each submember + x, M = [], [] + + # Step through each submember in the physical member + x_o = 0 + for submember in self.sub_members.values(): + + # Get moment result for the sub-member + x_submember, M_submember = submember.moment_array(Direction, n_points, combo_name) + x_submember = [x_o + x for x in x_submember] + x.extend(x_submember) + M.extend(M_submember) + x_o += submember.L() + + PhysMember.__plt.plot(x, M) + PhysMember.__plt.ylabel('Moment') + PhysMember.__plt.xlabel('Location') + PhysMember.__plt.title('Member ' + self.name + '\n' + combo_name) + PhysMember.__plt.show() + + def torque(self, x, combo_name='Combo 1'): + """ + Returns the torsional moment at a point along the member's length + + Parameters + ---------- + x : number + The location at which to find the torque + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + member, x_mod = self.find_member(x) + return member.torque(x_mod, combo_name) + + def max_torque(self, combo_name='Combo 1'): + + Tmax = None + for member in self.sub_members.values(): + T = member.max_torque(combo_name) + if Tmax is None or T > Tmax: + Tmax = T + return Tmax + + def min_torque(self, combo_name='Combo 1'): + """ + Returns the minimum torsional moment in the member. + + Parameters + ---------- + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + Tmin = None + for member in self.sub_members.values(): + T = member.min_torque(combo_name) + if Tmin is None or T < Tmin: + Tmin = T + return Tmin + + def plot_torque(self, combo_name='Combo 1', n_points=20): + """ + Plots the torque diagram for the member + + Parameters + ---------- + combo_name : string + The name of the load combination to get the results for (not the combination itself). + n_points: int + The number of points used to generate the plot + """ + + # Import 'pyplot' if not already done + if PhysMember.__plt is None: + from matplotlib import pyplot as plt + PhysMember.__plt = plt + + fig, ax = PhysMember.__plt.subplots() + ax.axhline(0, color='black', lw=1) + ax.grid() + + # Initialize the torque diagram coordinates for each submember + x, T = [], [] + + # Step through each submember in the physical member + x_o = 0 + for submember in self.sub_members.values(): + + # Get moment result for the sub-member + x_submember, T_submember = submember.torque_array(n_points, combo_name) + x_submember = [x_o + x for x in x_submember] + x.extend(x_submember) + T.extend(T_submember) + x_o += submember.L() + + PhysMember.__plt.plot(x, T) + PhysMember.__plt.ylabel('Torque') + PhysMember.__plt.xlabel('Location') + PhysMember.__plt.title('Member ' + self.name + '\n' + combo_name) + PhysMember.__plt.show() + + def axial(self, x, combo_name='Combo 1'): + """ + Returns the axial force at a point along the member's length. + + Parameters + ---------- + x : number + The location at which to find the axial force. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + member, x_mod = self.find_member(x) + return member.axial(x_mod, combo_name) + + def max_axial(self, combo_name='Combo 1'): + + Pmax = None + for member in self.sub_members.values(): + P = member.max_axial(combo_name) + if Pmax is None or P > Pmax: + Pmax = P + return Pmax + + def min_axial(self, combo_name='Combo 1'): + + Pmin = None + for member in self.sub_members.values(): + P = member.min_axial(combo_name) + if Pmin is None or P < Pmin: + Pmin = P + return Pmin + + def plot_axial(self, combo_name='Combo 1', n_points=20): + """ + Plots the axial force diagram for the member + + Parameters + ---------- + combo_name : string + The name of the load combination to get the results for (not the combination itself). + n_points: int + The number of points used to generate the plot + """ + + # Import 'pyplot' if not already done + if PhysMember.__plt is None: + from matplotlib import pyplot as plt + PhysMember.__plt = plt + + fig, ax = PhysMember.__plt.subplots() + ax.axhline(0, color='black', lw=1) + ax.grid() + + # Initialize the axial force diagram coordinates for each submember + x, P = [], [] + + # Step through each submember in the physical member + x_o = 0 + for submember in self.sub_members.values(): + + # Get moment result for the sub-member + x_submember, P_submember = submember.axial_array(n_points, combo_name) + x_submember = [x_o + x for x in x_submember] + x.extend(x_submember) + P.extend(P_submember) + x_o += submember.L() + + PhysMember.__plt.plot(x, P) + PhysMember.__plt.ylabel('Axial Force') + PhysMember.__plt.xlabel('Location') + PhysMember.__plt.title('Member ' + self.name + '\n' + combo_name) + PhysMember.__plt.show() + + def deflection(self, Direction, x, combo_name='Combo 1'): + """ + Returns the deflection at a point along the member's length. + + Parameters + ---------- + Direction : string + The direction in which to find the deflection. Must be one of the following: + 'dx' = Deflection in the local x-axis. + 'dy' = Deflection in the local y-axis. + 'dz' = Deflection in the local z-axis. + x : number + The location at which to find the deflection. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + member, x_mod = self.find_member(x) + return member.deflection(Direction, x_mod, combo_name) + + def max_deflection(self, Direction, combo_name='Combo 1'): + """ + Returns the maximum deflection in the member. + + Parameters + ---------- + Direction : {'dy', 'dz'} + The direction in which to find the maximum deflection. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + dmax = None + for member in self.sub_members.values(): + d = member.max_deflection(Direction, combo_name) + if dmax is None or d > dmax: + dmax = d + return dmax + + def min_deflection(self, Direction, combo_name='Combo 1'): + """ + Returns the minimum deflection in the member. + + Parameters + ---------- + Direction : {'dy', 'dz'} + The direction in which to find the minimum deflection. + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + """ + + dmin = None + for member in self.sub_members.values(): + d = member.min_deflection(Direction, combo_name) + if dmin is None or d < dmin: + dmin = d + return dmin + + def rel_deflection(self, Direction, x, combo_name='Combo 1'): + """ + Returns the relative deflection at a point along the member's length + + Parameters + ---------- + Direction : string + The direction in which to find the relative deflection. Must be one of the following: + 'dy' = Deflection in the local y-axis + 'dz' = Deflection in the local x-axis + x : number + The location at which to find the relative deflection + combo_name : string + The name of the load combination to get the results for (not the combination itself). + """ + + member, x_mod = self.find_member(x) + return member.rel_deflection(Direction, x_mod, combo_name) + + + def plot_deflection(self, Direction, combo_name='Combo 1', n_points=20): + """ + Plots the deflection diagram for the member + + Parameters + ---------- + Direction : string + The direction in which to plot the deflection. Must be one of the following: + 'dy' = Deflection in the local y-axis. + 'dz' = Deflection in the local z-axis. + combo_name : string + The name of the load combination to get the results for (not the combination itself). + n_points: int + The number of points used to generate the plot + """ + + # Import 'pyplot' if not already done + if PhysMember.__plt is None: + from matplotlib import pyplot as plt + PhysMember.__plt = plt + + fig, ax = PhysMember.__plt.subplots() + ax.axhline(0, color='black', lw=1) + ax.grid() + + # Initialize the deflection diagram coordinates for each submember + x, d = [], [] + + # Step through each submember in the physical member + x_o = 0 + for submember in self.sub_members.values(): + + # Get moment result for the sub-member + x_submember, d_submember = submember.deflection_array(Direction, n_points, combo_name) + x_submember = [x_o + x for x in x_submember] + x.extend(x_submember) + d.extend(d_submember) + x_o += submember.L() + + PhysMember.__plt.plot(x, d) + PhysMember.__plt.ylabel('Deflection') + PhysMember.__plt.xlabel('Location') + PhysMember.__plt.title('Member ' + self.name + '\n' + combo_name) + PhysMember.__plt.show() + + def find_member(self, x): + """ + Returns the sub-member that the physical member's local point 'x' lies on, and 'x' modified for that sub-member's local coordinate system. + """ + + # Initialize a summation of sub-member lengths + L = 0 + + # Step through each sub-member (in order from start to end) + for i, member in enumerate(self.sub_members.values()): + + # Sum the sub-member's length + L += member.L() + + # Check if 'x' lies on this sub-member + if x < L or (isclose(x, L) and i == len(self.sub_members.values()) - 1): + + # Return the sub-member, and a modified value for 'x' relative to the sub-member's + # i-node + return member, x - (L - member.L()) + + # Exit the 'for' loop + break + else: + raise ValueError(f"Location x={x} does not lie on this member") + \ No newline at end of file diff --git a/Old Pynite Folder/Plastic Beam.py b/Old Pynite Folder/Plastic Beam.py new file mode 100644 index 00000000..8f7d6a65 --- /dev/null +++ b/Old Pynite Folder/Plastic Beam.py @@ -0,0 +1,56 @@ +# Matrix Structural Analysis, 2nd Ed, Problem 8.6 + +from Pynite import FEModel3D +from Pynite.Section import SteelSection + +# Create the model +plastic_beam = FEModel3D() + +# Define a material +E = 29000 # ksi +G = 11200 # ksi +nu = 0.3 +rho = 0.490/12**3 # kci +fy = 50 # ksi +plastic_beam.add_material('Stl_A992', E, G, nu, rho, fy) + +# Define a cross-section +plastic_beam.add_steel_section('W12x65', 19.1, 20, 533, 1, 15, 96.8, 'Stl_A992') + +# Add nodes +plastic_beam.add_node('N1', 0, 0, 0) +plastic_beam.add_node('N2', 8*12, 0, 0) +plastic_beam.add_node('N3', 24*12, 0, 0) + +# Add supports +plastic_beam.def_support('N1', True, True, True, True, True, True) +plastic_beam.def_support('N3', False, True, True, False, False, False) + +# Add a member +plastic_beam.add_member('M1', 'N1', 'N3', 'Stl_A992', 'W12x65') + +# Add a load +plastic_beam.add_node_load('N3', 'FY', -0.0001, 'D') +plastic_beam.add_node_load('N2', 'FY', -0.3*325.7, 'Push') +plastic_beam.add_node_load('N3', 'FX', -1*325.7, 'Push') + +# Add a load combination +plastic_beam.add_load_combo('1.4D', {'D':1.4}) +plastic_beam.add_load_combo('Pushover', {'Push':0.01}) + +# Analyze the model +plastic_beam._not_ready_yet_analyze_pushover(log=True, check_stability=False, push_combo='Pushover', max_iter=30, sparse=True, combo_tags=None) + +# Plot the moment diagram +# plastic_beam.members['M1'].plot_shear('Fy', '1.4D') +plastic_beam.members['M1'].plot_moment('Mz', '1.4D') +# plastic_beam.members['M1'].plot_deflection('dy', '1.4D') + +# Render the model +# from Pynite.Visualization import Renderer +# rndr = Renderer(plastic_beam) +# rndr.combo_name = '1.4D' +# rndr.render_loads = True +# rndr.deformed_shape = True +# rndr.deformed_scale = 100 +# rndr.render_model() \ No newline at end of file diff --git a/Old Pynite Folder/Plate3D.py b/Old Pynite Folder/Plate3D.py new file mode 100644 index 00000000..d7c7da77 --- /dev/null +++ b/Old Pynite Folder/Plate3D.py @@ -0,0 +1,679 @@ +from numpy import zeros, array, matmul, cross, add +from numpy.linalg import inv, norm, det + +#%% +class Plate3D(): + + def __init__(self, name, i_node, j_node, m_node, n_node, t, material_name, model, kx_mod=1.0, + ky_mod=1.0): + """ + A rectangular plate element + + Parameters + ---------- + name : string + A unique plate name + i_node : Node3D + The plate's i-node + j_node : Node3D + The plate's j-node + m_node : Node3D + The plate's m-node + n_node : Node3D + The plate's n-node + t : number + Plate thickness + material_name : string + The name of the plate material + kx_mod : number + Modification factor for stiffness in the plate's local x-direction. Default value is + 1.0, which indicates no stiffness modification (100% stiffness). + ky_mod : number + Modification factor for stiffness in the plate's local y-direction. Default value is + 1.0, which indicates no stiffness modification (100% stiffness). + model : FEModel3D + The model the plate is a part of + """ + + self.name = name + self.ID = None + self.type = 'Rect' + + self.i_node = i_node + self.j_node = j_node + self.m_node = m_node + self.n_node = n_node + + self.t = t + + self.kx_mod = kx_mod + self.ky_mod = ky_mod + + self.pressures = [] # A list of surface pressures [pressure, case='Case 1'] + + # Plates need a link to the model they belong to + self.model = model + + # Get material properties for the plate from the model + try: + self.E = self.model.materials[material_name].E + self.nu = self.model.materials[material_name].nu + except: + raise KeyError('Please define the material ' + str(material_name) + ' before assigning it to plates.') + + def width(self): + """ + Returns the width of the plate along its local x-axis + """ + return self.i_node.distance(self.j_node) + + def height(self): + """ + Returns the height of the plate along its local y-axis + """ + return self.i_node.distance(self.n_node) + + def Dm(self): + """ + Returns the plane stress constitutive matrix for orthotropic materials [Dm] + """ + + # Apply the stiffness modification factors for each direction to obtain orthotropic + # behavior. Stiffness modification factors of 1.0 in each direction (the default) will + # model isotropic behavior. Orthotropic behavior is limited to the element's local + # coordinate system. + Ex = self.E*self.kx_mod + Ey = self.E*self.ky_mod + nu_xy = self.nu + nu_yx = self.nu + + # The shear modulus will be unafected by orthotropic behavior + # Logan, Appendix C.3, page 750 + G = self.E/(2*(1 + self.nu)) + + # Calculate the constitutive matrix [Dm] + Dm = 1/(1 - nu_xy*nu_yx)*array([[ Ex, nu_yx*Ex, 0 ], + [nu_xy*Ey, Ey, 0 ], + [ 0, 0, (1 - nu_xy*nu_yx)*G]]) + + # Return the constitutive matrix [Dm] + return Dm + + def Db(self): + """ + Returns the bending constitutive matrix for orthotropic materials [Db] + """ + + # Get the plate thickness and other material parameters + t = self.t + nu_xy = self.nu + nu_yx = self.nu + Ex = self.E*self.kx_mod + Ey = self.E*self.ky_mod + G = self.E/(2*(1 + self.nu)) + + # Calculate the constitutive matrix [Db] + Db = t**3/(12*(1 - nu_xy*nu_yx))*array([[ Ex, nu_yx*Ex, 0], + [nu_xy*Ey, Ey, 0], + [ 0, 0, G]]) + + # Return the constitutive matrix [Db] + return Db + + def J(self, r, s): + ''' + Returns the Jacobian matrix for the element + ''' + + # Get the local coordinates for the element's nodes + x1, y1, x2, y2, x3, y3, x4, y4 = 0, 0, self.width(), 0, self.width(), self.height(), 0, self.height() + + # Return the Jacobian matrix + return 1/4*array([[x1*(s - 1) - x2*(s - 1) + x3*(s + 1) - x4*(s + 1), y1*(s - 1) - y2*(s - 1) + y3*(s + 1) - y4*(s + 1)], + [x1*(r - 1) - x2*(r + 1) + x3*(r + 1) - x4*(r - 1), y1*(r - 1) - y2*(r + 1) + y3*(r + 1) - y4*(r - 1)]]) + + def B_m(self, r, s): + + # Differentiate the interpolation functions + # Row 1 = interpolation functions differentiated with respect to x + # Row 2 = interpolation functions differentiated with respect to y + # Note that the inverse of the Jacobian converts from derivatives with + # respect to r and s to derivatives with respect to x and y + dH = matmul(inv(self.J(r, s)), 1/4*array([[s - 1, -s + 1, s + 1, -s - 1], + [r - 1, -r - 1, r + 1, -r + 1]])) + + # Reference 1, Example 5.5 (page 353) + B_m = array([[dH[0, 0], 0, dH[0, 1], 0, dH[0, 2], 0, dH[0, 3], 0 ], + [ 0, dH[1, 0], 0, dH[1, 1], 0, dH[1, 2], 0, dH[1, 3]], + [dH[1, 0], dH[0, 0], dH[1, 1], dH[0, 1], dH[1, 2], dH[0, 2], dH[1, 3], dH[0, 3]]]) + + return B_m + + def k(self): + """ + returns the plate's local stiffness matrix + """ + return add(self.k_b(), self.k_m()) + + def k_m(self): + ''' + Returns the local stiffness matrix for membrane (in-plane) stresses. + + Plane stress is assumed + ''' + + t = self.t + Dm = self.Dm() + + # Define the gauss point for numerical integration + gp = 1/3**0.5 + + # Get the membrane B matrices for each gauss point + # Doing this now will save us from doing it twice below + B1 = self.B_m(-gp, -gp) + B2 = self.B_m(gp, -gp) + B3 = self.B_m(gp, gp) + B4 = self.B_m(-gp, gp) + + # See reference 1 at the bottom of page 353, and reference 2 page 466 + k = t*(matmul(B1.T, matmul(Dm, B1))*det(self.J(-gp, -gp)) + + matmul(B2.T, matmul(Dm, B2))*det(self.J(gp, -gp)) + + matmul(B3.T, matmul(Dm, B3))*det(self.J(gp, gp)) + + matmul(B4.T, matmul(Dm, B4))*det(self.J(-gp, gp))) + + k_exp = zeros((24, 24)) + + # Step through each term in the unexpanded stiffness matrix + # i = Unexpanded matrix row + for i in range(8): + + # j = Unexpanded matrix column + for j in range(8): + + # Find the corresponding term in the expanded stiffness + # matrix + + # m = Expanded matrix row + if i in [0, 2, 4, 6]: # indices associated with displacement in x + m = i*3 + if i in [1, 3, 5, 7]: # indices associated with displacement in y + m = i*3 - 2 + + # n = Expanded matrix column + if j in [0, 2, 4, 6]: # indices associated with displacement in x + n = j*3 + if j in [1, 3, 5, 7]: # indices associated with displacement in y + n = j*3 - 2 + + # Ensure the indices are integers rather than floats + m, n = round(m), round(n) + + # Add the term from the unexpanded matrix into the expanded matrix + k_exp[m, n] = k[i, j] + + return k_exp + + def k_b(self): + """ + Returns the local stiffness matrix for bending + """ + + b = self.width()/2 + c = self.height()/2 + + Ex = self.E + Ey = self.E + nu_xy = self.nu + nu_yx = self.nu + G = self.E/(2*(1 + self.nu)) + t = self.t + + # Stiffness matrix for plate bending. This matrix was derived using a jupyter notebook. The + # notebook can be found in the `Derivations`` folder of this project. + k = t**3/12*array([[(-Ex*nu_yx*b**2*c**2/4 - Ex*c**4 - Ey*nu_xy*b**2*c**2/4 - Ey*b**4 + 7*G*nu_xy*nu_yx*b**2*c**2/5 - 7*G*b**2*c**2/5)/(b**3*c**3*(nu_xy*nu_yx - 1)), (-Ex*nu_yx*c**2/2 - Ey*b**2 + G*nu_xy*nu_yx*c**2/5 - G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (Ex*c**2 + Ey*nu_xy*b**2/2 - G*nu_xy*nu_yx*b**2/5 + G*b**2/5)/(b**2*c*(nu_xy*nu_yx - 1)), (5*Ex*nu_yx*b**2*c**2 + 20*Ex*c**4 + 5*Ey*nu_xy*b**2*c**2 - 10*Ey*b**4 - 28*G*nu_xy*nu_yx*b**2*c**2 + 28*G*b**2*c**2)/(20*b**3*c**3*(nu_xy*nu_yx - 1)), (Ex*nu_yx*c**2/2 - Ey*b**2/2 - G*nu_xy*nu_yx*c**2/5 + G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (5*Ex*c**2 - G*nu_xy*nu_yx*b**2 + G*b**2)/(5*b**2*c*(nu_xy*nu_yx - 1)), (-5*Ex*nu_yx*b**2*c**2 + 10*Ex*c**4 - 5*Ey*nu_xy*b**2*c**2 + 10*Ey*b**4 + 28*G*nu_xy*nu_yx*b**2*c**2 - 28*G*b**2*c**2)/(20*b**3*c**3*(nu_xy*nu_yx - 1)), (-Ey*b**2/2 - G*nu_xy*nu_yx*c**2/5 + G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (Ex*c**2/2 + G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1)), (5*Ex*nu_yx*b**2*c**2 - 10*Ex*c**4 + 5*Ey*nu_xy*b**2*c**2 + 20*Ey*b**4 - 28*G*nu_xy*nu_yx*b**2*c**2 + 28*G*b**2*c**2)/(20*b**3*c**3*(nu_xy*nu_yx - 1)), (-5*Ey*b**2 + G*nu_xy*nu_yx*c**2 - G*c**2)/(5*b*c**2*(nu_xy*nu_yx - 1)), (Ex*c**2/2 - Ey*nu_xy*b**2/2 + G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1))], + [(-Ey*nu_xy*c**2/2 - Ey*b**2 + G*nu_xy*nu_yx*c**2/5 - G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), 4*(-5*Ey*b**2 + 2*G*nu_xy*nu_yx*c**2 - 2*G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), Ey*nu_xy/(nu_xy*nu_yx - 1), (Ey*nu_xy*c**2/2 - Ey*b**2/2 - G*nu_xy*nu_yx*c**2/5 + G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), 2*(-5*Ey*b**2 - 4*G*nu_xy*nu_yx*c**2 + 4*G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), 0, (Ey*b**2/2 + G*nu_xy*nu_yx*c**2/5 - G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (-5*Ey*b**2 + 2*G*nu_xy*nu_yx*c**2 - 2*G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), 0, (5*Ey*b**2 - G*nu_xy*nu_yx*c**2 + G*c**2)/(5*b*c**2*(nu_xy*nu_yx - 1)), 2*(-5*Ey*b**2 - G*nu_xy*nu_yx*c**2 + G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), 0], + [(Ex*nu_yx*b**2/2 + Ex*c**2 - G*nu_xy*nu_yx*b**2/5 + G*b**2/5)/(b**2*c*(nu_xy*nu_yx - 1)), Ex*nu_yx/(nu_xy*nu_yx - 1), 4*(-5*Ex*c**2 + 2*G*nu_xy*nu_yx*b**2 - 2*G*b**2)/(15*b*c*(nu_xy*nu_yx - 1)), (-5*Ex*c**2 + G*nu_xy*nu_yx*b**2 - G*b**2)/(5*b**2*c*(nu_xy*nu_yx - 1)), 0, 2*(-5*Ex*c**2 - G*nu_xy*nu_yx*b**2 + G*b**2)/(15*b*c*(nu_xy*nu_yx - 1)), -(Ex*c**2/2 + G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1)), 0, (-5*Ex*c**2 + 2*G*b**2*(nu_xy*nu_yx - 1))/(15*b*c*(nu_xy*nu_yx - 1)), (-Ex*nu_yx*b**2/2 + Ex*c**2/2 + G*nu_xy*nu_yx*b**2/5 - G*b**2/5)/(b**2*c*(nu_xy*nu_yx - 1)), 0, -(10*Ex*c**2 + 8*G*b**2*(nu_xy*nu_yx - 1))/(15*b*c*(nu_xy*nu_yx - 1))], + [(5*Ex*nu_yx*b**2*c**2 + 20*Ex*c**4 + 5*Ey*nu_xy*b**2*c**2 - 10*Ey*b**4 - 28*G*nu_xy*nu_yx*b**2*c**2 + 28*G*b**2*c**2)/(20*b**3*c**3*(nu_xy*nu_yx - 1)), (Ex*nu_yx*c**2/2 - Ey*b**2/2 - G*nu_xy*nu_yx*c**2/5 + G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (-5*Ex*c**2 + G*nu_xy*nu_yx*b**2 - G*b**2)/(5*b**2*c*(nu_xy*nu_yx - 1)), (-Ex*nu_yx*b**2*c**2/4 - Ex*c**4 - Ey*nu_xy*b**2*c**2/4 - Ey*b**4 + 7*G*nu_xy*nu_yx*b**2*c**2/5 - 7*G*b**2*c**2/5)/(b**3*c**3*(nu_xy*nu_yx - 1)), (-Ex*nu_yx*c**2/2 - Ey*b**2 + G*nu_xy*nu_yx*c**2/5 - G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (-Ex*c**2 - Ey*nu_xy*b**2/2 + G*nu_xy*nu_yx*b**2/5 - G*b**2/5)/(b**2*c*(nu_xy*nu_yx - 1)), (5*Ex*nu_yx*b**2*c**2 - 10*Ex*c**4 + 5*Ey*nu_xy*b**2*c**2 + 20*Ey*b**4 - 28*G*nu_xy*nu_yx*b**2*c**2 + 28*G*b**2*c**2)/(20*b**3*c**3*(nu_xy*nu_yx - 1)), (-5*Ey*b**2 + G*nu_xy*nu_yx*c**2 - G*c**2)/(5*b*c**2*(nu_xy*nu_yx - 1)), (-Ex*c**2/2 + Ey*nu_xy*b**2/2 - G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1)), (-5*Ex*nu_yx*b**2*c**2 + 10*Ex*c**4 - 5*Ey*nu_xy*b**2*c**2 + 10*Ey*b**4 + 28*G*nu_xy*nu_yx*b**2*c**2 - 28*G*b**2*c**2)/(20*b**3*c**3*(nu_xy*nu_yx - 1)), (-Ey*b**2/2 - G*nu_xy*nu_yx*c**2/5 + G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), -(Ex*c**2/2 + G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1))], + [(Ey*nu_xy*c**2/2 - Ey*b**2/2 - G*nu_xy*nu_yx*c**2/5 + G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), 2*(-5*Ey*b**2 - 4*G*nu_xy*nu_yx*c**2 + 4*G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), 0, (-Ey*nu_xy*c**2/2 - Ey*b**2 + G*nu_xy*nu_yx*c**2/5 - G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), 4*(-5*Ey*b**2 + 2*G*nu_xy*nu_yx*c**2 - 2*G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), -Ey*nu_xy/(nu_xy*nu_yx - 1), (5*Ey*b**2 - G*nu_xy*nu_yx*c**2 + G*c**2)/(5*b*c**2*(nu_xy*nu_yx - 1)), 2*(-5*Ey*b**2 - G*nu_xy*nu_yx*c**2 + G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), 0, (Ey*b**2/2 + G*nu_xy*nu_yx*c**2/5 - G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (-5*Ey*b**2 + 2*G*nu_xy*nu_yx*c**2 - 2*G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), 0], + [(5*Ex*c**2 - G*nu_xy*nu_yx*b**2 + G*b**2)/(5*b**2*c*(nu_xy*nu_yx - 1)), 0, 2*(-5*Ex*c**2 - G*nu_xy*nu_yx*b**2 + G*b**2)/(15*b*c*(nu_xy*nu_yx - 1)), (-Ex*nu_yx*b**2/2 - Ex*c**2 + G*nu_xy*nu_yx*b**2/5 - G*b**2/5)/(b**2*c*(nu_xy*nu_yx - 1)), -Ex*nu_yx/(nu_xy*nu_yx - 1), 4*(-5*Ex*c**2 + 2*G*nu_xy*nu_yx*b**2 - 2*G*b**2)/(15*b*c*(nu_xy*nu_yx - 1)), (Ex*nu_yx*b**2/2 - Ex*c**2/2 - G*nu_xy*nu_yx*b**2/5 + G*b**2/5)/(b**2*c*(nu_xy*nu_yx - 1)), 0, -(10*Ex*c**2 + 8*G*b**2*(nu_xy*nu_yx - 1))/(15*b*c*(nu_xy*nu_yx - 1)), (Ex*c**2/2 + G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1)), 0, (-5*Ex*c**2 + 2*G*b**2*(nu_xy*nu_yx - 1))/(15*b*c*(nu_xy*nu_yx - 1))], + [(-5*Ex*nu_yx*b**2*c**2 + 10*Ex*c**4 - 5*Ey*nu_xy*b**2*c**2 + 10*Ey*b**4 + 28*G*nu_xy*nu_yx*b**2*c**2 - 28*G*b**2*c**2)/(20*b**3*c**3*(nu_xy*nu_yx - 1)), (Ey*b**2/2 + G*nu_xy*nu_yx*c**2/5 - G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), -(Ex*c**2/2 + G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1)), (5*Ex*nu_yx*b**2*c**2 - 10*Ex*c**4 + 5*Ey*nu_xy*b**2*c**2 + 20*Ey*b**4 - 28*G*nu_xy*nu_yx*b**2*c**2 + 28*G*b**2*c**2)/(20*b**3*c**3*(nu_xy*nu_yx - 1)), (5*Ey*b**2 - G*nu_xy*nu_yx*c**2 + G*c**2)/(5*b*c**2*(nu_xy*nu_yx - 1)), (-5*Ex*c**2 - 25*Ey*nu_xy*b**2 + 2*b**2*(15*Ey*nu_xy - G*nu_xy*nu_yx + G))/(10*b**2*c*(nu_xy*nu_yx - 1)), (-Ex*nu_yx*b**2*c**2/4 - Ex*c**4 - Ey*nu_xy*b**2*c**2/4 - Ey*b**4 + 7*G*nu_xy*nu_yx*b**2*c**2/5 - 7*G*b**2*c**2/5)/(b**3*c**3*(nu_xy*nu_yx - 1)), (Ex*nu_yx*c**2/2 + Ey*b**2 - G*nu_xy*nu_yx*c**2/5 + G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (-Ex*c**2 - Ey*nu_xy*b**2/2 + G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1)), (5*Ex*nu_yx*b**2*c**2 + 20*Ex*c**4 + 5*Ey*nu_xy*b**2*c**2 - 10*Ey*b**4 - 28*G*nu_xy*nu_yx*b**2*c**2 + 28*G*b**2*c**2)/(20*b**3*c**3*(nu_xy*nu_yx - 1)), (-Ex*nu_yx*c**2/2 + Ey*b**2/2 + G*nu_xy*nu_yx*c**2/5 - G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (-Ex*c**2 + G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1))], + [(-Ey*b**2/2 - G*nu_xy*nu_yx*c**2/5 + G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (-5*Ey*b**2 + 2*G*nu_xy*nu_yx*c**2 - 2*G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), 0, (-5*Ey*b**2 + G*nu_xy*nu_yx*c**2 - G*c**2)/(5*b*c**2*(nu_xy*nu_yx - 1)), 2*(-5*Ey*b**2 - G*nu_xy*nu_yx*c**2 + G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), 0, (Ey*nu_xy*c**2/2 + Ey*b**2 - G*nu_xy*nu_yx*c**2/5 + G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), 4*(-5*Ey*b**2 + 2*G*nu_xy*nu_yx*c**2 - 2*G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), Ey*nu_xy/(nu_xy*nu_yx - 1), (-Ey*nu_xy*c**2/2 + Ey*b**2/2 + G*nu_xy*nu_yx*c**2/5 - G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), 2*(-5*Ey*b**2 - 4*G*nu_xy*nu_yx*c**2 + 4*G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), 0], + [(Ex*c**2/2 + G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1)), 0, (-5*Ex*c**2 + 2*G*b**2*(nu_xy*nu_yx - 1))/(15*b*c*(nu_xy*nu_yx - 1)), (Ex*nu_yx*b**2/2 - Ex*c**2/2 - G*nu_xy*nu_yx*b**2/5 + G*b**2/5)/(b**2*c*(nu_xy*nu_yx - 1)), 0, -(10*Ex*c**2 + 8*G*b**2*(nu_xy*nu_yx - 1))/(15*b*c*(nu_xy*nu_yx - 1)), (-Ex*nu_yx*b**2/2 - Ex*c**2 + G*nu_xy*nu_yx*b**2/5 - G*b**2/5)/(b**2*c*(nu_xy*nu_yx - 1)), Ex*nu_yx/(nu_xy*nu_yx - 1), 4*(-5*Ex*c**2 + 2*G*b**2*(nu_xy*nu_yx - 1))/(15*b*c*(nu_xy*nu_yx - 1)), (Ex*c**2 - G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1)), 0, -(10*Ex*c**2 + 2*G*b**2*(nu_xy*nu_yx - 1))/(15*b*c*(nu_xy*nu_yx - 1))], + [(5*Ex*nu_yx*b**2*c**2 - 10*Ex*c**4 + 5*Ey*nu_xy*b**2*c**2 + 20*Ey*b**4 - 28*G*nu_xy*nu_yx*b**2*c**2 + 28*G*b**2*c**2)/(20*b**3*c**3*(nu_xy*nu_yx - 1)), (5*Ey*b**2 - G*nu_xy*nu_yx*c**2 + G*c**2)/(5*b*c**2*(nu_xy*nu_yx - 1)), (5*Ex*c**2 + 25*Ey*nu_xy*b**2 - 2*b**2*(15*Ey*nu_xy - G*nu_xy*nu_yx + G))/(10*b**2*c*(nu_xy*nu_yx - 1)), (-5*Ex*nu_yx*b**2*c**2 + 10*Ex*c**4 - 5*Ey*nu_xy*b**2*c**2 + 10*Ey*b**4 + 28*G*nu_xy*nu_yx*b**2*c**2 - 28*G*b**2*c**2)/(20*b**3*c**3*(nu_xy*nu_yx - 1)), (Ey*b**2/2 + G*nu_xy*nu_yx*c**2/5 - G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (Ex*c**2/2 + G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1)), (5*Ex*nu_yx*b**2*c**2 + 20*Ex*c**4 + 5*Ey*nu_xy*b**2*c**2 - 10*Ey*b**4 - 28*G*nu_xy*nu_yx*b**2*c**2 + 28*G*b**2*c**2)/(20*b**3*c**3*(nu_xy*nu_yx - 1)), (-Ex*nu_yx*c**2/2 + Ey*b**2/2 + G*nu_xy*nu_yx*c**2/5 - G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (Ex*c**2 - G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1)), (-Ex*nu_yx*b**2*c**2/4 - Ex*c**4 - Ey*nu_xy*b**2*c**2/4 - Ey*b**4 + 7*G*nu_xy*nu_yx*b**2*c**2/5 - 7*G*b**2*c**2/5)/(b**3*c**3*(nu_xy*nu_yx - 1)), (Ex*nu_yx*c**2/2 + Ey*b**2 - G*nu_xy*nu_yx*c**2/5 + G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (Ex*c**2 + Ey*nu_xy*b**2/2 - G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1))], + [(-5*Ey*b**2 + G*nu_xy*nu_yx*c**2 - G*c**2)/(5*b*c**2*(nu_xy*nu_yx - 1)), 2*(-5*Ey*b**2 - G*nu_xy*nu_yx*c**2 + G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), 0, (-Ey*b**2/2 - G*nu_xy*nu_yx*c**2/5 + G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), (-5*Ey*b**2 + 2*G*nu_xy*nu_yx*c**2 - 2*G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), 0, (-Ey*nu_xy*c**2/2 + Ey*b**2/2 + G*nu_xy*nu_yx*c**2/5 - G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), 2*(-5*Ey*b**2 - 4*G*nu_xy*nu_yx*c**2 + 4*G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), 0, (Ey*nu_xy*c**2/2 + Ey*b**2 - G*nu_xy*nu_yx*c**2/5 + G*c**2/5)/(b*c**2*(nu_xy*nu_yx - 1)), 4*(-5*Ey*b**2 + 2*G*nu_xy*nu_yx*c**2 - 2*G*c**2)/(15*b*c*(nu_xy*nu_yx - 1)), -Ey*nu_xy/(nu_xy*nu_yx - 1)], + [(-Ex*nu_yx*b**2/2 + Ex*c**2/2 + G*nu_xy*nu_yx*b**2/5 - G*b**2/5)/(b**2*c*(nu_xy*nu_yx - 1)), 0, -(10*Ex*c**2 + 8*G*b**2*(nu_xy*nu_yx - 1))/(15*b*c*(nu_xy*nu_yx - 1)), -(Ex*c**2/2 + G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1)), 0, (-5*Ex*c**2 + 2*G*b**2*(nu_xy*nu_yx - 1))/(15*b*c*(nu_xy*nu_yx - 1)), (-Ex*c**2 + G*b**2*(nu_xy*nu_yx - 1)/5)/(b**2*c*(nu_xy*nu_yx - 1)), 0, -(10*Ex*c**2 + 2*G*b**2*(nu_xy*nu_yx - 1))/(15*b*c*(nu_xy*nu_yx - 1)), (Ex*nu_yx*b**2/2 + Ex*c**2 - G*nu_xy*nu_yx*b**2/5 + G*b**2/5)/(b**2*c*(nu_xy*nu_yx - 1)), -Ex*nu_yx/(nu_xy*nu_yx - 1), 4*(-5*Ex*c**2 + 2*G*b**2*(nu_xy*nu_yx - 1))/(15*b*c*(nu_xy*nu_yx - 1))]]) + + # Calculate the stiffness of a weak spring for the drilling degree of freedom (rotation + # about local z). We'll set the weak spring to be 1000 times weaker than any of the other + # rotational stiffnesses in the matrix. + k_rz = min(abs(k[1, 1]), abs(k[2, 2]), abs(k[4, 4]), abs(k[5, 5]), + abs(k[7, 7]), abs(k[8, 8]), abs(k[10, 10]), abs(k[11, 11]) + )/1000 + + # The matrix currently only holds terms related to bending action. We need to expand it to + # with placeholders for all the degrees of freedom so it can be directly added to the + # membrane stiffness matrix later on. + + # Initialize the expanded stiffness matrix to all zeros + k_exp = zeros((24, 24)) + + # Step through each term in the unexpanded stiffness matrix + + # i = Unexpanded matrix row + for i in range(12): + + # j = Unexpanded matrix column + for j in range(12): + + # Find the corresponding term in the expanded stiffness + # matrix + + # m = Expanded matrix row + if i in [0, 3, 6, 9]: # indices associated with deflection in z + m = 2*i + 2 + if i in [1, 4, 7, 10]: # indices associated with rotation about x + m = 2*i + 1 + if i in [2, 5, 8, 11]: # indices associated with rotation about y + m = 2*i + + # n = Expanded matrix column + if j in [0, 3, 6, 9]: # indices associated with deflection in z + n = 2*j + 2 + if j in [1, 4, 7, 10]: # indices associated with rotation about x + n = 2*j + 1 + if j in [2, 5, 8, 11]: # indices associated with rotation about y + n = 2*j + + # Ensure the indices are integers rather than floats + m, n = round(m), round(n) + + # Add the term from the unexpanded matrix into the expanded + # matrix + k_exp[m, n] = k[i, j] + + # Add the drilling degree of freedom's weak spring + k_exp[5, 5] = k_rz + k_exp[11, 11] = k_rz + k_exp[17, 17] = k_rz + k_exp[23, 23] = k_rz + + # Return the local stiffness matrix + return k_exp + + def f(self, combo_name='Combo 1'): + """ + Returns the plate's local end force vector + """ + + # Calculate and return the plate's local end force vector + return add(matmul(self.k(), self.d(combo_name)), self.fer(combo_name)) + + def fer(self, combo_name='Combo 1'): + """ + Returns the rectangle's local fixed end reaction vector. + + Parameters + ---------- + combo_name : string + The name of the load combination to get the load vector for. + """ + + # Initialize the fixed end reaction vector + fer = zeros((12, 1)) + + # Get the requested load combination + combo = self.model.load_combos[combo_name] + + # Initialize the element's surface pressure to zero + p = 0 + + # Loop through each load case and factor in the load combination + for case, factor in combo.factors.items(): + + # Sum the pressures + for pressure in self.pressures: + + # Check if the current pressure corresponds to the current load case + if pressure[1] == case: + + # Sum the pressures multiplied by their load factors + p += factor*pressure[0] + + b = self.width()/2 + c = self.height()/2 + + fer = -4*p*c*b*array([[1/4], [c/12], [-b/12], [1/4], [c/12], [b/12], [1/4], [-c/12], [b/12], [1/4], [-c/12], [-b/12]]) + + # At this point `fer` only contains terms for the degrees of freedom + # associated with membrane action. Expand `fer` to include zero terms for + # the degrees of freedom related to bending action. This will allow + # the bending and membrane vectors to be summed directly + # later on. `numpy` has an `insert` function that can be used to + # insert rows and columns of zeros one at a time, but it is very slow + # as it makes a temporary copy of the vector term by term each time + # it's called. The algorithm used here accomplishes the same thing + # much faster. Terms are copied only once. + + # Initialize the expanded vector to all zeros + fer_exp = zeros((24, 1)) + + # Step through each term in the unexpanded vector + # i = Unexpanded vector row + for i in range(12): + + # Find the corresponding term in the expanded vector + + # m = Expanded vector row + if i in [0, 3, 6, 9]: # indices associated with deflection in z + m = 2*i + 2 + if i in [1, 4, 7, 10]: # indices associated with rotation about x + m = 2*i + 1 + if i in [2, 5, 8, 11]: # indices associated with rotation about y + m = 2*i + + # Ensure the index is an integer rather than a float + m = round(m) + + # Add the term from the unexpanded vector into the expanded vector + fer_exp[m, 0] = fer[i, 0] + + return fer_exp + + def d(self, combo_name='Combo 1'): + """ + Returns the plate's local displacement vector + """ + + # Calculate and return the local displacement vector + return matmul(self.T(), self.D(combo_name)) + + def F(self, combo_name='Combo 1'): + """ + Returns the plate's global nodal force vector + """ + + # Calculate and return the global force vector + return matmul(inv(self.T()), self.f(combo_name)) + + def D(self, combo_name='Combo 1'): + """ + Returns the plate's global displacement vector for the given load combination. + """ + + # Initialize the displacement vector + D = zeros((24, 1)) + + # Read in the global displacements from the nodes + D[0, 0] = self.i_node.DX[combo_name] + D[1, 0] = self.i_node.DY[combo_name] + D[2, 0] = self.i_node.DZ[combo_name] + D[3, 0] = self.i_node.RX[combo_name] + D[4, 0] = self.i_node.RY[combo_name] + D[5, 0] = self.i_node.RZ[combo_name] + + D[6, 0] = self.j_node.DX[combo_name] + D[7, 0] = self.j_node.DY[combo_name] + D[8, 0] = self.j_node.DZ[combo_name] + D[9, 0] = self.j_node.RX[combo_name] + D[10, 0] = self.j_node.RY[combo_name] + D[11, 0] = self.j_node.RZ[combo_name] + + D[12, 0] = self.m_node.DX[combo_name] + D[13, 0] = self.m_node.DY[combo_name] + D[14, 0] = self.m_node.DZ[combo_name] + D[15, 0] = self.m_node.RX[combo_name] + D[16, 0] = self.m_node.RY[combo_name] + D[17, 0] = self.m_node.RZ[combo_name] + + D[18, 0] = self.n_node.DX[combo_name] + D[19, 0] = self.n_node.DY[combo_name] + D[20, 0] = self.n_node.DZ[combo_name] + D[21, 0] = self.n_node.RX[combo_name] + D[22, 0] = self.n_node.RY[combo_name] + D[23, 0] = self.n_node.RZ[combo_name] + + # Return the global displacement vector + return D + + def T(self): + """ + Returns the plate's transformation matrix + """ + + # Calculate the direction cosines for the local x-axis + # The local x-axis will run from the i-node to the j-node + xi = self.i_node.X + xj = self.j_node.X + yi = self.i_node.Y + yj = self.j_node.Y + zi = self.i_node.Z + zj = self.j_node.Z + x = [(xj - xi), (yj - yi), (zj - zi)] + x = x/norm(x) + + # The local y-axis will be in the plane of the plate + # Find a vector in the plate's local xy plane + xn = self.n_node.X + yn = self.n_node.Y + zn = self.n_node.Z + xy = [xn - xi, yn - yi, zn - zi] + + # Find a vector perpendicular to the plate surface to get the orientation of the local z-axis + z = cross(x, xy) + + # Divide the vector by its magnitude to produce a unit z-vector of direction cosines + z = z/norm(z) + + # Calculate the local y-axis as a vector perpendicular to the local z and x-axes + y = cross(z, x) + + # Divide the z-vector by its magnitude to produce a unit vector of direction cosines + y = y/norm(y) + + # Create the direction cosines matrix + dirCos = array([x, y, z]) + + # Build the transformation matrix + transMatrix = zeros((24, 24)) + transMatrix[0:3, 0:3] = dirCos + transMatrix[3:6, 3:6] = dirCos + transMatrix[6:9, 6:9] = dirCos + transMatrix[9:12, 9:12] = dirCos + transMatrix[12:15, 12:15] = dirCos + transMatrix[15:18, 15:18] = dirCos + transMatrix[18:21, 18:21] = dirCos + transMatrix[21:24, 21:24] = dirCos + + return transMatrix + + def K(self): + """ + Returns the plate's global stiffness matrix + """ + + # Calculate and return the stiffness matrix in global coordinates + return matmul(matmul(inv(self.T()), self.k()), self.T()) + + def FER(self, combo_name='Combo 1'): + """ + Returns the global fixed end reaction vector. + + Parameters + ---------- + combo_name : string + The name of the load combination to calculate the fixed end + reaction vector for (not the load combination itself). + """ + + # Calculate and return the fixed end reaction vector + return matmul(inv(self.T()), self.fer(combo_name)) + + def _C(self): + """ + Returns the plate's displacement coefficient matrix [C] + """ + + # Find the local x and y coordinates at each node + xi = 0 + yi = 0 + xj = self.width() + yj = 0 + xm = xj + ym = self.height() + xn = 0 + yn = ym + + # Calculate the [C] coefficient matrix + C = array([[1, xi, yi, xi**2, xi*yi, yi**2, xi**3, xi**2*yi, xi*yi**2, yi**3, xi**3*yi, xi*yi**3], + [0, 0, 1, 0, xi, 2*yi, 0, xi**2, 2*xi*yi, 3*yi**2, xi**3, 3*xi*yi**2], + [0, -1, 0, -2*xi, -yi, 0, -3*xi**2, -2*xi*yi, -yi**2, 0, -3*xi**2*yi, -yi**3], + + [1, xj, yj, xj**2, xj*yj, yj**2, xj**3, xj**2*yj, xj*yj**2, yj**3, xj**3*yj, xj*yj**3], + [0, 0, 1, 0, xj, 2*yj, 0, xj**2, 2*xj*yj, 3*yj**2, xj**3, 3*xj*yj**2], + [0, -1, 0, -2*xj, -yj, 0, -3*xj**2, -2*xj*yj, -yj**2, 0, -3*xj**2*yj, -yj**3], + + [1, xm, ym, xm**2, xm*ym, ym**2, xm**3, xm**2*ym, xm*ym**2, ym**3, xm**3*ym, xm*ym**3], + [0, 0, 1, 0, xm, 2*ym, 0, xm**2, 2*xm*ym, 3*ym**2, xm**3, 3*xm*ym**2], + [0, -1, 0, -2*xm, -ym, 0, -3*xm**2, -2*xm*ym, -ym**2, 0, -3*xm**2*ym, -ym**3], + + [1, xn, yn, xn**2, xn*yn, yn**2, xn**3, xn**2*yn, xn*yn**2, yn**3, xn**3*yn, xn*yn**3], + [0, 0, 1, 0, xn, 2*yn, 0, xn**2, 2*xn*yn, 3*yn**2, xn**3, 3*xn*yn**2], + [0, -1, 0, -2*xn, -yn, 0, -3*xn**2, -2*xn*yn, -yn**2, 0, -3*xn**2*yn, -yn**3]]) + + # Return the coefficient matrix + return C + + def _Q(self, x, y): + """ + Calculates and returns the plate curvature coefficient matrix [Q] at a given point (x, y) + in the plate's local system. + """ + + # Calculate the [Q] coefficient matrix + Q = array([[0, 0, 0, -2, 0, 0, -6*x, -2*y, 0, 0, -6*x*y, 0], + [0, 0, 0, 0, 0, -2, 0, 0, -2*x, -6*y, 0, -6*x*y], + [0, 0, 0, 0, -2, 0, 0, -4*x, -4*y, 0, -6*x**2, -6*y**2]]) + + # Return the [Q] coefficient matrix + return Q + + def _a(self, combo_name='Combo 1'): + """ + Returns the vector of plate bending constants for the displacement function. + + Parameters + ---------- + combo_name : string + The name of the load combination to get the vector for + """ + + # Get the plate's local displacement vector + # Slice out terms not related to plate bending + d = self.d(combo_name)[[2, 3, 4, 8, 9, 10, 14, 15, 16, 20, 21, 22], :] + + # Return the plate bending constants + return inv(self._C()) @ d + + def moment(self, x, y, local=True, combo_name='Combo 1'): + """ + Returns the internal moments (Mx, My, and Mxy) at any point (x, y) in the plate's local + coordinate system + + Parameters + ---------- + x : number + The x-coordinate in the plate's local coordinate system. + y : number + The y-coordinate in the plate's local coordinate system. + combo_name : string + The name of the load combination to evaluate. The default is 'Combo 1'. + + """ + + # Calculate and return internal moments + # A negative sign will be applied to change the sign convention to match that of + # Pynite's quadrilateral elements. + return -self.Db() @ self._Q(x, y) @ self._a(combo_name) + + def shear(self, x, y, local=True, combo_name='Combo 1'): + """ + Returns the internal shears (Qx and Qy) at any point (x, y) in the plate's local + coordinate system + + Parameters + ---------- + x : number + The x-coordinate in the plate's local coordinate system. + y : number + The y-coordinate in the plate's local coordinate system. + combo_name : string + The name of the load combination to evaluate. The default is 'Combo 1'. + + """ + + # Store matrices into local variables for quicker access + Db = self.Db() + a = self._a(combo_name) + + # Calculate the derivatives of the plate moments needed to compute shears + dMx_dx = (Db @ array([[0, 0, 0, 0, 0, 0, -6, 0, 0, 0, -6*y, 0], + [0, 0, 0, 0, 0, 0, 0, 0, -2, 0, 0, -6*y], + [0, 0, 0, 0, 0, 0, 0, -4, 0, 0, -12*x, 0]]) @ a)[0] + + dMxy_dy = (Db @ array([[0, 0, 0, 0, 0, 0, 0, -2, 0, 0, -6*x, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, -6, 0, -6*x], + [0, 0, 0, 0, 0, 0, 0, 0, -4, 0, 0, -12*y]]) @ a)[2] + + dMy_dy = (Db @ array([[0, 0, 0, 0, 0, 0, 0, -2, 0, 0, -6*x, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, -6, 0, -6*x], + [0, 0, 0, 0, 0, 0, 0, 0, -4, 0, 0, -12*y]]) @ a)[1] + + dMxy_dx = (Db @ array([[0, 0, 0, 0, 0, 0, -6, 0, 0, 0, -6*y, 0], + [0, 0, 0, 0, 0, 0, 0, 0, -2, 0, 0, -6*y], + [0, 0, 0, 0, 0, 0, 0, -4, 0, 0, -12*x, 0]]) @ a)[2] + + # Calculate internal shears + Qx = (dMx_dx + dMxy_dy)[0] + Qy = (dMy_dy + dMxy_dx)[0] + + # Return internal shears + return array([[Qx], + [Qy]]) + + def membrane(self, x, y, local=True, combo_name='Combo 1'): + + # Convert the (x, y) coordinates to (r, x) coordinates + r = -1 + 2*x/self.width() + s = -1 + 2*y/self.height() + + # Get the plate's local displacement vector + # Slice out terms not related to membrane forces + d = self.d(combo_name)[[0, 1, 6, 7, 12, 13, 18, 19], :] + + # Define the gauss point used for numerical integration + gp = 1/3**0.5 + + # Define extrapolated r and s points + r_ex = r/gp + s_ex = s/gp + + # Define the interpolation functions + H = 1/4*array([(1 - r_ex)*(1 - s_ex), (1 + r_ex)*(1 - s_ex), (1 + r_ex)*(1 + s_ex), (1 - r_ex)*(1 + s_ex)]) + + # Get the stress-strain matrix + Dm = self.Dm() + + # Calculate the internal stresses [Sx, Sy, Txy] at each gauss point + s1 = matmul(Dm, matmul(self.B_m(-gp, -gp), d)) + s2 = matmul(Dm, matmul(self.B_m(gp, -gp), d)) + s3 = matmul(Dm, matmul(self.B_m(gp, gp), d)) + s4 = matmul(Dm, matmul(self.B_m(-gp, gp), d)) + + # Extrapolate to get the value at the requested location + Sx = H[0]*s1[0] + H[1]*s2[0] + H[2]*s3[0] + H[3]*s4[0] + Sy = H[0]*s1[1] + H[1]*s2[1] + H[2]*s3[1] + H[3]*s4[1] + Txy = H[0]*s1[2] + H[1]*s2[2] + H[2]*s3[2] + H[3]*s4[2] + + return array([Sx, + Sy, + Txy]) diff --git a/Old Pynite Folder/Quad3D.py b/Old Pynite Folder/Quad3D.py new file mode 100644 index 00000000..2cc825de --- /dev/null +++ b/Old Pynite Folder/Quad3D.py @@ -0,0 +1,1162 @@ +# References used to derive this element: +# 1. "A Comparative Formulation of DKMQ, DSQ and MITC4 Quadrilateral Plate Elements with New Numerical Results Based on s-norm Tests", Irwan Katili, +# 2. "Finite Element Procedures, 2nd Edition", Klaus-Jurgen Bathe +# 3. "A First Course in the Finite Element Method, 4th Edition", Daryl L. Logan +# 4. "Finite Element Analysis Fundamentals", Richard H. Gallagher + +import numpy as np +from numpy import add +from numpy.linalg import inv, det, norm +from math import sin, cos + +class Quad3D(): + """ + An isoparametric general quadrilateral element, formulated by superimposing an isoparametric + DKMQ bending element with an isoparametric plane stress element. Drilling stability is + provided by adding a weak rotational spring stiffness at each node. Isotropic behavior is the + default, but orthotropic in-plane behavior can be modeled by specifying stiffness modification + factors for the element's local x and y axes. Orthotropic behavior is only available for + rectangular plates. + + This element performs well for thick and thin plates, and for skewed plates. Element center + stresses and corner FORCES converge rapidly; however, corner STRESSES are more representative + of center stresses. Minor errors are introduced into the solution due to the drilling + approximation. Orthotropic behavior is limited to acting along the plate's local axes. + """ + + def __init__(self, name, i_node, j_node, m_node, n_node, t, material_name, model, kx_mod=1.0, + ky_mod=1.0): + + self.name = name + self.ID = None + self.type = 'Quad' + + self.i_node = i_node + self.j_node = j_node + self.m_node = m_node + self.n_node = n_node + + self.t = t + self.kx_mod = kx_mod + self.ky_mod = ky_mod + + self.pressures = [] # A list of surface pressures [pressure, case='Case 1'] + + # Quads need a link to the model they belong to + self.model = model + + # Get material properties for the plate from the model + try: + self.E = self.model.materials[material_name].E + self.nu = self.model.materials[material_name].nu + except: + raise KeyError('Please define the material ' + str(material_name) + ' before assigning it to plates.') + + # def _local_coords(self): + # """ + # Calculates or recalculates and stores the local (x, y) coordinates for each node of the + # quadrilateral. + # """ + + # # Get the global coordinates for each node + # X1, Y1, Z1 = self.i_node.X, self.i_node.Y, self.i_node.Z + # X2, Y2, Z2 = self.j_node.X, self.j_node.Y, self.j_node.Z + # X3, Y3, Z3 = self.m_node.X, self.m_node.Y, self.m_node.Z + # X4, Y4, Z4 = self.n_node.X, self.n_node.Y, self.n_node.Z + + # # Node 1 will be used as the origin of the plate's local (x, y) coordinate system. Find the + # # vector from the origin to each node. + # Xi1, Yi1, Zi1 = (X1 + X4)/2, (Y1 + Y4)/2, (Z1 + Z4)/2 + # Xi2, Yi2, Zi2 = (X2 + X3)/2, (Y2 + Y3)/2, (Z2 + Z3)/2 + # Xo, Yo, Zo = (Xi1 + Xi2)/2, (Yi1 + Yi2)/2, (Zi1 + Zi2)/2 + + # x_axis = np.array([Xi2 - Xi1, Yi2 - Yi1, Zi2 - Zi1]).T + + # vector_01 = np.array([X1 - Xo, Y1 - Yo, Z1 - Zo]).T + # vector_02 = np.array([X2 - Xo, Y2 - Yo, Z2 - Zo]).T + # vector_03 = np.array([X3 - Xo, Y3 - Yo, Z3 - Zo]).T + # vector_04 = np.array([X4 - Xo, Y4 - Yo, Z4 - Zo]).T + + # # Define the plate's local y, and z axes + # vector_x3 = np.array([X3 - Xi1, Y3 - Yi1, Z3 - Zi1]).T + # z_axis = np.cross(x_axis, vector_x3) + # y_axis = np.cross(z_axis, x_axis) + + # # Convert the x, y and z axes into unit vectors + # x_axis = x_axis/norm(x_axis) + # y_axis = y_axis/norm(y_axis) + # z_axis = z_axis/norm(z_axis) + + # # Calculate the local (x, y) coordinates for each node + # self.x1 = np.dot(vector_01, x_axis) + # self.x2 = np.dot(vector_02, x_axis) + # self.x3 = np.dot(vector_03, x_axis) + # self.x4 = np.dot(vector_04, x_axis) + # self.y1 = np.dot(vector_01, y_axis) + # self.y2 = np.dot(vector_02, y_axis) + # self.y3 = np.dot(vector_03, y_axis) + # self.y4 = np.dot(vector_04, y_axis) + + def _local_coords(self): + """ + Calculates or recalculates and stores the local (x, y) coordinates for each node of the quadrilateral. + """ + + # Get the global coordinates for each node + X1, Y1, Z1 = self.i_node.X, self.i_node.Y, self.i_node.Z + X2, Y2, Z2 = self.j_node.X, self.j_node.Y, self.j_node.Z + X3, Y3, Z3 = self.m_node.X, self.m_node.Y, self.m_node.Z + X4, Y4, Z4 = self.n_node.X, self.n_node.Y, self.n_node.Z + + # Node 1 will be used as the origin of the plate's local (x, y) coordinate system. Find the + # vector from the origin to each node. + vector_12 = np.array([X2 - X1, Y2 - Y1, Z2 - Z1]).T + vector_13 = np.array([X3 - X1, Y3 - Y1, Z3 - Z1]).T + vector_14 = np.array([X4 - X1, Y4 - Y1, Z4 - Z1]).T + + # Define the plate's local x, y, and z axes + x_axis = vector_12 + z_axis = np.cross(x_axis, vector_13) + y_axis = np.cross(z_axis, x_axis) + + # Convert the x and y axes into unit vectors + x_axis = x_axis/norm(x_axis) + y_axis = y_axis/norm(y_axis) + + # Calculate the local (x, y) coordinates for each node + self.x1 = 0 + self.x2 = np.dot(vector_12, x_axis) + self.x3 = np.dot(vector_13, x_axis) + self.x4 = np.dot(vector_14, x_axis) + self.y1 = 0 + self.y2 = np.dot(vector_12, y_axis) + self.y3 = np.dot(vector_13, y_axis) + self.y4 = np.dot(vector_14, y_axis) + + def L_k(self, k): + + # Figures 3 and 5 + if k == 5: + return ((self.x2 - self.x1)**2 + (self.y2 - self.y1)**2)**0.5 + elif k == 6: + return ((self.x3 - self.x2)**2 + (self.y3 - self.y2)**2)**0.5 + elif k == 7: + return ((self.x4 - self.x3)**2 + (self.y4 - self.y3)**2)**0.5 + elif k == 8: + return ((self.x1 - self.x4)**2 + (self.y1 - self.y4)**2)**0.5 + else: + raise Exception('Invalid value for k. k must be 5, 6, 7, or 8.') + + def dir_cos(self, k): + + L_k = self.L_k(k) + + # Figures 3 and 5 + if k == 5: + C = (self.x2 - self.x1)/L_k + S = (self.y2 - self.y1)/L_k + elif k == 6: + C = (self.x3 - self.x2)/L_k + S = (self.y3 - self.y2)/L_k + elif k == 7: + C = (self.x4 - self.x3)/L_k + S = (self.y4 - self.y3)/L_k + elif k == 8: + C = (self.x1 - self.x4)/L_k + S = (self.y1 - self.y4)/L_k + else: + raise Exception('Invalid value for k. k must be 5, 6, 7, or 8.') + + return C, S + + def phi_k(self, k): + + kappa = 5/6 + + # Equation 74 + return 2/(kappa*(1-self.nu))*(self.t/self.L_k(k))**2 + + def N_i(self, i, xi, eta): + """ + Returns the interpolation function for any given coordinate in the natural (xi, eta) coordinate system + """ + + if i == 1: + return 1/4*(1 - xi)*(1 - eta) + elif i == 2: + return 1/4*(1 + xi)*(1 - eta) + elif i == 3: + return 1/4*(1 + xi)*(1 + eta) + elif i == 4: + return 1/4*(1 - xi)*(1 + eta) + else: + raise Exception('Unable to calculate interpolation function. Invalid value specifed for i.') + + def P_k(self, k, xi, eta): + + if k == 5: + return 1/2*(1 - xi**2)*(1 - eta) + elif k == 6: + return 1/2*(1 + xi)*(1 - eta**2) + elif k == 7: + return 1/2*(1 - xi**2)*(1 + eta) + elif k == 8: + return 1/2*(1 - xi)*(1 - eta**2) + else: + raise Exception('Unable to calculate shape function. Invalid value specified for k.') + + def Co(self, xi, eta): + """This alternate calculation of the Jacobian matrix follows "The development of DKMQ plate bending element for thick to thin shell analysis based on the Naghdi/Reissner/Mindlin shell theory" by Katili, Batoz, Maknun and Hamdouni (2015). In the reference "C^o" is used instead of "J" to refer to the Jacobian. This method does not seem to produce correct results, but will be kept for future reference. It is helpful for understanding this plate element and may prove a useful simplification to the code base if implemented correctly someday. + """ + + # Nodal global coordinates (i=1, j=2, m=3, n=4) + x_1 = np.array([[self.i_node.X, self.i_node.Y, self.i_node.Z]]) + x_2 = np.array([[self.j_node.X, self.j_node.Y, self.j_node.Z]]) + x_3 = np.array([[self.m_node.X, self.m_node.Y, self.m_node.Z]]) + x_4 = np.array([[self.n_node.X, self.n_node.Y, self.n_node.Z]]) + + # Derivatives of the bilinear interpolation functions + N1_xi = 0.25*(eta - 1) + N2_xi = -0.25*(eta - 1) + N3_xi = 0.25*(eta + 1) + N4_xi = -0.25*(eta + 1) + N1_eta = 0.25*(xi - 1) + N2_eta = -0.25*(xi + 1) + N3_eta = 0.25*(xi + 1) + N4_eta = -0.25*(xi - 1) + + # Equation 4 - Katili 2015 + a_1 = N1_xi*x_1 + N2_xi*x_2 + N3_xi*x_3 + N4_xi*x_4 + a_2 = N1_eta*x_1 + N2_eta*x_2 + N3_eta*x_3 + N4_eta*x_4 + + # Normal vector + n = np.cross(a_1, a_2)/np.linalg.norm(np.cross(a_1, a_2)) + + # Global unit vectors + i = np.array([[1.0, 0.0, 0.0]]) + k = np.array([[0.0, 0.0, 1.0]]) + + if np.array_equal(n, k) or np.array_equal(n, -k): + t_1 = i + else: + t_1 = np.cross(n, k) + + t_2 = np.cross(n, t_1) + + a_11 = np.dot(a_1, a_1.T)[0, 0] + a_12 = np.dot(a_1, a_2.T)[0, 0] + a_21 = np.dot(a_2, a_1.T)[0, 0] + a_22 = np.dot(a_2, a_2.T)[0, 0] + + a = np.array([[a_11, a_12], + [a_21, a_22]]) + + a_det = np.linalg.det(a) + + a1 = 1/a_det*(a_22*a_1 - a_12*a_2) + a2 = 1/a_det*(-a_21*a_1 + a_11*a_2) + + Co = np.array([[np.dot(a1, t_1.T)[0, 0], np.dot(a1, t_2.T)[0, 0]], + [np.dot(a2, t_1.T)[0, 0], np.dot(a2, t_2.T)[0, 0]]]) + + return Co + + def J(self, xi, eta): + """ + Returns the Jacobian matrix for the element + """ + + # Get the local coordinates for the element + x1, y1, x2, y2, x3, y3, x4, y4 = self.x1, self.y1, self.x2, self.y2, self.x3, self.y3, self.x4, self.y4 + + # Return the Jacobian matrix + return 1/4*np.array([[x1*(eta - 1) - x2*(eta - 1) + x3*(eta + 1) - x4*(eta + 1), y1*(eta - 1) - y2*(eta - 1) + y3*(eta + 1) - y4*(eta + 1)], + [x1*(xi - 1) - x2*(xi + 1) + x3*(xi + 1) - x4*(xi - 1), y1*(xi - 1) - y2*(xi + 1) + y3*(xi + 1) - y4*(xi - 1)]]) + + def N_gamma(self, xi, eta): + + # Equation 44 + return np.array([[1/2*(1 - eta), 0, 1/2*(1 + eta), 0 ], + [ 0, 1/2*(1 + xi), 0, 1/2*(1 - xi)]]) + + def A_gamma(self): + + L5 = self.L_k(5) + L6 = self.L_k(6) + L7 = self.L_k(7) + L8 = self.L_k(8) + + # Equation 46 + return np.array([[L5/2, 0, 0, 0 ], + [ 0, L6/2, 0, 0 ], + [ 0, 0, -L7/2, 0 ], + [ 0, 0, 0, -L8/2]]) + + def A_u(self): + + # Calculate the length of each side of the quad + L5 = self.L_k(5) + L6 = self.L_k(6) + L7 = self.L_k(7) + L8 = self.L_k(8) + + # Get the direction cosines for each side of the quad + C5, S5 = self.dir_cos(5) + C6, S6 = self.dir_cos(6) + C7, S7 = self.dir_cos(7) + C8, S8 = self.dir_cos(8) + + # Return the [A_u] matrix + return 1/2*np.array([[-2/L5, C5, S5, 2/L5, C5, S5, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, -2/L6, C6, S6, 2/L6, C6, S6, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, -2/L7, C7, S7, 2/L7, C7, S7], + [ 2/L8, C8, S8, 0, 0, 0, 0, 0, 0, -2/L8, C8, S8]]) + + def A_Delta_inv_DKMQ(self): + + phi5 = self.phi_k(5) + phi6 = self.phi_k(6) + phi7 = self.phi_k(7) + phi8 = self.phi_k(8) + + return -3/2*np.array([[1/(1+phi5), 0, 0, 0 ], + [ 0, 1/(1+phi6), 0, 0 ], + [ 0, 0, 1/(1+phi7), 0 ], + [ 0, 0, 0, 1/(1+phi8)]]) + + def A_phi_Delta(self): + + phi5 = self.phi_k(5) + phi6 = self.phi_k(6) + phi7 = self.phi_k(7) + phi8 = self.phi_k(8) + + return np.array([[phi5/(1+phi5), 0, 0, 0 ], + [ 0, phi6/(1+phi6), 0, 0 ], + [ 0, 0, phi7/(1+phi7), 0 ], + [ 0, 0, 0, phi8/(1+phi8)]]) + + def B_b_beta(self, xi, eta): + + # Get the inverse of the Jacobian matrix + J_inv = inv(self.J(xi, eta)) + + # Get the individual terms for the Jacobian inverse + j11 = J_inv[0, 0] + j12 = J_inv[0, 1] + j21 = J_inv[1, 0] + j22 = J_inv[1, 1] + + # Derivatives of the bilinear interpolation functions + N1_xi = 0.25*(eta - 1) + N2_xi = -0.25*(eta - 1) + N3_xi = 0.25*(eta + 1) + N4_xi = -0.25*(eta + 1) + N1_eta = 0.25*(xi - 1) + N2_eta = -0.25*(xi + 1) + N3_eta = 0.25*(xi + 1) + N4_eta = -0.25*(xi - 1) + + N1x = j11*N1_xi + j12*N1_eta + N1y = j21*N1_xi + j22*N1_eta + N2x = j11*N2_xi + j12*N2_eta + N2y = j21*N2_xi + j22*N2_eta + N3x = j11*N3_xi + j12*N3_eta + N3y = j21*N3_xi + j22*N3_eta + N4x = j11*N4_xi + j12*N4_eta + N4y = j21*N4_xi + j22*N4_eta + + return np.array([[0, N1x, 0, 0, N2x, 0, 0, N3x, 0, 0, N4x, 0 ], + [0, 0, N1y, 0, 0, N2y, 0, 0, N3y, 0, 0, N4y], + [0, N1y, N1x, 0, N2y, N2x, 0, N3y, N3x, 0, N4y, N4x]]) + + def B_b_Delta_beta(self, xi, eta): + + # Get the inverse of the Jacobian matrix + J_inv = inv(self.J(xi, eta)) + + # Get the individual terms for the Jacobian inverse + j11 = J_inv[0, 0] + j12 = J_inv[0, 1] + j21 = J_inv[1, 0] + j22 = J_inv[1, 1] + + # Derivatives of the quadratic interpolation functions + P5_xi = xi*(eta - 1) + P6_xi = -0.5*(eta - 1)*(eta + 1) + P7_xi = -xi*(eta + 1) + P8_xi = 0.5*(eta - 1)*(eta + 1) + P5_eta = 0.5*(xi - 1)*(xi + 1) + P6_eta = -eta*(xi + 1) + P7_eta = -0.5*(xi - 1)*(xi + 1) + P8_eta = eta*(xi - 1) + + P5x = j11*P5_xi + j12*P5_eta + P5y = j21*P5_xi + j22*P5_eta + P6x = j11*P6_xi + j12*P6_eta + P6y = j21*P6_xi + j22*P6_eta + P7x = j11*P7_xi + j12*P7_eta + P7y = j21*P7_xi + j22*P7_eta + P8x = j11*P8_xi + j12*P8_eta + P8y = j21*P8_xi + j22*P8_eta + + C5, S5 = self.dir_cos(5) + C6, S6 = self.dir_cos(6) + C7, S7 = self.dir_cos(7) + C8, S8 = self.dir_cos(8) + + return np.array([[ P5x*C5, P6x*C6, P7x*C7, P8x*C8 ], + [ P5y*S5, P6y*S6, P7y*S7, P8y*S8, ], + [P5y*C5 + P5x*S5, P6y*C6 + P6x*S6, P7y*C7 + P7x*S7, P8y*C8 + P8x*S8]]) + + def B_b(self, xi, eta): + """ + Returns the [B_b] matrix for bending + """ + + # Return the [B] matrix for bending + return add(self.B_b_beta(xi, eta), self.B_b_Delta_beta(xi, eta) @ self.A_Delta_inv_DKMQ() @ self.A_u()) + + def B_s(self, xi, eta): + """ + Returns the [B_s] matrix for shear + """ + + # Return the [B] matrix for shear + return inv(self.J(xi, eta)) @ self.N_gamma(xi, eta) @ self.A_gamma() @ self.A_phi_Delta() @ self.A_u() + + def B_m(self, xi, eta): + + # Differentiate the interpolation functions + # Row 1 = interpolation functions differentiated with respect to x + # Row 2 = interpolation functions differentiated with respect to y + # Note that the inverse of the Jacobian converts from derivatives with + # respect to xi and eta to derivatives with respect to x and y + dH = np.matmul(inv(self.J(xi, eta)), 1/4*np.array([[eta - 1, -eta + 1, eta + 1, -eta - 1], + [xi - 1, -xi - 1, xi + 1, -xi + 1 ]])) + + # Reference 2, Example 5.5 (page 353) + B_m = np.array([[dH[0, 0], 0, dH[0, 1], 0, dH[0, 2], 0, dH[0, 3], 0 ], + [ 0, dH[1, 0], 0, dH[1, 1], 0, dH[1, 2], 0, dH[1, 3]], + [dH[1, 0], dH[0, 0], dH[1, 1], dH[0, 1], dH[1, 2], dH[0, 2], dH[1, 3], dH[0, 3]]]) + + return B_m + + def Hb(self): + ''' + Returns the stress-strain matrix for plate bending. + ''' + + # Referemce 1, Table 4.3, page 194 + nu = self.nu + E = self.E + h = self.t + + Hb = E*h**3/(12*(1 - nu**2))*np.array([[1, nu, 0 ], + [nu, 1, 0 ], + [0, 0, (1 - nu)/2]]) + + return Hb + + def Hs(self): + ''' + Returns the stress-strain matrix for shear. + ''' + # Reference 2, Equations (5.97), page 422 + k = 5/6 + E = self.E + h = self.t + nu = self.nu + + Hs = E*h*k/(2*(1 + nu))*np.array([[1, 0], + [0, 1]]) + + return Hs + + def Cm(self): + """ + Returns the stress-strain matrix for an isotropic or orthotropic plane stress element + """ + + # Apply the stiffness modification factors for each direction to obtain orthotropic + # behavior. Stiffness modification factors of 1.0 in each direction (the default) will + # model isotropic behavior. Orthotropic behavior is limited to the element's local + # coordinate system. + Ex = self.E*self.kx_mod + Ey = self.E*self.ky_mod + nu_xy = self.nu + nu_yx = self.nu + + # The shear modulus will be unafected by orthotropic behavior + # Logan, Appendix C.3, page 750 + G = self.E/(2*(1 + self.nu)) + + # Gallagher, Equation 9.3, page 251 + Cm = 1/(1 - nu_xy*nu_yx)*np.array([[ Ex, nu_yx*Ex, 0 ], + [nu_xy*Ey, Ey, 0 ], + [ 0, 0, (1 - nu_xy*nu_yx)*G]]) + + return Cm + + def k_b(self): + ''' + Returns the local stiffness matrix for bending stresses + ''' + + Hb = self.Hb() + Hs = self.Hs() + + # Define the gauss point for numerical integration + gp = 1/3**0.5 + + # Get the determinant of the Jacobian matrix for each gauss point. Doing this now will save us from doing it twice below. + J1 = det(self.J(-gp, -gp)) + J2 = det(self.J( gp, -gp)) + J3 = det(self.J( gp, gp)) + J4 = det(self.J(-gp, gp)) + + # Get the bending [B_b] matrices for each gauss point + B1 = self.B_b(-gp, -gp) + B2 = self.B_b( gp, -gp) + B3 = self.B_b( gp, gp) + B4 = self.B_b(-gp, gp) + + # Create the stiffness matrix with bending stiffness terms + # See 2, Equation 5.94 + k = ((B1.T @ Hb @ B1)*J1 + + (B2.T @ Hb @ B2)*J2 + + (B3.T @ Hb @ B3)*J3 + + (B4.T @ Hb @ B4)*J4) + + # Get the shear [B_s] matrices for each gauss point + B1 = self.B_s(-gp, -gp) + B2 = self.B_s( gp, -gp) + B3 = self.B_s( gp, gp) + B4 = self.B_s(-gp, gp) + + # Add shear stiffness terms to the stiffness matrix + k += ((B1.T @ Hs @ B1)*J1 + + (B2.T @ Hs @ B2)*J2 + + (B3.T @ Hs @ B3)*J3 + + (B4.T @ Hs @ B4)*J4) + + # Following Bathe's recommendation for the drilling degree of freedom + # from Example 4.19 in "Finite Element Procedures, 2nd Ed.", calculate + # the drilling stiffness as 1/1000 of the smallest diagonal term in + # the element's stiffness matrix. This is not theoretically correct, + # but it allows the model to solve without singularities, and should + # have a minimal effect on the final solution. Bathe recommends 1/1000 + # as a value that is weak enough but not so small that it affect the + # results. Bathe recommends looking at all the diagonals in the + # combined bending plus membrane stiffness matrix. Some of those terms + # relate to translational stiffness. It seems more rational to only + # look at the terms relating to rotational stiffness. That will be + # Pynite's approach. + k_rz = min(abs(k[1, 1]), abs(k[2, 2]), abs(k[4, 4]), abs(k[5, 5]), + abs(k[7, 7]), abs(k[8, 8]), abs(k[10, 10]), abs(k[11, 11]) + )/1000 + + # Initialize the expanded stiffness matrix to all zeros + k_exp = np.zeros((24, 24)) + + # Step through each term in the unexpanded stiffness matrix + # i = Unexpanded matrix row + for i in range(12): + + # j = Unexpanded matrix column + for j in range(12): + + # Find the corresponding term in the expanded stiffness + # matrix + + # m = Expanded matrix row + if i in [0, 3, 6, 9]: # indices associated with deflection in z + m = 2*i + 2 + if i in [1, 4, 7, 10]: # indices associated with rotation about x + m = 2*i + 1 + if i in [2, 5, 8, 11]: # indices associated with rotation about y + m = 2*i + + # n = Expanded matrix column + if j in [0, 3, 6, 9]: # indices associated with deflection in z + n = 2*j + 2 + if j in [1, 4, 7, 10]: # indices associated with rotation about x + n = 2*j + 1 + if j in [2, 5, 8, 11]: # indices associated with rotation about y + n = 2*j + + # Ensure the indices are integers rather than floats + m, n = round(m), round(n) + + # Add the term from the unexpanded matrix into the expanded + # matrix + k_exp[m, n] = k[i, j] + + # Add the drilling degree of freedom's weak spring + k_exp[5, 5] = k_rz + k_exp[11, 11] = k_rz + k_exp[17, 17] = k_rz + k_exp[23, 23] = k_rz + + # Invert the local +y bending sign convention to match Pynite's + k_exp[[4, 10, 16, 22], :] *= -1 + k_exp[:, [4, 10, 16, 22]] *= -1 + + # The way the DKMQ element was derived, the local x and y axes of the element are swapped from Pynite's global definitions of x and y. Swap them to match Pynite. + k_exp[[3, 4, 9, 10, 15, 16, 21, 22], :] = k_exp[[4, 3, 10, 9, 16, 15, 22, 21], :] + k_exp[:, [3, 4, 9, 10, 15, 16, 21, 22]] = k_exp[:, [4, 3, 10, 9, 16, 15, 22, 21]] + + return k_exp + + def k_m(self): + ''' + Returns the local stiffness matrix for membrane (in-plane) stresses. + + Plane stress is assumed + ''' + + t = self.t + Cm = self.Cm() + + # Define the gauss point for numerical integration + gp = 1/3**0.5 + + # Get the membrane B matrices for each gauss point + # Doing this now will save us from doing it twice below + B1 = self.B_m(-gp, -gp) + B2 = self.B_m( gp, -gp) + B3 = self.B_m( gp, gp) + B4 = self.B_m(-gp, gp) + + # See reference 2 at the bottom of page 353, and reference 2 page 466 + k = t*((B1.T @ Cm @ B1)*det(self.J(-gp, -gp)) + + (B2.T @ Cm @ B2)*det(self.J( gp, -gp)) + + (B3.T @ Cm @ B3)*det(self.J( gp, gp)) + + (B4.T @ Cm @ B4)*det(self.J(-gp, gp))) + + k_exp = np.zeros((24, 24)) + + # Step through each term in the unexpanded stiffness matrix + # i = Unexpanded matrix row + for i in range(8): + + # j = Unexpanded matrix column + for j in range(8): + + # Find the corresponding term in the expanded stiffness + # matrix + + # m = Expanded matrix row + if i in [0, 2, 4, 6]: # indices associated with displacement in x + m = i*3 + if i in [1, 3, 5, 7]: # indices associated with displacement in y + m = i*3 - 2 + + # n = Expanded matrix column + if j in [0, 2, 4, 6]: # indices associated with displacement in x + n = j*3 + if j in [1, 3, 5, 7]: # indices associated with displacement in y + n = j*3 - 2 + + # Ensure the indices are integers rather than floats + m, n = round(m), round(n) + + # Add the term from the unexpanded matrix into the expanded matrix + k_exp[m, n] = k[i, j] + + return k_exp + + def k(self): + ''' + Returns the quad element's local stiffness matrix. + ''' + + # Recalculate the local coordinate system + self._local_coords() + + # Sum the bending and membrane stiffness matrices + return np.add(self.k_b(), self.k_m()) + + def f(self, combo_name='Combo 1'): + """ + Returns the quad element's local end force vector + """ + + # Calculate and return the plate's local end force vector + return np.add(self.k() @ self.d(combo_name), self.fer(combo_name)) + + def fer(self, combo_name='Combo 1'): + """ + Returns the quadrilateral's local fixed end reaction vector. + + Parameters + ---------- + combo_name : string + The name of the load combination to get the consistent load vector for. + """ + + Hw = lambda xi, eta : 1/4*np.array([[(1 - xi)*(1 - eta), 0, 0, (1 + xi)*(1 - eta), 0, 0, (1 + xi)*(1 + eta), 0, 0, (1 - xi)*(1 + eta), 0, 0]]) + + # Initialize the fixed end reaction vector + fer = np.zeros((12, 1)) + + # Get the requested load combination + combo = self.model.load_combos[combo_name] + + # Define the gauss point used for numerical integration + gp = 1/3**0.5 + + # Initialize the element's surface pressure to zero + p = 0 + + # Loop through each load case and factor in the load combination + for case, factor in combo.factors.items(): + + # Sum the pressures + for pressure in self.pressures: + + # Check if the current pressure corresponds to the current load case + if pressure[1] == case: + + # Sum the pressures + p -= factor*pressure[0] + + fer = (Hw(-gp, -gp).T*p*det(self.J(-gp, -gp)) + + Hw( gp, -gp).T*p*det(self.J( gp, -gp)) + + Hw( gp, gp).T*p*det(self.J( gp, gp)) + + Hw(-gp, gp).T*p*det(self.J(-gp, gp))) + + # Initialize the expanded vector to all zeros + fer_exp = np.zeros((24, 1)) + + # Step through each term in the unexpanded vector + # i = Unexpanded vector row + for i in range(12): + + # Find the corresponding term in the expanded vector + + # m = Expanded vector row + if i in [0, 3, 6, 9]: # indices associated with deflection in z + m = 2*i + 2 + if i in [1, 4, 7, 10]: # indices associated with rotation about x + m = 2*i + 1 + if i in [2, 5, 8, 11]: # indices associated with rotation about y + m = 2*i + + # Ensure the index is an integer rather than a float + m = round(m) + + # Add the term from the unexpanded vector into the expanded vector + fer_exp[m, 0] = fer[i, 0] + + return fer_exp + + def d(self, combo_name='Combo 1'): + """ + Returns the quad element's local displacement vector + """ + + # Calculate and return the local displacement vector + return self.T() @ self.D(combo_name) + + def F(self, combo_name='Combo 1'): + """ + Returns the quad element's global force vector + + Parameters + ---------- + combo_name : string + The load combination to get results for. + """ + + # Calculate and return the global force vector + return inv(self.T()) @ self.f(combo_name) + + def D(self, combo_name='Combo 1'): + ''' + Returns the quad element's global displacement vector for the given + load combination. + + Parameters + ---------- + combo_name : string + The name of the load combination to get the displacement vector + for (not the load combination itself). + ''' + + # Initialize the displacement vector + D = np.zeros((24, 1)) + + # Read in the global displacements from the nodes + D[0, 0] = self.i_node.DX[combo_name] + D[1, 0] = self.i_node.DY[combo_name] + D[2, 0] = self.i_node.DZ[combo_name] + D[3, 0] = self.i_node.RX[combo_name] + D[4, 0] = self.i_node.RY[combo_name] + D[5, 0] = self.i_node.RZ[combo_name] + + D[6, 0] = self.j_node.DX[combo_name] + D[7, 0] = self.j_node.DY[combo_name] + D[8, 0] = self.j_node.DZ[combo_name] + D[9, 0] = self.j_node.RX[combo_name] + D[10, 0] = self.j_node.RY[combo_name] + D[11, 0] = self.j_node.RZ[combo_name] + + D[12, 0] = self.m_node.DX[combo_name] + D[13, 0] = self.m_node.DY[combo_name] + D[14, 0] = self.m_node.DZ[combo_name] + D[15, 0] = self.m_node.RX[combo_name] + D[16, 0] = self.m_node.RY[combo_name] + D[17, 0] = self.m_node.RZ[combo_name] + + D[18, 0] = self.n_node.DX[combo_name] + D[19, 0] = self.n_node.DY[combo_name] + D[20, 0] = self.n_node.DZ[combo_name] + D[21, 0] = self.n_node.RX[combo_name] + D[22, 0] = self.n_node.RY[combo_name] + D[23, 0] = self.n_node.RZ[combo_name] + + # Return the global displacement vector + return D + + def K(self): + ''' + Returns the quad element's global stiffness matrix + ''' + + # Get the transformation matrix + T = self.T() + + # Calculate and return the stiffness matrix in global coordinates + return inv(T) @ self.k() @ T + + # Global fixed end reaction vector + def FER(self, combo_name='Combo 1'): + ''' + Returns the global fixed end reaction vector. + + Parameters + ---------- + combo_name : string + The name of the load combination to calculate the fixed end + reaction vector for (not the load combination itself). + ''' + + # Calculate and return the fixed end reaction vector + return inv(self.T()) @ self.fer(combo_name) + + def T(self): + """ + Returns the coordinate transformation matrix for the quad element. + """ + + xi = self.i_node.X + xj = self.j_node.X + yi = self.i_node.Y + yj = self.j_node.Y + zi = self.i_node.Z + zj = self.j_node.Z + + # Calculate the direction cosines for the local x-axis.The local x-axis will run from + # the i-node to the j-node + x = [xj - xi, yj - yi, zj - zi] + + # Divide the vector by its magnitude to produce a unit x-vector of + # direction cosines + # mag = (x[0]**2 + x[1]**2 + x[2]**2)**0.5 + # x = [x[0]/mag, x[1]/mag, x[2]/mag] + x = x/norm(x) + + # The local y-axis will be in the plane of the plate. Find a vector in + # the plate's local xy plane. + xn = self.n_node.X + yn = self.n_node.Y + zn = self.n_node.Z + xy = [xn - xi, yn - yi, zn - zi] + + # Find a vector perpendicular to the plate surface to get the + # orientation of the local z-axis. + z = np.cross(x, xy) + + # Divide the z-vector by its magnitude to produce a unit z-vector of + # direction cosines. + # mag = (z[0]**2 + z[1]**2 + z[2]**2)**0.5 + # z = [z[0]/mag, z[1]/mag, z[2]/mag] + z = z/norm(z) + + # Calculate the local y-axis as a vector perpendicular to the local z + # and x-axes. + y = np.cross(z, x) + + # Divide the y-vector by its magnitude to produce a unit vector of + # direction cosines. + # mag = (y[0]**2 + y[1]**2 + y[2]**2)**0.5 + # y = [y[0]/mag, y[1]/mag, y[2]/mag] + y = y/norm(y) + + # Create the direction cosines matrix. + dir_cos = np.array([x, + y, + z]) + + # Build the transformation matrix. + T = np.zeros((24, 24)) + T[0:3, 0:3] = dir_cos + T[3:6, 3:6] = dir_cos + T[6:9, 6:9] = dir_cos + T[9:12, 9:12] = dir_cos + T[12:15, 12:15] = dir_cos + T[15:18, 15:18] = dir_cos + T[18:21, 18:21] = dir_cos + T[21:24, 21:24] = dir_cos + + # Return the transformation matrix. + return T + + # def T(self): + # """ + # Returns the coordinate transformation matrix for the quad element. + # """ + + # xi = self.i_node.X + # xj = self.j_node.X + # yi = self.i_node.Y + # yj = self.j_node.Y + # zi = self.i_node.Z + # zj = self.j_node.Z + + # # Calculate the direction cosines for the local x-axis.The local x-axis will run from + # # the i-node to the j-node + # x = [xj - xi, yj - yi, zj - zi] + + # # Divide the vector by its magnitude to produce a unit x-vector of + # # direction cosines + # mag = (x[0]**2 + x[1]**2 + x[2]**2)**0.5 + # x = [x[0]/mag, x[1]/mag, x[2]/mag] + + # # The local y-axis will be in the plane of the plate. Find a vector in + # # the plate's local xy plane. + # xn = self.n_node.X + # yn = self.n_node.Y + # zn = self.n_node.Z + # xy = [xn - xi, yn - yi, zn - zi] + + # # Find a vector perpendicular to the plate surface to get the + # # orientation of the local z-axis. + # z = np.cross(x, xy) + + # # Divide the z-vector by its magnitude to produce a unit z-vector of + # # direction cosines. + # mag = (z[0]**2 + z[1]**2 + z[2]**2)**0.5 + # z = [z[0]/mag, z[1]/mag, z[2]/mag] + + # # Calculate the local y-axis as a vector perpendicular to the local z + # # and x-axes. + # y = np.cross(z, x) + + # # Divide the y-vector by its magnitude to produce a unit vector of + # # direction cosines. + # mag = (y[0]**2 + y[1]**2 + y[2]**2)**0.5 + # y = [y[0]/mag, y[1]/mag, y[2]/mag] + + # # Create the direction cosines matrix. + # dir_cos = np.array([x, + # y, + # z]) + + # # Build the transformation matrix. + # T = np.zeros((24, 24)) + # T[0:3, 0:3] = dir_cos + # T[3:6, 3:6] = dir_cos + # T[6:9, 6:9] = dir_cos + # T[9:12, 9:12] = dir_cos + # T[12:15, 12:15] = dir_cos + # T[15:18, 15:18] = dir_cos + # T[18:21, 18:21] = dir_cos + # T[21:24, 21:24] = dir_cos + + # # Return the transformation matrix. + # return T + + def shear(self, xi=0, eta=0, local=True, combo_name='Combo 1'): + """ + Returns the interal shears at any point in the quad element. + + Internal shears are reported as a 2D array [[Qx], [Qy]] at the + specified location in the (xi, eta) natural coordinate system. + + Parameters + ---------- + xi : number + The xi-coordinate. Default is 0. + eta : number + The eta-coordinate. Default is 0. + + Returns + ------- + Internal shear force per unit length of the quad element: [[Qx], [Qy]] + """ + + # Get the plate's local displacement vector + d = self.d(combo_name) + + # Correct the sign convention for x-axis rotation - note that +x bending and +x rotation are opposite in the DKMQ derivation. Hence when correcting d we correct the x terms, but when correcting k we correct the y terms + d[[3, 9, 15, 21], :] *= -1 + + # Slice out terms not related to plate bending, and swap the local x and y to match the DKMQ derivation + d = d[[2, 4, 3, 8, 10, 9, 14, 16, 15, 20, 22, 21], :] + + # Define the gauss point used for numerical integration + gp = 1/3**0.5 + + # Define extrapolated r and s points + xi_ex = xi/gp + eta_ex = eta/gp + + # Define the interpolation functions + H = 1/4*np.array([(1 - xi_ex)*(1 - eta_ex), (1 + xi_ex)*(1 - eta_ex), (1 + xi_ex)*(1 + eta_ex), (1 - xi_ex)*(1 + eta_ex)]) + + # Get the stress-strain matrix + Hs = self.Hs() + + # Calculate the internal shears [Qx, Qy] at each gauss point + q1 = np.matmul(Hs, np.matmul(self.B_s(-gp, -gp), d)) + q2 = np.matmul(Hs, np.matmul(self.B_s(gp, -gp), d)) + q3 = np.matmul(Hs, np.matmul(self.B_s(gp, gp), d)) + q4 = np.matmul(Hs, np.matmul(self.B_s(-gp, gp), d)) + + # Extrapolate to get the value at the requested location + Qx = H[0]*q1[0] + H[1]*q2[0] + H[2]*q3[0] + H[3]*q4[0] + Qy = H[0]*q1[1] + H[1]*q2[1] + H[2]*q3[1] + H[3]*q4[1] + + if local: + + return np.array([Qx, + Qy]) + + else: + + # Get the direction cosines for the plate's local coordinate system + dir_cos = self.T()[:3, :3] + + return np.matmul(dir_cos.T, np.array([Qx, + Qy, + [0]])) + + def moment(self, xi=0, eta=0, local=True, combo_name='Combo 1'): + """ + Returns the interal moments at any point in the quad element. + + Internal moments are reported as a 2D array [[Mx], [My], [Mxy]] at the + specified location in the (xi, eta) natural coordinate system. + + Parameters + ---------- + xi : number + The xi-coordinate. Default is 0. + eta : number + The eta-coordinate. Default is 0. + + Returns + ------- + Internal moment per unit length of the quad element: [[Mx], [My], [Mxy]] + """ + + # Get the plate's local displacement vector + d = self.d(combo_name) + + # Correct the sign convention for x-axis rotation - note that +x bending and +x rotation are opposite in the DKMQ derivation. Hence when correcting d we correct the x terms, but when correcting k we correct the y terms + d[[3, 9, 15, 21], :] *= -1 + + # Slice out terms not related to plate bending, and swap the local x and y to match the DKMQ derivation + d = d[[2, 4, 3, 8, 10, 9, 14, 16, 15, 20, 22, 21], :] + + # Define the gauss point used for numerical integration + gp = 1/3**0.5 + + # # Define extrapolated `xi` and `eta` points + xi_ex = xi/gp + eta_ex = eta/gp + + # Define the interpolation functions + H = 1/4*np.array([(1 - xi_ex)*(1 - eta_ex), (1 + xi_ex)*(1 - eta_ex), (1 + xi_ex)*(1 + eta_ex), (1 - xi_ex)*(1 + eta_ex)]) + + # Get the stress-strain matrix + Hb = self.Hb() + + # Calculate the internal moments [-My, Mx, Mxy] at each gauss point + m1 = np.matmul(Hb, np.matmul(self.B_b(-gp, -gp), d)) + m2 = np.matmul(Hb, np.matmul(self.B_b( gp, -gp), d)) + m3 = np.matmul(Hb, np.matmul(self.B_b( gp, gp), d)) + m4 = np.matmul(Hb, np.matmul(self.B_b(-gp, gp), d)) + + # Extrapolate to get the value at the requested location + Mx = H[0]*m1[0] + H[1]*m2[0] + H[2]*m3[0] + H[3]*m4[0] + My = H[0]*m1[1] + H[1]*m2[1] + H[2]*m3[1] + H[3]*m4[1] + Mxy = H[0]*m1[2] + H[1]*m2[2] + H[2]*m3[2] + H[3]*m4[2] + + if local: + + return np.array([Mx, + My, + Mxy]) + + else: + + # Get the direction cosines for the plate's local coordinate system + dir_cos = self.T()[:3, :3] + + # Convert the local results to global results + M_global = np.matmul(dir_cos, np.array([ -My, + Mx, + [0]])) + + M_global = M_global[[1, 0, 2], :] + + return M_global + + def membrane(self, xi=0, eta=0, local=True, combo_name='Combo 1'): + + # Get the plate's local displacement vector. Slice out terms not related to membrane stresses. + d = self.d(combo_name)[[0, 1, 6, 7, 12, 13, 18, 19], :] + + # Define the gauss point used for numerical integration + gp = 1/3**0.5 + + # Define extrapolated r and s points + xi_ex = xi/gp + eta_ex = eta/gp + + # Define the interpolation functions + H = 1/4*np.array([(1 - xi_ex)*(1 - eta_ex), (1 + xi_ex)*(1 - eta_ex), (1 + xi_ex)*(1 + eta_ex), (1 - xi_ex)*(1 + eta_ex)]) + + # Get the stress-strain matrix + Cm = self.Cm() + + # Calculate the internal stresses [Sx, Sy, Txy] at each gauss point + s1 = np.matmul(Cm, np.matmul(self.B_m(-gp, -gp), d)) + s2 = np.matmul(Cm, np.matmul(self.B_m(gp, -gp), d)) + s3 = np.matmul(Cm, np.matmul(self.B_m(gp, gp), d)) + s4 = np.matmul(Cm, np.matmul(self.B_m(-gp, gp), d)) + + # Extrapolate to get the value at the requested location + Sx = H[0]*s1[0] + H[1]*s2[0] + H[2]*s3[0] + H[3]*s4[0] + Sy = H[0]*s1[1] + H[1]*s2[1] + H[2]*s3[1] + H[3]*s4[1] + Txy = H[0]*s1[2] + H[1]*s2[2] + H[2]*s3[2] + H[3]*s4[2] + + if local: + + return np.array([Sx, + Sy, + Txy]) + + else: + + # Get the direction cosines for the plate's local coordinate system + dir_cos = self.T()[:3, :3] + + # Convert the plate membrane stresses to global coordinates + return np.matmul(dir_cos.T, np.array([Sx, + Sy, + [0]])) + diff --git a/Old Pynite Folder/Rendering.py b/Old Pynite Folder/Rendering.py new file mode 100644 index 00000000..6bb43fab --- /dev/null +++ b/Old Pynite Folder/Rendering.py @@ -0,0 +1,1316 @@ + +from json import load +import warnings + +from IPython.display import Image +import numpy as np +import pyvista as pv +import math + +# Allow for 3D interaction within jupyter notebook using trame +try: + pv.global_theme.trame.jupyter_extension_enabled = True +except: + # Ignore the exception that is produced if we are not running the code via jupyter + pass +pv.set_jupyter_backend('trame') + +class Renderer: + """Used to render finite element models. + """ + + scalar = None + + def __init__(self, model): + + self.model = model + + # Default settings for rendering + self._annotation_size = 5 + self._deformed_shape = False + self._deformed_scale = 30 + self._render_nodes = True + self._render_loads = True + self._color_map = None + self._combo_name = 'Combo 1' + self._case = None + self._labels = True + self._scalar_bar = False + self._scalar_bar_text_size = 24 + self.theme = 'default' + + self.plotter = pv.Plotter() + self.plotter.set_background('white') # Setting background color + # self.plotter.add_logo_widget('./Resources/Full Logo No Buffer - Transparent.png') + # self.plotter.view_isometric() + self.plotter.view_xy() + self.plotter.show_axes() + + # Initialize load labels + self._load_label_points = [] + self._load_labels = [] + + # Initialize spring labels + self._spring_label_points = [] + self._spring_labels = [] + + @property + def window_width(self): + return self.plotter.window_size[0] + + @window_width.setter + def window_width(self, width): + height = self.plotter.window_size[1] + self.plotter.window_size = (width, height) + + @property + def window_height(self): + return self.plotter.window_size[1] + + @window_height.setter + def window_height(self, height): + width = self.plotter.window_size[0] + self.plotter.window_size = (width, height) + + @property + def annotation_size(self): + return self._annotation_size + + @annotation_size.setter + def annotation_size(self, size): + self._annotation_size = size + + @property + def deformed_shape(self): + return self._deformed_shape + + @deformed_shape.setter + def deformed_shape(self, deformed_shape): + self._deformed_shape = deformed_shape + + @property + def deformed_scale(self): + return self._deformed_scale + + @deformed_scale.setter + def deformed_scale(self, scale): + self._deformed_scale = scale + + @property + def render_nodes(self): + return self._render_nodes + + @render_nodes.setter + def render_nodes(self, render_nodes): + self._render_nodes = render_nodes + + @property + def render_loads(self): + return self._render_loads + + @render_loads.setter + def render_loads(self, render_loads): + self._render_loads = render_loads + + @property + def color_map(self): + return self._color_map + + @color_map.setter + def color_map(self, color_map): + self._color_map = color_map + + @property + def combo_name(self): + return self._combo_name + + @combo_name.setter + def combo_name(self, combo_name): + self._combo_name = combo_name + self._case = None + + @property + def case(self): + return self._case + + @case.setter + def case(self, case): + self._case = case + self._combo_name = None + + @property + def show_labels(self): + return self._labels + + @show_labels.setter + def show_labels(self, show_labels): + self._labels = show_labels + + @property + def scalar_bar(self): + return self._scalar_bar + + @scalar_bar.setter + def scalar_bar(self, scalar_bar): + self._scalar_bar = scalar_bar + + @property + def scalar_bar_text_size(self): + return self._scalar_bar_text_size + + @scalar_bar_text_size.setter + def scalar_bar_text_size(self, text_size): + self._scalar_bar_text_size = text_size + + def render_model(self, reset_camera=True): + """ + Renders the model in a window + + Parameters + ---------- + interact : bool + Suppresses interacting with the window if set to `False`. This can be used to capture a + screenshot without pausing the program for the user to interact. Default is `True`. + reset_camera : bool + Resets the camera if set to `True`. Default is `True`. + """ + + # Update the plotter with the latest geometry + self.update(reset_camera) + + # Render the model (code execution will pause here until the user closes the window) + self.plotter.show(title='Pynite - Simple Finite Element Analysis for Python', auto_close=False) + + def screenshot(self, filepath='./Pynite_Image.png', interact=True, reset_camera=True): + """Saves a screenshot of the rendered model. Important: Press `q` to capture the screenshot after positioning the view. Pressing the `X` button in the corner of the window will ignore the positioning and shut down the entire renderer against further use once the screenshot is taken. + + :param filepath: The filepath to write the image to. When set to 'jupyter', the resulting plot is placed inline in a jupyter notebook. Defaults to 'jupyter'. + :type filepath: str, optional + :param interact: When set to `True` the user can set the scene before the screenshot is taken. Once the scene is set, press 'q' to take the screenshot. Defaults to `True` + :type interact: bool, optional + :param reset_camera: Resets the plotter's camera. Defaults to `True` + :type reset_camera: bool, optional + """ + + # Update the plotter with the latest geometry + self.update(reset_camera) + + # Determine if the user should interact with the window before capturing the screenshot + if interact == False: + + # Don't bother showing the image before capturing the screenshot + self.plotter.off_screen = True + + # Save the screenshot to the specified filepath. Note that `auto_close` shuts down the entire plotter after the screenshot is taken, rather than just closing the window. We'll set `auto_close=False` to allow the plotter to remain active. Note that the window must be closed by pressing `q`. Closing it with the 'X' button in the window's corner will close the whole plotter down. + self.plotter.show(title='Pynite - Simple Finite Element Anlaysis for Python', screenshot=filepath, auto_close=False) + + def update(self, reset_camera=True): + """ + Builds or rebuilds the pyvista plotter + + Parameters + ---------- + reset_camera : bool + Resets the camera if set to `True`. Default is `True`. + """ + + # Input validation + if self.deformed_shape and self.case != None: + raise Exception('Deformed shape is only available for load combinations,' + ' not load cases.') + if self.model.load_combos == {} and self.render_loads == True and self.case == None: + self.render_loads = False + warnings.warn('Unable to render load combination. No load combinations defined.', UserWarning) + + # Clear out the old plot (if any) + self.plotter.clear() + + # Clear out internally stored labels (if any) + self._load_label_points = [] + self._load_labels = [] + + self._spring_label_points = [] + self._spring_labels = [] + + # Check if nodes are to be rendered + if self.render_nodes == True: + + if self.theme == 'print': + color = 'black' + else: + color = 'grey' + + # Plot each node in the model + for node in self.model.nodes.values(): + self.plot_node(node, color) + + # Render node labels + label_points = [[node.X, node.Y, node.Z] for node in self.model.nodes.values()] + labels = [node.name for node in self.model.nodes.values()] + + self.plotter.add_point_labels(label_points, labels, bold=False, text_color='black', show_points=True, point_color='grey', point_size=5, shape=None, render_points_as_spheres=True) + + # Check if there are springs in the model + if self.model.springs: + + # Render the springs + for spring in self.model.springs.values(): + self.plot_spring(spring, 'grey') + + # Render the spring labels + self.plotter.add_point_labels(self._spring_label_points, self._spring_labels, text_color='black', bold=False, shape=None, render_points_as_spheres=False) + + # Render the members + for member in self.model.members.values(): + self.plot_member(member) + + # Render the member labels + label_points = [[(member.i_node.X+member.j_node.X)/2, (member.i_node.Y+member.j_node.Y)/2, (member.i_node.Z+member.j_node.Z)/2] for member in self.model.members.values()] + labels = [member.name for member in self.model.members.values()] + self.plotter.add_point_labels(label_points, labels, bold=False, text_color='black', show_points=False, shape=None, render_points_as_spheres=False) + + # Render the deformed shape if requested + if self.deformed_shape == True: + + # Render deformed nodes + # for node in self.model.nodes.values(): + # self.plot_deformed_node(node, self.deformed_scale) + + # Render deformed members + for member in self.model.members.values(): + self.plot_deformed_member(member, self.deformed_scale) + + # Render deformed springs + for spring in self.model.springs.values(): + self.plot_spring(spring, 'red', deformed=True) + + # _DeformedShape(self.model, self.deformed_scale, self.annotation_size, self.combo_name, self.render_nodes, self.theme) + + # Render the loads if requested + if (self.combo_name != None or self.case != None) and self.render_loads != False: + + # Plot the loads + self.plot_loads() + + # Plot the load labels + self.plotter.add_point_labels(self._load_label_points, self._load_labels, bold=False, text_color='green', show_points=False, shape=None, render_points_as_spheres=False) + + # Render the plates and quads, if present + if self.model.quads or self.model.plates: + self.plot_plates(self.deformed_shape, self.deformed_scale, self.color_map, self.combo_name) + + # Determine whether to show or hide the scalar bar + # if self._scalar_bar == False: + # self.plotter.scalar_bar.VisibilityOff() + + # Reset the camera if requested by the user + if reset_camera: + self.plotter.reset_camera() + + def plot_node(self, node, color='grey'): + """Adds a node to the plotter + + :param node: node + :type node: Node3D + """ + + # Get the node's position + X = node.X # Global X coordinate + Y = node.Y # Global Y coordinate + Z = node.Z # Global Z coordinate + + # Generate any supports that occur at the node + # Check for a fixed suppport + if node.support_DX and node.support_DY and node.support_DZ and node.support_RX and node.support_RY and node.support_RZ: + + # Create a cube using PyVista + self.plotter.add_mesh(pv.Cube(center=(node.X, node.Y, node.Z), + x_length=self.annotation_size*2, + y_length=self.annotation_size*2, + z_length=self.annotation_size*2), + color=color) + + # Check for a pinned support + elif node.support_DX and node.support_DY and node.support_DZ and not node.support_RX and not node.support_RY and not node.support_RZ: + + # Create a cone using PyVista's Cone function + self.plotter.add_mesh(pv.Cone(center=(node.X, node.Y - self.annotation_size, node.Z), + direction=(0, 1, 0), + height=self.annotation_size*2, + radius=self.annotation_size*2), + color=color) + + # Other support conditions + else: + + # Generate a sphere for the node + # sphere = pv.Sphere(center=(X, Y, Z), radius=0.4*self.annotation_size) + # self.plotter.add_mesh(sphere, name='Node: '+ node.name, color=color) + + # Restrained against X translation + if node.support_DX: + + # Line showing support direction + self.plotter.add_mesh(pv.Line((node.X - self.annotation_size, node.Y, node.Z), + (node.X + self.annotation_size, node.Y, node.Z)), + color=color) + + # Cones at both ends + self.plotter.add_mesh(pv.Cone(center=(node.X - self.annotation_size, node.Y, + node.Z), + direction=(1, 0, 0), height=self.annotation_size*0.6, + radius=self.annotation_size*0.3), + color=color) + self.plotter.add_mesh(pv.Cone(center=(node.X + self.annotation_size, node.Y, + node.Z), + direction=(-1, 0, 0), + height=self.annotation_size*0.6, + radius=self.annotation_size*0.3), + color=color) + + # Restrained against Y translation + if node.support_DY: + + # Line showing support direction + self.plotter.add_mesh(pv.Line((node.X, node.Y - self.annotation_size, node.Z), + (node.X, node.Y + self.annotation_size, node.Z)), + color=color) + + # Cones at both ends + self.plotter.add_mesh(pv.Cone(center=(node.X, node.Y - self.annotation_size, + node.Z), direction=(0, 1, 0), + height=self.annotation_size*0.6, + radius=self.annotation_size*0.3), + color=color) + self.plotter.add_mesh(pv.Cone(center=(node.X, node.Y + self.annotation_size, + node.Z), + direction=(0, -1, 0), + height=self.annotation_size*0.6, + radius=self.annotation_size*0.3), + color=color) + + # Restrained against Z translation + if node.support_DZ: + + # Line showing support direction + self.plotter.add_mesh(pv.Line((node.X, node.Y, node.Z-self.annotation_size), + (node.X, node.Y, node.Z+self.annotation_size)), + color=color) + + # Cones at both ends + self.plotter.add_mesh(pv.Cone(center=(node.X, node.Y, node.Z-self.annotation_size), + direction=(0, 0, 1), + height=self.annotation_size*0.6, + radius=self.annotation_size*0.3), + color=color) + self.plotter.add_mesh(pv.Cone(center=(node.X, node.Y, node.Z+self.annotation_size), + direction=(0, 0, -1), + height=self.annotation_size*0.6, + radius=self.annotation_size*0.3), + color=color) + + # Restrained against X rotation + if node.support_RX: + + # Line showing support direction + self.plotter.add_mesh(pv.Line((node.X-1.6*self.annotation_size, node.Y, node.Z), + (node.X+1.6*self.annotation_size, node.Y, node.Z)), + color=color) + + # Cubes at both ends + self.plotter.add_mesh(pv.Cube(center=(node.X-1.9*self.annotation_size, node.Y, + node.Z), + x_length=self.annotation_size*0.6, + y_length=self.annotation_size*0.6, + z_length=self.annotation_size*0.6), + color=color) + self.plotter.add_mesh(pv.Cube(center=(node.X+1.9 *self.annotation_size, node.Y, + node.Z), + x_length=self.annotation_size*0.6, + y_length=self.annotation_size*0.6, + z_length=self.annotation_size*0.6), + color=color) + + # Restrained against rotation about the Y-axis + if node.support_RY: + + # Line showing support direction + self.plotter.add_mesh(pv.Line((node.X, node.Y-1.6*self.annotation_size, node.Z), + (node.X, node.Y+1.6*self.annotation_size, node.Z)), + color=color) + + # Cubes at both ends + self.plotter.add_mesh(pv.Cube(center=(node.X, node.Y-1.9*self.annotation_size, + node.Z), + x_length=self.annotation_size*0.6, + y_length=self.annotation_size*0.6, + z_length=self.annotation_size*0.6), + color=color) + self.plotter.add_mesh(pv.Cube(center=(node.X, node.Y+1.9*self.annotation_size, + node.Z), + x_length=self.annotation_size*0.6, + y_length=self.annotation_size*0.6, + z_length=self.annotation_size*0.6), + color=color) + + # Restrained against rotation about the Z-axis + if node.support_RZ: + + # Line showing support direction + self.plotter.add_mesh(pv.Line((node.X, node.Y, node.Z-1.6*self.annotation_size), + (node.X, node.Y, node.Z+1.6*self.annotation_size)), + color=color) + + # Cubes at both ends + self.plotter.add_mesh(pv.Cube(center=(node.X, node.Y, + node.Z-1.9*self.annotation_size), + x_length=self.annotation_size*0.6, + y_length=self.annotation_size*0.6, + z_length=self.annotation_size*0.6), + color=color) + self.plotter.add_mesh(pv.Cube(center=(node.X, node.Y, + node.Z+1.9*self.annotation_size), + x_length=self.annotation_size*0.6, + y_length=self.annotation_size*0.6, + z_length=self.annotation_size*0.6), + color=color) + + def plot_member(self, member, theme='default'): + """ + Adds a member to the plotter. This method generates a line representing a structural member between two nodes, and adds it to the plotter with specified theme settings. + + Parameters + ========== + :param member: The structural member to be plotted, containing information about its end nodes. + :type member: Member + :param theme: The theme for plotting the member. Default is 'default'. + :type theme: str + """ + + # Generate a line for the member + line = pv.Line() + + Xi = member.i_node.X + Yi = member.i_node.Y + Zi = member.i_node.Z + line.points[0] = [Xi, Yi, Zi] + + Xj = member.j_node.X + Yj = member.j_node.Y + Zj = member.j_node.Z + line.points[1] = [Xj, Yj, Zj] + + self.plotter.add_mesh(line, color='black', line_width=2) + + def plot_spring(self, spring, color='grey', deformed=False): + """ + Adds a spring to the plotter. This method generates a zig-zag line representing a spring between two nodes, and adds it to the plotter with specified theme settings. + """ + + # Scale the spring's zigzags + size = self.annotation_size + + # Find the spring's i-node and j-node + i_node = spring.i_node + j_node = spring.j_node + + # Find the spring's node coordinates + Xi, Yi, Zi = i_node.X, i_node.Y, i_node.Z + Xj, Yj, Zj = j_node.X, j_node.Y, j_node.Z + + # Determine if the spring should be plotted in its deformed shape + if deformed: + Xi = Xi + i_node.DX[self.combo_name]*self.deformed_scale + Yi = Yi + i_node.DY[self.combo_name]*self.deformed_scale + Zi = Zi + i_node.DZ[self.combo_name]*self.deformed_scale + Xj = Xj + j_node.DX[self.combo_name]*self.deformed_scale + Yj = Yj + j_node.DY[self.combo_name]*self.deformed_scale + Zj = Zj + j_node.DZ[self.combo_name]*self.deformed_scale + + # Calculate the spring direction vector and length + direction = np.array([Xj, Yj, Zj]) - np.array([Xi, Yi, Zi]) + length = np.linalg.norm(direction) + + # Normalize the direction vector + direction = direction / length + + # Calculate perpendicular vectors for zig-zag plane + arbitrary_vector = np.array([1, 0, 0]) + if np.allclose(direction, arbitrary_vector) or np.allclose(direction, -arbitrary_vector): + arbitrary_vector = np.array([0, 1, 0]) + perp_vector1 = np.cross(direction, arbitrary_vector) + perp_vector1 /= np.linalg.norm(perp_vector1) + perp_vector2 = np.cross(direction, perp_vector1) + perp_vector2 /= np.linalg.norm(perp_vector2) + + # Define the length of the straight segments + straight_segment_length = length / 10 + zigzag_length = length - 2 * straight_segment_length + + # Generate points for the zig-zag line + num_zigs = 4 + num_points = num_zigs * 2 + amplitude = size + t = np.linspace(0, zigzag_length, num_points) + zigzag_pattern = amplitude * np.tile([1, -1], num_zigs) + zigzag_points = np.outer(t, direction) + np.outer(zigzag_pattern, perp_vector1) + + # Add the straight segments to the start and end + start_point = np.array([Xi, Yi, Zi]) + end_point = np.array([Xj, Yj, Zj]) + start_segment = start_point + direction * straight_segment_length + end_segment = end_point - direction * straight_segment_length + + # Adjust the zigzag points to the correct position + zigzag_points += start_segment + + # Combine the points + points = np.vstack([start_point, start_segment, zigzag_points, end_segment, end_point]) + + # Add lines connecting the points + num_points = len(points) + lines = np.zeros((num_points - 1, 3), dtype=int) + lines[:, 0] = 2 + lines[:, 1] = np.arange(num_points - 1, dtype=int) + lines[:, 2] = np.arange(1, num_points, dtype=int) + + # Create a PolyData object for the zig-zag line + zigzag_line = pv.PolyData(points, lines=lines) + + # Create a plotter and add the zig-zag line + self.plotter.add_mesh(zigzag_line, color=color, line_width=2) + + # Add the spring label to the list of labels + self._spring_labels.append(spring.name) + self._spring_label_points.append([(Xi + Xj) / 2, (Yi + Yj) / 2, (Zi + Zj) / 2]) + + + def plot_plates(self, deformed_shape, deformed_scale, color_map, combo_name): + + # Start a list of vertices + plate_vertices = [] + + # Start a list of plates (faces) for the mesh. + plate_faces = [] + + # `plate_results` will store the results in a list for PyVista + plate_results = [] + + # Each element will be assigned a unique element number `i` beginning at 0 + i = 0 + + # Calculate the smoothed contour results at each node + _PrepContour(self.model, color_map, combo_name) + + # Add each plate and quad in the model to the PyVista dataset + for item in list(self.model.plates.values()) + list(self.model.quads.values()): + + # Create a point for each corner (must be in counter clockwise order) + if deformed_shape: + p0 = [item.i_node.X + item.i_node.DX[combo_name]*deformed_scale, + item.i_node.Y + item.i_node.DY[combo_name]*deformed_scale, + item.i_node.Z + item.i_node.DZ[combo_name]*deformed_scale] + p1 = [item.j_node.X + item.j_node.DX[combo_name]*deformed_scale, + item.j_node.Y + item.j_node.DY[combo_name]*deformed_scale, + item.j_node.Z + item.j_node.DZ[combo_name]*deformed_scale] + p2 = [item.m_node.X + item.m_node.DX[combo_name]*deformed_scale, + item.m_node.Y + item.m_node.DY[combo_name]*deformed_scale, + item.m_node.Z + item.m_node.DZ[combo_name]*deformed_scale] + p3 = [item.n_node.X + item.n_node.DX[combo_name]*deformed_scale, + item.n_node.Y + item.n_node.DY[combo_name]*deformed_scale, + item.n_node.Z + item.n_node.DZ[combo_name]*deformed_scale] + else: + p0 = [item.i_node.X, item.i_node.Y, item.i_node.Z] + p1 = [item.j_node.X, item.j_node.Y, item.j_node.Z] + p2 = [item.m_node.X, item.m_node.Y, item.m_node.Z] + p3 = [item.n_node.X, item.n_node.Y, item.n_node.Z] + + # Add the points to the PyVista dataset + plate_vertices.append(p0) + plate_vertices.append(p1) + plate_vertices.append(p2) + plate_vertices.append(p3) + plate_faces.append([4, i*4, i*4 + 1, i*4 + 2, i*4 + 3]) + + # Get the contour value for each node + r0 = item.i_node.contour + r1 = item.j_node.contour + r2 = item.m_node.contour + r3 = item.n_node.contour + + # Add plate results to the results list if the user has requested them + if color_map: + + # Save the results for each corner of the plate - one entry for each corner + plate_results.append(r0) + plate_results.append(r1) + plate_results.append(r2) + plate_results.append(r3) + + # Move on to the next plate in our lists to repeat the process + i+=1 + + # Add the vertices and the faces to our lists + plate_vertices = np.array(plate_vertices) + plate_faces = np.array(plate_faces) + + # Create a new PyVista dataset to store plate data + plate_polydata = pv.PolyData(plate_vertices, plate_faces) + + # Add the results as point data to the PyVista dataset + if color_map: + + plate_polydata = plate_polydata.separate_cells() + plate_polydata['Contours'] = np.array(plate_results) + + # Add the scalar bar for the contours + if self._scalar_bar == True: + self.plotter.add_mesh(plate_polydata, scalars='Contours', show_edges=True) + else: + self.plotter.add_mesh(plate_polydata) + + else: + self.plotter.add_mesh(plate_polydata) + + def plot_deformed_node(self, node, scale_factor, color='grey'): + + # Calculate the node's deformed position + newX = node.X + scale_factor * (node.DX[self.combo_name]) + newY = node.Y + scale_factor * (node.DY[self.combo_name]) + newZ = node.Z + scale_factor * (node.DZ[self.combo_name]) + + # Generate a sphere source for the node in its deformed position + sphere = pv.Sphere(radius=0.4*self.annotation_size, center=[newX, newY, newZ]) + + # Add the mesh to the plotter + self.plotter.add_mesh(sphere, color=color) + + def plot_deformed_member(self, member, scale_factor): + + # Determine if this member is active for each load combination + if member.active: + + L = member.L() # Member length + T = member.T() # Member local transformation matrix + + cos_x = np.array([T[0, 0:3]]) # Direction cosines of local x-axis + cos_y = np.array([T[1, 0:3]]) # Direction cosines of local y-axis + cos_z = np.array([T[2, 0:3]]) # Direction cosines of local z-axis + + # Find the initial position of the local i-node + Xi = member.i_node.X + Yi = member.i_node.Y + Zi = member.i_node.Z + + # Calculate the local y-axis displacements at 20 points along the member's length + DY_plot = np.empty((0, 3)) + for i in range(20): + + # Calculate the local y-direction displacement + dy_tot = member.deflection('dy', L / 19 * i, self.combo_name) + + # Calculate the scaled displacement in global coordinates + DY_plot = np.append(DY_plot, dy_tot * cos_y * scale_factor, axis=0) + + # Calculate the local z-axis displacements at 20 points along the member's length + DZ_plot = np.empty((0, 3)) + for i in range(20): + + # Calculate the local z-direction displacement + dz_tot = member.deflection('dz', L / 19 * i, self.combo_name) + + # Calculate the scaled displacement in global coordinates + DZ_plot = np.append(DZ_plot, dz_tot * cos_z * scale_factor, axis=0) + + # Calculate the local x-axis displacements at 20 points along the member's length + DX_plot = np.empty((0, 3)) + for i in range(20): + + # Displacements in local coordinates + dx_tot = [[Xi, Yi, Zi]] + (L / 19 * i + member.deflection('dx', L / 19 * i, self.combo_name) * scale_factor) * cos_x + + # Magnified displacements in global coordinates + DX_plot = np.append(DX_plot, dx_tot, axis=0) + + # Sum the component displacements to obtain overall displacement + D_plot = DY_plot + DZ_plot + DX_plot + + # Create lines connecting the points + for i in range(len(D_plot)-1): + line = pv.Line(D_plot[i], D_plot[i+1]) + self.plotter.add_mesh(line, color='red', line_width=2) + + def plot_pt_load(self, position, direction, length, label_text=None, color='green'): + + # Create a unit vector in the direction of the 'direction' vector + unitVector = direction/np.linalg.norm(direction) + + # Determine if the load is positive or negative + if length == 0: + sign = 1 + else: + sign = abs(length)/length + + # Generate the tip of the load arrow + tip_length = abs(length) / 4 + radius = abs(length) / 16 + tip = pv.Cone(center=(position[0] - tip_length*sign*unitVector[0]/2, + position[1] - tip_length*sign*unitVector[1]/2, + position[2] - tip_length*sign*unitVector[2]/2), + direction=(direction[0]*sign, direction[1]*sign, direction[2]*sign), + height=tip_length, radius=radius) + + # Plot the tip + self.plotter.add_mesh(tip, color=color) + + # Create the shaft (you'll need to specify the second point) + X_tail = position[0] - unitVector[0]*length + Y_tail = position[1] - unitVector[1]*length + Z_tail = position[2] - unitVector[2]*length + shaft = pv.Line(pointa=position, pointb=(X_tail, Y_tail, Z_tail)) + + # Save the data necessary to create the load's label + if label_text is not None: + self._load_labels.append(sig_fig_round(label_text, 3)) + self._load_label_points.append([X_tail, Y_tail, Z_tail]) + + # Plot the shaft + self.plotter.add_mesh(shaft, line_width=2, color=color) + + def plot_dist_load(self, position1, position2, direction, length1, length2, label_text1, label_text2, color='green'): + + # Calculate the length of the distributed load + load_length = ((position2[0] - position1[0])**2 + (position2[1] - position1[1])**2 + (position2[2] - position1[2])**2)**0.5 + + # Find the direction cosines for the line the load acts on + line_dir_cos = [(position2[0] - position1[0])/load_length, + (position2[1] - position1[1])/load_length, + (position2[2] - position1[2])/load_length] + + # Find the direction cosines for the direction the load acts in + dir_dir_cos = direction/np.linalg.norm(direction) + + # Create point loads at intervals roughly equal to 75% of the load's largest length + # Add text labels to the first and last load arrow + if load_length > 0: + num_steps = int(round(0.75 * load_length/max(abs(length1), abs(length2)), 0)) + else: + num_steps = 0 + + num_steps = max(num_steps, 1) + step = load_length/num_steps + + for i in range(num_steps + 1): + + # Calculate the position (X, Y, Z) of this load arrow's point + position = (position1[0] + i*step*line_dir_cos[0], + position1[1] + i*step*line_dir_cos[1], + position1[2] + i*step*line_dir_cos[2]) + + # Determine the length of this load arrow + length = length1 + (length2 - length1)/load_length*i*step + + # Determine the label's text + if i == 0: + label_text = label_text1 + elif i == num_steps: + label_text = label_text2 + else: + label_text = None + + # Plot the load arrow + self.plot_pt_load(position, dir_dir_cos, length, label_text, color) + + # Draw a line between the first and last load arrow's tails (using cylinder here for better visualization) + tail_line = pv.Line(position1 - dir_dir_cos*length1, position2 - dir_dir_cos*length2) + + # Combine all geometry into a single PolyData object + self.plotter.add_mesh(tail_line, color=color) + + def plot_moment(self, center, direction, radius, label_text=None, color='green'): + + # Convert the direction vector into a unit vector + v1 = direction/np.linalg.norm(direction) + + # Find any vector perpendicular to the moment direction vector. This will serve as a + # vector from the center of the arc pointing to the tail of the moment arc. + v2 = _PerpVector(v1) + + # Generate the arc for the moment + arc = pv.CircularArcFromNormal(center, resolution=20, normal=v1, angle=215, polar=v2*radius) + + # Add the arc to the plot + self.plotter.add_mesh(arc, line_width=2, color=color) + + # Generate the arrow tip at the end of the arc + tip_length = radius/4 + cone_radius = radius/16 + cone_direction = -np.cross(v1, arc.center - arc.points[-1]) + tip = pv.Cone(center=arc.points[-1], direction=cone_direction, height=tip_length, + radius=cone_radius) + + # Add the tip to the plot + self.plotter.add_mesh(tip, color=color) + + # Create the text label + if label_text: + text_pos = center + (radius + 0.25*self.annotation_size)*v2 + self._load_label_points.append(text_pos) + self._load_labels.append(label_text) + + def plot_area_load(self, position0, position1, position2, position3, direction, length, label_text, color='green'): + + # Find the direction cosines for the direction the load acts in + dir_dir_cos = direction / np.linalg.norm(direction) + + # Find the positions of the tails of all the arrows at the corners + self.p0 = position0 - dir_dir_cos * length + self.p1 = position1 - dir_dir_cos * length + self.p2 = position2 - dir_dir_cos * length + self.p3 = position3 - dir_dir_cos * length + + # Plot the area load arrows + self.plot_pt_load(position0, dir_dir_cos, length, label_text, color) + self.plot_pt_load(position1, dir_dir_cos, length, color=color) + self.plot_pt_load(position2, dir_dir_cos, length, color=color) + self.plot_pt_load(position3, dir_dir_cos, length, color=color) + + # Create the area load polygon (quad) + quad = pv.Quadrilateral([self.p0, self.p1, self.p2, self.p3]) + + self.plotter.add_mesh(quad, color=color) + + def _calc_max_loads(self): + + max_pt_load = 0 + max_moment = 0 + max_dist_load = 0 + max_area_load = 0 + + # Find the requested load combination or load case + if self.case == None: + + # Step through each node + for node in self.model.nodes.values(): + + # Step through each nodal load to find the largest one + for load in node.NodeLoads: + + # Find the largest loads in the load combination + if load[2] in self.model.load_combos[self.combo_name].factors: + if load[0] == 'FX' or load[0] == 'FY' or load[0] == 'FZ': + if abs(load[1]*self.model.load_combos[self.combo_name].factors[load[2]]) > max_pt_load: + max_pt_load = abs(load[1]*self.model.load_combos[self.combo_name].factors[load[2]]) + else: + if abs(load[1]*self.model.load_combos[self.combo_name].factors[load[2]]) > max_moment: + max_moment = abs(load[1]*self.model.load_combos[self.combo_name].factors[load[2]]) + + # Step through each member + for member in self.model.members.values(): + + # Step through each member point load + for load in member.PtLoads: + + # Find and store the largest point load and moment in the load combination + if load[3] in self.model.load_combos[self.combo_name].factors: + + if (load[0] == 'Fx' or load[0] == 'Fy' or load[0] == 'Fz' + or load[0] == 'FX' or load[0] == 'FY' or load[0] == 'FZ'): + if abs(load[1]*self.model.load_combos[self.combo_name].factors[load[3]]) > max_pt_load: + max_pt_load = abs(load[1]*self.model.load_combos[self.combo_name].factors[load[3]]) + else: + if abs(load[1]*self.model.load_combos[self.combo_name].factors[load[3]]) > max_moment: + max_moment = abs(load[1]*self.model.load_combos[self.combo_name].factors[load[3]]) + + # Step through each member distributed load + for load in member.DistLoads: + + #Find and store the largest distributed load in the load combination + if load[5] in self.model.load_combos[self.combo_name].factors: + + if abs(load[1]*self.model.load_combos[self.combo_name].factors[load[5]]) > max_dist_load: + max_dist_load = abs(load[1]*self.model.load_combos[self.combo_name].factors[load[5]]) + if abs(load[2]*self.model.load_combos[self.combo_name].factors[load[5]]) > max_dist_load: + max_dist_load = abs(load[2]*self.model.load_combos[self.combo_name].factors[load[5]]) + + # Step through each plate + for plate in self.model.plates.values(): + + # Step through each plate load + for load in plate.pressures: + + if load[1] in self.model.load_combos[self.combo_name].factors: + if abs(load[0]*self.model.load_combos[self.combo_name].factors[load[1]]) > max_area_load: + max_area_load = abs(load[0]*self.model.load_combos[self.combo_name].factors[load[1]]) + + # Step through each quad + for quad in self.model.quads.values(): + + # Step through each plate load + for load in quad.pressures: + + # Check to see if the load case is in the requested load combination + if load[1] in self.model.load_combos[self.combo_name].factors: + if abs(load[0]*self.model.load_combos[self.combo_name].factors[load[1]]) > max_area_load: + max_area_load = abs(load[0]*self.model.load_combos[self.combo_name].factors[load[1]]) + + # Behavior if case has been specified + else: + + # Step through each node + for node in self.model.nodes.values(): + + # Step through each nodal load to find the largest one + for load in node.NodeLoads: + + # Find the largest loads in the load case + if load[2] == self.case: + if load[0] == 'FX' or load[0] == 'FY' or load[0] == 'FZ': + if abs(load[1]) > max_pt_load: + max_pt_load = abs(load[1]) + else: + if abs(load[1]) > max_moment: + max_moment = abs(load[1]) + + # Step through each member + for member in self.model.members.values(): + + # Step through each member point load + for load in member.PtLoads: + + # Find and store the largest point load and moment in the load case + if load[3] == self.case: + + if (load[0] == 'Fx' or load[0] == 'Fy' or load[0] == 'Fz' + or load[0] == 'FX' or load[0] == 'FY' or load[0] == 'FZ'): + if abs(load[1]) > max_pt_load: + max_pt_load = abs(load[1]) + else: + if abs(load[1]) > max_moment: + max_moment = abs(load[1]) + + # Step through each member distributed load + for load in member.DistLoads: + + # Find and store the largest distributed load in the load case + if load[5] == self.case: + + if abs(load[1]) > max_dist_load: + max_dist_load = abs(load[1]) + if abs(load[2]) > max_dist_load: + max_dist_load = abs(load[2]) + + # Step through each plate + for plate in self.model.plates.values(): + + # Step through each plate load + for load in plate.pressures: + + if load[1] == self.case: + + if abs(load[0]) > max_area_load: + max_area_load = abs(load[0]) + + # Step through each quad + for quad in self.model.quads.values(): + + # Step through each plate load + for load in quad.pressures: + + if load[1] == self.case: + + if abs(load[0]) > max_area_load: + max_area_load = abs(load[0]) + + # Return the maximum loads for the load combo or load case + return max_pt_load, max_moment, max_dist_load, max_area_load + + def plot_loads(self): + + # Get the maximum load magnitudes that will be used to normalize the display scale + max_pt_load, max_moment, max_dist_load, max_area_load = self._calc_max_loads() + + # Display the requested load combination, or 'Combo 1' if no load combo or case has been + # specified + if self.case is None: + # Store model.load_combos[combo].factors under a simpler name for use below + load_factors = self.model.load_combos[self.combo_name].factors + else: + # Set up a load combination dictionary that represents the load case + load_factors = {self.case: 1} + + # Step through each node + for node in self.model.nodes.values(): + + # Step through and display each nodal load + for load in node.NodeLoads: + + # Determine if this load is part of the requested LoadCombo or case + if load[2] in load_factors: + + # Calculate the factored value for this load and it's sign (positive or + # negative) + load_value = load[1]*load_factors[load[2]] + if load_value != 0: + sign = load_value/abs(load_value) + else: + sign = 1 + + # Determine the direction of this load + if load[0] == 'FX' or load[0] == 'MX': direction = (sign, 0, 0) + elif load[0] == 'FY' or load[0] == 'MY': direction = (0, sign, 0) + elif load[0] == 'FZ' or load[0] == 'MZ': direction = (0, 0, sign) + + # Display the load + if load[0] in {'FX', 'FY', 'FZ'}: + self.plot_pt_load((node.X, node.Y, node.Z), direction, + abs(load_value/max_pt_load)*5*self.annotation_size, + load_value, 'green') + elif load[0] in {'MX', 'MY', 'MZ'}: + self.plot_moment((node.X, node.Y, node.Z), direction, abs(load_value/max_moment)*2.5*self.annotation_size, str(load_value), 'green') + + # Step through each member + for member in self.model.members.values(): + + # Get the direction cosines for the member's local axes + dir_cos = member.T()[0:3, 0:3] + + # Get the starting point for the member + x_start, y_start, z_start = member.i_node.X, member.i_node.Y, member.i_node.Z + + # Step through each member point load + for load in member.PtLoads: + + # Determine if this load is part of the requested load combination + if load[3] in load_factors: + + # Calculate the factored value for this load and it's sign (positive or negative) + load_value = load[1]*load_factors[load[3]] + sign = load_value/abs(load_value) + + # Calculate the load's location in 3D space + x = load[2] + position = [x_start + dir_cos[0, 0]*x, y_start + dir_cos[0, 1]*x, z_start + dir_cos[0, 2]*x] + + # Display the load + if load[0] == 'Fx': + self.plot_pt_load(position, dir_cos[0, :], load_value/max_pt_load*5*self.annotation_size, load_value) + elif load[0] == 'Fy': + self.plot_pt_load(position, dir_cos[1, :], load_value/max_pt_load*5*self.annotation_size, load_value) + elif load[0] == 'Fz': + self.plot_pt_load(position, dir_cos[2, :], load_value/max_pt_load*5*self.annotation_size, load_value) + elif load[0] == 'Mx': + self.plot_moment(position, dir_cos[0, :]*sign, abs(load_value)/max_moment*2.5*self.annotation_size, str(load_value)) + elif load[0] == 'My': + self.plot_moment(position, dir_cos[1, :]*sign, abs(load_value)/max_moment*2.5*self.annotation_size, str(load_value)) + elif load[0] == 'Mz': + self.plot_moment(position, dir_cos[2, :]*sign, abs(load_value)/max_moment*2.5*self.annotation_size, str(load_value)) + elif load[0] == 'FX': + self.plot_pt_load(position, [1, 0, 0], load_value/max_pt_load*5*self.annotation_size, load_value) + elif load[0] == 'FY': + self.plot_pt_load(position, [0, 1, 0], load_value/max_pt_load*5*self.annotation_size, load_value) + elif load[0] == 'FZ': + self.plot_pt_load(position, [0, 0, 1], load_value/max_pt_load*5*self.annotation_size, load_value) + elif load[0] == 'MX': + self.plot_moment(position, [1*sign, 0, 0], abs(load_value)/max_moment*2.5*self.annotation_size, str(load_value)) + elif load[0] == 'MY': + self.plot_moment(position, [0, 1*sign, 0], abs(load_value)/max_moment*2.5*self.annotation_size, str(load_value)) + elif load[0] == 'MZ': + self.plot_moment(position, [0, 0, 1*sign], abs(load_value)/max_moment*2.5*self.annotation_size, str(load_value)) + + # Step through each member distributed load + for load in member.DistLoads: + + # Determine if this load is part of the requested load combination + if load[5] in load_factors: + + # Calculate the factored value for this load and it's sign (positive or negative) + w1 = load[1]*load_factors[load[5]] + w2 = load[2]*load_factors[load[5]] + + # Calculate the loads location in 3D space + x1 = load[3] + x2 = load[4] + position1 = [x_start + dir_cos[0, 0]*x1, y_start + dir_cos[0, 1]*x1, z_start + dir_cos[0, 2]*x1] + position2 = [x_start + dir_cos[0, 0]*x2, y_start + dir_cos[0, 1]*x2, z_start + dir_cos[0, 2]*x2] + + # Display the load + if load[0] in {'Fx', 'Fy', 'Fz', 'FX', 'FY', 'FZ'}: + + # Determine the load direction + if load[0] == 'Fx': direction = dir_cos[0, :] + elif load[0] == 'Fy': direction = dir_cos[1, :] + elif load[0] == 'Fz': direction = dir_cos[2, :] + elif load[0] == 'FX': direction = [1, 0, 0] + elif load[0] == 'FY': direction = [0, 1, 0] + elif load[0] == 'FZ': direction = [0, 0, 1] + + # Plot the distributed load + self.plot_dist_load(position1, position2, direction, w1/max_dist_load*5*self.annotation_size, w2/max_dist_load*5*self.annotation_size, str(sig_fig_round(w1, 3)), str(sig_fig_round(w2, 3)), 'green') + + # Step through each plate + for plate in list(self.model.plates.values()) + list(self.model.quads.values()): + + # Get the direction cosines for the plate's local z-axis + dir_cos = plate.T()[0:3, 0:3] + dir_cos = dir_cos[2] + + # Step through each plate load + for load in plate.pressures: + + # Determine if this load is part of the requested load combination + if load[1] in load_factors: + + # Calculate the factored value for this load + load_value = load[0]*load_factors[load[1]] + + # Find the sign for this load. Intercept any divide by zero errors + if load[0] == 0: + sign = 1 + else: + sign = abs(load[0])/load[0] + + # Find the position of the load's 4 corners + position0 = [plate.i_node.X, plate.i_node.Y, plate.i_node.Z] + position1 = [plate.j_node.X, plate.j_node.Y, plate.j_node.Z] + position2 = [plate.m_node.X, plate.m_node.Y, plate.m_node.Z] + position3 = [plate.n_node.X, plate.n_node.Y, plate.n_node.Z] + + # Create an area load and get its data + self.plot_area_load(position0, position1, position2, position3, dir_cos*sign, load_value/max_area_load*5*self.annotation_size, str(sig_fig_round(load_value, 3)), color='green') + +def _PerpVector(v): + ''' + Returns a unit vector perpendicular to v=[i, j, k] + ''' + + i = v[0] + j = v[1] + k = v[2] + + # Find a vector in a direction perpendicular to + if i == 0: + i2 = 1 + j2 = 0 + k2 = 0 + elif j == 0: + i2 = 0 + j2 = 1 + k2 = 0 + elif k == 0: + i2 = 0 + j2 = 0 + k2 = 1 + else: + i2 = 1 + j2 = 1 + k2 = -(i*i2+j*j2)/k + + # Return the unit vector + return [i2, j2, k2]/np.linalg.norm([i2, j2, k2]) + +def _PrepContour(model, stress_type='Mx', combo_name='Combo 1'): + + if stress_type != None: + + # Erase any previous contours + for node in model.nodes.values(): + node.contour = [] + + # Check for global stresses: + if stress_type in ['MX', 'MY', 'MZ', 'QX', 'QY', 'QZ', 'SX', 'SY']: + local = False + else: + local = True + + # Step through each element in the model + for element in list(model.quads.values()) + list(model.plates.values()): + + # Rectangular elements and quadrilateral elements have different local coordinate systems. Rectangles are based on a traditional (x, y) system, while quadrilaterals are based on a 'natural' (r, s) coordinate system. To reduce duplication of code for both these elements we'll define the edges of the plate here for either element using the (r, s) terminology. + if element.type == 'Rect': + r_left = 0 + r_right = element.width() + s_bot = 0 + s_top = element.height() + else: + r_left = -1 + r_right = 1 + s_bot = -1 + s_top = 1 + + # Determine which stress result has been requested by the user + if stress_type == 'dz': + i, j, m, n = element.d(combo_name)[[2, 8, 14, 20], :] + element.i_node.contour.append(i) + element.j_node.contour.append(j) + element.m_node.contour.append(m) + element.n_node.contour.append(n) + elif stress_type.upper() == 'MX': + element.i_node.contour.append(element.moment(r_left, s_bot, local, combo_name)[0]) + element.j_node.contour.append(element.moment(r_right, s_bot, local, combo_name)[0]) + element.m_node.contour.append(element.moment(r_right, s_top, local, combo_name)[0]) + element.n_node.contour.append(element.moment(r_left, s_top, local, combo_name)[0]) + elif stress_type.upper() == 'MY': + element.i_node.contour.append(element.moment(r_left, s_bot, local, combo_name)[1]) + element.j_node.contour.append(element.moment(r_right, s_bot, local, combo_name)[1]) + element.m_node.contour.append(element.moment(r_right, s_top, local, combo_name)[1]) + element.n_node.contour.append(element.moment(r_left, s_top, local, combo_name)[1]) + elif stress_type.upper() == 'MXY': + element.i_node.contour.append(element.moment(r_left, s_bot, local, combo_name)[2]) + element.j_node.contour.append(element.moment(r_right, s_bot, local, combo_name)[2]) + element.m_node.contour.append(element.moment(r_right, s_top, local, combo_name)[2]) + element.n_node.contour.append(element.moment(r_left, s_top, local, combo_name)[2]) + elif stress_type.upper() == 'QX': + element.i_node.contour.append(element.shear(r_left, s_bot, local, combo_name)[0]) + element.j_node.contour.append(element.shear(r_right, s_bot, local, combo_name)[0]) + element.m_node.contour.append(element.shear(r_right, s_top, local, combo_name)[0]) + element.n_node.contour.append(element.shear(r_left, s_top, local, combo_name)[0]) + elif stress_type.upper() == 'QY': + element.i_node.contour.append(element.shear(r_left, s_bot, local, combo_name)[1]) + element.j_node.contour.append(element.shear(r_right, s_bot, local, combo_name)[1]) + element.m_node.contour.append(element.shear(r_right, s_top, local, combo_name)[1]) + element.n_node.contour.append(element.shear(r_left, s_top, local, combo_name)[1]) + elif stress_type.upper() == 'SX': + element.i_node.contour.append(element.membrane(r_left, s_bot, local, combo_name)[0]) + element.j_node.contour.append(element.membrane(r_right, s_bot, local, combo_name)[0]) + element.m_node.contour.append(element.membrane(r_right, s_top, local, combo_name)[0]) + element.n_node.contour.append(element.membrane(r_left, s_top, local, combo_name)[0]) + elif stress_type.upper() == 'SY': + element.i_node.contour.append(element.membrane(r_left, s_bot, local, combo_name)[1]) + element.j_node.contour.append(element.membrane(r_right, s_bot, local, combo_name)[1]) + element.m_node.contour.append(element.membrane(r_right, s_top, local, combo_name)[1]) + element.n_node.contour.append(element.membrane(r_left, s_top, local, combo_name)[1]) + elif stress_type.upper() == 'TXY': + element.i_node.contour.append(element.membrane(r_left, s_bot, local, combo_name)[2]) + element.j_node.contour.append(element.membrane(r_right, s_bot, local, combo_name)[2]) + element.m_node.contour.append(element.membrane(r_right, s_top, local, combo_name)[2]) + element.n_node.contour.append(element.membrane(r_left, s_top, local, combo_name)[2]) + + # Average the values at each node to obtain a smoothed contour + for node in model.nodes.values(): + # Prevent divide by zero errors for nodes with no contour values + if node.contour != []: + node.contour = sum(node.contour)/len(node.contour) + +def sig_fig_round(number, sig_figs): + + # Check for strings or other convertible data types + if not isinstance(number, (float, int)): + try: + number = float(number) + except: + raise ValueError(f"{number} is not a number. Ensure that `number` is numeric.") + + if number == 0: + return 0 + + # Calculate the magnitude of the number + magnitude = math.floor(math.log10(abs(number))) + + # Calculate the number of decimal places to round to + decimal_places = sig_figs - 1 - magnitude + + # Round the number to the specified number of decimal places + rounded_number = round(number, decimal_places) + + return rounded_number diff --git a/Old Pynite Folder/Report_Template.html b/Old Pynite Folder/Report_Template.html new file mode 100644 index 00000000..0637101d --- /dev/null +++ b/Old Pynite Folder/Report_Template.html @@ -0,0 +1,634 @@ + + + + +

Pynite Analysis Report

+ + + + + {% if node_table == True %} +

Nodes & Supports

+ + + + + + + + + + + + + + + + + + {% for node in nodes %} + + + + + + + + + + + + + {% endfor %} + + +
NodeXYZSupport XSupport YSupport ZSupport RXSupport RYSupport RZ
{{ node.name }}{{ "%.4g" | format(node.X) }}{{ "%.4g" | format(node.Y) }}{{ "%.4g" | format(node.Z) }}{{ node.support_DX }}{{ node.support_DY }}{{ node.support_DZ }}{{ node.support_RX }}{{ node.support_RY }}{{ node.support_RZ }}
+ {% endif %} + + {% if member_table == True %} +

Members

+ + + + + + + + + + + + + + + + {% for member in members %} + + + + + + + + + + + + {% endfor %} + +
Memberi-Nodej-NodeAIyIzJEG
{{ member.name }}{{ member.i_node.name }}{{ member.j_node.name }}{{ "%.4g" | format(member.section.A) }}{{ "%.4g" | format(member.section.Iy) }}{{ "%.4g" | format(member.section.Iz) }}{{ "%.4g" | format(member.section.J) }}{{ "%.4g" | format(member.material.E) }}{{ "%.4g" | format(member.material.G) }}
+ {% endif %} + + {% if member_releases == True %} +

Member End Releases

+ + + + + + + + + + + + + + + + + + + + {% for member in members %} + + + + + + + + + + + + + + + + {% endfor %} + +
MemberΔxiΔyiΔziθxiθyiθziΔxjΔyjΔzjθxjθyjθzj
{{ member.name }}{{ member.Releases[0] }}{{ member.Releases[1] }}{{ member.Releases[2] }}{{ member.Releases[3] }}{{ member.Releases[4] }}{{ member.Releases[5] }}{{ member.Releases[6] }}{{ member.Releases[7] }}{{ member.Releases[8] }}{{ member.Releases[9] }}{{ member.Releases[10] }}{{ member.Releases[11] }}
+ {% endif %} + + {% if plate_table == True %} +

Plates

+ + + + + + + + + + + + + + + {% for plate in plates %} + + + + + + + + + + + {% endfor %} + +
Platei-Nodej-Nodem-Noden-NodetEν
{{ plate.name }}{{ plate.i_node.name }}{{ plate.j_node.name }}{{ plate.m_node.name }}{{ plate.n_node.name }}{{ "%.4g" | format(plate.t) }}{{ "%.4g" | format(plate.E) }}{{ "%.4g" | format(plate.nu) }}
+ {% endif %} + + {% if plate_table == True %} +

Quads

+ + + + + + + + + + + + + + + {% for quad in quads %} + + + + + + + + + + + {% endfor %} + +
Quadi-Nodej-Nodem-Noden-NodetEν
{{ quad.name }}{{ quad.i_node.name }}{{ quad.j_node.name }}{{ quad.m_node.name }}{{ quad.n_node.name }}{{ "%.4g" | format(quad.t) }}{{ "%.4g" | format(quad.E) }}{{ "%.4g" | format(quad.nu) }}
+ {% endif %} + + {% if node_reactions == True %} +

Node Reactions

+ + + + + + + + + + + + + + + + {% for combo in load_combos %} + {% for node in nodes %} + + + + + + + + + + + {% endfor %} + {% endfor %} + + +
NodeLCFXFYFZMXMYMZ
{{ node.name }}{{ combo }}{{ "%.4g" | format(node.RxnFX[combo]) }}{{ "%.4g" | format(node.RxnFY[combo]) }}{{ "%.4g" | format(node.RxnFZ[combo]) }}{{ "%.4g" | format(node.RxnMX[combo]) }}{{ "%.4g" | format(node.RxnMY[combo]) }}{{ "%.4g" | format(node.RxnMZ[combo]) }}
+ {% endif %} + + {% if node_displacements == True %} +

Node Displacements

+ + + + + + + + + + + + + + + {% for combo in load_combos %} + {% for node in nodes %} + + + + + + + + + + + {% endfor %} + {% endfor %} + +
NodeLCΔXΔYΔZθXθYθZ
{{ node.name }}{{ combo }}{{ "%.4g" | format(node.DX[combo]) }}{{ "%.4g" | format(node.DY[combo]) }}{{ "%.4g" | format(node.DZ[combo]) }}{{ "%.4g" | format(node.RX[combo]) }}{{ "%.4g" | format(node.RY[combo]) }}{{ "%.4g" | format(node.RZ[combo]) }}
+ {% endif %} + + {% if member_end_forces == True %} +

Member End Forces

+ + + + + + + + + + + + + + + + + + + + + {% for combo in load_combos %} + {% for member in members %} + + + + + + + + + + + + + + + + + {% endfor %} + {% endfor %} + +
MemberLCPiPjVyiVziVyjVzjMyiMziMyjMzjTiTj
{{ member.name }}{{ combo }}{{ "%.4g" | format(member.axial(0, combo)) }}{{ "%.4g" | format(member.axial(member.L(), combo)) }}{{ "%.4g" | format(member.shear("Fy", 0, combo)) }}{{ "%.4g" | format(member.shear("Fz", 0, combo)) }}{{ "%.4g" | format(member.shear("Fy", member.L(), combo)) }}{{ "%.4g" | format(member.shear("Fz", member.L(), combo)) }}{{ "%.4g" | format(member.moment("My", 0, combo)) }}{{ "%.4g" | format(member.moment("Mz", 0, combo)) }}{{ "%.4g" | format(member.moment("My", member.L(), combo)) }}{{ "%.4g" | format(member.moment("Mz", member.L(), combo)) }}{{ "%.4g" | format(member.torque(0, combo)) }}{{ "%.4g" | format(member.torque(member.L(), combo)) }}
+ {% endif %} + + {% if member_internal_forces == True %} +

Member Max/Min Internal Forces

+ + + + + + + + + + + + + + + + + + + + + {% for combo in load_combos %} + {% for member in members %} + + + + + + + + + + + + + + + + + {% endfor %} + {% endfor %} + +
MemberLCPmaxPminVy,maxVy,minVz,maxVz,minMy,maxMy,minMz,maxMz,minTmaxTmin
{{ member.name }}{{ combo }}{{ "%.4g" | format(member.max_axial(combo)) }}{{ "%.4g" | format(member.min_axial(combo)) }}{{ "%.4g" | format(member.max_shear("Fy", combo)) }}{{ "%.4g" | format(member.min_shear("Fy", combo)) }}{{ "%.4g" | format(member.max_shear("Fz", combo)) }}{{ "%.4g" | format(member.min_shear("Fz", combo)) }}{{ "%.4g" | format(member.max_moment("My", combo)) }}{{ "%.4g" | format(member.min_moment("My", combo)) }}{{ "%.4g" | format(member.max_moment("Mz", combo)) }}{{ "%.4g" | format(member.min_moment("Mz", combo)) }}{{ "%.4g" | format(member.max_torque(combo)) }}{{ "%.4g" | format(member.min_torque(combo)) }}
+ {% endif %} + + {% if plate_corner_forces == True %} +

Plate Out-of-Plane Corner Forces

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for combo in load_combos %} + {% for plate in plates %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + + {% for quad in quads %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + {% endfor %} + + +
PlateLCi-Nodej-Nodem-Noden-Node
QxQyMxMyMxyQxQyMxMyMxyQxQyMxMyMxyQxQyMxMyMxy
{{ plate.name }}{{ combo }}{{ "%.3g" | format(plate.shear(0, 0, combo)[0][0]) }}{{ "%.3g" | format(plate.shear(0, 0, combo)[1][0]) }}{{ "%.3g" | format(plate.moment(0, 0, combo)[0][0]) }}{{ "%.3g" | format(plate.moment(0, 0, combo)[1][0]) }}{{ "%.3g" | format(plate.moment(0, 0, combo)[2][0]) }}{{ "%.3g" | format(plate.shear(0, plate.height(), combo)[0][0]) }}{{ "%.3g" | format(plate.shear(0, plate.height(), combo)[1][0]) }}{{ "%.3g" | format(plate.moment(0, plate.height(), combo)[0][0]) }}{{ "%.3g" | format(plate.moment(0, plate.height(), combo)[1][0]) }}{{ "%.3g" | format(plate.moment(0, plate.height(), combo)[2][0]) }}{{ "%.3g" | format(plate.shear(plate.width(), plate.height(), combo)[0][0]) }}{{ "%.3g" | format(plate.shear(plate.width(), plate.height(), combo)[1][0]) }}{{ "%.3g" | format(plate.moment(plate.width(), plate.height(), combo)[0][0]) }}{{ "%.3g" | format(plate.moment(plate.width(), plate.height(), combo)[1][0]) }}{{ "%.3g" | format(plate.moment(plate.width(), plate.height(), combo)[2][0]) }}{{ "%.3g" | format(plate.shear(plate.width(), 0, combo)[0][0]) }}{{ "%.3g" | format(plate.shear(plate.width(), 0, combo)[1][0]) }}{{ "%.3g" | format(plate.moment(plate.width(), 0, combo)[0][0]) }}{{ "%.3g" | format(plate.moment(plate.width(), 0, combo)[1][0]) }}{{ "%.3g" | format(plate.moment(plate.width(), 0, combo)[2][0]) }}
{{ quad.name }}{{ combo }}{{ "%.3g" | format(quad.shear(-1, -1, combo)[0][0]) }}{{ "%.3g" | format(quad.shear(-1, -1, combo)[1][0]) }}{{ "%.3g" | format(quad.moment(-1, -1, combo)[0][0]) }}{{ "%.3g" | format(quad.moment(-1, -1, combo)[1][0]) }}{{ "%.3g" | format(quad.moment(-1, -1, combo)[2][0]) }}{{ "%.3g" | format(quad.shear(1, -1, combo)[0][0]) }}{{ "%.3g" | format(quad.shear(1, -1, combo)[1][0]) }}{{ "%.3g" | format(quad.moment(1, -1, combo)[0][0]) }}{{ "%.3g" | format(quad.moment(1, -1, combo)[1][0]) }}{{ "%.3g" | format(quad.moment(1, -1, combo)[2][0]) }}{{ "%.3g" | format(quad.shear(1, 1, combo)[0][0]) }}{{ "%.3g" | format(quad.shear(1, 1, combo)[1][0]) }}{{ "%.3g" | format(quad.moment(1, 1, combo)[0][0]) }}{{ "%.3g" | format(quad.moment(1, 1, combo)[1][0]) }}{{ "%.3g" | format(quad.moment(1, 1, combo)[2][0]) }}{{ "%.3g" | format(quad.shear(-1, 1, combo)[0][0]) }}{{ "%.3g" | format(quad.shear(-1, 1, combo)[1][0]) }}{{ "%.3g" | format(quad.moment(-1, 1, combo)[0][0]) }}{{ "%.3g" | format(quad.moment(-1, 1, combo)[1][0]) }}{{ "%.3g" | format(quad.moment(-1, 1, combo)[2][0]) }}
+ {% endif %} + + {% if plate_center_forces == True %} +

Plate Out-of-Plane Center Forces

+ + + + + + + + + + + + + + + + {% for combo in load_combos %} + + {% for plate in plates %} + + + + + + + + + + {% endfor %} + + {% for quad in quads %} + + + + + + + + + + {% endfor %} + + {% endfor %} + + +
PlateLCQxQyMxMyMxy
{{ plate.name }}{{ combo }}{{ "%.3g" | format(plate.shear(plate.width()/2, plate.height()/2, combo)[0][0]) }}{{ "%.3g" | format(plate.shear(plate.width()/2, plate.height()/2, combo)[1][0]) }}{{ "%.3g" | format(plate.moment(plate.width()/2, plate.height()/2, combo)[0][0]) }}{{ "%.3g" | format(plate.moment(plate.width()/2, plate.height()/2, combo)[1][0]) }}{{ "%.3g" | format(plate.moment(plate.width()/2, plate.height()/2, combo)[2][0]) }}
{{ quad.name }}{{ combo }}{{ "%.3g" | format(quad.shear(0, 0, combo)[0][0]) }}{{ "%.3g" | format(quad.shear(0, 0, combo)[1][0]) }}{{ "%.3g" | format(quad.moment(0, 0, combo)[0][0]) }}{{ "%.3g" | format(quad.moment(0, 0, combo)[1][0]) }}{{ "%.3g" | format(quad.moment(0, 0, combo)[2][0]) }}
+ {% endif %} + + {% if plate_corner_membrane == True %} +

In-Plane (Membrane) Corner Stresses

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for combo in load_combos %} + + {% for plate in plates %} + + + + + + + + + + + + + + + + + + + + + {% endfor %} + + {% for quad in quads %} + + + + + + + + + + + + + + + + + + + + + {% endfor %} + {% endfor %} + + +
PlateLCi-Nodej-Nodem-Noden-Node
σxσyτxyσxσyτxyσxσyτxyσxσyτxy
{{ plate.name }}{{ combo }}{{ "%.3g" | format(plate.membrane(0, 0, combo)[0]) }}{{ "%.3g" | format(plate.membrane(0, 0, combo)[1]) }}{{ "%.3g" | format(plate.membrane(0, 0, combo)[2]) }}{{ "%.3g" | format(plate.membrane(0, plate.height(), combo)[0]) }}{{ "%.3g" | format(plate.membrane(0, plate.height(), combo)[1]) }}{{ "%.3g" | format(plate.membrane(0, plate.height(), combo)[2]) }}{{ "%.3g" | format(plate.membrane(plate.width(), plate.height(), combo)[0]) }}{{ "%.3g" | format(plate.membrane(plate.width(), plate.height(), combo)[1]) }}{{ "%.3g" | format(plate.membrane(plate.width(), plate.height(), combo)[2]) }}{{ "%.3g" | format(plate.membrane(plate.width(), 0, combo)[0]) }}{{ "%.3g" | format(plate.membrane(plate.width(), 0, combo)[1]) }}{{ "%.3g" | format(plate.membrane(plate.width(), 0, combo)[2]) }}
{{ quad.name }}{{ combo }}{{ "%.3g" | format(quad.membrane(-1, -1, combo)[0]) }}{{ "%.3g" | format(quad.membrane(-1, -1, combo)[1]) }}{{ "%.3g" | format(quad.membrane(-1, -1, combo)[2]) }}{{ "%.3g" | format(quad.membrane(1, -1, combo)[0]) }}{{ "%.3g" | format(quad.membrane(1, -1, combo)[1]) }}{{ "%.3g" | format(quad.membrane(1, -1, combo)[2]) }}{{ "%.3g" | format(quad.membrane(1, 1, combo)[0]) }}{{ "%.3g" | format(quad.membrane(1, 1, combo)[1]) }}{{ "%.3g" | format(quad.membrane(1, 1, combo)[2]) }}{{ "%.3g" | format(quad.membrane(-1, 1, combo)[0]) }}{{ "%.3g" | format(quad.membrane(-1, 1, combo)[1]) }}{{ "%.3g" | format(quad.membrane(-1, 1, combo)[2]) }}
+ {% endif %} + + {% if plate_center_membrane == True %} +

In-Plane (Membrane) Center Stresses

+ + + + + + + + + + + + {% for combo in load_combos %} + + {% for plate in plates %} + + + + + + + + {% endfor %} + + {% for quad in quads %} + + + + + + + + {% endfor %} + + {% endfor %} + +
PlateLCσxσyτxy
{{ plate.name }}{{ combo }}{{ "%.3g" | format(plate.membrane(plate.width()/2, plate.height()/2, combo)[0][0]) }}{{ "%.3g" | format(plate.membrane(plate.width()/2, plate.height()/2, combo)[1][0]) }}{{ "%.3g" | format(plate.membrane(plate.width()/2, plate.height()/2, combo)[2][0]) }}
{{ quad.name }}{{ combo }}{{ "%.3g" | format(quad.membrane(0, 0, combo)[0][0]) }}{{ "%.3g" | format(quad.membrane(0, 0, combo)[1][0]) }}{{ "%.3g" | format(quad.membrane(0, 0, combo)[2][0]) }}
+ {% endif %} + + + + diff --git a/Old Pynite Folder/Reporting.py b/Old Pynite Folder/Reporting.py new file mode 100644 index 00000000..b9122c55 --- /dev/null +++ b/Old Pynite Folder/Reporting.py @@ -0,0 +1,68 @@ +# Import libraries necessary for report printing +from jinja2 import Environment, PackageLoader +import pdfkit + + +# Determine the filepath to the local Pynite installation +from pathlib import Path +path = Path(__file__).parent + +# Set up the jinja2 template environment +env = Environment( + loader=PackageLoader('Pynite', '.'), +) + +# Get the report template +template = env.get_template('Report_Template.html') + +def create_report(model, output_filepath=path/'./Pynite Report.pdf', **kwargs): + """Creates a pdf report for a given finite element model. + + :param model: The model to generate the report for. + :type model: ``FEModel3D`` + :param output_filepath: The filepath to send the report to. Defaults to 'Pynite Report.pdf' in your ``PYTHONPATH`` + :type output_filepath: ``str``, optional + :param \**kwargs: See below for a list of valid arguments. + :Keyword Arguments: + * *node_table* (``bool``) -- Set to ``True`` if you want node data included in the report. Defaults to ``True``. + * *member_table* (``bool``) -- Set to ``True`` if you want member data included in the report. Defaults to ``True``. + * *member_releases* (``bool``) -- Set to ``True`` if you want member end release data included in the report. Defaults to ``True``. + * *plate_table* (``bool``) -- Set to ``True if you want plate/quad data included in the report. Defaults to ``True``. + * *node_reactions* (``bool``) -- Set to ``True`` if you want node reactions included in the report. Defaults to ``True`` + * *node_displacements* (``bool``) -- Set to ``True`` if you want node displacement results included in the report. Defaults to ``True``. + * *member_end_forces* (``bool``) -- Set to ``True`` if you want member end force results included in the report. Defaults to ``True``. + * *member_internal_forces* (``bool``) -- Set to ``True`` if you want member internal force results included in the report. Defaults to ``True`` + * *plate_corner_forces* (``bool``) -- Set to ``True`` if you want plate/quad corner force results (out-of-plane/bending) included in the report. Defaults to ``True``. + * *plate_center_forces* (``bool``) -- Set to ``True`` if you want plate/quad center force results (out-of-plane/bending) included in the report. Defaults to ``True``. + * *plate_corner_membrane* (``bool``) -- Set to ``True`` if you want plate/quad corner membrane (in-plane) force results included in the report. Defaults to ``True``. + * *plate_center_membrane* (``bool``) -- Set to ``True`` if you want plate/quad center membrane (in-plane) force results included in the report. Defaults to ``True``. + """ + + # Create default report settings + if 'node_table' not in kwargs: kwargs['node_table'] = True + if 'member_table' not in kwargs: kwargs['member_table'] = True + if 'member_releases' not in kwargs: kwargs['member_releases'] = True + if 'plate_table' not in kwargs: kwargs['plate_table'] = True + if 'node_reactions' not in kwargs: kwargs['node_reactions'] = True + if 'node_displacements' not in kwargs: kwargs['node_displacements'] = True + if 'member_end_forces' not in kwargs: kwargs['member_end_forces'] = True + if 'member_internal_forces' not in kwargs: kwargs['member_internal_forces'] = True + if 'plate_corner_forces' not in kwargs: kwargs['plate_corner_forces'] = True + if 'plate_center_forces' not in kwargs: kwargs['plate_center_forces'] = True + if 'plate_corner_membrane' not in kwargs: kwargs['plate_corner_membrane'] = True + if 'plate_center_membrane' not in kwargs: kwargs['plate_center_membrane'] = True + + # Pass the dictionaries to the report template + kwargs['nodes'] = model.nodes.values() + kwargs['members'] = model.members.values() + kwargs['plates'] = model.plates.values() + kwargs['quads'] = model.quads.values() + + # Create the report HTML using jinja2 + HTML = template.render(**kwargs) + + # Convert the HTML to pdf format using PDFKit + # Note that wkhtmltopdf must be installed on the system, and included on the system's PATH environment variable for PDFKit to work + pdfkit.from_string(HTML, output_filepath, css=path / './MainStyleSheet.css') + + return diff --git a/Old Pynite Folder/Section.py b/Old Pynite Folder/Section.py new file mode 100644 index 00000000..8e0c5119 --- /dev/null +++ b/Old Pynite Folder/Section.py @@ -0,0 +1,140 @@ + +import numpy as np + +class Section(): + """ + A class representing a section assigned to a Member3D element in a finite element model. + + This class stores all properties related to the geometry of the member + """ + def __init__(self, model, name:str, A:float, Iy:float, Iz:float, J:float) -> None: + """ + :param model: The finite element model to which this section belongs + :type model: FEModel3D + :param name: Name of the section + :type name: str + :param A: Cross-sectional area of the section + :type A: float + :param Iy: The second moment of area the section about the Y (minor) axis + :type Iy: float + :param Iz: The second moment of area the section about the Z (major) axis + :type Iz: float + :param J: The torsion constant of the section + :type J: float + """ + self.model = model + self.name = name + self.A = A + self.Iy = Iy + self.Iz = Iz + self.J = J + + def Phi(self): + pass + + def G(self, fx, my, mz): + """ +<<<<<<< HEAD + Returns the gradient to the yield surface at a given point using numerical differentiation. This is a default solution. For a better solution, overwrite this method with a more precise one in the material/shape specific child class that inherits from this class. +======= + Returns the gradient to the yield surface at a given point using numerical differentiation. + This is a default solution. For a better solution, overwrite this method with a more precies + one in the material/shape specific child class that inherits from this class. +>>>>>>> e49bff2e7890fa6b4069c6c9e2b013a2a90fe7e1 + """ + + # Small increment for numerical differentiation + epsilon = 1e-6 + + # Calculate the central differences for each parameter + dPhi_dfx = (self.Phi(fx + epsilon, my, mz) - self.Phi(fx - epsilon, my, mz)) / (2 * epsilon) + dPhi_dmy = (self.Phi(fx, my + epsilon, mz) - self.Phi(fx, my - epsilon, mz)) / (2 * epsilon) + dPhi_dmz = (self.Phi(fx, my, mz + epsilon) - self.Phi(fx, my, mz - epsilon)) / (2 * epsilon) + + # Return the gradient + return np.array([[dPhi_dfx], + [0], + [0], + [0], + [dPhi_dmy], + [dPhi_dmz]]) + +class SteelSection(Section): + + def __init__(self, model, name, A, Iy, Iz, J, Zy, Zz, material_name): + + # Basic section properties + super().__init__(model, name, A, Iy, Iz, J, material_name) + + # Additional section properties for steel + self.ry = (Iy/A)**0.5 + self.rz = (Iz/A)**0.5 + self.Zy = Zy + self.Zz = Zz + + self.material = model.materials[material_name] + + def Phi(self, fx, my, mz): + """ + A method used to determine whether the cross section is elastic or plastic. + Values less than 1 indicate the section is elastic. + + :param fx: Axial force divided by axial strength. + :type fx: float + :param my: Weak axis moment divided by weak axis strength. + :type my: float + :param mz: Strong axis moment divided by strong axis strength. + :type mz: float + :return: The total stress ratio for the cross section. + :rtype: float + """ + + # Plastic strengths for material nonlinearity + Py = self.material.fy*self.A + Mpy = self.material.fy*self.Zy + Mpz = self.material.fy*self.Zz + + # Values for p, my, and mz based on actual loads + p = fx/Py + m_y = my/Mpy + m_z = mz/Mpz + + # "Matrix Structural Analysis, 2nd Edition", Equation 10.18 + return p**2 + m_z**2 + m_y**4 + 3.5*p**2*m_z**2 + 3*p**6*m_y**2 + 4.5*m_z**4*m_y**2 + + def G(self, fx, my, mz): + """Returns the gradient to the material's yield surface for the given load. Used to construct the plastic reduction matrix for nonlinear behavior. + + :param fx: Axial force at the cross-section. + :type fx: float + :param my: y-axis (weak) moment at the cross-section. + :type my: float + :param mz: z-axis (strong) moment at the cross-section. + :type mz: float + :return: The gradient to the material's yield surface at the cross-section. + :rtype: array + """ + + # Plastic strengths for material nonlinearity + Py = self.material.fy*self.A + Mpy = self.material.fy*self.Zy + Mpz = self.material.fy*self.Zz + + # Partial derivatives of Phi + dPhi_dfx = 18*fx**5*my**2/(Mpy**2*Py**6) + 2*fx/Py**2 + 7.0*fx*mz**2/(Mpz**2*Py**2) + dPhi_dmy = 6*fx**6*my/(Mpy**2*Py**6) + 2*my/Mpy**2 + 9.0*my*mz**4/(Mpy**2*Mpz**4) + dPhi_dmz = 7.0*fx**2*mz/(Mpz**2*Py**2) + 2*mz/Mpz**2 + 18.0*my**2*mz**3/(Mpy**2*Mpz**4) + + # Return the gradient + return np.array([[dPhi_dfx], + [0], + [0], + [0], + [dPhi_dmy], + [dPhi_dmz]]) + +# test_section = SteelSection('W8x31', 9.13, 37.1, 110, 0.536, 14.1, 30.4, 50) +# print(test_section.G(15, 30, 50)) + + + diff --git a/Old Pynite Folder/ShearWall.py b/Old Pynite Folder/ShearWall.py new file mode 100644 index 00000000..9b38a3cc --- /dev/null +++ b/Old Pynite Folder/ShearWall.py @@ -0,0 +1,889 @@ +from math import isclose +from numpy import average +import io + +from Pynite.FEModel3D import FEModel3D +from prettytable import PrettyTable + +import matplotlib.pyplot as plt +from matplotlib.patches import Rectangle + +class ShearWall(): + """Creates a new shear wall model that allows for modeling of complex shear walls. Shear wall models are 2D (aside from flanges) standalone models. You can add openings and flanges (wall returns). Diaphragm levels can be defined in order to apply shear forces along the length of the wall. Diaphgrams can be full or partial length. Supports can be applied at any level in the shear wall. Supports can also be full or partial length. A `ky_mod` factor is built in to account for cracking. Shear walls can automatically detect shear wall piers and coupling beams, and sum internal forces in those components. + """ + + def __init__(self): + + self.model = FEModel3D() + self._L = None + self._H = None + self._t = None + self._ky_mod = 0.35 + self._mesh_size = 1 + self._openings = [] + self._flanges = [] + self._supports = [] + self._stories = [] + self._shears = [] + self._axials = [] + self._materials = [] + self.piers = {} + self.coupling_beams = {} + + @property + def L(self): + return self._L + + @L.setter + def L(self, value): + self._L = value + + @property + def H(self): + return self._H + + @H.setter + def H(self, value): + self._H = value + + @property + def mesh_size(self): + return self._mesh_size + + @mesh_size.setter + def mesh_size(self, value): + self._mesh_size = value + + @property + def ky_mod(self): + return self._ky_mod + + @ky_mod.setter + def ky_mod(self, value): + self._ky_mod = value + + def add_load_combo(self, name, factors, combo_type='strength'): + self.model.add_load_combo(name, factors, combo_type) + + def add_material(self, name, E, G, nu, rho, t, x_start=None, x_end=None, y_start=None, y_end=None): + if x_start is None: x_start = 0 + if x_end is None: x_end = self._L + if y_start is None: y_start = 0 + if y_end is None: y_end = self._H + self._materials.append([name, E, G, nu, rho, t, x_start, x_end, y_start, y_end]) + + def add_opening(self, name, x_start, y_start, width, height, tie=None): + self._openings.append([name, x_start, y_start, width, height, None]) + + def add_flange(self, thickness, width, x, y_start, y_end, material, side): + self._flanges.append([thickness, width, x, y_start, y_end, material, side]) + + def add_support(self, elevation=None, x_start=None, x_end=None): + if elevation is None: elevation = 0 + if x_start is None: x_start = 0 + if x_end is None: x_end = self._L + self._supports.append([elevation, x_start, x_end]) + + def add_story(self, story_name, elevation, x_start=None, x_end=None): + + # Validate input + if elevation is None: elevation = self._H + if x_start is None: x_start = 0 + if x_end is None: x_end = self._L + + # Add the story to the model + self._stories.append([story_name, elevation, x_start, x_end]) + + # Add a load combination to use when calculating the story's stiffness + self.model.add_load_combo('Stiffness: ' + story_name, {story_name: 1.0}, 'stiffness') + + # Add a 100 kip story shear to the model to use when calculating the story's stiffness + self.add_shear(story_name, 100, case=story_name) + + def add_shear(self, story_name, force, case='Case 1'): + self._shears.append([story_name, force, case]) + + def add_axial(self, story_name, force, case='Case 1'): + self._axials.append([story_name, force, case]) + + def generate(self): + + # Add materials to the model + for material in self._materials: + name, E, G, nu, rho = material[0:5] + self.model.add_material(name, E, G, nu, rho) + + # Identify mesh control points + x_control = [0, self._L] + y_control = [0, self._H] + + for material in self._materials: + x_control.append(material[6]) + x_control.append(material[7]) + y_control.append(material[8]) + y_control.append(material[9]) + + z_control = [0] + for flg in self._flanges: + if flg[6] == 'NS': z_control.append(flg[1]) + else: z_control.append(-flg[1]) + x_control.append(flg[2]) + y_control.append(flg[3]) + y_control.append(flg[4]) + + for support in self._supports: + x_control.append(support[1]) + x_control.append(support[2]) + y_control.append(support[0]) + + for story in self._stories: + x_control.append(story[2]) + x_control.append(story[3]) + y_control.append(story[1]) + + # While opening control points are auto-generated by the wall's mesh, we have no way of generating them for the flange meshes. We'll add some control points for the sake of the flanges. Duplicate control point values in the wall be be automatically resolved by Pynite. + for opng in self._openings: + y_control.append(opng[2]) + y_control.append(opng[2] + opng[4]) + + # Add the wall mesh to the model + self.model.add_rectangle_mesh('Wall', self._mesh_size, self._L, self._H, 12, self._materials[0][0], 1, self.ky_mod, x_control=x_control, y_control=y_control) + + # Add the openings to the mesh + self.model.add_material('Tie', 1, 1, 0, 0) + for opng in self._openings: + + name, x_start, y_start, width, height, AE = opng + self.model.meshes['Wall'].add_rect_opening(name, x_start, y_start, width, height) + + # Add any ties over the opening + if AE is not None: + + i_node_name = self.model.unique_name(self.model.nodes, 'N') + self.model.add_node(i_node_name, x_start, y_start + height, 0) + + j_node_name = self.model.unique_name(self.model.nodes, 'N') + self.model.add_node(j_node_name, x_start + width, y_start + height, 0) + + tie_name = self.model.unique_name(self.model.Members, 'Tie ') + self.model.add_member(tie_name, i_node_name, j_node_name, 'Tie', 1, 1, 1, AE) + self.model.def_releases(tie_name, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1) + + # Add the flanges to the mesh + for i, flg in enumerate(self._flanges): + + # Read in the flange's parameters + t, b, x, y_start, y_end, material, side = flg + + # Determine which side of the wall to place the flange on and define control points for the flange mesh so nodes line up properly with other meshes + if side == 'NS': + z = 0 + flg_x_control = [val for val in z_control if round(val, 10) >= 0 and round(val, 10) <= b] + else: + z = -b + flg_x_control = [b - (-val) for val in z_control if round(val, 10) <= 0 and round(val, 10) >= -b] + + flg_y_control = [y - y_start for y in y_control if round(y, 10) >= round(y_start, 10) and round(y, 10) <= round(y_end, 10)] + + # Add the flange to the model + self.model.add_rectangle_mesh('Flg'+str(i+1), self._mesh_size, b, y_end-y_start, t, material, 1, self.ky_mod, [x, y_start, z], 'YZ', flg_x_control, flg_y_control) + + # Generate the meshes + self.model.meshes['Wall'].generate() + + for i, flg in enumerate(self._flanges): + self.model.meshes['Flg'+str(i+1)].generate() + + # Merge the flange nodes with the rest of the wall + self.model.merge_duplicate_nodes() + + # Step through each plate in the model + for plate in self.model.quads.values(): + + # Step through each material in the wall + for material in self._materials: + + # Get the material properties + name, E, G, nu, rho, t, x_start, x_end, y_start, y_end = material + + # Determine if the current plate is part of a flange + if isclose(plate.i_node.X, plate.j_node.X): + # Flanges already have material properties and thicknesses assigned properly + pass + else: + # Determine if the current plate is this material + if round(plate.i_node.X, 10) >= round(x_start, 10) and round(plate.m_node.X, 10) <= round(x_end, 10) and round(plate.i_node.Y, 10) >= round(y_start, 10) and round(plate.m_node.Y, 10) <= round(y_start, 10): + + # Assign material properties to the plate + plate.E = E + plate.nu = nu + plate.t = t + + # Add supports + for support in self._supports: + elevation, x_start, x_end = support + for node in self.model.nodes.values(): + if isclose(node.Y, elevation) and round(node.X, 10) >= round(x_start, 10) and round(node.X, 10) <= round(x_end, 10): + self.model.def_support(node.name, True, True, True, True, True, True) + + # Add shear forces to the wall + for story in self._stories: + + # Read in parameters for this story + story_name, elevation, x_start, x_end = story + + # Initialize a list of story nodes + node_list = [] + + # Step through each node in the model + for node in self.model.nodes.values(): + + # Check if this node belongs to this story + if isclose(node.Y, elevation) and node.X >= x_start and node.X <= x_end and isclose(node.Z, 0): + + # Add the node to the list of nodes in the current story + node_list.append(node) + + # Add shear and axial forces to all the nodes in the story + for node in node_list: + + # Determine how many nodes are in the current story + num_nodes = len(node_list) + + # Step through each shear force in the model + for shear in self._shears: + + # Read in parameters for this shear + story, force, case = shear + + # Determine if this shear acts on this story + if story == story_name: + self.model.add_node_load(node.name, 'FX', force/num_nodes, case) + + # Step through each axial force in the model + for axial in self._axials: + + # Read in parameters for this axial force + story, force, case = axial + + # Determine if this axial force acts on this story + if story == story_name: + self.model.add_node_load(node.name, 'FY', -force/num_nodes, case) + + # Populate dictionaries of piers and coupling beams for the wall + self._identify_piers() + self._identify_coupling_beams() + + + def _identify_piers(self): + + # Reset all piers in the wall + self.piers = {} + + # Create a list of x and y coordinates that represent the edges of the wall + x_vals = [0, self._L] + y_vals = [0, self._H] + + # Add the edges of the openings to the lists + for opng in self._openings: + x_vals.append(opng[1]) + x_vals.append(opng[1] + opng[3]) + y_vals.append(opng[2]) + y_vals.append(opng[2] + opng[4]) + + # Sort the lists (ascending) + x_vals = sorted(x_vals) + y_vals = sorted(y_vals) + + # Remove duplicate (or near duplicate) values + unique_list = [] + for i in range(len(x_vals) - 1): + # Only keep the value at `i` if it's not a duplicate or near duplicate of the next value + if not isclose(x_vals[i], x_vals[i+1]): + unique_list.append(x_vals[i]) + unique_list.append(x_vals[-1]) # The last value will always be a keeper + x_vals = unique_list + + unique_list = [] + for i in range(len(y_vals) - 1): + # Only keep the value at `i` if it's not a duplicate or near duplicate of the next value + if not isclose(y_vals[i], y_vals[i+1]): + unique_list.append(y_vals[i]) + unique_list.append(y_vals[-1]) # The last value will always be a keeper + y_vals = unique_list + + # Divide the wall into vertical strip piers using the left and right edges of each opening as strip boundaries + self.piers = {} + for i in range(len(x_vals) - 1): + width = x_vals[i+1] - x_vals[i] + height = self._H + x = x_vals[i] + y = 0 + self.piers['P' + str(i+1)] = Pier('P' + str(i+1), x, y, width, height) + + # Divide the strip piers further into rectanglular piers using the top and bottom of each opening as pier boundaries + new_piers = {} + pier_count = 1 + for pier in self.piers.values(): + for i in range(len(y_vals) - 1): + width = pier.width + height = y_vals[i+1] - y_vals[i] + x = pier.x + y = y_vals[i] + new_piers['P' + str(pier_count)] = Pier('P' + str(pier_count), x, y, width, height) + pier_count += 1 + self.piers = new_piers + + # Delete any piers that fall within an opening + delete_list = [] + for pier in self.piers.values(): + # Check if this pier is inside any of the openings + for opng in self._openings: + if (round(pier.x, 10) >= round(opng[1], 10) + and round(pier.x + pier.width, 10) <= round(opng[1] + opng[3], 10) + and round(pier.y, 10) >= round(opng[2], 10) + and round(pier.y + pier.height, 10) <= round(opng[2] + opng[4], 10)): + delete_list.append(pier.name) + break + + for pier in delete_list: + del self.piers[pier] + + # Working horizontally (left to right), rejoin any rectangles that share a vertical edge to form a larger rectangle + found_duplicate = True + while found_duplicate == True: + + found_duplicate = False + piers_copy = self.piers.copy() + + for key1, pier1 in piers_copy.items(): + + for key2, pier2 in piers_copy.items(): + + # Check for piers that need to be merged + if (key1 != key2 + and isclose(pier1.y, pier2.y) + and isclose(pier1.x + pier1.width, pier2.x) + and isclose(pier1.height, pier2.height)): + + # Merge the piers in the `self.piers` dictionary + self.piers[key1].width = pier1.width + pier2.width + + # Delete the 2nd pier from the `self.piers` dictionary + del self.piers[key2] + + # Since the `self.piers` dictionary has changed we need `piers_copy` to get updated. Flag that we found a duplicate and break the loops. + found_duplicate = True + break + + # Break the `for` loop if a duplicate was found so we can get an updated copy of `self.piers` + if found_duplicate == True: + break + + # Working vertically (bottom to top), rejoin any rectangles that share a horizontal edge to form a larger rectangle + found_duplicate = True + while found_duplicate == True: + + found_duplicate = False + piers_copy = self.piers.copy() + + for key1, pier1 in piers_copy.items(): + + for key2, pier2 in piers_copy.items(): + + if (key1 != key2 + and isclose(pier1.x, pier2.x) + and isclose(pier1.y + pier1.height, pier2.y) + and isclose(pier1.width, pier2.width)): + + # Merge the piers in the `self.piers` dictionary + self.piers[key1].height = pier1.height + pier2.height + + # Delete the 2nd pier from the `self.piers` dictionary + del self.piers[key2] + + # Since the `self.piers` dictionary has changed we need `piers_copy` to get updated. Flag that we found a duplicate and break the loops. + found_duplicate = True + break + + # Break the `for` loop if a duplicate was found so we can get an updated copy of `self.piers` + if found_duplicate == True: + break + + # Generate a list of new keys in ascending order + new_keys = [f'P{i+1}' for i in range(len(self.piers))] + + # Replace the old dicionary with one that has updated keys + self.piers = dict(zip(new_keys, self.piers.values())) + for key, pier in self.piers.items(): + pier.name = key + + # Assign plates to each pier + for plate in self.model.quads.values(): + Y_avg = (plate.i_node.Y + plate.m_node.Y)/2 + X_avg = (plate.i_node.X + plate.m_node.X)/2 + for pier in self.piers.values(): + if (round(X_avg, 10) >= round(pier.x, 10) + and round(X_avg, 10) <= round(pier.x + pier.width, 10) + and round(Y_avg, 10) >= round(pier.y, 10) + and round(Y_avg, 10) <= round (pier.y + pier.height, 10)): + pier.plates.append(plate) + + def _identify_coupling_beams(self): + + # Reset all coupling beams in the wall + self.coupling_beams = {} + + # Create a list of x and y coordinates that represent the edges of the wall + x_vals = [0, self._L] + y_vals = [0, self._H] + + # Add the edges of the openings to the lists + for opng in self._openings: + x_vals.append(opng[1]) + x_vals.append(opng[1] + opng[3]) + y_vals.append(opng[2]) + y_vals.append(opng[2] + opng[4]) + + # Sort the lists (ascending) + x_vals = sorted(x_vals) + y_vals = sorted(y_vals) + + # Remove duplicate (or near duplicate) values + unique_list = [] + for i in range(len(x_vals) - 1): + # Only keep the value at `i` if it's not a duplicate or near duplicate of the next value + if not isclose(x_vals[i], x_vals[i+1]): + unique_list.append(x_vals[i]) + unique_list.append(x_vals[-1]) # The last value will always be a keeper + x_vals = unique_list + + unique_list = [] + for i in range(len(y_vals) - 1): + # Only keep the value at `i` if it's not a duplicate or near duplicate of the next value + if not isclose(y_vals[i], y_vals[i+1]): + unique_list.append(y_vals[i]) + unique_list.append(y_vals[-1]) # The last value will always be a keeper + y_vals = unique_list + + # Divide the wall into horizontal strips using the bottom and top edges of each opening as strip boundaries + self.coupling_beams = {} + for i in range(len(y_vals) - 1): + height = y_vals[i + 1] - y_vals[i] + length = self._L + y = y_vals[i] + x = 0 + self.coupling_beams['B' + str(i+1)] = CouplingBeam('B' + str(i+1), x, y, length, height) + + # Divide the strips further into rectanglular beams using the left and right of each opening as beam boundaries + new_beams = {} + beam_count = 1 + for beam in self.coupling_beams.values(): + for i in range(len(x_vals) - 1): + height = beam.height + length = x_vals[i+1] - x_vals[i] + y = beam.y + x = x_vals[i] + new_beams['B' + str(beam_count)] = CouplingBeam('B' + str(beam_count), x, y, length, height) + beam_count += 1 + self.coupling_beams = new_beams + + # Delete any beams that fall within an opening + delete_list = [] + for beam in self.coupling_beams.values(): + + # Check if this beam is inside any of the openings + for opng in self._openings: + + if (round(beam.x, 10) >= round(opng[1], 10) + and round(beam.x + beam.length, 10) <= round(opng[1] + opng[3], 10) + and round(beam.y, 10) >= round(opng[2], 10) + and round(beam.y + beam.height, 10) <= round(opng[2] + opng[4], 10)): + delete_list.append(beam.name) + break + + for beam in delete_list: + del self.coupling_beams[beam] + + # Working vertically (bottom to top), rejoin any rectangles that share a horizontal edge to form a larger rectangle + found_duplicate = True + while found_duplicate == True: + + found_duplicate = False + beams_copy = self.coupling_beams.copy() + + for key1, beam1 in beams_copy.items(): + + for key2, beam2 in beams_copy.items(): + + # Check for beams that need to be merged + if (key1 != key2 + and isclose(beam1.x, beam2.x) + and isclose(beam1.y + beam1.height, beam2.y) + and isclose(beam1.length, beam2.length)): + + # Merge the beams in the `self.coupling_beams` dictionary + self.coupling_beams[key1].height = beam1.height + beam2.height + + # Delete the 2nd beam from the `self.coupling_beams` dictionary + del self.coupling_beams[key2] + + # Since the `self.coupling_beams` dictionary has changed we need `beams_copy` to get updated. Flag that we found a duplicate and break the loops. + found_duplicate = True + break + + # Break the `for` loop if a duplicate was found so we can get an updated copy of `self.coupling_beams` + if found_duplicate == True: + break + + # Working horizontally (left to right), rejoin any rectangles that share a vertical edge to form a larger rectangle + found_duplicate = True + while found_duplicate == True: + + found_duplicate = False + beams_copy = self.coupling_beams.copy() + + for key1, beam1 in beams_copy.items(): + + for key2, beam2 in beams_copy.items(): + + if (key1 != key2 + and isclose(beam1.y, beam2.y) + and isclose(beam1.x + beam1.length, beam2.x) + and isclose(beam1.height, beam2.height)): + + # Merge the beams in the `self.coupling_beams` dictionary + self.coupling_beams[key1].length = beam1.length + beam2.length + + # Delete the 2nd beam from the `self.coupling_beams` dictionary + del self.coupling_beams[key2] + + # Since the `self.couping_beams` dictionary has changed we need `beams_copy` to get updated. Flag that we found a duplicate and break the loops. + found_duplicate = True + break + + # Break the `for` loop if a duplicate was found so we can get an updated copy of `self.coupling_beams` + if found_duplicate == True: + break + + # Check for any coupling beams at the bottom of the wall. There should not be any. Delete them as they are found + delete_list = [] + for beam in self.coupling_beams.values(): + if beam.y == 0: + delete_list.append(beam.name) + + for beam in delete_list: + del self.coupling_beams[beam] + + # Generate a list of new keys in ascending order + new_keys = [f'B{i + 1}' for i in range(len(self.coupling_beams))] + + # Replace the old dicionary with one that has updated keys + self.coupling_beams = dict(zip(new_keys, self.coupling_beams.values())) + for key, beam in self.coupling_beams.items(): + beam.name = key + + # Assign plates to each beam + for plate in self.model.quads.values(): + Y_avg = (plate.i_node.Y + plate.m_node.Y)/2 + X_avg = (plate.i_node.X + plate.m_node.X)/2 + for beam in self.coupling_beams.values(): + if (round(X_avg, 10) >= round(beam.x, 10) + and round(X_avg, 10) <= round(beam.x + beam.length, 10) + and round(Y_avg, 10) >= round(beam.y, 10) + and round(Y_avg, 10) <= round (beam.y + beam.height, 10)): + beam.plates.append(plate) + + def draw_piers(self, show=False): + + fig, ax = plt.subplots() + + ax.patch.set_facecolor((0.8, 0.8, 0.8)) + + for pier in self.piers.values(): + self._add_rectangle(ax, pier.x, pier.y, pier.width, pier.height, pier.name) + + # Adjust the aspect ratio of the plot + ax.set_aspect('equal') + + # Slim down the margins + plt.tight_layout() + + # show plot or return it + if show == True: plt.show() + else: return plt + + def draw_coupling_beams(self, show=False): + + fig, ax = plt.subplots() + + ax.patch.set_facecolor((0.8, 0.8, 0.8)) + + # Draw the overall Wall + self._add_rectangle(ax, 0, 0, self.L, self.H, "", 'white') + + # Draw the openings + for opng in self._openings: + self._add_rectangle(ax, opng[1], opng[2], opng[3], opng[4], '', 'grey') + + for beam in self.coupling_beams.values(): + self._add_rectangle(ax, beam.x, beam.y, beam.length, beam.height, beam.name, 'white') + + # Adjust the aspect ratio of the plot + ax.set_aspect('equal') + + # Slim down the margins + plt.tight_layout() + + # show plot or return it + if show == True: plt.show() + else: return plt + + def _add_rectangle(self, ax, x, y, w, h, name, color='white'): + """Adds a rectangle to the pyplot + """ + + # create rectangle + rect = Rectangle((x, y), w, h, linewidth=1, edgecolor='r', facecolor=color) + ax.add_patch(rect) + + # add name to center of rectangle + ax.text(x + w/2, y + h/2, name, ha='center', va='center') + + # set plot limits + ax.set_xlim(0, max(ax.get_xlim()[1], x + w)) + ax.set_ylim(0, max(ax.get_ylim()[1], y + h)) + + def _sort_openings(self): + + # Sort the openings based on y-coordinates + n = len(self._openings) + for i in range(n): + for j in range(0, n-i-1): + if self._openings[j][2] > self._openings[j+1][2]: + self._openings[j], self._openings[j+1] = self._openings[j+1], self._openings[j] + + # Sort the openings based on x-coordinates + n = len(self._openings) + for i in range(n): + for j in range(0, n-i-1): + if self._openings[j][1] > self._openings[j+1][1]: + self._openings[j], self._openings[j+1] = self._openings[j+1], self._openings[j] + + def stiffness(self, story_name): + + # TODO: Validate that the specified story exists in the shear wall + + # Step through each story in the model to find the one we're looking for + for story in self._stories: + + # Determine if this story is the one we are interested in + if story[0] == story_name: + + # Exit the loop + break + + # 100 kips is being applied to the story for the purpose of determining stiffness + V = 100 + + # Initialize the maximum wall deflection to zero + d_max = 0 + + # Step through each node in the model + for node in self.model.nodes.values(): + + # Determine if this node is in this story + if round(node.X, 10) >= round(story[2], 10) and round(node.X, 10) <= round(story[3], 10) and isclose(story[1], node.Y) and isclose(node.Z, 0): + + # Check if this deflection is the largest in the story + if node.DX['Stiffness: ' + story_name] > d_max: d_max = node.DX['Stiffness: ' + story_name] + + # Return the story's stiffness: + return V/(d_max*12) + + def render(self, color_map='Txy', combo_name='Combo 1'): + + from Pynite.Visualization import Renderer + renderer = Renderer(self.model) + renderer.annotation_size = 0.25 + renderer.render_loads = True + renderer.combo_name = combo_name + renderer.color_map = color_map + renderer.scalar_bar = True + renderer.deformed_shape = True + renderer.deformed_scale = 300 + renderer.labels = False + renderer.render_model() + + def screenshots(self, combo_name='Combo 1', dir_path='./'): + + from Pynite.Rendering import Renderer + + renderer = Renderer(self.model) + renderer.window_width = 750 + renderer.window_height = 750 + renderer.annotation_size = self.mesh_size/6 + renderer.deformed_shape = True + renderer.deformed_scale = 400 + renderer.render_loads = True + renderer.scalar_bar = True + renderer.combo_name = combo_name + renderer.labels = False + + # Save the shear plot screenshot to this file's directory + renderer.color_map = 'Txy' + renderer.screenshot(dir_path + '/shear_wall_screenshot1.png', interact=True) + + # Save the shear plot screenshot to this file's directory + renderer.color_map = 'Sy' + renderer.screenshot(dir_path + '/shear_wall_screenshot2.png', interact=False, reset_camera=False) + + # Save the pier screenshot to this file's directory + pier_sketch = self.draw_piers(show=False) + pier_sketch.savefig(dir_path + '/shear_wall_piers.png', format='png') + + def print_piers(self, combo_name='Combo 1'): + """Tabulates and prints pier results for the shear wall + """ + + # Create a PrettyTable object + table = PrettyTable() + + # Define the headers + table.field_names = ["ID", "Length", "Height", "M/(VL)", "V", "M", "P"] + + # Add rows to the table + for pier_id, pier in self.piers.items(): + P, M, V, M_VL = pier.sum_forces(combo_name) + table.add_row([pier.name, pier.width, pier.height, M_VL, V, M, P]) + + # Print the table + print('+-------------------+') + print('| Wall Pier Results |') + print('+-------------------+') + print(table) + + def print_coupling_beams(self, combo_name='Combo 1'): + """Tabulates and prints coupling beam results for the shear wall + """ + + # Create a PrettyTable object + table = PrettyTable() + + # Define the headers + table.field_names = ["ID", "Length", "Height", "M/(VH)", "V", "M", "P"] + + # Add rows to the table + for beam_id, beam in self.coupling_beams.items(): + P, M, V, M_VL = beam.sum_forces(combo_name) + table.add_row([beam.name, beam.length, beam.height, M_VL, V, M, P]) + + # Print the table + print('+----------------------------+') + print('| Wall Coupling Beam Results |') + print('+----------------------------+') + print(table) + +#%% +class Pier(): + + def __init__(self, name, x, y, width, height): + self.name = name + self.x = x # The location of the left side of the pier + self.y = y # The height of the bottom of the pier + self.width = width + self.height = height + self.plates = [] + + def sum_forces(self, combo_name='Combo 1'): + + # Initialize the forces in the plate + P, M, V = 0, 0, 0 + + # Step through each plate in the pier + for plate in self.plates: + + # Determine if this plate is at the bottom of the pier + if isclose(plate.i_node.Y, self.y): + + # Find and sum the axial forces in this plate + Pi = plate.F(combo_name)[1][0] + Pj = plate.F(combo_name)[7][0] + P += -Pi - Pj + + # Find and sum the moments about the pier's center in this plate + xi = plate.i_node.X - (self.x + self.width/2) + xj = plate.j_node.X - (self.x + self.width/2) + Mi = plate.F(combo_name)[1][0]*xi + Mj = plate.F(combo_name)[7][0]*xj + M += -Mi - Mj + + # Find and sum the shear forces in this plate + # Check if this is a flange plate or a web plate + if isclose(plate.i_node.X, plate.j_node.X): + Vi = -plate.F(combo_name)[2][0] + Vj = -plate.F(combo_name)[8][0] + else: + Vi = -plate.F(combo_name)[0][0] + Vj = -plate.F(combo_name)[6][0] + V += -Vi - Vj + + # Calculate the shear span ratio + M_VL = M/(V*self.width) + + # Return the summed forces and shear span ratio + return P, M, V, M_VL + +#%% +class CouplingBeam(): + + def __init__(self, name, x, y, length, height): + self.name = name + self.x = x # The location of the left side of the coupling beam + self.y = y # The height to the bottom of the coupling beam + self.length = length + self.height = height + self.plates = [] + + def sum_forces(self, combo_name='Combo 1'): + + # Initialize plate forces to zero + P, M, V = 0, 0, 0 + + # Step through each plate in the coupling beam + for plate in self.plates: + + # Determine if this plate is at the left edge of the coupling beam + if isclose(plate.i_node.X, self.x): + + # Check if this is a wall flange plate or a wall web plate + if isclose(plate.i_node.X, plate.j_node.X): + + # Plates that form wall flanges should not affect coupling beams, so forces will not be summed + pass + + else: + + # Find and sum the axial forces in this plate + Pi = plate.F(combo_name)[0][0] + Pn = plate.F(combo_name)[18][0] + P += -Pi - Pn + + # Find and sum the moments about the coupling beam's center in this plate + xi = plate.i_node.Y - (self.y + self.height/2) + xn = plate.n_node.Y - (self.y + self.height/2) + Mi = plate.F(combo_name)[0][0]*xi + Mn = plate.F(combo_name)[18][0]*xn + M += -Mi - Mn + + # Find and sum the shear forces in this plate + Vi = -plate.F(combo_name)[1][0] + Vn = -plate.F(combo_name)[19][0] + + V += -Vi - Vn + + # Calculate the shear span ratio + M_VH = M/(V*self.height) + + # Return the summed forces and shear span ratio + return P, M, V, M_VH diff --git a/Old Pynite Folder/Spring3D.py b/Old Pynite Folder/Spring3D.py new file mode 100644 index 00000000..a33b4f7b --- /dev/null +++ b/Old Pynite Folder/Spring3D.py @@ -0,0 +1,243 @@ +# %% +from numpy import zeros, array, add, subtract, matmul, insert, cross, divide +from numpy.linalg import inv +from math import isclose +import Pynite.FixedEndReactions +from Pynite.LoadCombo import LoadCombo + +# %% +class Spring3D(): + """A class representing a 3D spring element in a finite element model. + """ + + # '__plt' is used to store the 'pyplot' from matplotlib once it gets imported. Setting it to 'None' for now allows us to defer importing it until it's actually needed. + __plt = None + +#%% + def __init__(self, name, i_node, j_node, ks, LoadCombos={'Combo 1':LoadCombo('Combo 1', factors={'Case 1':1.0})}, + tension_only=False, comp_only=False): + ''' + Initializes a new spring. + ''' + self.name = name # A unique name for the spring given by the user + self.ID = None # Unique index number for the spring assigned by the program + self.i_node = i_node # The spring's i-node + self.j_node = j_node # The spring's j-node + self.ks = ks # The spring constant (force/displacement) + self.load_combos = LoadCombos # The dictionary of load combinations in the model this spring belongs to + self.tension_only = tension_only # Indicates whether the spring is tension-only + self.comp_only = comp_only # Indicates whether the spring is compression-only + + # Springs need to track whether they are active or not for any given load combination. + # They may become inactive for a load combination during a tension/compression-only + # analysis. This dictionary will be used when the model is solved. + self.active = {} # Key = load combo name, Value = True or False + +#%% + def L(self): + ''' + Returns the length of the spring. + ''' + + # Return the distance between the two nodes + return self.i_node.distance(self.j_node) + +#%% + def k(self): + ''' + Returns the local stiffness matrix for the spring. + ''' + + # Get the spring constant + ks = self.ks + + # Calculate the local stiffness matrix + k = array([ + [ks, 0, 0, 0, 0, 0, -ks, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [-ks, 0, 0, 0, 0, 0, ks, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]) + + # Return the local stiffness matrix + return k + +#%% + def f(self, combo_name='Combo 1'): + ''' + Returns the spring's local end force vector for the given load combination. + + Parameters + ---------- + combo_name : string + The name of the load combination to calculate the local end force vector for (not the load combination itself). + ''' + + # Calculate and return the spring's local end force vector + return matmul(self.k(), self.d(combo_name)) + +#%% + def d(self, combo_name='Combo 1'): + ''' + Returns the spring's local displacement vector. + + Parameters + ---------- + combo_name : string + The name of the load combination to construct the displacement vector for (not the load combination itself). + ''' + + # Calculate and return the local displacement vector + return matmul(self.T(), self.D(combo_name)) + +#%% + def T(self): + ''' + Returns the transformation matrix for the spring. + ''' + + x1 = self.i_node.X + x2 = self.j_node.X + y1 = self.i_node.Y + y2 = self.j_node.Y + z1 = self.i_node.Z + z2 = self.j_node.Z + L = self.L() + + # Calculate the direction cosines for the local x-axis + x = [(x2-x1)/L, (y2-y1)/L, (z2-z1)/L] + + # Calculate the remaining direction cosines. The local z-axis will be kept parallel to the global XZ plane in all cases + # Vertical springs + if isclose(x1, x2) and isclose(z1, z2): + + # For vertical springs, keep the local y-axis in the XY plane to make 2D problems easier to solve in the XY plane + if y2 > y1: + y = [-1, 0, 0] + z = [0, 0, 1] + else: + y = [1, 0, 0] + z = [0, 0, 1] + + # Horizontal springs + elif isclose(y1, y2): + + # Find a vector in the direction of the local z-axis by taking the cross-product + # of the local x-axis and the local y-axis. This vector will be perpendicular to + # both the local x-axis and the local y-axis. + y = [0, 1, 0] + z = cross(x, y) + + # Divide the z-vector by its magnitude to produce a unit vector of direction cosines + z = divide(z, (z[0]**2 + z[1]**2 + z[2]**2)**0.5) + + # Members neither vertical or horizontal + else: + + # Find the projection of x on the global XZ plane + proj = [x2-x1, 0, z2-z1] + + # Find a vector in the direction of the local z-axis by taking the cross-product + # of the local x-axis and its projection on a plane parallel to the XZ plane. This + # produces a vector perpendicular to both the local x-axis and its projection. This + # vector will always be horizontal since it's parallel to the XZ plane. The order + # in which the vectors are 'crossed' has been selected to ensure the y-axis always + # has an upward component (i.e. the top of the beam is always on top). + if y2 > y1: + z = cross(proj, x) + else: + z = cross(x, proj) + + # Divide the z-vector by its magnitude to produce a unit vector of direction cosines + z = divide(z, (z[0]**2 + z[1]**2 + z[2]**2)**0.5) + + # Find the direction cosines for the local y-axis + y = cross(z, x) + y = divide(y, (y[0]**2 + y[1]**2 + y[2]**2)**0.5) + + # Create the direction cosines matrix + dirCos = array([x, y, z]) + + # Build the transformation matrix + transMatrix = zeros((12, 12)) + transMatrix[0:3, 0:3] = dirCos + transMatrix[3:6, 3:6] = dirCos + transMatrix[6:9, 6:9] = dirCos + transMatrix[9:12, 9:12] = dirCos + + return transMatrix + +#%% + def K(self): + ''' + Spring global stiffness matrix + ''' + + # Calculate and return the stiffness matrix in global coordinates + return matmul(matmul(inv(self.T()), self.k()), self.T()) + +#%% + def F(self, combo_name='Combo 1'): + ''' + Returns the spring's global end force vector for the given load combination. + ''' + + # Calculate and return the global force vector + return matmul(inv(self.T()), self.f(combo_name)) + +#%% + def D(self, combo_name='Combo 1'): + ''' + Returns the spring's global displacement vector. + + Parameters + ---------- + combo_name : string + The name of the load combination to construct the global + displacement vector for (not the load combination itelf). + ''' + + # Initialize the displacement vector + D = zeros((12, 1)) + + # Read in the global displacements from the nodes + # Apply axial displacements only if the spring is active + if self.active[combo_name] == True: + D[0, 0] = self.i_node.DX[combo_name] + D[6, 0] = self.j_node.DX[combo_name] + + # Apply the remaining displacements + D[1, 0] = self.i_node.DY[combo_name] + D[2, 0] = self.i_node.DZ[combo_name] + D[3, 0] = self.i_node.RX[combo_name] + D[4, 0] = self.i_node.RY[combo_name] + D[5, 0] = self.i_node.RZ[combo_name] + D[7, 0] = self.j_node.DY[combo_name] + D[8, 0] = self.j_node.DZ[combo_name] + D[9, 0] = self.j_node.RX[combo_name] + D[10, 0] = self.j_node.RY[combo_name] + D[11, 0] = self.j_node.RZ[combo_name] + + # Return the global displacement vector + return D + +#%% + def axial(self, combo_name='Combo 1'): + ''' + Returns the axial force in the spring. + + Parameters + ---------- + combo_name : string + The name of the load combination to get the results for (not the load combination itself). + ''' + + # Calculate the axial force + return self.f(combo_name)[0, 0] \ No newline at end of file diff --git a/Old Pynite Folder/Visualization.py b/Old Pynite Folder/Visualization.py new file mode 100644 index 00000000..18008c52 --- /dev/null +++ b/Old Pynite Folder/Visualization.py @@ -0,0 +1,1863 @@ + +from json import load +import warnings + +from IPython.display import Image +from numpy import array, empty, append, cross +from numpy.linalg import norm +import vtk + +class Renderer(): + """Used to render finite element models. + """ + + scalar = None + + def __init__(self, model): + + self.model = model + + # Default settings for rendering + self.annotation_size = 5 + self.deformed_shape = False + self.deformed_scale = 30 + self.render_nodes = True + self.render_loads = True + self.color_map = None + self.combo_name = 'Combo 1' + self.case = None + self.labels = True + self.scalar_bar = False + self.scalar_bar_text_size = 24 + self.theme = 'default' + + # Initialize VTK objects + self.renderer = vtk.vtkRenderer() + self.window = vtk.vtkRenderWindow() + self.window.SetWindowName('Pynite - Simple Finite Element Analysis in Python') + self.window.AddRenderer(self.renderer) + + @property + def window_width(self): + return self.window.GetSize()[0] + + @window_width.setter + def window_width(self, width): + height = self.window.GetSize()[1] + self.window.SetSize(width, height) + + @property + def window_height(self): + return self.window.GetSize()[1] + + @window_height.setter + def window_height(self, height): + width = self.window.GetSize()[0] + self.window.SetSize(width, height) + + def set_annotation_size(self, size=5): + self.annotation_size = size + + def set_deformed_shape(self, deformed_shape=False): + self.deformed_shape = deformed_shape + + def set_deformed_scale(self, scale=30): + self.deformed_scale = scale + + def set_render_nodes(self, render_nodes=True): + self.render_nodes = render_nodes + + def set_render_loads(self, render_loads=True): + self.render_loads = render_loads + + def set_color_map(self, color_map=None): + """Sets the color map for plate contours. + + :param color_map: The color map to use: Valid options are 'Qx', Qy', 'Mx', 'My', 'Mxy', 'Sx', 'Sy' 'Txy'. Qx and Qy are out-of-plane shear forces. Mx, My, and Mxy are local out-of-plane bending moments. 'Sx' and 'Sy' are membrane forces, and 'Txy' is an in-plane shear force. Defaults to None. + :type color_map: str, optional + """ + self.color_map = color_map + + def set_combo_name(self, combo_name='Combo 1'): + self.combo_name = combo_name + self.case = None + + def set_case(self, case=None): + self.case = case + self.combo_name = None + + def set_show_labels(self, show_labels=True): + self.labels = show_labels + + def set_scalar_bar(self, scalar_bar=False): + self.scalar_bar = scalar_bar + + def set_scalar_bar_text_size(self, text_size=24): + self.scalar_bar_text_size = text_size + + def render_model(self, interact=True, reset_camera=True): + """ + Renders the model in a window + + Parameters + ---------- + interact : bool + Suppresses interacting with the window if set to `False`. This can be used to capture a + screenshot without pausing the program for the user to interact. Default is `True`. + reset_camera : bool + Resets the camera if set to `True`. Default is `True`. + """ + + # Get the render window + window = self.window + + # Update the renderer + self.update(reset_camera) + + # Render the window + window.Render() + + # Handle user interaction if requested by the user + if interact: + + # Set up an interactor. The interactor style determines how user interactions affect the + # view. The trackball camera style behaves much like popular commercial CAD programs. + interactor = vtk.vtkRenderWindowInteractor() + style = vtk.vtkInteractorStyleTrackballCamera() + interactor.SetInteractorStyle(style) + interactor.SetRenderWindow(self.window) + + # Start the interactor. Code execution will pause here until the user closes the window. + interactor.Start() + + # Finalize the render window once the user closes out of it. I don't understand everything + # this does, but I've found screenshots will cause the program to crash if this line is + # omitted. I have noticed it will shut down the interactor. + window.Finalize() + + return window + + def screenshot(self, filepath='console', interact=True, reset_camera=True): + """ + Renders the model in a window. When the window is closed a screenshot is captured. + + Parameters + ---------- + filepath : string + Sends a screenshot to the specified filepath. The screenshot will be taken when the + user closes out of the render window. If `filepath` is set to 'console' the screenshot + will be returned as an IPython image. If set to 'BytesIO' it will return the image as a + `BytesIO` object. Default is 'console'. + interact : bool + Suppresses interacting with the window if set to `False`. This can be used to capture a + screenshot without pausing the program for the user to interact. Default is `True`. + reset_camera : boolju + Resets the camera if set to `True`. Default is `True`. + """ + + # Render the model in a window and save the window + window = self.render_model(interact, reset_camera) + + # Screenshot code + w2if = vtk.vtkWindowToImageFilter() + w2if.SetInput(window) + w2if.SetInputBufferTypeToRGB() + w2if.ReadFrontBufferOff() + + # These next two lines are in the examples and documentation for VTK, but don't seem to do + # anything. I've left them here in case I find a bug somewhere down the line that needs + # fixing. + # w2if.Update() + # w2if.Modified() + + writer = vtk.vtkPNGWriter() + writer.SetInputConnection(w2if.GetOutputPort()) + + if filepath == 'console' or filepath == 'BytesIO': + + writer.SetWriteToMemory(1) + writer.Write() + fig_file = memoryview(writer.GetResult()).tobytes() + + # Now that we're done with the render window, finalize it + window.Finalize() + + if filepath == 'console': + return Image(fig_file) + elif filepath == 'BytesIO': + from io import BytesIO + return BytesIO(fig_file) + else: + + writer.SetFileName(filepath) + writer.Write() + + # Now that we're done with the render window, finalize it + window.Finalize() + + return + + def update(self, reset_camera=True): + """ + Builds or rebuilds the VTK renderer + + Parameters + ---------- + reset_camera : bool + Resets the camera if set to `True`. Default is `True`. + """ + + # Input validation + if self.deformed_shape and self.case != None: + raise Exception('Deformed shape is only available for load combinations,' + ' not load cases.') + if self.model.load_combos == {} and self.render_loads == True and self.case == None: + self.render_loads = False + warnings.warn('Unable to render load combination. No load combinations defined.', UserWarning) + + # Check if nodes are to be rendered + if self.render_nodes == True: + + if self.theme == 'print': + color = 'black' + else: + color = None + + # Create a visual node for each node in the model + vis_nodes = [] + for node in self.model.nodes.values(): + vis_nodes.append(VisNode(node, self.annotation_size, color)) + + # Create a visual spring for each spring in the model + vis_springs = [] + for spring in self.model.springs.values(): + vis_springs.append(VisSpring(spring, self.model.nodes, self.annotation_size)) + + # Create a visual member for each member in the model + vis_members = [] + for member in self.model.members.values(): + vis_members.append(VisMember(member, self.model.nodes, self.annotation_size, self.theme)) + + # Get the renderer + renderer = self.renderer + + # Clear out all the old actors from any previous renderings + for actor in renderer.GetActors(): + renderer.RemoveActor(actor) + + # Add actors for each spring + for vis_spring in vis_springs: + + # Add the actor for the spring + renderer.AddActor(vis_spring.actor) + + if self.labels == True: + # Add the actor for the spring label + renderer.AddActor(vis_spring.lblActor) + + # Set the text to follow the camera as the user interacts. This will + # require a reset of the camera (see below) + vis_spring.lblActor.SetCamera(renderer.GetActiveCamera()) + + # Add actors for each member + for vis_member in vis_members: + + # Add the actor for the member + renderer.AddActor(vis_member.actor) + + if self.labels == True: + + # Add the actor for the member label + renderer.AddActor(vis_member.lblActor) + + # Set the text to follow the camera as the user interacts. This will + # require a reset of the camera (see below) + vis_member.lblActor.SetCamera(renderer.GetActiveCamera()) + + # Check if nodes are to be rendered + if self.render_nodes == True: + + # Combine the polydata from each node + + # Create an append filter for combining node polydata + node_polydata = vtk.vtkAppendPolyData() + + for vis_node in vis_nodes: + + # Add the node's polydata + node_polydata.AddInputData(vis_node.polydata.GetOutput()) + + if self.labels == True: + + if self.theme == 'print': + vis_node.lblActor.GetProperty().SetColor(0, 0, 0) # black + + # Add the actor for the node label + renderer.AddActor(vis_node.lblActor) + + # Set the text to follow the camera as the user interacts. This will + # require a reset of the camera (see below) + vis_node.lblActor.SetCamera(renderer.GetActiveCamera()) + + # Update the node polydata in the append filter + node_polydata.Update() + + # Create a mapper and actor for the nodes + node_mapper = vtk.vtkPolyDataMapper() + node_mapper.SetInputConnection(node_polydata.GetOutputPort()) + node_actor = vtk.vtkActor() + node_actor.SetMapper(node_mapper) + + # Adjust the color of the nodes here. + if self.theme == 'print': + node_actor.GetProperty().SetColor(0, 0, 0) # Black + + # Add the node actor to the renderer + renderer.AddActor(node_actor) + + # Render the deformed shape if requested + if self.deformed_shape == True: + _DeformedShape(self.model, renderer, self.deformed_scale, self.annotation_size, self.combo_name, self.render_nodes, self.theme) + + # Render the loads if requested + if (self.combo_name != None or self.case != None) and self.render_loads != False: + _RenderLoads(self.model, renderer, self.annotation_size, self.combo_name, self.case, self.theme) + + # Render the plates and quads, if present + if self.model.quads or self.model.plates: + _RenderContours(self.model, renderer, self.deformed_shape, self.deformed_scale, + self.color_map, self.scalar_bar, self.scalar_bar_text_size, + self.combo_name, self.theme) + + # Set the window's background color + if self.theme == 'default': + renderer.SetBackground(0, 0, 128) # Blue + elif self.theme == 'print': + renderer.SetBackground(255, 255, 255) # White + + # Reset the camera + if reset_camera: renderer.ResetCamera() + +#%% +# Converts a node object into a node for the viewer +class VisNode(): + + # Constructor + def __init__(self, node, annotation_size=5, color=None): + + # Create an append filter to append all the sources related to the node into a single 'PolyData' object + self.polydata = vtk.vtkAppendPolyData() + + # Get the node's position + X = node.X # Global X coordinate + Y = node.Y # Global Y coordinate + Z = node.Z # Global Z coordinate + + # Generate a sphere source for the node + sphere = vtk.vtkSphereSource() + sphere.SetCenter(X, Y, Z) + sphere.SetRadius(0.6*annotation_size) + sphere.Update() + self.polydata.AddInputData(sphere.GetOutput()) + + # Create the text for the node label + label = vtk.vtkVectorText() + label.SetText(node.name) + + # Set up a mapper for the node label + lblMapper = vtk.vtkPolyDataMapper() + lblMapper.SetInputConnection(label.GetOutputPort()) + + # Set up an actor for the node label + self.lblActor = vtk.vtkFollower() + self.lblActor.SetMapper(lblMapper) + self.lblActor.SetScale(annotation_size, annotation_size, annotation_size) + self.lblActor.SetPosition(X + 0.6*annotation_size, Y + 0.6*annotation_size, Z) + + # Generate any supports that occur at the node + # Check for a fixed suppport + if node.support_DX == True and node.support_DY == True and node.support_DZ == True \ + and node.support_RX == True and node.support_RY == True and node.support_RZ == True: + + # Create the fixed support + support = vtk.vtkCubeSource() + support.SetCenter(node.X, node.Y, node.Z) + support.SetXLength(annotation_size*1.2) + support.SetYLength(annotation_size*1.2) + support.SetZLength(annotation_size*1.2) + + # Copy and append the support data to the append filter + support.Update() + self.polydata.AddInputData(support.GetOutput()) + + # Check for a pinned support + elif node.support_DX == True and node.support_DY == True and node.support_DZ == True \ + and node.support_RX == False and node.support_RY == False and node.support_RZ == False: + + # Create the pinned support + support = vtk.vtkConeSource() + support.SetCenter(node.X, node.Y-0.6*annotation_size, node.Z) + support.SetDirection((0, 1, 0)) + support.SetHeight(annotation_size*1.2) + support.SetRadius(annotation_size*1.2) + + # Copy and append the support data to the append filter + support.Update() + self.polydata.AddInputData(support.GetOutput()) + + # Other support conditions + else: + + # Restrained against X translation + if node.support_DX == True: + + # Create the support + support1 = vtk.vtkLineSource() # The line showing the support direction + support1.SetPoint1(node.X-annotation_size, node.Y, node.Z) + support1.SetPoint2(node.X+annotation_size, node.Y, node.Z) + + # Copy and append the support data to the append filter + support1.Update() + self.polydata.AddInputData(support1.GetOutput()) + + support2 = vtk.vtkConeSource() + support2.SetCenter(node.X-annotation_size, node.Y, node.Z) + support2.SetDirection((1, 0, 0)) + support2.SetHeight(annotation_size*0.6) + support2.SetRadius(annotation_size*0.3) + + # Copy and append the support data to the append filter + support2.Update() + self.polydata.AddInputData(support2.GetOutput()) + + support3 = vtk.vtkConeSource() + support3.SetCenter(node.X+annotation_size, node.Y, node.Z) + support3.SetDirection((-1, 0, 0)) + support3.SetHeight(annotation_size*0.6) + support3.SetRadius(annotation_size*0.3) + + # Copy and append the support data to the append filter + support3.Update() + self.polydata.AddInputData(support3.GetOutput()) + + # Restrained against Y translation + if node.support_DY == True: + + # Create the support + support1 = vtk.vtkLineSource() # The line showing the support direction + support1.SetPoint1(node.X, node.Y-annotation_size, node.Z) + support1.SetPoint2(node.X, node.Y+annotation_size, node.Z) + + # Copy and append the support data to the append filter + support1.Update() + self.polydata.AddInputData(support1.GetOutput()) + + support2 = vtk.vtkConeSource() + support2.SetCenter(node.X, node.Y-annotation_size, node.Z) + support2.SetDirection((0, 1, 0)) + support2.SetHeight(annotation_size*0.6) + support2.SetRadius(annotation_size*0.3) + + # Copy and append the support data to the append filter + support2.Update() + self.polydata.AddInputData(support2.GetOutput()) + + support3 = vtk.vtkConeSource() + support3.SetCenter(node.X, node.Y+annotation_size, node.Z) + support3.SetDirection((0, -1, 0)) + support3.SetHeight(annotation_size*0.6) + support3.SetRadius(annotation_size*0.3) + + # Copy and append the support data to the append filter + support3.Update() + self.polydata.AddInputData(support3.GetOutput()) + + # Restrained against Z translation + if node.support_DZ == True: + + # Create the support + support1 = vtk.vtkLineSource() # The line showing the support direction + support1.SetPoint1(node.X, node.Y, node.Z-annotation_size) + support1.SetPoint2(node.X, node.Y, node.Z+annotation_size) + + # Copy and append the support data to the append filter + support1.Update() + self.polydata.AddInputData(support1.GetOutput()) + + support2 = vtk.vtkConeSource() + support2.SetCenter(node.X, node.Y, node.Z-annotation_size) + support2.SetDirection((0, 0, 1)) + support2.SetHeight(annotation_size*0.6) + support2.SetRadius(annotation_size*0.3) + + # Copy and append the support data to the append filter + support2.Update() + self.polydata.AddInputData(support2.GetOutput()) + + support3 = vtk.vtkConeSource() + support3.SetCenter(node.X, node.Y, node.Z+annotation_size) + support3.SetDirection((0, 0, -1)) + support3.SetHeight(annotation_size*0.6) + support3.SetRadius(annotation_size*0.3) + + # Copy and append the support data to the append filter + support3.Update() + self.polydata.AddInputData(support3.GetOutput()) + + # Restrained against rotation about the X-axis + if node.support_RX == True: + + # Create the support + support1 = vtk.vtkLineSource() # The line showing the support direction + support1.SetPoint1(node.X-1.6*annotation_size, node.Y, node.Z) + support1.SetPoint2(node.X+1.6*annotation_size, node.Y, node.Z) + + # Copy and append the support data to the append filter + support1.Update() + self.polydata.AddInputData(support1.GetOutput()) + + support2 = vtk.vtkCubeSource() + support2.SetCenter(node.X-1.9*annotation_size, node.Y, node.Z) + support2.SetXLength(annotation_size*0.6) + support2.SetYLength(annotation_size*0.6) + support2.SetZLength(annotation_size*0.6) + + # Copy and append the support data to the append filter + support2.Update() + self.polydata.AddInputData(support2.GetOutput()) + + support3 = vtk.vtkCubeSource() + support3.SetCenter(node.X+1.9*annotation_size, node.Y, node.Z) + support3.SetXLength(annotation_size*0.6) + support3.SetYLength(annotation_size*0.6) + support3.SetZLength(annotation_size*0.6) + + # Copy and append the support data to the append filter + support3.Update() + self.polydata.AddInputData(support3.GetOutput()) + + # Restrained against rotation about the Y-axis + if node.support_RY == True: + + # Create the support + support1 = vtk.vtkLineSource() # The line showing the support direction + support1.SetPoint1(node.X, node.Y-1.6*annotation_size, node.Z) + support1.SetPoint2(node.X, node.Y+1.6*annotation_size, node.Z) + + # Copy and append the support data to the append filter + support1.Update() + self.polydata.AddInputData(support1.GetOutput()) + + support2 = vtk.vtkCubeSource() + support2.SetCenter(node.X, node.Y-1.9*annotation_size, node.Z) + support2.SetXLength(annotation_size*0.6) + support2.SetYLength(annotation_size*0.6) + support2.SetZLength(annotation_size*0.6) + + # Copy and append the support data to the append filter + support2.Update() + self.polydata.AddInputData(support2.GetOutput()) + + support3 = vtk.vtkCubeSource() + support3.SetCenter(node.X, node.Y+1.9*annotation_size, node.Z) + support3.SetXLength(annotation_size*0.6) + support3.SetYLength(annotation_size*0.6) + support3.SetZLength(annotation_size*0.6) + + # Copy and append the support data to the append filter + support3.Update() + self.polydata.AddInputData(support3.GetOutput()) + + # Restrained against rotation about the Z-axis + if node.support_RZ == True: + + # Create the support + support1 = vtk.vtkLineSource() # The line showing the support direction + support1.SetPoint1(node.X, node.Y, node.Z-1.6*annotation_size) + support1.SetPoint2(node.X, node.Y, node.Z+1.6*annotation_size) + + # Copy and append the support data to the append filter + support1.Update() + self.polydata.AddInputData(support1.GetOutput()) + + support2 = vtk.vtkCubeSource() + support2.SetCenter(node.X, node.Y, node.Z-1.9*annotation_size) + support2.SetXLength(annotation_size*0.6) + support2.SetYLength(annotation_size*0.6) + support2.SetZLength(annotation_size*0.6) + + # Copy and append the support data to the append filter + support2.Update() + self.polydata.AddInputData(support2.GetOutput()) + + support3 = vtk.vtkCubeSource() + support3.SetCenter(node.X, node.Y, node.Z+1.9*annotation_size) + support3.SetXLength(annotation_size*0.6) + support3.SetYLength(annotation_size*0.6) + support3.SetZLength(annotation_size*0.6) + + # Copy and append the support data to the append filter + support3.Update() + self.polydata.AddInputData(support3.GetOutput()) + + # Update the append filter + self.polydata.Update() + + # Create a mapper and actor + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(self.polydata.GetOutputPort()) + self.actor = vtk.vtkActor() + + # Set the mapper for the node's actor + self.actor.SetMapper(mapper) + + # Color will be added to the node actors outside of this class once they are all combined into one `vtkAppendFilter` object. + + # TODO: Delete legacy code below once it's been proven to be unnecessary over time. + + # Add color to the node and label actors if specified + # if color == 'red': + # self.actor.GetProperty().SetColor(255, 0, 0) # Red + # self.lblActor.GetProperty().SetColor(255, 0, 0) # Red + # elif color == 'yellow': + # self.actor.GetProperty().SetColor(255, 255, 0) # Yellow + # self.lblActor.GetProperty().SetColor(255, 255, 0) # Yellow + # elif color == 'black': + # self.actor.GetProperty().SetColor(0, 0, 0) # Black + # self.lblActor.GetProperty().SetColor(0, 0, 0) # Black + +class VisSpring(): + + def __init__(self, spring, nodes, annotation_size=5, color=None): + + # Generate a line source for the spring + line = vtk.vtkLineSource() + + # Step through each node in the model and find the position of the + # i-node and j-node + for node in nodes.values(): + + # Check to see if the current node is the i-node + if node.name == spring.i_node.name: + Xi = node.X + Yi = node.Y + Zi = node.Z + line.SetPoint1(Xi, Yi, Zi) + + # Check to see if the current node is the j-node + elif node.name == spring.j_node.name: + Xj = node.X + Yj = node.Y + Zj = node.Z + line.SetPoint2(Xj, Yj, Zj) + + # Set up a mapper for the spring + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(line.GetOutputPort()) + + # Set up an actor and a mapper for the spring + self.actor = vtk.vtkActor() + self.actor.SetMapper(mapper) + + # Create the text for the spring label + label = vtk.vtkVectorText() + label.SetText(spring.name) + + # Set up a mapper for the spring label + lblMapper = vtk.vtkPolyDataMapper() + lblMapper.SetInputConnection(label.GetOutputPort()) + + # Set up an actor for the spring label + self.lblActor = vtk.vtkFollower() + self.lblActor.SetMapper(lblMapper) + self.lblActor.SetScale(annotation_size, annotation_size, annotation_size) + self.lblActor.SetPosition((Xi+Xj)/2, (Yi+Yj)/2, (Zi+Zj)/2) + + # Add some color + if color is None: + self.actor.GetProperty().SetColor(255, 0, 255) # Magenta + self.lblActor.GetProperty().SetColor(255, 0, 255) + elif color == 'black': + self.actor.GetProperty().SetColor(0, 0, 0) # Black + self.lblActor.GetProperty().SetColor(0, 0, 0) + +# Converts a member object into a member for the viewer +class VisMember(): + + # Constructor + def __init__(self, member, nodes, annotation_size=5, theme='default'): + + # Generate a line for the member + line = vtk.vtkLineSource() + + # Step through each node in the model and find the position of the i-node and j-node + for node in nodes.values(): + + # Check to see if the current node is the i-node + if node.name == member.i_node.name: + Xi = node.X + Yi = node.Y + Zi = node.Z + line.SetPoint1(Xi, Yi, Zi) + + # Check to see if the current node is the j-node + elif node.name == member.j_node.name: + Xj = node.X + Yj = node.Y + Zj = node.Z + line.SetPoint2(Xj, Yj, Zj) + + # Set up a mapper for the member + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(line.GetOutputPort()) + + # Set up an actor for the member + self.actor = vtk.vtkActor() + self.actor.SetMapper(mapper) + + # Create the text for the member label + label = vtk.vtkVectorText() + label.SetText(member.name) + + # Set up a mapper for the member label + lblMapper = vtk.vtkPolyDataMapper() + lblMapper.SetInputConnection(label.GetOutputPort()) + + # Set up an actor for the member label + self.lblActor = vtk.vtkFollower() + self.lblActor.SetMapper(lblMapper) + self.lblActor.SetScale(annotation_size, annotation_size, annotation_size) + self.lblActor.SetPosition((Xi+Xj)/2, (Yi+Yj)/2, (Zi+Zj)/2) + + # Adjust the color of the member if the theme is 'print' + if theme == 'print': + self.actor.GetProperty().SetColor(0/255, 0/255, 0/255) # Black + self.lblActor.GetProperty().SetColor(0/255, 0/255, 0/255) # Black + +# Converts a node object into a node in its deformed position for the viewer +class VisDeformedNode(): + + def __init__(self, node, scale_factor, annotation_size=5, combo_name='Combo 1'): + + # Calculate the node's deformed position + newX = node.X + scale_factor*(node.DX[combo_name]) + newY = node.Y + scale_factor*(node.DY[combo_name]) + newZ = node.Z + scale_factor*(node.DZ[combo_name]) + + # Generate a sphere source for the node in its deformed position + self.source = vtk.vtkSphereSource() + self.source.SetCenter(newX, newY, newZ) + self.source.SetRadius(0.6*annotation_size) + self.source.Update() + +class VisDeformedMember(): + + def __init__(self, member, nodes, scale_factor, combo_name='Combo 1'): + + # Determine if this member is active for each load combination + self.active = member.active + + L = member.L() # Member length + T = member.T() # Member local transformation matrix + + cos_x = array([T[0,0:3]]) # Direction cosines of local x-axis + cos_y = array([T[1,0:3]]) # Direction cosines of local y-axis + cos_z = array([T[2,0:3]]) # Direction cosines of local z-axis + + # Find the initial position of the local i-node + # Step through each node + for node in nodes.values(): + + # Check to see if the current node is the i-node + if node.name == member.i_node.name: + Xi = node.X + Yi = node.Y + Zi = node.Z + + # Calculate the local y-axis displacements at 20 points along the member's + # length + DY_plot = empty((0, 3)) + for i in range(20): + + # Calculate the local y-direction displacement + dy_tot = member.deflection('dy', L/19*i, combo_name) + + # Calculate the scaled displacement in global coordinates + DY_plot = append(DY_plot, dy_tot*cos_y*scale_factor, axis=0) + + # Calculate the local z-axis displacements at 20 points along the member's + # length + DZ_plot = empty((0, 3)) + for i in range(20): + + # Calculate the local z-direction displacement + dz_tot = member.deflection('dz', L/19*i, combo_name) + + # Calculate the scaled displacement in global coordinates + DZ_plot = append(DZ_plot, dz_tot*cos_z*scale_factor, axis=0) + + # Calculate the local x-axis displacements at 20 points along the member's + # length + DX_plot = empty((0, 3)) + for i in range(20): + + # Displacements in local coordinates + dx_tot = [[Xi, Yi, Zi]] + (L/19*i + member.deflection('dx', L/19*i, combo_name)*scale_factor)*cos_x + + # Magnified displacements in global coordinates + DX_plot = append(DX_plot, dx_tot, axis=0) + + # Sum the component displacements to obtain overall displacement + D_plot = DY_plot + DZ_plot + DX_plot + + # Generate vtk points + points = vtk.vtkPoints() + points.SetNumberOfPoints(len(D_plot)) + + for i in range(len(D_plot)): + points.SetPoint(i, D_plot[i, 0], D_plot[i, 1], D_plot[i, 2]) + + # Generate vtk lines + lines = vtk.vtkCellArray() + lines.InsertNextCell(len(D_plot)) + + for i in range(len(D_plot)): + lines.InsertCellPoint(i) + + # Create a polyline source from the defined points and lines + self.source = vtk.vtkPolyData() + self.source.SetPoints(points) + self.source.SetLines(lines) + +class VisDeformedSpring(): + + def __init__(self, spring, nodes, scale_factor, combo_name='Combo 1'): + + # Determine if this spring is active for each load combination + self.active = spring.active + + # Generate a line source for the spring + self.source = vtk.vtkLineSource() + + # Find the deformed position of the local i-node + # Step through each node + for node in nodes.values(): + + # Check to see if the current node is the i-node + if node.name == spring.i_node.name: + Xi = node.X + node.DX[combo_name]*scale_factor + Yi = node.Y + node.DY[combo_name]*scale_factor + Zi = node.Z + node.DZ[combo_name]*scale_factor + self.source.SetPoint1(Xi, Yi, Zi) + + # Check to see if the current node is the i-node + if node.name == spring.j_node.name: + Xj = node.X + node.DX[combo_name]*scale_factor + Yj = node.Y + node.DY[combo_name]*scale_factor + Zj = node.Z + node.DZ[combo_name]*scale_factor + self.source.SetPoint2(Xj, Yj, Zj) + + self.source.Update() + +class VisPtLoad(): + ''' + Creates a point load for the viewer + ''' + + def __init__(self, position, direction, length, label_text=None, annotation_size=5, color=None): + ''' + Constructor. + + Parameters + ---------- + position : tuple + A tuple of X, Y and Z coordinates for the point of the load arrow: (X, Y, Z). + direction : tuple + A tuple indicating the direction vector for the load arrow: (i, j, k). + length : number + The length of the load arrow. + tip_length : number + The height of the arrow head. + label_text : string + Text that will show up at the tail of the arrow. If set to 'None' no text will be displayed. + ''' + + # Create a unit vector in the direction of the 'direction' vector + unitVector = direction/norm(direction) + + # Create a 'vtkAppendPolyData' filter to append the tip and shaft together into a single dataset + self.polydata = vtk.vtkAppendPolyData() + + # Determine if the load is positive or negative + if length == 0: + sign = 1 + else: + sign = abs(length)/length + + # Generate the tip of the load arrow + tip_length = abs(length)/4 + radius = abs(length)/16 + tip = vtk.vtkConeSource() + tip.SetCenter(position[0] - tip_length*sign*0.5*unitVector[0], \ + position[1] - tip_length*sign*0.5*unitVector[1], \ + position[2] - tip_length*sign*0.5*unitVector[2]) + tip.SetDirection([direction[0]*sign, direction[1]*sign, direction[2]*sign]) + tip.SetHeight(tip_length) + tip.SetRadius(radius) + tip.Update() + + # Add the arrow tip to the append filter + self.polydata.AddInputData(tip.GetOutput()) + + # Create the shaft + shaft = vtk.vtkLineSource() + shaft.SetPoint1(position) + shaft.SetPoint2((position[0]-length*unitVector[0], position[1]-length*unitVector[1], position[2]-length*unitVector[2])) + shaft.Update() + + # Copy and append the shaft data to the append filter + self.polydata.AddInputData(shaft.GetOutput()) + self.polydata.Update() + + # Create a mapper and actor + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(self.polydata.GetOutputPort()) + self.actor = vtk.vtkActor() + if color is None: self.actor.GetProperty().SetColor(0, 255, 0) # Green + elif color == 'black': self.actor.GetProperty().SetColor(0, 0, 0) # Black + self.actor.SetMapper(mapper) + + # Create the label if needed + if label_text != None: + + # Create the label and set its text + self.label = vtk.vtkVectorText() + self.label.SetText(label_text) + + # Set up a mapper for the label + lblMapper = vtk.vtkPolyDataMapper() + lblMapper.SetInputConnection(self.label.GetOutputPort()) + + # Set up an actor for the label + self.lblActor = vtk.vtkFollower() + self.lblActor.SetMapper(lblMapper) + self.lblActor.SetScale(annotation_size, annotation_size, annotation_size) + self.lblActor.SetPosition(position[0] - (length - 0.6*annotation_size)*unitVector[0], \ + position[1] - (length - 0.6*annotation_size)*unitVector[1], \ + position[2] - (length - 0.6*annotation_size)*unitVector[2]) + if color is None: self.lblActor.GetProperty().SetColor(0, 255, 0) # Green + elif color == 'black': self.lblActor.GetProperty().SetColor(0, 0, 0) # Black + +class VisDistLoad(): + ''' + Creates a distributed load for the viewer + ''' + + def __init__(self, position1, position2, direction, length1, length2, label_text1, label_text2, annotation_size=5, color=None): + ''' + Constructor. + ''' + + # Calculate the length of the distributed load + loadLength = ((position2[0]-position1[0])**2 + (position2[1]-position1[1])**2 + (position2[2]-position1[2])**2)**0.5 + + # Find the direction cosines for the line the load acts on + lineDirCos = [(position2[0]-position1[0])/loadLength, (position2[1]-position1[1])/loadLength, (position2[2]-position1[2])/loadLength] + + # Find the direction cosines for the direction the load acts in + dirDirCos = direction/norm(direction) + + # Create point loads at intervals roughly equal to 75% of the load's largest length (magnitude) + # Add text labels to the first and last load arrow + if loadLength > 0: + num_steps = int(round(0.75*loadLength/max(abs(length1), abs(length2)), 0)) + else: + num_steps = 0 + + num_steps = max(num_steps, 1) + step = loadLength/num_steps + ptLoads = [] + + for i in range(num_steps + 1): + + # Calculate the position (X, Y, Z) of this load arrow's point + position = (position1[0] + i*step*lineDirCos[0], position1[1] + i*step*lineDirCos[1], position1[2] + i*step*lineDirCos[2]) + + # Determine the length of this load arrow + length = length1 + (length2 - length1)/loadLength*i*step + + # Determine the label's text + if i == 0: + label_text = label_text1 + elif i == num_steps: + label_text = label_text2 + + # Create the load arrow + ptLoads.append(VisPtLoad(position, direction, length, label_text, annotation_size=annotation_size)) + + # Draw a line between the first and last load arrow's tails + tail_line = vtk.vtkLineSource() + tail_line.SetPoint1((position1[0] - length1*dirDirCos[0], position1[1] - length1*dirDirCos[1], position1[2] - length1*dirDirCos[2])) + tail_line.SetPoint2((position2[0] - length2*dirDirCos[0], position2[1] - length2*dirDirCos[1], position2[2] - length2*dirDirCos[2])) + + # Combine all the geometry into one 'vtkPolyData' object + self.polydata = vtk.vtkAppendPolyData() + for arrow in ptLoads: + arrow.polydata.Update() + self.polydata.AddInputData(arrow.polydata.GetOutput()) + + tail_line.Update() + self.polydata.AddInputData(tail_line.GetOutput()) + self.polydata.Update() + + # Create a mapper and actor for the geometry + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(self.polydata.GetOutputPort()) + self.actor = vtk.vtkActor() + if color is None: self.actor.GetProperty().SetColor(0, 255, 0) # Green + elif color == 'black': self.actor.GetProperty().SetColor(0, 0, 0) # Black + self.actor.SetMapper(mapper) + + # Get the actors for the labels + self.lblActors = [ptLoads[0].lblActor, ptLoads[len(ptLoads) - 1].lblActor] + +class VisMoment(): + ''' + Creates a concentrated moment for the viewer + ''' + + def __init__(self, center, direction, radius, label_text=None, annotation_size=5, color=None): + ''' + Constructor. + + Parameters + ---------- + center : tuple + A tuple of X, Y and Z coordinates for center of the moment: (X, Y, Z). + direction : tuple + A tuple indicating the direction vector for the moment: (i, j, k). + radius : number + The radius of the moment. + tip_length : number + The height of the arrow head. + label_text : string + Text that will show up at the tail of the moment. If set to 'None' no text will be displayed. + ''' + + # Create an append filter to store load polydata in + self.polydata = vtk.vtkAppendPolyData() + + # Find a vector perpendicular to the directional unit vector + v1 = direction/norm(direction) # v1 = The directional unit vector for the moment + v2 = _PerpVector(v1) # v2 = A unit vector perpendicular to v1 + v3 = cross(v1, v2) + v3 = v3/norm(v3) # v3 = A unit vector perpendicular to v1 and v2 + + # Generate an arc for the moment + Xc, Yc, Zc = center + arc = vtk.vtkArcSource() + arc.SetCenter(Xc, Yc, Zc) + arc.SetPoint1(Xc + v2[0]*radius, Yc + v2[1]*radius, Zc + v2[2]*radius) + arc.SetPoint2(Xc + v3[0]*radius, Yc + v3[1]*radius, Zc + v3[2]*radius) + arc.SetNegative(True) + arc.SetResolution(20) + arc.Update() + self.polydata.AddInputData(arc.GetOutput()) + + # Generate the arrow tip at the end of the arc + tip_length = radius/2 + cone_radius = radius/8 + tip = vtk.vtkConeSource() + tip.SetCenter(arc.GetPoint1()[0], arc.GetPoint1()[1], arc.GetPoint1()[2]) + tip.SetDirection(cross(v1, v2)) + tip.SetHeight(tip_length) + tip.SetRadius(cone_radius) + tip.Update() + self.polydata.AddInputData(tip.GetOutput()) + + # Update the polydata one last time now that we're done appending items to it + self.polydata.Update() + + # Create the text label + label = vtk.vtkVectorText() + label.SetText(label_text) + lblMapper = vtk.vtkPolyDataMapper() + lblMapper.SetInputConnection(label.GetOutputPort()) + self.lblActor = vtk.vtkFollower() + self.lblActor.SetMapper(lblMapper) + self.lblActor.SetScale(annotation_size, annotation_size, annotation_size) + self.lblActor.SetPosition(Xc + v3[0]*(radius + 0.25*annotation_size), \ + Yc + v3[1]*(radius + 0.25*annotation_size), \ + Zc + v3[2]*(radius + 0.25*annotation_size)) + if color is None: self.lblActor.GetProperty().SetColor(0, 255, 0) # Green + elif color == 'black': self.lblActor.GetProperty().SetColor(0, 0, 0) # Black + +class VisAreaLoad(): + ''' + Creates an area load for the viewer + ''' + + def __init__(self, position0, position1, position2, position3, direction, length, label_text, annotation_size=5, theme='default'): + ''' + Constructor + ''' + + # Create a point load for each corner of the area load + ptLoads = [] + ptLoads.append(VisPtLoad(position0, direction, length, label_text, annotation_size=annotation_size)) + ptLoads.append(VisPtLoad(position1, direction, length, label_text, annotation_size=annotation_size)) + ptLoads.append(VisPtLoad(position2, direction, length, label_text, annotation_size=annotation_size)) + ptLoads.append(VisPtLoad(position3, direction, length, label_text, annotation_size=annotation_size)) + + # Find the direction cosines for the direction the load acts in + dirDirCos = direction/norm(direction) + + # Find the positions of the tails of all the arrows at the corners of the area load. This is + # where we will place the polygon. + self.p0 = position0 - dirDirCos*length + self.p1 = position1 - dirDirCos*length + self.p2 = position2 - dirDirCos*length + self.p3 = position3 - dirDirCos*length + + # Combine all geometry into one 'vtkPolyData' object + self.polydata = vtk.vtkAppendPolyData() + for arrow in ptLoads: + self.polydata.AddInputData(arrow.polydata.GetOutput()) + self.polydata.Update() + + # Add a label + self.label_actor = ptLoads[0].lblActor + + # Add color to the area load label + if theme == 'print': + self.label_actor.GetProperty().SetColor(255/255, 0/255, 0/255) # red + elif theme == 'default': + self.label_actor.GetProperty().SetColor(0/255, 255/255, 0/255) # green + +def _PerpVector(v): + ''' + Returns a unit vector perpendicular to v=[i, j, k] + ''' + + i = v[0] + j = v[1] + k = v[2] + + # Find a vector in a direction perpendicular to + if i == 0: + i2 = 1 + j2 = 0 + k2 = 0 + elif j == 0: + i2 = 0 + j2 = 1 + k2 = 0 + elif k == 0: + i2 = 0 + j2 = 0 + k2 = 1 + else: + i2 = 1 + j2 = 1 + k2 = -(i*i2+j*j2)/k + + # Return the unit vector + return [i2, j2, k2]/norm([i2, j2, k2]) + +def _PrepContour(model, stress_type='Mx', combo_name='Combo 1'): + + if stress_type != None: + + # Erase any previous contours + for node in model.nodes.values(): + node.contour = [] + + # Step through each element in the model + for element in list(model.quads.values()) + list(model.plates.values()): + + # Rectangular elements and quadrilateral elements have different local coordinate systems. + # Rectangles are based on a traditional (x, y) system, while quadrilaterals are based on a + # 'natural' (r, s) coordinate system. To reduce duplication of code for both these elements + # we'll define the edges of the plate here for either element using the (r, s) terminology. + if element.type == 'Rect': + r_left = 0 + r_right = element.width() + s_bot = 0 + s_top = element.height() + else: + r_left = -1 + r_right = 1 + s_bot = -1 + s_top = 1 + + # Determine which stress result has been requested by the user + if stress_type == 'dz': + # Internally Pynite defines the nodes for a rectangular element in the order (i, j, m, n), + # while it defines the nodes for a quadrilateral element in the order (m, n, i, j) + if element.type == 'Rect': + i, j, m, n = element.d(combo_name)[[2, 8, 14, 20], :] + else: + i, j, m, n = element.d(combo_name)[[14, 20, 2, 8], :] + element.i_node.contour.append(i) + element.j_node.contour.append(j) + element.m_node.contour.append(m) + element.n_node.contour.append(n) + elif stress_type == 'Mx': + element.i_node.contour.append(element.moment(r_left, s_bot, combo_name)[0]) + element.j_node.contour.append(element.moment(r_right, s_bot, combo_name)[0]) + element.m_node.contour.append(element.moment(r_right, s_top, combo_name)[0]) + element.n_node.contour.append(element.moment(r_left, s_top, combo_name)[0]) + elif stress_type == 'My': + element.i_node.contour.append(element.moment(r_left, s_bot, combo_name)[1]) + element.j_node.contour.append(element.moment(r_right, s_bot, combo_name)[1]) + element.m_node.contour.append(element.moment(r_right, s_top, combo_name)[1]) + element.n_node.contour.append(element.moment(r_left, s_top, combo_name)[1]) + elif stress_type == 'Mxy': + element.i_node.contour.append(element.moment(r_left, s_bot, combo_name)[2]) + element.j_node.contour.append(element.moment(r_right, s_bot, combo_name)[2]) + element.m_node.contour.append(element.moment(r_right, s_top, combo_name)[2]) + element.n_node.contour.append(element.moment(r_left, s_top, combo_name)[2]) + elif stress_type == 'Qx': + element.i_node.contour.append(element.shear(r_left, s_bot, combo_name)[0]) + element.j_node.contour.append(element.shear(r_right, s_bot, combo_name)[0]) + element.m_node.contour.append(element.shear(r_right, s_top, combo_name)[0]) + element.n_node.contour.append(element.shear(r_left, s_top, combo_name)[0]) + elif stress_type == 'Qy': + element.i_node.contour.append(element.shear(r_left, s_bot, combo_name)[1]) + element.j_node.contour.append(element.shear(r_right, s_bot, combo_name)[1]) + element.m_node.contour.append(element.shear(r_right, s_top, combo_name)[1]) + element.n_node.contour.append(element.shear(r_left, s_top, combo_name)[1]) + elif stress_type == 'Sx': + element.i_node.contour.append(element.membrane(r_left, s_bot, combo_name)[0]) + element.j_node.contour.append(element.membrane(r_right, s_bot, combo_name)[0]) + element.m_node.contour.append(element.membrane(r_right, s_top, combo_name)[0]) + element.n_node.contour.append(element.membrane(r_left, s_top, combo_name)[0]) + elif stress_type == 'Sy': + element.i_node.contour.append(element.membrane(r_left, s_bot, combo_name)[1]) + element.j_node.contour.append(element.membrane(r_right, s_bot, combo_name)[1]) + element.m_node.contour.append(element.membrane(r_right, s_top, combo_name)[1]) + element.n_node.contour.append(element.membrane(r_left, s_top, combo_name)[1]) + elif stress_type == 'Txy': + element.i_node.contour.append(element.membrane(r_left, s_bot, combo_name)[2]) + element.j_node.contour.append(element.membrane(r_right, s_bot, combo_name)[2]) + element.m_node.contour.append(element.membrane(r_right, s_top, combo_name)[2]) + element.n_node.contour.append(element.membrane(r_left, s_top, combo_name)[2]) + + # Average the values at each node to obtain a smoothed contour + for node in model.nodes.values(): + # Prevent divide by zero errors for nodes with no contour values + if node.contour != []: + node.contour = sum(node.contour)/len(node.contour) + +def _DeformedShape(model, vtk_renderer, scale_factor, annotation_size, combo_name, render_nodes=True, theme='default'): + ''' + Renders the deformed shape of a model. + + Parameters + ---------- + model : FEModel3D + Finite element model to be rendered. + renderer : vtk.vtkRenderer + The VTK renderer object that will render the model. + scale_factor : number + The scale factor to apply to the model deformations. + annotation_size : number + Controls the height of text displayed with the model. The units used for `annotation_size` are + the same as those used for lengths in the model. Sizes of other objects (such as nodes) are + related to this value. + combo_name : string + The load case used for rendering the deflected shape. + + Returns + ------- + None. + ''' + + # Create an append filter to add all the shape polydata to + append_filter = vtk.vtkAppendPolyData() + + # Check if nodes are to be rendered + if render_nodes == True: + + # Add the deformed nodes to the append filter + for node in model.nodes.values(): + + vis_node = VisDeformedNode(node, scale_factor, annotation_size, combo_name) + append_filter.AddInputData(vis_node.source.GetOutput()) + + # Add the springs to the append filter + for spring in model.springs.values(): + + # Only add the spring if it is active for the given load combination + if spring.active[combo_name] == True: + + vis_spring = VisDeformedSpring(spring, model.nodes, scale_factor, combo_name) + append_filter.AddInputData(vis_spring.source.GetOutput()) + + # Add the members to the append filter + for member in model.members.values(): + + # Only add the member if it is active for the given load combination. + if member.active[combo_name] == True: + + vis_member = VisDeformedMember(member, model.nodes, scale_factor, combo_name) + append_filter.AddInputData(vis_member.source) + + # Create a mapper and actor for the append filter + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(append_filter.GetOutputPort()) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + + # Adjust the color + if theme == 'default': + actor.GetProperty().SetColor(255/255, 255/255, 0/255) # Yellow + elif theme == 'print': + actor.GetProperty().SetColor(26/255, 26/255, 26/255) # Dark Grey + + # Add the actor to the renderer + vtk_renderer.AddActor(actor) + +def _RenderLoads(model, renderer, annotation_size, combo_name, case, theme='default'): + + # Create an append filter to store all the polydata in. This will allow us to use fewer actors to + # display all the loads, which will greatly improve rendering speed as the user interacts. VTK + # becomes very slow when a large number of actors are used. + polydata = vtk.vtkAppendPolyData() + + # Polygons are treated as cells in VTK. Create a cell array to store all the area load polygons + # in. We'll also create a list of points to store the polygon points in. The polydata for these + # polygons will be stored separately from the other load data. + polygons = vtk.vtkCellArray() + polygon_points = vtk.vtkPoints() + polygon_polydata = vtk.vtkPolyData() + + # Get the maximum load magnitudes that will be used to normalize the display scale + max_pt_load, max_moment, max_dist_load, max_area_load = _MaxLoads(model, combo_name, case) + + # Display the requested load combination, or 'Combo 1' if no load combo or case has been + # specified + if case == None: + # Store model.load_combos[combo].factors under a simpler name for use below + load_factors = model.load_combos[combo_name].factors + else: + # Set up a load combination dictionary that represents the load case + load_factors = {case: 1} + + # Step through each node + for node in model.nodes.values(): + + # Step through and display each nodal load + for load in node.NodeLoads: + + # Determine if this load is part of the requested LoadCombo or case + if load[2] in load_factors: + + # Calculate the factored value for this load and it's sign (positive or negative) + load_value = load[1]*load_factors[load[2]] + if load_value != 0: + sign = load_value/abs(load_value) + else: + sign = 1 + + # Display the load + if load[0] == 'FX': + ptLoad = VisPtLoad((node.X - 0.6*annotation_size*sign, node.Y, node.Z), [1, 0, 0], load_value/max_pt_load*5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'FY': + ptLoad = VisPtLoad((node.X, node.Y - 0.6*annotation_size*sign, node.Z), [0, 1, 0], load_value/max_pt_load*5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'FZ': + ptLoad = VisPtLoad((node.X, node.Y, node.Z - 0.6*annotation_size*sign), [0, 0, 1], load_value/max_pt_load*5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'MX': + ptLoad = VisMoment((node.X, node.Y, node.Z), (1*sign, 0, 0), abs(load_value)/max_moment*2.5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'MY': + ptLoad = VisMoment((node.X, node.Y, node.Z), (0, 1*sign, 0), abs(load_value)/max_moment*2.5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'MZ': + ptLoad = VisMoment((node.X, node.Y, node.Z), (0, 0, 1*sign), abs(load_value)/max_moment*2.5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + + polydata.AddInputData(ptLoad.polydata.GetOutput()) + renderer.AddActor(ptLoad.lblActor) + ptLoad.lblActor.SetCamera(renderer.GetActiveCamera()) + + # Step through each member + for member in model.members.values(): + + # Get the direction cosines for the member's local axes + dir_cos = member.T()[0:3, 0:3] + + # Get the starting point for the member + x_start, y_start, z_start = member.i_node.X, member.i_node.Y, member.i_node.Z + + # Step through each member point load + for load in member.PtLoads: + + # Determine if this load is part of the requested load combination + if load[3] in load_factors: + + # Calculate the factored value for this load and it's sign (positive or negative) + load_value = load[1]*load_factors[load[3]] + sign = load_value/abs(load_value) + + # Calculate the load's location in 3D space + x = load[2] + position = [x_start + dir_cos[0, 0]*x, y_start + dir_cos[0, 1]*x, z_start + dir_cos[0, 2]*x] + + # Display the load + if load[0] == 'Fx': + ptLoad = VisPtLoad(position, dir_cos[0, :], load_value/max_pt_load*5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'Fy': + ptLoad = VisPtLoad(position, dir_cos[1, :], load_value/max_pt_load*5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'Fz': + ptLoad = VisPtLoad(position, dir_cos[2, :], load_value/max_pt_load*5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'Mx': + ptLoad = VisMoment(position, dir_cos[0, :]*sign, abs(load_value)/max_moment*2.5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'My': + ptLoad = VisMoment(position, dir_cos[1, :]*sign, abs(load_value)/max_moment*2.5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'Mz': + ptLoad = VisMoment(position, dir_cos[2, :]*sign, abs(load_value)/max_moment*2.5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'FX': + ptLoad = VisPtLoad(position, [1, 0, 0], load_value/max_pt_load*5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'FY': + ptLoad = VisPtLoad(position, [0, 1, 0], load_value/max_pt_load*5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'FZ': + ptLoad = VisPtLoad(position, [0, 0, 1], load_value/max_pt_load*5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'MX': + ptLoad = VisMoment(position, [1*sign, 0, 0], abs(load_value)/max_moment*2.5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'MY': + ptLoad = VisMoment(position, [0, 1*sign, 0], abs(load_value)/max_moment*2.5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + elif load[0] == 'MZ': + ptLoad = VisMoment(position, [0, 0, 1*sign], abs(load_value)/max_moment*2.5*annotation_size, '{:.3g}'.format(load_value), annotation_size) + + polydata.AddInputData(ptLoad.polydata.GetOutput()) + renderer.AddActor(ptLoad.lblActor) + ptLoad.lblActor.SetCamera(renderer.GetActiveCamera()) + + # Step through each member distributed load + for load in member.DistLoads: + + # Determine if this load is part of the requested load combination + if load[5] in load_factors: + + # Calculate the factored value for this load and it's sign (positive or negative) + w1 = load[1]*load_factors[load[5]] + w2 = load[2]*load_factors[load[5]] + + # Calculate the loads location in 3D space + x1 = load[3] + x2 = load[4] + position1 = [x_start + dir_cos[0, 0]*x1, y_start + dir_cos[0, 1]*x1, z_start + dir_cos[0, 2]*x1] + position2 = [x_start + dir_cos[0, 0]*x2, y_start + dir_cos[0, 1]*x2, z_start + dir_cos[0, 2]*x2] + + # Display the load + if load[0] == 'Fx': + distLoad = VisDistLoad(position1, position2, dir_cos[0, :], w1/max_dist_load*5*annotation_size, w2/max_dist_load*5*annotation_size, '{:.3g}'.format(w1), '{:.3g}'.format(w2), annotation_size) + elif load[0] == 'Fy': + distLoad = VisDistLoad(position1, position2, dir_cos[1, :], w1/max_dist_load*5*annotation_size, w2/max_dist_load*5*annotation_size, '{:.3g}'.format(w1), '{:.3g}'.format(w2), annotation_size) + elif load[0] == 'Fz': + distLoad = VisDistLoad(position1, position2, dir_cos[2, :], w1/max_dist_load*5*annotation_size, w2/max_dist_load*5*annotation_size, '{:.3g}'.format(w1), '{:.3g}'.format(w2), annotation_size) + elif load[0] == 'FX': + distLoad = VisDistLoad(position1, position2, [1, 0, 0], w1/max_dist_load*5*annotation_size, w2/max_dist_load*5*annotation_size, '{:.3g}'.format(w1), '{:.3g}'.format(w2), annotation_size) + elif load[0] == 'FY': + distLoad = VisDistLoad(position1, position2, [0, 1, 0], w1/max_dist_load*5*annotation_size, w2/max_dist_load*5*annotation_size, '{:.3g}'.format(w1), '{:.3g}'.format(w2), annotation_size) + elif load[0] == 'FZ': + distLoad = VisDistLoad(position1, position2, [0, 0, 1], w1/max_dist_load*5*annotation_size, w2/max_dist_load*5*annotation_size, '{:.3g}'.format(w1), '{:.3g}'.format(w2), annotation_size) + + polydata.AddInputData(distLoad.polydata.GetOutput()) + renderer.AddActor(distLoad.lblActors[0]) + renderer.AddActor(distLoad.lblActors[1]) + distLoad.lblActors[0].SetCamera(renderer.GetActiveCamera()) + distLoad.lblActors[1].SetCamera(renderer.GetActiveCamera()) + + # Step through each plate + i = 0 + for plate in list(model.plates.values()) + list(model.quads.values()): + + # Get the direction cosines for the plate's local z-axis + dir_cos = plate.T()[0:3, 0:3] + dir_cos = dir_cos[2] + + # Step through each plate load + for load in plate.pressures: + + # Determine if this load is part of the requested load combination + if load[1] in load_factors: + + # Calculate the factored value for this load + load_value = load[0]*load_factors[load[1]] + + # Find the sign for this load. Intercept any divide by zero errors + if load[0] == 0: + sign = 1 + else: + sign = abs(load[0])/load[0] + + # Find the position of the load's 4 corners + position0 = [plate.i_node.X, plate.i_node.Y, plate.i_node.Z] + position1 = [plate.j_node.X, plate.j_node.Y, plate.j_node.Z] + position2 = [plate.m_node.X, plate.m_node.Y, plate.m_node.Z] + position3 = [plate.n_node.X, plate.n_node.Y, plate.n_node.Z] + + # Create an area load and get its data + area_load = VisAreaLoad(position0, position1, position2, position3, dir_cos*sign, abs(load_value)/max_area_load*5*annotation_size, '{:.3g}'.format(load_value), annotation_size, theme) + + # Add the area load's arrows to the overall load polydata + polydata.AddInputData(area_load.polydata.GetOutput()) + + # Add the 4 points at the corners of this area load to the list of points + polygon_points.InsertNextPoint(area_load.p0[0], area_load.p0[1], area_load.p0[2]) + polygon_points.InsertNextPoint(area_load.p1[0], area_load.p1[1], area_load.p1[2]) + polygon_points.InsertNextPoint(area_load.p2[0], area_load.p2[1], area_load.p2[2]) + polygon_points.InsertNextPoint(area_load.p3[0], area_load.p3[1], area_load.p3[2]) + + # Create a polygon based on the four points we just defined. + # The 1st number in `SetId()` is the local point id + # The 2nd number in `SetId()` is the global point id + polygon = vtk.vtkPolygon() + polygon.GetPointIds().SetNumberOfIds(4) + polygon.GetPointIds().SetId(0, i*4) + polygon.GetPointIds().SetId(1, i*4 + 1) + polygon.GetPointIds().SetId(2, i*4 + 2) + polygon.GetPointIds().SetId(3, i*4 + 3) + + # Add the polygon to the list of polygons + polygons.InsertNextCell(polygon) + + # Add the load label + renderer.AddActor(area_load.label_actor) + + # Set the text to follow the camera as the user interacts + area_load.label_actor.SetCamera(renderer.GetActiveCamera()) + + # `i` keeps track of the next polygon's ID. We've just added a polygon, so `i` needs to + # go up 1. + i += 1 + + # Create polygon polydata from all the points and polygons we just defined + polygon_polydata.SetPoints(polygon_points) + polygon_polydata.SetPolys(polygons) + + # Set up an actor and mapper for the loads + load_mapper = vtk.vtkPolyDataMapper() + load_mapper.SetInputConnection(polydata.GetOutputPort()) + load_actor = vtk.vtkActor() + load_actor.SetMapper(load_mapper) + + # Colorize the loads + if theme == 'default': + load_actor.GetProperty().SetColor(0/255, 255/255, 0/255) # Green + elif theme == 'print': + load_actor.GetProperty().SetColor(255/255, 0/255, 0/255) # Red + + # Add the load actor to the renderer + renderer.AddActor(load_actor) + + # Set up an actor and a mapper for the area load polygons + polygon_mapper = vtk.vtkPolyDataMapper() + polygon_mapper.SetInputData(polygon_polydata) + polygon_actor = vtk.vtkActor() + + # polygon_actor.GetProperty().SetOpacity(0.5) # 50% opacity + polygon_actor.SetMapper(polygon_mapper) + renderer.AddActor(polygon_actor) + + # Set the color of the area load polygons + if theme == 'default': + polygon_actor.GetProperty().SetColor(0/255, 255/255, 0/255) # Green + elif theme == 'print': + polygon_actor.GetProperty().SetColor(255/255, 0/255, 0/255) # Red + +def _RenderContours(model, renderer, deformed_shape, deformed_scale, color_map, scalar_bar, scalar_bar_text_size, combo_name, theme='default'): + + # Create a new `vtkCellArray` object to store the elements + plates = vtk.vtkCellArray() + + # Create a `vtkPoints` object to store the coordinates of the corners of the elements + plate_points = vtk.vtkPoints() + + # Create 2 lists to store plate result + # `results` will store the results in a Python iterable list + # `plate_results` will store the results in a `vtkDoubleArray` for VTK + results = [] + plate_results = vtk.vtkDoubleArray() + plate_results.SetNumberOfComponents(1) + + # Each element will be assigned a unique element number `i` beginning at 0 + i = 0 + + # Calculate the smoothed contour results at each node + _PrepContour(model, color_map, combo_name) + + # Add each plate and quad in the model to the cell array we just created + for item in list(model.plates.values()) + list(model.quads.values()): + + # Create a point for each corner (must be in counter clockwise order) + if deformed_shape == True: + p0 = [item.i_node.X + item.i_node.DX[combo_name]*deformed_scale, + item.i_node.Y + item.i_node.DY[combo_name]*deformed_scale, + item.i_node.Z + item.i_node.DZ[combo_name]*deformed_scale] + p1 = [item.j_node.X + item.j_node.DX[combo_name]*deformed_scale, + item.j_node.Y + item.j_node.DY[combo_name]*deformed_scale, + item.j_node.Z + item.j_node.DZ[combo_name]*deformed_scale] + p2 = [item.m_node.X + item.m_node.DX[combo_name]*deformed_scale, + item.m_node.Y + item.m_node.DY[combo_name]*deformed_scale, + item.m_node.Z + item.m_node.DZ[combo_name]*deformed_scale] + p3 = [item.n_node.X + item.n_node.DX[combo_name]*deformed_scale, + item.n_node.Y + item.n_node.DY[combo_name]*deformed_scale, + item.n_node.Z + item.n_node.DZ[combo_name]*deformed_scale] + else: + p0 = [item.i_node.X, item.i_node.Y, item.i_node.Z] + p1 = [item.j_node.X, item.j_node.Y, item.j_node.Z] + p2 = [item.m_node.X, item.m_node.Y, item.m_node.Z] + p3 = [item.n_node.X, item.n_node.Y, item.n_node.Z] + + # Add the points to the `vtkPoints` object we created earlier + plate_points.InsertNextPoint(p0) + plate_points.InsertNextPoint(p1) + plate_points.InsertNextPoint(p2) + plate_points.InsertNextPoint(p3) + + # Create a `vtkQuad` based on the four points we just defined + # The 1st number in `SetId()` is the local point id + # The 2nd number in `SetId()` is the global point id + quad = vtk.vtkQuad() + quad.GetPointIds().SetId(0, i*4) + quad.GetPointIds().SetId(1, i*4 + 1) + quad.GetPointIds().SetId(2, i*4 + 2) + quad.GetPointIds().SetId(3, i*4 + 3) + + # Get the contour value for each node + r0 = item.i_node.contour + r1 = item.j_node.contour + r2 = item.m_node.contour + r3 = item.n_node.contour + + if color_map != None: + + # Save the results to the Python list of results we created earlier + results.append(r0) + results.append(r1) + results.append(r2) + results.append(r3) + + # Save the results to the `vtkDoubleArray` list of results for VTK + plate_results.InsertNextTuple([r0]) + plate_results.InsertNextTuple([r1]) + plate_results.InsertNextTuple([r2]) + plate_results.InsertNextTuple([r3]) + + # Insert the quad into the cell array + plates.InsertNextCell(quad) + + # Increment `i` for the next plate + i += 1 + + # Create a `vtkPolyData` object to store plate data in + plate_polydata = vtk.vtkPolyData() + + # Add the points and plates to the dataset + plate_polydata.SetPoints(plate_points) + plate_polydata.SetPolys(plates) + + # Setup actor and mapper for the plates + plate_mapper = vtk.vtkPolyDataMapper() + plate_mapper.SetInputData(plate_polydata) + plate_actor = vtk.vtkActor() + plate_actor.SetMapper(plate_mapper) + + # Map the results to the plates + if color_map != None: + + plate_polydata.GetPointData().SetScalars(plate_results) + + # Create a `vtkLookupTable` for the colors used to map results + lut = vtk.vtkLookupTable() + lut.SetTableRange(min(results), max(results)) + lut.SetNumberOfColors(256) + # The commented code below can be uncommented and modified to change the color scheme + # ctf = vtk.vtkColorTransferFunction() + # ctf.SetColorSpaceToDiverging() + # ctf.AddRGBPoint(min(results), 255, 0, 255) # Purple + # ctf.AddRGBPoint(max(results), 255, 0, 0) # Red + # for i in range(256): + # rgb = list(ctf.GetColor(float(i)/256)) + # rgb.append(1.0) + # lut.SetTableValue(i, *rgb) + plate_mapper.SetLookupTable(lut) + plate_mapper.SetUseLookupTableScalarRange(True) + plate_mapper.SetScalarModeToUsePointData() + lut.Build() + + # Add the scalar bar for the contours. + if scalar_bar: + + if Renderer.scalar == None: + Renderer.scalar = vtk.vtkScalarBarActor() + + scalar = Renderer.scalar + + # This next group of lines controls the font on the scalar bar + scalar.SetUnconstrainedFontSize(1) + scalar_text = vtk.vtkTextProperty() + scalar_text.SetFontSize(max(int(scalar_bar_text_size), 1)) + scalar_text.SetBold(1) + + # The `vtkTextProperty` object is white by default + if theme == 'print': + scalar_text.SetColor(255/255, 255/255, 255/255) # Black + + scalar.SetLabelTextProperty(scalar_text) + + scalar.SetMaximumWidthInPixels(100) + + scalar.SetTextPositionToPrecedeScalarBar() + + scalar.SetLookupTable(lut) + + renderer.AddActor(scalar) + + # Add the actor for the plates + renderer.AddActor(plate_actor) + +def _MaxLoads(model, combo_name=None, case=None): + + max_pt_load = 0 + max_moment = 0 + max_dist_load = 0 + max_area_load = 0 + + # Find the requested load combination or load case + if case == None: + + # Step through each node + for node in model.nodes.values(): + + # Step through each nodal load to find the largest one + for load in node.NodeLoads: + + # Find the largest loads in the load combination + if load[2] in model.load_combos[combo_name].factors: + if load[0] == 'FX' or load[0] == 'FY' or load[0] == 'FZ': + if abs(load[1]*model.load_combos[combo_name].factors[load[2]]) > max_pt_load: + max_pt_load = abs(load[1]*model.load_combos[combo_name].factors[load[2]]) + else: + if abs(load[1]*model.load_combos[combo_name].factors[load[2]]) > max_moment: + max_moment = abs(load[1]*model.load_combos[combo_name].factors[load[2]]) + + # Step through each member + for member in model.members.values(): + + # Step through each member point load + for load in member.PtLoads: + + # Find and store the largest point load and moment in the load combination + if load[3] in model.load_combos[combo_name].factors: + + if (load[0] == 'Fx' or load[0] == 'Fy' or load[0] == 'Fz' + or load[0] == 'FX' or load[0] == 'FY' or load[0] == 'FZ'): + if abs(load[1]*model.load_combos[combo_name].factors[load[3]]) > max_pt_load: + max_pt_load = abs(load[1]*model.load_combos[combo_name].factors[load[3]]) + else: + if abs(load[1]*model.load_combos[combo_name].factors[load[3]]) > max_moment: + max_moment = abs(load[1]*model.load_combos[combo_name].factors[load[3]]) + + # Step through each member distributed load + for load in member.DistLoads: + + #Find and store the largest distributed load in the load combination + if load[5] in model.load_combos[combo_name].factors: + + if abs(load[1]*model.load_combos[combo_name].factors[load[5]]) > max_dist_load: + max_dist_load = abs(load[1]*model.load_combos[combo_name].factors[load[5]]) + if abs(load[2]*model.load_combos[combo_name].factors[load[5]]) > max_dist_load: + max_dist_load = abs(load[2]*model.load_combos[combo_name].factors[load[5]]) + + # Step through each plate + for plate in model.plates.values(): + + # Step through each plate load + for load in plate.pressures: + + if load[1] in model.load_combos[combo_name].factors: + if abs(load[0]*model.load_combos[combo_name].factors[load[1]]) > max_area_load: + max_area_load = abs(load[0]*model.load_combos[combo_name].factors[load[1]]) + + # Step through each quad + for quad in model.quads.values(): + + # Step through each plate load + for load in quad.pressures: + + # Check to see if the load case is in the requested load combination + if load[1] in model.load_combos[combo_name].factors: + if abs(load[0]*model.load_combos[combo_name].factors[load[1]]) > max_area_load: + max_area_load = abs(load[0]*model.load_combos[combo_name].factors[load[1]]) + + # Behavior if case has been specified + else: + + # Step through each node + for node in model.nodes.values(): + + # Step through each nodal load to find the largest one + for load in node.NodeLoads: + + # Find the largest loads in the load case + if load[2] == case: + if load[0] == 'FX' or load[0] == 'FY' or load[0] == 'FZ': + if abs(load[1]) > max_pt_load: + max_pt_load = abs(load[1]) + else: + if abs(load[1]) > max_moment: + max_moment = abs(load[1]) + + # Step through each member + for member in model.members.values(): + + # Step through each member point load + for load in member.PtLoads: + + # Find and store the largest point load and moment in the load case + if load[3] == case: + + if (load[0] == 'Fx' or load[0] == 'Fy' or load[0] == 'Fz' + or load[0] == 'FX' or load[0] == 'FY' or load[0] == 'FZ'): + if abs(load[1]) > max_pt_load: + max_pt_load = abs(load[1]) + else: + if abs(load[1]) > max_moment: + max_moment = abs(load[1]) + + # Step through each member distributed load + for load in member.DistLoads: + + # Find and store the largest distributed load in the load case + if load[5] == case: + + if abs(load[1]) > max_dist_load: + max_dist_load = abs(load[1]) + if abs(load[2]) > max_dist_load: + max_dist_load = abs(load[2]) + + # Step through each plate + for plate in model.plates.values(): + + # Step through each plate load + for load in plate.pressures: + + if load[1] == case: + + if abs(load[0]) > max_area_load: + max_area_load = abs(load[0]) + + # Step through each quad + for quad in model.quads.values(): + + # Step through each plate load + for load in quad.pressures: + + if load[1] == case: + + if abs(load[0]) > max_area_load: + max_area_load = abs(load[0]) + + # Return the maximum loads in the load combination or load case + return max_pt_load, max_moment, max_dist_load, max_area_load diff --git a/Old Pynite Folder/__init__.py b/Old Pynite Folder/__init__.py new file mode 100644 index 00000000..0ae234e8 --- /dev/null +++ b/Old Pynite Folder/__init__.py @@ -0,0 +1,2 @@ +# Select libraries that will be imported into Pynite for the user +from Pynite.FEModel3D import FEModel3D \ No newline at end of file