├── README.md ├── Set Shell Offsets.py ├── ExportForceReactions.py ├── Add_Deformation_Stress_and_ReactionProbes.py ├── ExportForceReactions-alternative_method.py ├── HighStressRegions.py └── bolt_macro.py /README.md: -------------------------------------------------------------------------------- 1 | # ANSYS-Scripts 2 | This is a collection of scripts showing how to use ANSYS Mechanical Python interface for automation of GUI interaction and underlying data. 3 | 4 | ExportForceReactions: Export force probe values as they are shown in the UI to a csv 5 | 6 | ExportForceReactions-alternative_method: Export reaction forces for fixed and displacement supports for all time steps and analyses in a project by interacting with the underlying results file 7 | 8 | HighStressRegions: Create named selections of element groups where the elements are all over a given limit 9 | 10 | Set Shell Offsets: Set the shell offset property on each surface body in the model based on a simple naming convention 11 | 12 | Add_Deformation_Stress_and_ReactionProbes: Makes sure a force probe exists for each support at each time step and that a deformation result exists 13 | 14 | -------------------------------------------------------------------------------- /Set Shell Offsets.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Set the shell offsets for all of the bodies in a model. 3 | 4 | Looks through the names of all bodies in the tree and if they end in 5 | "BOTTOM" or "MID" set them to the corresponding offset. 6 | Otherwise set them to TOP offset 7 | ''' 8 | 9 | def set_offset(obj): 10 | ''' 11 | Set the offset of the part 12 | ''' 13 | if not obj.GetType().Equals(Ansys.ACT.Automation.Mechanical.Body): 14 | return 15 | if obj.Name[-6:] == "BOTTOM": 16 | obj.InternalObject.ShellOffsetType = 2# 2=Bottom 17 | elif obj.Name[-3:] == "MID": 18 | obj.InternalObject.ShellOffsetType = 1 # 1=Midsurface 19 | else: 20 | obj.InternalObject.ShellOffsetType = 0 # 0=Top 21 | 22 | def set_offset_in_children(obj): 23 | ''' 24 | Set the offset of all of the children of obj in the tree 25 | ''' 26 | for c in obj.Children: 27 | #print("Name: {} Type: {}".format(c.Name,c.InternalObject.GeometryType)) 28 | if len(c.Children)>0: 29 | set_offset_in_children(c) 30 | elif c.InternalObject.GeometryType == 1: # type 0 is solid, type 1 is surface 31 | set_offset(c) 32 | set_offset_in_children(Model.Geometry) 33 | -------------------------------------------------------------------------------- /ExportForceReactions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Author: Neil Dencklau 3 | Title: Export Force Reactions 4 | Version: v1.1 5 | Created: 2022-10-26 6 | Revised: 2022-10-26 7 | Ansys version: 2022R2 8 | 9 | Description: 10 | Export force reactions for each time step, from every analysis in the current project to a csv file. 11 | Does this by reading Force Reaction probes in the analysis. 12 | 13 | Change log: 14 | v1.1: 15 | * Outputs to the 'user_files' directory of the current project 16 | * Use Quantity's Value method instead of manually parsing 17 | """ 18 | 19 | import os 20 | working_dir = ExtAPI.DataModel.AnalysisList[0].WorkingDir 21 | user_files = os.path.join(working_dir[:working_dir.rfind("_files")+6],"user_files") 22 | output_fname = os.path.join(user_files,"forces.csv") 23 | 24 | # Create the header for the output file 25 | output_data = "Analysis Name, Result Name, Result Time, Unit,X Axis, Y Axis, Z Axis\n" 26 | 27 | for analysis in DataModel.AnalysisList: # go over every analysis in the tree 28 | for step in analysis.StepsEndTime:# go over every time step in the analysis 29 | t = Quantity("{} [sec]".format(step)) 30 | for result in analysis.Solution.Children: # Search the results for reactions matching the time step 31 | # Check to see if the result is a reaction force probe 32 | # The 'not' keyword is a boolean inversion 33 | # The 'continue' keyword skips any following code and goes to the next iteration of the loop 34 | # Done this way to avoid a bunch of nested if statements 35 | if not result.GetType().Equals(Ansys.ACT.Automation.Mechanical.Results.ProbeResults.ForceReaction): 36 | continue 37 | 38 | # Need to handle case where DisplayTime is last. To get the 'Last' a value of '0' is set as the 39 | # display time. Check for this and set the correct time. 40 | result_time = result.DisplayTime 41 | if result.DisplayTime.Equals(Quantity("0 [sec]")): 42 | # Get the last time and set the result_time 43 | last_time = analysis.StepsEndTime[analysis.StepsEndTime.Count-1] 44 | result_time = Quantity("{} [sec]".format(last_time)) 45 | 46 | if result_time.Equals(t): 47 | msg = Ansys.Mechanical.Application.Message("Exporting reaction {} for time {} in analysis {}".format(result.Name,result_time,analysis.Name), MessageSeverityType.Info) 48 | ExtAPI.Application.Messages.Add(msg) 49 | #Results are type Quantity which will print out with the unit which we do not want 50 | x = result.XAxis.Value 51 | y = result.YAxis.Value 52 | z = result.ZAxis.Value 53 | unit = result.XAxis.Unit 54 | output_data += "{},{},{},{},{},{},{}\n".format(analysis.Name,result.Name,result_time,unit,x,y,z) 55 | 56 | # Open the file in write mode ('w' is write, 'r' is read , 'a'is append) 57 | # keywork with is a context manager. It will automatically close the file at the end of the indented block 58 | #with open(output_fname,'w') as fp: 59 | with open(output_fname,'w') as fp: 60 | fp.write(output_data) 61 | -------------------------------------------------------------------------------- /Add_Deformation_Stress_and_ReactionProbes.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Given a selected object in the tree, add a Total Deformation and Eqv Stress for each time step 3 | Also add a force reaction probe for each support in the tree at each time step 4 | * Will rename the probes based on the support name 5 | 6 | Will look for existing solution objects so it will not add duplicates 7 | ''' 8 | 9 | fao = Tree.FirstActiveObject 10 | 11 | if fao.GetType().ToString().Equals('Ansys.ACT.Automation.Mechanical.Analysis'): 12 | print("Analysis selected") 13 | analysis = fao 14 | solution = analysis.Solution 15 | elif fao.GetType().ToString.Equals('Ansys.ACT.Automation.Mechanical.Solution'): 16 | print("Solution selected") 17 | analysis = fao.Parent 18 | solution = fao 19 | # is result 20 | elif fao.Parent.GetType().ToString().Equals('Ansys.ACT.Automation.Mechanical.Solution'): 21 | print("Child of solution selected") 22 | solution = fao.Parent 23 | analysis = solution.Parent 24 | 25 | elif fao.Parent.GetType().ToString().Equals('Ansys.ACT.Automation.Mechanical.Analysis'): 26 | print("Child of analysis selected") 27 | analysis = fao.Parent 28 | solution = analysis.Solution 29 | else: 30 | print("Did not find a match") 31 | analysis = None 32 | solution = None 33 | boundary_conditions = {} 34 | for child in analysis.Children: 35 | ansys_type = child.GetType().ToString() 36 | if 'Ansys.ACT.Automation.Mechanical.BoundaryConditions.RemoteDisplacement' in ansys_type: 37 | boundary_conditions[child.ObjectId] = child 38 | 39 | existing_results = {} 40 | # get all the existing results 41 | for rst in solution.Children: 42 | ansys_type = rst.GetType().ToString() 43 | if ansys_type.Equals('Ansys.ACT.Automation.Mechanical.Results.StressResults.EquivalentStress') or ansys_type.Equals('Ansys.ACT.Automation.Mechanical.Results.DeformationResults.TotalDeformation'): 44 | t = rst.DisplayTime.ToString() 45 | t = t[:t.find(" [")] 46 | s = "{} t{}".format(rst.GetType().ToString(),t) 47 | existing_results[s] = 1 48 | if 'Ansys.ACT.Automation.Mechanical.Results.ProbeResults' in ansys_type: 49 | t = rst.DisplayTime.ToString() 50 | t = t[:t.find(" [")] 51 | s = "{} t{}".format(rst.BoundaryConditionSelection.ObjectId.ToString(),t) 52 | existing_results[s] = 1 53 | 54 | # Add the results as needed 55 | time_steps = analysis.AnalysisSettings.InternalObject.NumberOfSteps 56 | for time_step in range(1,time_steps+1): 57 | if 'Ansys.ACT.Automation.Mechanical.Results.DeformationResults.TotalDeformation t{}'.format(time_step) not in existing_results: 58 | rst = solution.AddTotalDeformation() 59 | rst.DisplayTime = Quantity("{} [sec]".format(time_step)) 60 | rst.Name = rst.Name + "t{}".format(time_step) 61 | if 'Ansys.ACT.Automation.Mechanical.Results.DeformationResults.TotalDeformation t{}'.format(time_step) not in existing_results: 62 | rst = solution.AddEquivalentStress() 63 | rst.DisplayTime = Quantity("{} [sec]".format(time_step)) 64 | rst.Name = rst.Name + "t{}".format(time_step) 65 | 66 | for bc in boundary_conditions: 67 | #print("Adding force reaction {} t{}".format(boundary_conditions[bc].Name,t)) 68 | if "{} t{}".format(bc,time_step) not in existing_results: 69 | print("Adding force reaction {} t{}".format(boundary_conditions[bc].Name,time_step)) 70 | rst = solution.AddForceReaction() 71 | rst.Name = "{} t{}".format(boundary_conditions[bc].Name,time_step) 72 | rst.DisplayTime = Quantity("{} [sec]".format(time_step)) 73 | rst.LocationMethod = LocationDefinitionMethod().BoundaryCondition 74 | rst.BoundaryConditionSelection = boundary_conditions[bc] 75 | -------------------------------------------------------------------------------- /ExportForceReactions-alternative_method.py: -------------------------------------------------------------------------------- 1 | """ 2 | Author: Neil Dencklau 3 | Title: Export Force Reactions - Alternative Method 4 | Version: v1.0 5 | Created: 2022-10-26 6 | Revised: 2022-10-26 7 | Ansys version: 2022R2 8 | 9 | Description: 10 | Export force reactions for each time step, from every analysis in the current project to a csv file. 11 | Method: 12 | 1. Get all the supports in the analysis 13 | 2. Identify all the nodes attached to each support 14 | 3. Reads the result file, summing the forces on the nodes in each support 15 | 4. Formats the result for output to csv 16 | 17 | Change log: 18 | 19 | Known Issues: 20 | * Only supports that directly attach to nodes works (ie displacement, fixed, nodal). 21 | Any supports that attach via MPC (remote displacement) will not work with this code, 22 | but this code could be updated to output remote displacement as well. 23 | """ 24 | 25 | 26 | def get_nodes_in_supports(analysis, support_types): 27 | """ 28 | Given an analysis object and the types of supports to export results for 29 | Create a dictionary mapping the support name to the node ids in the support 30 | """ 31 | nodes = {} 32 | meshObj = analysis.MeshData 33 | for support_type in support_types: 34 | for child in analysis.GetChildren(support_type, True): 35 | nodes[child.Name] = [] 36 | for location_id in child.Location.Ids: 37 | nodes[child.Name].extend( 38 | meshObj.MeshRegionById(location_id).NodeIds 39 | ) 40 | return nodes 41 | 42 | def read_results_for_analysis(analysis, nodes): 43 | """ 44 | Given an analysis and a dictionary of {: } 45 | Create a list of results for each support at each time step 46 | """ 47 | results = [] 48 | for support_name, node_ids in nodes.items(): 49 | with analysis.GetResultsData() as reader: 50 | times = reader.ListTimeFreq # [1, 2, 3] 51 | for idx in range(reader.ResultSetCount): 52 | # ResultSet is indexed starting at 1, not 0 53 | reader.CurrentResultSet = idx+1 54 | force = reader.GetResult("F") 55 | result = { 56 | "analysis_name": analysis.Name, 57 | "support_name": support_name, 58 | "time": times[idx], 59 | "unit": {}, 60 | "quantity_type": {}, 61 | "data": {}, 62 | } 63 | 64 | for component in force.Components: 65 | comp_info = force.GetComponentInfo(component) 66 | result["unit"][component] = comp_info.Unit 67 | result["quantity_type"][component] = comp_info.QuantityName 68 | force.SelectComponents([component]) 69 | result["data"][component] = sum(force.GetNodeValues(node_ids)) 70 | results.append(result) 71 | return results 72 | 73 | 74 | def get_unique_keys(data, key): 75 | """ 76 | Given a list of dictionaries data with format: 77 | [ 78 | {: {"X": 0, "Y": 323, "Z": 43, "ASDF": -1}}, 79 | {: {"X": 0, "Y": 323, "Z": 43, "QWE}}, 80 | ] 81 | return: list of unique keys in 'key': ["ASDF", "QWE", "X", "Y", "Z"] 82 | """ 83 | keys = set() 84 | for row in data: 85 | keys.update(row[key].keys()) 86 | return sorted(list(keys)) 87 | 88 | 89 | def get_output_string(results): 90 | """ 91 | Convert the list of results to a csv style string 92 | with each component fully listed 93 | """ 94 | components = get_unique_keys(results, "data") 95 | output = "Analysis Name,Support Name,Time," 96 | output += ",".join(["Result {}".format(c) for c in components]) + "," 97 | output += ",".join(["Unit {}".format(c) for c in components]) + "," 98 | output += ",".join(["Quantity {}".format(c) for c in components]) +"\n" 99 | for result in results: 100 | line = [ 101 | result["analysis_name"], 102 | result["support_name"], 103 | "{}".format(result["time"]) 104 | ] 105 | line.extend(["{}".format(result["data"][c]) for c in components]) 106 | line.extend(["{}".format(result["unit"][c]) for c in components]) 107 | line.extend(["{}".format(result["quantity_type"][c]) for c in components]) 108 | 109 | output += ",".join(line) 110 | output += "\n" 111 | return output 112 | 113 | 114 | # Now run the functions 115 | support_types = [ 116 | DataModelObjectCategory.FixedSupport, 117 | DataModelObjectCategory.Displacement, 118 | ] 119 | results = [] 120 | for analysis in ExtAPI.DataModel.AnalysisList: 121 | nodes = get_nodes_in_supports(analysis, support_types) 122 | results.extend(read_results_for_analysis(analysis, nodes)) 123 | output = get_output_string(results) 124 | 125 | with open("C:\\temp\\forces.csv",'w') as fp: 126 | fp.write(output) -------------------------------------------------------------------------------- /HighStressRegions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Author: Neil Dencklau 3 | Title: Highlight High Stress Elements 4 | Version: v1.1 5 | Created: 2022-10-26 6 | Revised: 2022-10-26 7 | Ansys version: 2022R2 8 | 9 | Description: 10 | Create a Named Selection for each distinct region the model that is over a given threshold. 11 | Method: 12 | 1. Get the nodal stress values using the Equivalent Stress result object in the tree 13 | 2. Filter by nodal stress over the given stress limit 14 | 3. Create a named selection containing all the elements over the given limit. 15 | 4. Identify unique groups of elements over stress limit 16 | 5. Create named selection for each unique group 17 | 18 | Change Log: 19 | v1.1: 20 | * Refactored into functions to make code easier to read. No functional or algorithm changes. 21 | 22 | """ 23 | from collections import deque 24 | 25 | # Set the stress limit, All elements with nodal stresses over this value will be added to the named selection 26 | stressLimit = Quantity("4000. [psi]") # can use units of MPa, Pa, psi 27 | analysis = DataModel.AnalysisList[0] # This is the first analysis, change the index to run on others 28 | 29 | 30 | def get_stress_result(analysis): 31 | """ 32 | Return the first Equivalent stress result in the tree for the analysis 33 | 34 | """ 35 | # First find the first vonMises stress in the analysis 36 | # If it does not exist throw an error 37 | stress_result = None 38 | for child in analysis.Solution.Children: 39 | if child.GetType().Equals(Ansys.ACT.Automation.Mechanical.Results.StressResults.EquivalentStress): 40 | stress_result = child 41 | if stress_result is None: 42 | msg = Ansys.Mechanical.Application.Message("Did not find a Equivalent Stress result in for analysis {}".format(analysis.Name), MessageSeverityType.Error) 43 | ExtAPI.Application.Messages.Add(msg) 44 | # For some reason raise does not work with buttons, but does in console. 45 | # But it still fails, just in a very unclear way..... 46 | raise Exception("Did not find a Equivalent Stress result in for analysis {}".format(analysis.Name)) 47 | return stress_result 48 | 49 | 50 | def filter_stress_result(stress_result, stressLimit): 51 | """ 52 | Given a stress result, return the listing of nodes over the given stressLimit 53 | 54 | stress_result: Ansys.ACT.Automation.Mechanical.Results.StressResults.EquivalentStress (a Equivalent Stress result object) 55 | stressLimit: Quantity (the stress value and unit to filter out values below) 56 | 57 | returns: list[int] (list of node numbers) 58 | """ 59 | # Next get a listing of the node ids and their corresponding stress value 60 | # Note that the PlotData property does not exist before ANSYS 2020R1 61 | nodeIdsInResult = stress_result.PlotData.Values[1] 62 | stressAtNodes = stress_result.PlotData.Values[2] 63 | stressUnit = stress_result.PlotData.Dependents.Values[0].Unit 64 | 65 | # Go over every node and see if it exceeds the stressLimit, and add them to a list 66 | # If there are performance issues, this is probably a good place to start looking 67 | nodesHighStress = [] 68 | for node,stress in zip(nodeIdsInResult,stressAtNodes): 69 | if Quantity("{} [{}]".format(stress,stressUnit)) > stressLimit: 70 | nodesHighStress.append(node) 71 | 72 | return nodesHighStress 73 | 74 | 75 | def create_named_selection(ids, selection_type, name): 76 | """ 77 | Given a list of ids (ex element ids), create a named selection with name 78 | 79 | ids: list[int] (ex: [1,2,3]) 80 | selection_type: SelectionTypeEnum (ex:SelectionTypeEnum.MeshElements) 81 | name: str (ex: "my_named_selection") 82 | """ 83 | sm = ExtAPI.SelectionManager 84 | # The SelectionManager allows you to do the equivalent of manually selecting 85 | # items in the graphics. It can be scoped to any geometry feature type, or mesh feature type 86 | # First create a SelectionInfo object with all the data that you want to select, then actually 87 | # select the data. This allows for doing many modification to the selection without needing 88 | # to update the graphics which will slow things down 89 | sm.ClearSelection() 90 | selectionInfo = sm.CreateSelectionInfo(selection_type) 91 | selectionInfo.Ids = ids 92 | sm.NewSelection(selectionInfo) 93 | 94 | # Create the named selection. Just like when using Mechanical normally, anything that is selected 95 | # when a named selection is created is added to the named selection 96 | ns = DataModel.Project.Model.AddNamedSelection() 97 | ns.Name = name 98 | 99 | 100 | def create_element_groups(elements_high_stress): 101 | """ 102 | Given a list of elements, create groups where elements that share at least 103 | one node are in the same group 104 | 105 | elements_high_stress: list[int] (ex: [1, 2, 3, 4]) 106 | 107 | returns: list[list[int]] (list containing lists of adjacent elements) 108 | 109 | This section using Breadth First Search to find connected components 110 | The underlying theory is beyond the scope of this example. 111 | """ 112 | 113 | def getNeighborElements(element): 114 | '''' 115 | Return the element ids for elements that are adjacent to the element 116 | 117 | Note: functions can be defined inside of functions, 118 | but they are only available inside of their parent function 119 | ''' 120 | nodes = meshObj.NodeIdsFromElementIds([element]) 121 | elements = meshObj.ElementIdsFromNodeIds(nodes) 122 | # list(set()) gets a list of unique values from a list 123 | elements = list(set(elements)) 124 | return elements 125 | 126 | # Dictionary to allow fast lookup of elements that have high stress 127 | # This also allows mapping of element id to grouping id 128 | # Initialize the tracker with -1 for each element, meaning the element has not been explored 129 | # this is an implementation detail and there are many different ways to approach this 130 | elementTracker = {} 131 | for element in elements_high_stress: 132 | elementTracker[element] = -1 133 | 134 | groups = [] # The element Ids 135 | # Now use breadth first search to find groups of high stress elements 136 | for element in elements_high_stress: 137 | if (element in elementTracker) and (elementTracker[element] == -1): 138 | # Assign the element to the latest group. 139 | # The new group is not added to the groups list till all items in the group 140 | # are found, so the current group is the length of groups, with starting index == 0 141 | elementTracker[element] = len(groups) 142 | currentGroup = [element] 143 | 144 | q = deque() 145 | q.append(element) 146 | while len(q) > 0: 147 | elem = q.pop() 148 | # Add all neighboring elements that have not been explored 149 | for e in getNeighborElements(elem): 150 | if (e in elementTracker) and (elementTracker[e] == -1): 151 | q.append(e) 152 | elementTracker[e] = len(groups) 153 | currentGroup.append(e) 154 | # all the elements in the group have been added to currentGroup, so add 155 | # the current group to the list of groups 156 | groups.append(currentGroup) 157 | return groups 158 | 159 | 160 | # Get the stress values and filter them 161 | stress_result = get_stress_result(analysis) 162 | nodes_high_stress = filter_stress_result(stress_result, stressLimit) 163 | 164 | # Convert from nodes to elements, then group adjacent elements 165 | meshObj = analysis.MeshData 166 | elements_high_stress = meshObj.ElementIdsFromNodeIds(nodes_high_stress) 167 | groups = create_element_groups(elements_high_stress) 168 | 169 | # Finally create the Named Selections 170 | create_named_selection(elements_high_stress, SelectionTypeEnum.MeshElements, "Elements with nodes over {}".format(stressLimit.ToString())) 171 | for idx, group in enumerate(groups): 172 | create_named_selection(group, SelectionTypeEnum.MeshElements, "Elements with nodes over {} in group {}".format(stressLimit.ToString(),idx)) -------------------------------------------------------------------------------- /bolt_macro.py: -------------------------------------------------------------------------------- 1 | """Bolt Load Calculation Macro 2 | This code extracts axial and shear forces on all beams in the model, performs 3 | bolted joint calculations on them, and exports the results to the project 4 | user_files directory. Each run of script creates a new file with a timestamp. 5 | 6 | It requires: 7 | * ANSYS 23.2 or newer 8 | * Nodal Forces are exported stored in results 9 | * Any solver units or user units are allowed. Everything is converted to N mm 10 | * Do not need to add any command snippets anywhere 11 | * Beam naming is not required but be set to customize results 12 | using format 'M___' 13 | 14 | Optional Inputs: 15 | * Beams can be named 'M___' 16 | * If name does not follow this convention will assume 8.8 and grip length 17 | being the length of the beam at solve time 18 | 19 | Assumptions: 20 | * Steel to Steel connections with steel bolts 21 | * Can change stiffness of bolt and clamp material in `Bolt.__init__()` 22 | This could be extended in the future to be retrieved from model 23 | * Can change joint coefficient of friction in `Bolt.__init__()` 24 | This could be extended in the future to be retrieved from model 25 | * Torsion and moment loads on bolts are insignificant 26 | * No torsion or moment data is used anywhere 27 | * Pay extra attention to this if the joint slips! 28 | 29 | Release History: 30 | v1: 2023-09-01 Neil Dencklau 31 | * Initial release 32 | * Is direct migragation of 'Bolt_Calc_V15.txt' 33 | """ 34 | 35 | import clr 36 | import os 37 | import Ansys.Utilities 38 | 39 | clr.AddReferenceToFileAndPath( 40 | os.path.join( 41 | Ansys.Utilities.ApplicationConfiguration.DefaultConfiguration.WorkbenchInstallRootDirectoryPath, 42 | "Addins", 43 | "ACT", 44 | "bin", 45 | "Win64", 46 | "MathNet.Numerics.dll", 47 | ) 48 | ) 49 | 50 | import datetime 51 | 52 | from System import Array as sys_array 53 | import MathNet 54 | import MathNet.Numerics.LinearAlgebra as la 55 | import mech_dpf 56 | import Ans.DataProcessing as dpf 57 | import units 58 | import wbjn 59 | 60 | mech_dpf.setExtAPI(ExtAPI) 61 | 62 | 63 | def array(*x): 64 | """Convert arbitrary collection of values to sys_array of Double""" 65 | return sys_array[float](x) # float is equivalent to .Net double 66 | 67 | 68 | def cross_product(a, b): 69 | """Return Vector of a cross b 70 | MathNet doesn't have a cross product, so implement one 71 | 72 | Args: 73 | a (list[float) OR MathNet.Numerics.LinearAlgebra.Double.Vector) 74 | b (list[float) OR MathNet.Numerics.LinearAlgebra.Double.Vector) 75 | Returns: 76 | Cross product of a cross b as a MathNet.Numerics.LinearAlgebra.Double.Vector 77 | """ 78 | x = a[1] * b[2] - a[2] * b[1] 79 | y = a[2] * b[0] - a[0] * b[2] 80 | z = a[0] * b[1] - a[1] * b[0] 81 | return la.Double.Vector.Build.Dense(array(x, y, z)) 82 | 83 | 84 | def align_force_to_beam(node1, node2, force_global): 85 | """Converts beam result forces to beam coordinates 86 | Beam nodes are aligned to global coordinates, but we care 87 | about the axial and shear forces 88 | 89 | This takes the end nodes of the beam and the force in global CS 90 | and converts the force to axial tension == +Z 91 | Note: The X and Y directions alignment is not controlled 92 | 93 | Uses Rodrigues' rotation formula: 94 | https://en.m.wikipedia.org/wiki/Rodrigues%27_rotation_formula 95 | Rough idea is rotate force vector about axis normal to the 96 | global Z vector and beam axis vector. 97 | 98 | Args: 99 | node1 (list[float]): List of 3 floating point numbers describing the 100 | first (I) node of the beam in global coordinates 101 | node2 (list[float]): List of 3 floating point numbers describing the 102 | second (J) node of the beam in global coordinates 103 | force_global (list[float]): List of 3 floating point numbers of the beam 104 | nodal force in global coordinates 105 | 106 | Returns: 107 | list[float]: 3 Floats indicating force aligned to beam axis 108 | is +Z is axial tension, X and Y are not aligned to anything in 109 | particular, just normal to Z 110 | """ 111 | # Get inputs as vectors 112 | node1_v = la.Double.Vector.Build.Dense(array(*node1)) 113 | node2_v = la.Double.Vector.Build.Dense(array(*node2)) 114 | force_global_v = la.Double.Vector.Build.Dense(array(*force_global)) 115 | 116 | v_source = (node1_v - node2_v).Normalize(2) 117 | v_target = la.Double.Vector.Build.Dense(array(0, 0, 1)) 118 | 119 | # Cosine of angle between source and target 120 | cos_theta = v_target.DotProduct(v_source) 121 | theta = MathNet.Numerics.Trig.Acos(cos_theta) 122 | sin_theta = MathNet.Numerics.Trig.Sin(theta) 123 | 124 | if MathNet.Numerics.Precision.AlmostEqual(cos_theta, 1, 1e-8): 125 | # Beam axis points +Z, nothing to do 126 | force_local = force_global_v 127 | elif MathNet.Numerics.Precision.AlmostEqual(cos_theta, -1, 1e-8): 128 | # Beam axis points -Z, just flip the vector 129 | force_local = force_global_v.Negate() 130 | else: 131 | # Need to rotate the force 132 | # Get vector normal to global Z and target Z 133 | K = cross_product(v_source, v_target).Normalize(2) 134 | # Apply Rodrigue's 135 | force_local = ( 136 | force_global_v * cos_theta 137 | + cross_product(K, force_global_v) * sin_theta 138 | + K * K.DotProduct(force_global_v) * (1 - cos_theta) 139 | ) 140 | 141 | return [force_local[0], force_local[1], force_local[2]] 142 | 143 | 144 | class Bolt: 145 | """Defines a bolt's physical properties 146 | Things that are constant for a bolt 147 | 148 | Holds values for: 149 | * name: self.name from the beam name in the UI 150 | * diameter: self.diameter 151 | * grade: self.grade 152 | * stiffness: self.stiffness_bolt, self.stiffnes_clamp 153 | * coefficent of friction: self.joint_friction_coeff 154 | * proof stress: self.proof_stress 155 | * yield stress: self.yield_stress 156 | * stress area: self.stess_area 157 | * ideal clamp load: self.clamp_load 158 | * Length (not always): If user specifies length in name it 159 | is stored here. Otherwise it must be computed from the results file. 160 | The value could be retrieved from the UI if desired. 161 | """ 162 | 163 | def __init__(self, ui_beam): 164 | # These values are material dependant! 165 | # In the future should probabably determines from the model itself 166 | self.joint_friction_coeff = 0.1 167 | self.stiffness_bolt = 2.11e5 # Eb, MPa 168 | self.stiffness_clamp = 2.05e5 # Ec, MPa 169 | 170 | self.ui_beam = ui_beam 171 | self.diameter = self._get_bolt_size() 172 | self._parse_name_grade_length() 173 | 174 | def _get_bolt_size(self): 175 | """Get the nominal bolt diameter [mm] from UI 176 | Is the UI value converted to mm rounded to nearest full mm 177 | """ 178 | d = units.ConvertUnit(self.ui_beam.Radius.Value, self.ui_beam.Radius.Unit, "mm") 179 | d = round(2 * d) 180 | allowed_sizes = [ 181 | 4, 182 | 5, 183 | 6, 184 | 7, 185 | 8, 186 | 10, 187 | 12, 188 | 14, 189 | 16, 190 | 18, 191 | 20, 192 | 24, 193 | 30, 194 | 36, 195 | ] 196 | if d not in allowed_sizes: 197 | raise ValueError( 198 | "Unexpected bolt size {}mm. " 199 | "Allowed sizes are {} [mm]".format(d, allowed_sizes) 200 | ) 201 | return d 202 | 203 | def _parse_name_grade_length(self): 204 | """Parse the UI name of the beam 205 | Sets values: 206 | grade (float): If not specified sets to 8.8 207 | grip_length (float or None): If not specified sets to None 208 | name (str): If name starts with 'M_' uses name as: 209 | 'M___'. Otherwise uses full name from UI 210 | """ 211 | name_full = self.ui_beam.Name 212 | if name_full.startswith("M_"): 213 | name_split = name_full.split("_") 214 | if len(name_split) <= 3: 215 | raise ValueError( 216 | "Beam named incorrectly. " 217 | "Expected format 'M___'" 218 | ) 219 | 220 | grade_str = name_split[1] 221 | try: 222 | if "." in grade_str: # Entered as '_8.8_', '_08.8_' 223 | grade = float(grade_str) 224 | else: # allother cases assume needs to be divided by 10 225 | grade = float(grade_str) / 10.0 226 | except Exception: 227 | raise ValueError( 228 | "Could not parse grade, got '{}' " 229 | "which could not be parsed into a grade.".format(grade_str) 230 | ) 231 | allowed_grades = [8.8, 10.9] 232 | if grade not in allowed_grades: 233 | raise ValueError( 234 | "Bad grade specified, got {} " 235 | "but only {} are allowed.".format(grade, allowed_grades) 236 | ) 237 | self.grade = grade 238 | 239 | try: 240 | self.grip_length = float(name_split[2]) 241 | except Exception: 242 | raise ValueError( 243 | "Could not parse length, got '{}' " 244 | "which could not be parsed into a length.".format(name_split[2]) 245 | ) 246 | 247 | # Build the beam name back up, rejoining any '_' 248 | # and removing any commas that will mess with csv output 249 | name = "_".join(name_split[3:]) # Everything after 3'rd '_' is the name 250 | name = name.replace(",", "") 251 | self.name = name 252 | else: 253 | self.grade = 8.8 254 | self.grip_length = None 255 | self.name = name_full 256 | 257 | @property 258 | def area_stress(self): 259 | """Stress Area in [mm^2]""" 260 | area_by_diameter = { 261 | 4: 8.78, 262 | 5: 14.2, 263 | 6: 20.1, 264 | 7: 28.8, 265 | 8: 36.6, 266 | 10: 58.0, 267 | 12: 84.0, 268 | 14: 115.0, 269 | 16: 157.0, 270 | 18: 192.0, 271 | 20: 245.0, 272 | 24: 353.0, 273 | 30: 561.0, 274 | 36: 817.0, 275 | } 276 | area_stress = area_by_diameter[self.diameter] 277 | return area_stress 278 | 279 | @property 280 | def yield_stress(self): 281 | """Yield Stress in MPa based on grade""" 282 | if self.grade == 8.8: 283 | yield_stress = 640 # MPa 284 | elif self.grade == 10.9: 285 | yield_stress = 940 # MPa 286 | else: 287 | raise ValueError("Invalid grade {}".format(self.grade)) 288 | return yield_stress 289 | 290 | @property 291 | def proof_load(self): 292 | """Proof Load in [N] 293 | Based on 90% of yield stress 294 | """ 295 | proof_load = 0.9 * self.yield_stress * self.area_stress 296 | return proof_load 297 | 298 | @property 299 | def clamp_load(self): 300 | """Clamp Load in [N] 301 | Based on 75% of proof load 302 | """ 303 | clamp_load = 0.75 * self.proof_load 304 | return clamp_load 305 | 306 | 307 | class BoltResult: 308 | """Values for a bolt under load 309 | Contains all data about state of a bolt under load 310 | """ 311 | 312 | def __init__( 313 | self, bolt, analysis, mesh, elemental_nodal_forces, analysis_time_step 314 | ): 315 | self.bolt = bolt 316 | self.analysis = analysis 317 | self.solver_data = analysis.Solution.SolverData 318 | self.mesh = mesh 319 | self.elemental_nodal_forces = elemental_nodal_forces 320 | self.analysis_time_step = analysis_time_step 321 | 322 | self._load_mesh_data() 323 | self._set_grip_lengths() 324 | self._load_and_compute_forces() 325 | self._run_bolt_calc() 326 | 327 | def _load_and_compute_forces(self): 328 | """Load beam forces, convert to local coordinates, axial and shear 329 | 330 | Sets: 331 | forces_global: [xi, yi, zi, xj, yj, zj] in solver units global coordinates 332 | forces: [xi, yi, zi, xj, yj, zj] in solver units with Z axial 333 | axial_force: Force in N along beam axis, + is tension 334 | shear_force: Total shear force in N 335 | """ 336 | self.forces_global = list( 337 | self.elemental_nodal_forces.GetEntityDataById(self.element.Id) 338 | ) 339 | node1_pos = [ 340 | self.nodes[0].X, 341 | self.nodes[0].Y, 342 | self.nodes[0].Z, 343 | ] 344 | node2_pos = [ 345 | self.nodes[1].X, 346 | self.nodes[1].Y, 347 | self.nodes[1].Z, 348 | ] 349 | forces_i = align_force_to_beam(node1_pos, node2_pos, self.forces_global[:3]) 350 | forces_j = align_force_to_beam(node1_pos, node2_pos, self.forces_global[3:]) 351 | 352 | self.forces = [ 353 | forces_i[0], 354 | forces_i[1], 355 | forces_i[2], 356 | forces_j[0], 357 | forces_j[1], 358 | forces_j[2], 359 | ] 360 | self.axial_force = self._compute_axial_force() 361 | self.shear_force = self._compute_shear_force() 362 | 363 | def _load_mesh_data(self): 364 | element_id = self.solver_data.GetObjectData(self.bolt.ui_beam).ElementId 365 | self.element = self.mesh.ElementById(element_id) 366 | self.nodes = self.element.Nodes 367 | 368 | def _set_grip_lengths(self): 369 | """Set the grip_length and grip_length_nodal in [mm] 370 | Computes the `grip_length_nodal` from the beam node positions 371 | Sets `grip_length` to self.bolt.grip_length (if specified) 372 | otherwise sets it to grip_length_nodal 373 | 374 | """ 375 | grip_solver_units = ( 376 | (self.nodes[0].X - self.nodes[1].X) ** 2 377 | + (self.nodes[0].Y - self.nodes[1].Y) ** 2 378 | + (self.nodes[0].Z - self.nodes[1].Z) ** 2 379 | ) ** 0.5 380 | self.grip_length_nodal = units.ConvertUnit( 381 | grip_solver_units, self.mesh.Unit, "mm" 382 | ) # L, grip length in mm 383 | 384 | if self.bolt.grip_length is not None: 385 | self.grip_length = self.bolt.grip_length 386 | else: 387 | self.grip_length = self.grip_length_nodal 388 | 389 | def _compute_axial_force(self): 390 | """Compute the axial load on the beam in [N] 391 | Coordinate system for beams is Z axial (after it was rotated to be correct) 392 | When looking at the reference end (node I) 393 | +Z is 'away' from mobile end (node J) 394 | This means a +Z on I is away from J 395 | but +Z on J is towards I 396 | Invert sign on J to make it 'Positive is pulling apart' 397 | However also want 'load on the bolt by the plate' 398 | but the reaction force on the node is 'load on the plate by the bolt' 399 | so invert the whole thing 400 | This is '-max(zi,-zj)' which is just max(-zi, zj) 401 | """ 402 | i = self.forces[2] 403 | j = self.forces[5] 404 | axial = max(-i, j) 405 | axial_N = units.ConvertUnit(axial, self.elemental_nodal_forces.Unit, "N") 406 | return axial_N 407 | 408 | def _compute_shear_force(self): 409 | """Compute the shear load on the beam in [N] 410 | Computes the vector sum of shear forces at each end 411 | and returns the larger of the 2 412 | """ 413 | xi = self.forces[0] 414 | yi = self.forces[1] 415 | xj = self.forces[3] 416 | yj = self.forces[4] 417 | shear_i = (xi**2 + yi**2) ** 0.5 418 | shear_j = (xj**2 + yj**2) ** 0.5 419 | shear = max(shear_i, shear_j) 420 | shear_N = units.ConvertUnit(shear, self.elemental_nodal_forces.Unit, "N") 421 | return shear_N 422 | 423 | def _run_bolt_calc(self): 424 | d_hole = 1.5 * self.bolt.diameter # Dh 425 | d_washer = 2.0 * self.bolt.diameter # Dw 426 | 427 | d3 = d_washer + 0.5774 * self.grip_length # d3 428 | d_clamp = (d3 + d_washer) / 2.0 # Dc 429 | area_clamp = 0.785 * (d_clamp**2 - d_hole**2) # Ac 430 | k_bolt = ( 431 | self.bolt.area_stress * self.bolt.stiffness_bolt 432 | ) / self.grip_length # kb 433 | k_clamp = (area_clamp * self.bolt.stiffness_clamp) / self.grip_length # kc 434 | 435 | k_ratio = k_clamp / k_bolt # Ratio 436 | 437 | f_clamp = self.bolt.clamp_load - (k_clamp / (k_clamp + k_bolt)) * max( 438 | self.axial_force, 0 439 | ) # Fc 440 | f_bolt = self.bolt.clamp_load + (k_bolt / (k_clamp + k_bolt)) * max( 441 | self.axial_force, 0 442 | ) # Fb 443 | 444 | shear_capacity_joint = self.bolt.joint_friction_coeff * f_clamp # Csl, N 445 | shear_capacity_bolt = ( 446 | self.bolt.yield_stress / 1.732 447 | ) * self.bolt.area_stress # Csh, N 448 | axial_capacity_bolt = self.bolt.yield_stress * self.bolt.area_stress # Cax, N 449 | 450 | # Compute the safety factors on the joint and bolt 451 | sf_shear_joint = shear_capacity_joint / self.shear_force 452 | sf_shear_bolt = shear_capacity_bolt / self.shear_force 453 | sf_axial_bolt = axial_capacity_bolt / f_bolt 454 | 455 | # If the bolt slips then find the bolt shear stress, 456 | # else the bolt shear stress is 0 457 | if sf_shear_joint < 1.0: 458 | shear_stress_bolt = self.shear_force / self.bolt.area_stress 459 | else: 460 | shear_stress_bolt = 0.0 461 | 462 | axial_stress_bolt = f_bolt / self.bolt.area_stress 463 | total_stress_bolt = ( 464 | axial_stress_bolt**2 + 3.0 * shear_stress_bolt**2 465 | ) ** 0.5 466 | 467 | self.f_clamp = f_clamp 468 | self.f_bolt = f_bolt 469 | self.k_ratio = k_ratio 470 | self.shear_capacity_joint = shear_capacity_joint 471 | self.shear_capacity_bolt = shear_capacity_bolt 472 | self.axial_capacity_bolt = axial_capacity_bolt 473 | self.sf_shear_joint = sf_shear_joint 474 | self.sf_shear_bolt = sf_shear_bolt 475 | self.sf_axial_bolt = sf_axial_bolt 476 | self.shear_stress_bolt = shear_stress_bolt 477 | self.axial_stress_bolt = axial_stress_bolt 478 | self.total_stress_bolt = total_stress_bolt 479 | 480 | def result_string(self): 481 | """Return csv formatted string for this result""" 482 | values = [ 483 | self.analysis.Name.replace( 484 | ",", " " 485 | ), # Analysis Name, clear out any ',' that will mess up csv file 486 | "{}".format(self.analysis_time_step), # Time [s] 487 | self.bolt.name, # Name 488 | "{:.1f}".format(self.bolt.diameter), # Bolt Size [mm] 489 | "{:.1f}".format(self.bolt.grade), # Grade 490 | "{:.2f}".format(self.grip_length), # Grip Length [mm] 491 | "{:.2f}".format(self.grip_length_nodal), # Grip Length (nodal) [mm] 492 | "{:.1f}".format(self.shear_force), # Shear Force [N] 493 | "{:.1f}".format(self.axial_force), # Axial Force [N] 494 | "{:.1f}".format(self.shear_capacity_joint), # Shear Capacity Slip [N] 495 | "{:.1f}".format(self.shear_capacity_bolt), # Shear Capacity Bolt [N] 496 | "{:.1f}".format(self.axial_capacity_bolt), # Axial Capacity Bolt [N] 497 | "{:.3f}".format(self.sf_shear_joint), # SF Slip" 498 | "{:.3f}".format(self.sf_shear_bolt), # SF Shear Bolt 499 | "{:.1f}".format(self.sf_axial_bolt), # SF Axial Bolt 500 | "{:.1f}".format(self.shear_stress_bolt), # Shear Stress Bolt [MPa] 501 | "{:.1f}".format(self.axial_stress_bolt), # Axial Stress Bolt [MPa] 502 | "{:.1f}".format(self.total_stress_bolt), # Equivalent Stress Bolt [MPa 503 | ] 504 | result_csv = ",".join(values) 505 | return result_csv 506 | 507 | def result_string_header(self): 508 | """Return csv formatted header for output file""" 509 | column_names = [ 510 | "Analysis Name", 511 | "Time [s]", 512 | "Name", 513 | "Bolt Size [mm]", 514 | "Grade", 515 | "Grip Length [mm]", 516 | "Grip Length (nodal) [mm]", 517 | "Shear Force [N]", 518 | "Axial Force [N]", 519 | "Shear Capacity Slip [N]", 520 | "Shear Capacity Bolt [N]", 521 | "Axial Capacity Bolt [N]", 522 | "SF Slip", 523 | "SF Shear Bolt", 524 | "SF Axial Bolt", 525 | "Shear Stress Bolt [MPa]", 526 | "Axial Stress Bolt [MPa]", 527 | "Equivalent Stress Bolt [MPa]", 528 | ] 529 | header_csv = ",".join(column_names) 530 | return header_csv 531 | 532 | 533 | def get_bolts_from_ui_beams(): 534 | """Get list of Bolts based on beams in the UI 535 | Returns: 536 | list[Bolt] 537 | """ 538 | bolts = [] 539 | ui_beams = ExtAPI.DataModel.Project.Model.Connections.GetChildren[ 540 | Ansys.ACT.Automation.Mechanical.Connections.Beam 541 | ](True) 542 | for ui_beam in ui_beams: 543 | # Dont try and use any suppressed beams 544 | if ui_beam.Suppressed: 545 | continue 546 | 547 | try: 548 | bolts.append(Bolt(ui_beam)) 549 | except Exception as e: 550 | msg = Ansys.Mechanical.Application.Message( 551 | "Failed to make a bolt for '{}'. Got error: {}".format(ui_beam.Name, e), 552 | MessageSeverityType.Error, 553 | ) 554 | ExtAPI.Application.Messages.Add(msg) 555 | continue 556 | 557 | return bolts 558 | 559 | 560 | def process_analsysis(analysis, bolts): 561 | """ 562 | Args: 563 | analysis (Ansys.ACT.Automation.Mechanical.Analysis): 564 | The analysis to get results from 565 | Example of getting input: `analysis = ExtAPI.DataModel.AnalysisList[0]` 566 | bolts (list[Bolt]): All bolts to extract loads for 567 | Example of getting input: `bolts = get_bolts_from_ui_beams()` 568 | 569 | Returns: 570 | list[BoltResults]: 1 BoltResult instance for each bolt/analysis/time_step 571 | combination 572 | """ 573 | 574 | bolt_results_analysis = [] 575 | 576 | model = dpf.Model(analysis.ResultFileName) 577 | mesh = model.Mesh 578 | 579 | beam_elements = dpf.operators.scoping.on_mesh_property() 580 | beam_elements.inputs.property_name.Connect("beam_elements") 581 | beam_elements.inputs.mesh.Connect(mesh) 582 | 583 | for step_end_time in analysis.StepsEndTime: 584 | # Get the data scoped to the expected time 585 | # By default this loads values as 'rotated to global' 586 | op_nodal = dpf.operators.result.element_nodal_forces( 587 | data_sources=model.DataSources, 588 | mesh_scoping=beam_elements.outputs.mesh_scoping, 589 | time_scoping=step_end_time, 590 | ) 591 | # Data is returned as list (by time), want the 1st time 592 | # which is the time we just scoped to 593 | elemental_nodal_forces = op_nodal.outputs.fields_container.GetData()[0] 594 | for bolt in bolts: 595 | try: 596 | result = BoltResult( 597 | bolt=bolt, 598 | analysis=analysis, 599 | mesh=mesh, 600 | elemental_nodal_forces=elemental_nodal_forces, 601 | analysis_time_step=step_end_time, 602 | ) 603 | bolt_results_analysis.append(result) 604 | except Exception as e: 605 | msg = Ansys.Mechanical.Application.Message( 606 | "Failed to process bolt '{}' in Analysis '{}'. Error: {}".format(bolt.ui_beam.Name, analysis.Name, e), 607 | MessageSeverityType.Warning 608 | ) 609 | ExtAPI.Application.Messages.Add(msg) 610 | continue 611 | 612 | return bolt_results_analysis 613 | 614 | 615 | def save_results(bolt_results, filename): 616 | """Write the results to user_files 617 | If filename already exists will overwrite it 618 | 619 | Args: 620 | bolt_results (list[BoltResult]) 621 | filename (str): Path to the file to save all results to 622 | """ 623 | with open(filename, "w") as fp: 624 | fp.write(bolt_results[0].result_string_header()) 625 | fp.write("\n") 626 | for item in bolt_results: 627 | fp.write(item.result_string()) 628 | fp.write("\n") 629 | 630 | 631 | def run_bolt_calc_macro(): 632 | """Run the full bolt calc macro""" 633 | 634 | bolts = get_bolts_from_ui_beams() 635 | bolt_results = [] 636 | for analysis in ExtAPI.DataModel.AnalysisList: 637 | # fmt: off 638 | # Do error checking on the analysis 639 | if analysis.Solution.Status!= Ansys.Mechanical.DataModel.Enums.SolutionStatusType.Done: 640 | msg = Ansys.Mechanical.Application.Message( 641 | "Analysis '{}' is not solved, cannot export bolt forces".format(analysis.Name), 642 | MessageSeverityType.Error 643 | ) 644 | ExtAPI.Application.Messages.Add(msg) 645 | continue 646 | elif analysis.AnalysisSettings.NodalForces != Ansys.Mechanical.DataModel.Enums.OutputControlsNodalForcesType.Yes: 647 | msg = Ansys.Mechanical.Application.Message( 648 | "Analysis '{}' does not have 'Analysis Settings -> Output Controls -> Nodal Forces' " 649 | "turned on, cannot export bolt forces.".format(analysis.Name), 650 | MessageSeverityType.Error 651 | ) 652 | ExtAPI.Application.Messages.Add(msg) 653 | continue 654 | elif analysis.AnalysisType != Ansys.Mechanical.DataModel.Enums.AnalysisType.Static: 655 | msg = Ansys.Mechanical.Application.Message( 656 | "Analysis '{}' is not 'Static Sturctural, cannot export bolt forces".format(analysis.Name), 657 | MessageSeverityType.Error 658 | ) 659 | ExtAPI.Application.Messages.Add(msg) 660 | continue 661 | # fmt: on 662 | 663 | # Process each analysis handling errors 664 | # An error on 1 analysis should not prevent results from other analysis 665 | try: 666 | bolt_results_analysis = process_analsysis(analysis, bolts) 667 | bolt_results.extend(bolt_results_analysis) 668 | msg = Ansys.Mechanical.Application.Message( 669 | "Extracted '{}' bolt results for " 670 | "analysis '{}'".format(len(bolt_results_analysis), analysis.Name), 671 | MessageSeverityType.Info, 672 | ) 673 | ExtAPI.Application.Messages.Add(msg) 674 | except Exception as e: 675 | msg = Ansys.Mechanical.Application.Message( 676 | "Failed to calculate bolt results for " 677 | "analysis '{}': {}".format(analysis.Name, e), 678 | MessageSeverityType.Warning, 679 | ) 680 | ExtAPI.Application.Messages.Add(msg) 681 | 682 | user_files = wbjn.ExecuteCommand(ExtAPI, "returnValue(GetUserFilesDirectory())") 683 | output_fname = os.path.join( 684 | user_files, 685 | "bolt_loads_{}.csv".format(datetime.datetime.now().strftime("%Y%m%dT%H%M%S")), 686 | ) 687 | save_results(bolt_results, output_fname) 688 | 689 | msg = Ansys.Mechanical.Application.Message( 690 | "Exported {} bolt results to {}".format(len(bolt_results), output_fname), 691 | MessageSeverityType.Info, 692 | ) 693 | ExtAPI.Application.Messages.Add(msg) 694 | 695 | 696 | run_bolt_calc_macro() 697 | --------------------------------------------------------------------------------