#!/usr/bin/env python
# coding: utf-8
Code provided under BSD 3-Clause license, all other content under a Creative Commons Attribution license, CC-BY 4.0. (c) 2024 Francesco Mario Antonio Mitrotta.
# # Influence of Load Introduction Method on Wingbox Optimization with Nonlinear Structural Stability Constraints
#
# This notebook shows how to produce the figures published in the paper titled _Influence of Load Introduction Method on Wingbox Optimization with Nonlinear Structural Stability Constraints_, presented at the the International Forum on Aeroelasticity and Structudal Dynamics (IFASD) in June 2024 and authored by Francesco M. A. Mitrotta, Alberto Pirrera, Terence Macquart, Jonathan E. Cooper, Alex Pereira do Prado and Pedro Higino Cabral.
#
# * [Influence of load introduction method](#load-introduction)
# * [Nonlinear structural stability response of initial structure](#initial)
# * [Optimization with distributed non-follower forces](#distributed)
# * [Optimization with non-follower forces applied to load reference axis](#reference-axis)
# * [Optimization with follower forces](#follower)
# * [Comparative discussion](#comparative)
# * [Appendix](#appendix)
# In[1]:
import os # package for interacting with the operating system
import matplotlib.pyplot as plt # package for creating figures and plots
from matplotlib.lines import Line2D # class holding the list of markers
import tol_colors as tc # package for colorblind-friendly colors
import numpy as np # package for scientific computing
# Define name of directory where to find the analyses
analysis_directory_name = "21_Optimization_of_the_CRM-like_Box_Beam_with_Distributed_Load"
ANALYSIS_DIRECTORY_PATH = os.path.join(os.getcwd(), "analyses", analysis_directory_name)
# Define name of directory where to save figures
figures_directory_name = "IFASD_2024"
FIGURES_DIRECTORY_PATH = os.path.join(os.getcwd(), "analyses", figures_directory_name)
# Define default figure parameters
TEXTWIDTH_INCHES = 6.298242 # paper textwidth in inches
FIG_WIDTH_INCHES = TEXTWIDTH_INCHES*.5
FIG_HEIGHT_INCHES = FIG_WIDTH_INCHES*(4.8/6.4) # default figure height in inches
DEFAULT_FONT_SIZE = 8 # default font size of figures
DEFAULT_MARKER_SIZE = 3 # default marker size of lines
plt.rcParams.update({
'figure.dpi': 120, # default figure dpi
'text.usetex': True, # use LaTeX to render text
'font.family': 'serif', # use serif font family
'font.serif': 'Times New Roman', # use Times New Roman for serif font
'font.size': DEFAULT_FONT_SIZE, # default font size of figures
'mathtext.fontset': 'custom', # use custom font settings for math
'mathtext.rm': 'Times New Roman', # use Times New Roman for roman math text
'mathtext.it': 'Times New Roman:italic', # use Times New Roman italic for italic math text
'mathtext.bf': 'Times New Roman:bold', # use Times New Roman bold for bold math text
'lines.markersize': DEFAULT_MARKER_SIZE # set default marker size of lines
})
# Define default colors and markers
MARKERS = list(Line2D.markers.keys())[2:] # list of marker characters
plt.rc('axes', prop_cycle=plt.cycler('color', list(tc.tol_cset('bright')))) # set default color cycle to TOL bright
COLORS = plt.rcParams['axes.prop_cycle'].by_key()['color'] # retrieve list with succession of standard matplotlib colors
UNSTABLE_COLOR = COLORS[1] # red
GLASS_CEILING_COLOR = COLORS[2] # green
del COLORS[1:3] # delete green and red from list of colors
# Define default print options for numpy
np.set_printoptions(precision=1, suppress=True)
# Define constant variables for the analyses
L = 29.38e3 # [mm] box beam length
W = 3.41e3 # [mm] box beam width
H = .77e3 # [mm] box beam height
FIRST_SUBCASE_ID = 1 # id of first subcase
SECOND_SUBCASE_ID = 2 # id of second subcase
Z_COMPONENT_INDEX = 2 # index of z-component in vector of displacements
# Define function to adjust axes ticks and labels' position
def adjust_3d_plot(axes, colorbar=None):
axes.locator_params(axis="x", nbins=3) # set number of ticks of x-axis
axes.locator_params(axis="z", nbins=2) # set number of ticks of z-axis
axes.tick_params(axis="y", which="major", pad=20) # adjust position of ticks' label of y-axis
axes.tick_params(axis="z", which="major", pad=6) # adjust position of ticks' label of z-axis
axes.yaxis.labelpad = 80 # adjust position of y-axis's label
axes.zaxis.labelpad = 10 # adjust position of z-axis's label
if colorbar is not None:
colorbar.ax.set_position(colorbar.ax.get_position().shrunk(1.0, .62)) # decrease colorbar size
colorbar.ax.set_position(colorbar.ax.get_position().translated(-.07, .18)) # move colorbar upwards
# ## Influence of load introduction method
#
# ***
#
# ### Nonlinear structural stability response of initial structure
#
# Linear buckling load and mode for model under distributed non-follower forces.
# In[2]:
from pyNastran.op2.op2 import read_op2
from resources import plot_utils
# Read op2 file
input_name = "sol_105_33120_elements"
op2_filepath = os.path.join(ANALYSIS_DIRECTORY_PATH, input_name + '.op2')
sol_105_op2 = read_op2(op2_filename=op2_filepath, load_geometry=True, debug=None)
# Find buckling load and plot buckling mode
linear_buckling_load = sol_105_op2.eigenvectors[SECOND_SUBCASE_ID].eigr
print(f"Linear buckling load: {linear_buckling_load:.0f} N")
print("Buckling mode:")
amplification_factor = 1.5 # amplification factor of displacements
plt.rcParams.update({'font.size': DEFAULT_FONT_SIZE*(1/0.7),
'lines.markersize': 8}) # increase default font size
fig, ax, cbar = plot_utils.plot_buckling_mode(
op2=sol_105_op2, subcase_id=SECOND_SUBCASE_ID, displacement_component='tz', colormap='sunset',
length_unit='m', displacement_amplification_factor=amplification_factor, unit_scale_factor=1e-3)
# Find node where max displacement occurs
max_displacement_index = np.argmax(np.linalg.norm(sol_105_op2.eigenvectors[SECOND_SUBCASE_ID].data[
0, :, 0:3], axis=1)) # find index of max displacement magnitude
max_displacement_node_id = sol_105_op2.eigenvectors[SECOND_SUBCASE_ID].node_gridtype[
max_displacement_index, 0]
local_displacement_node_ids = [max_displacement_node_id] # initialize list of node ids where to plot local displacements
# Plot node where maximum displacement occurs
max_displacement_node_xyz = sol_105_op2.nodes[max_displacement_node_id].xyz*1e-3 + sol_105_op2.eigenvectors[
SECOND_SUBCASE_ID].data[0, max_displacement_index, 0:3]*amplification_factor # add displacement to node position and convert to m
ax.plot(max_displacement_node_xyz[0], max_displacement_node_xyz[1], max_displacement_node_xyz[2], 'x',
label=f"Node {max_displacement_node_id:d} (max displacement)", zorder=4)
ax.legend(bbox_to_anchor=(.93, .92))
# Adjust plot, show it and save figure
adjust_3d_plot(ax, cbar)
plt.show()
BBOX_COLORBAR = fig.bbox_inches.from_bounds(.8, .5, 5.2, 3.9) # create bounding box for figure
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "InitialBucklingMode.png"), format='png', bbox_inches=BBOX_COLORBAR, pad_inches=0, dpi=500)
# Reset default sizes
plt.rcParams.update({'font.size': DEFAULT_FONT_SIZE,
'lines.markersize': DEFAULT_MARKER_SIZE})
# Compare linear buckling loads obtained with the different load introduciton methods.
# In[3]:
# Initialize dictionary with names of input files
sol_105_input_name_dict = {"distributed non-follower forces": "sol_105_non_follower_linear_opt_start",
"distributed follower forces": "sol_105_follower_linear_opt_start",
"non-follower forces applied to LRA": "sol_105_lra_linear_opt_start"}
# Read linear buckling load for each analysis
linear_buckling_loads = []
for input_name in sol_105_input_name_dict.values():
op2_filepath = os.path.join(ANALYSIS_DIRECTORY_PATH, input_name + '.op2')
sol_105_op2 = read_op2(op2_filename=op2_filepath, load_geometry=True, debug=None)
linear_buckling_loads.append(sol_105_op2.eigenvectors[SECOND_SUBCASE_ID].eigrs[0])
# Plot bar chart with linear buckling loads
fig, ax = plt.subplots(figsize=(FIG_WIDTH_INCHES*1.5, FIG_HEIGHT_INCHES/2))
y_pos = np.arange(len(sol_105_input_name_dict.keys())) # position of bars
p = ax.barh(y_pos, linear_buckling_loads, height=0.5) # plot bars
ax.bar_label(p, padding=3, fmt="{:,.3f}") # add labels to bars
ax.set_yticks(y_pos, labels=tuple(sol_105_input_name_dict.keys())) # set labels of y-axis
ax.invert_yaxis() # labels read top-to-bottom
ax.set_xlabel("$P_\mathrm{SOL\,105}/P_\mathrm{design}$") # set label of x-axis
ax.set_xlim(right=1.19) # set limits of x-axis
# Save figure
plt.tight_layout() # Ensures proper spacing between subplots
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "LinearBucklingBarChart.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Calculate percentage differences w.r.t. distributed non-follower forces
print(f"""Percentage differences of linear buckling loads w.r.t. distributed non-follower result:
- distributed follower forces --> {100*(linear_buckling_loads[1]-linear_buckling_loads[0])/linear_buckling_loads[0]:.1f}%
- non-follower forces applied to LRA --> {100*(linear_buckling_loads[2]-linear_buckling_loads[0])/linear_buckling_loads[0]:.1f}%
""")
# Compare root bending moment obtained with the different load introduciton methods.
# In[4]:
def read_rbm_from_f06(f06_path):
with open(f06_path, 'r') as file:
# Flag to indicate if we are in the relevant section
in_relevant_section = False
# Loop through the lines of the file
for line in file:
# Check if the line indicates the start of the relevant section
if 'SUBCASE/' in line:
in_relevant_section = True
# Once we are in the relevant section, look for the row starting with FZ
if in_relevant_section and line.startswith(' FZ'):
# Split the line into parts
parts = line.split()
# The R1 value is the 4th number from the end
r1_value = parts[-3]
return float(r1_value)
# If we encounter the word 'TOTALS', it means we are past the section of interest
if 'TOTALS' in line:
in_relevant_section = False
# Return None if the value is not found
return None
# Read root bending moment for each analysis
root_bending_moments = []
for input_name in sol_105_input_name_dict.values():
f06_filepath = os.path.join(ANALYSIS_DIRECTORY_PATH, input_name + '.f06')
root_bending_moments.append(read_rbm_from_f06(f06_filepath))
# Plot bar chart with linear buckling loads
fig, ax = plt.subplots(figsize=(FIG_WIDTH_INCHES*1.5, FIG_HEIGHT_INCHES/2))
y_pos = np.arange(len(sol_105_input_name_dict.keys())) # position of bars
p = ax.barh(y_pos, np.array(root_bending_moments)/1e3, height=0.5) # plot bars
ax.bar_label(p, padding=3, fmt="{:,.2e}") # add labels to bars
ax.set_yticks(y_pos, labels=tuple(sol_105_input_name_dict.keys())) # set labels of y-axis
ax.invert_yaxis() # labels read top-to-bottom
ax.set_xlabel("Root bending moment, Nm") # set label of x-axis
ax.set_xlim(right=4.8e5) # set limits of x-axis
# Save figure
plt.tight_layout() # Ensures proper spacing between subplots
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "RootBendingMomentBarChart.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Calculate percentage differences w.r.t. distributed non-follower forces
print(f"""Percentage differences of root bending moments w.r.t. distributed non-follower result:
- distributed follower forces --> {100*(root_bending_moments[1]-root_bending_moments[0])/root_bending_moments[0]:.2f}%
- non-follower forces applied to LRA --> {100*(root_bending_moments[2]-root_bending_moments[0])/root_bending_moments[0]:.2f}%
""")
# Comparison of nonlinear structural stability response of initial structure.
# In[5]:
from resources import pynastran_utils
# Design load is equal to linear buckling load of initial structure under distributed non-follower forces
design_load = linear_buckling_load
# Find id of tip node
tip_node_xyz = np.array([W/2, L, 0]) # tip node is taken at the center of the tip rib
nodes_xyz_array = np.array([sol_105_op2.nodes[i].xyz for i in sol_105_op2.nodes])
tip_node_id = np.argmin(np.linalg.norm(nodes_xyz_array - tip_node_xyz, axis=1)) + 1
# Initialize dictionary with names of input files
input_name_dict = {"distributed non-follower forces": "sol_106_33120_elements",
"distributed follower forces": "sol_106_follower_linear_opt_start",
"non-follower forces applied to LRA": "sol_106_lra_linear_opt_start"}
def plot_load_displacement_diagram(input_name_dict, max_nondimensional_load=1):
# Create one figure with two subplots side by side
fig, axes = plt.subplots(1, 2, sharey=True, figsize=(TEXTWIDTH_INCHES, FIG_HEIGHT_INCHES))
stable_lines = []
# Read eigenvalues, load and displacement histories and plot load-displacement diagrams
eigenvalues_dict = {}
sol_106_op2_dict = {}
nondimensional_loads_dict = {}
for count, (key, input_name) in enumerate(input_name_dict.items()):
# Read eigenvalues
f06_path = os.path.join(ANALYSIS_DIRECTORY_PATH, input_name + '.f06') # path to .f06 file
eigenvalues_dict[key] =pynastran_utils.read_kllrh_lowest_eigenvalues_from_f06(f06_path)
# Read load and displacement histories
sol_106_op2_dict[key] = read_op2(os.path.join(ANALYSIS_DIRECTORY_PATH, input_name + '.op2'), load_geometry=True, debug=None)
_, loads, displacements = pynastran_utils.read_load_displacement_history_from_op2(
op2=sol_106_op2_dict[key], node_ids=[max_displacement_node_id, tip_node_id])
nondimensional_loads_dict[key] = loads[FIRST_SUBCASE_ID][:, Z_COMPONENT_INDEX]/design_load
# Plot applied load vs root displacement
last_plot_index = np.argmin(np.abs(nondimensional_loads_dict[key] - max_nondimensional_load)) + 1 # limit plot to applied loads less than or equal to design load
if last_plot_index > eigenvalues_dict[key].shape[1]:
last_plot_index -= 1
plot_utils.plot_2d_load_displacements_stability(
axes[0],
displacements[max_displacement_node_id][FIRST_SUBCASE_ID][:last_plot_index, Z_COMPONENT_INDEX]/W*100,
nondimensional_loads_dict[key][:last_plot_index],
eigenvalues_dict[key][:, :last_plot_index],
MARKERS[count], COLORS[count])
stable_lines.append(Line2D([0], [0], linestyle='-', marker=MARKERS[count], color=COLORS[count], label=key))
# Plot applied load vs tip displacement
plot_utils.plot_2d_load_displacements_stability(
axes[1],
displacements[tip_node_id][FIRST_SUBCASE_ID][:last_plot_index, Z_COMPONENT_INDEX]/L*100,
nondimensional_loads_dict[key][:last_plot_index],
eigenvalues_dict[key][:, :last_plot_index],
MARKERS[count], COLORS[count])
# Return
return fig, axes, stable_lines, eigenvalues_dict, sol_106_op2_dict, nondimensional_loads_dict
# Plot load-displacement diagrams
fig, axes, stable_lines, eigenvalues_dict, sol_106_op2_dict, nondimensional_loads_dict = plot_load_displacement_diagram(
input_name_dict, max_nondimensional_load=2)
# Create proxy artists for unstable segments
unstable_line = Line2D([0], [0], linestyle='--', color=UNSTABLE_COLOR, label="unstable")
# Set plot appearance for the first subplot
axes[0].set_xlabel(f"$u_{{z,\,{max_displacement_node_id:d}}}/w$, \%")
axes[0].set_ylabel("$P/P_\mathrm{design}$")
axes[0].grid(visible=True)
# Set plot appearance for the second subplot
axes[1].set_xlabel("$u_{z,\,\mathrm{tip}}/l$, \%")
axes[1].grid(visible=True)
# Save figure
plt.tight_layout() # Ensures proper spacing between subplots
axes[0].legend(handles=stable_lines + [unstable_line], loc="upper left",
ncols=2, bbox_to_anchor=(-.03, 1.3), frameon=False) # add legend after tight_layout
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "InitialLoadDisplacementDiagram.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Compare applied load at first limit point.
# In[6]:
# Find limit point load for each analysis
first_negative_eigenvalue_index_dict = {}
limit_point_loads = []
for key in sol_105_input_name_dict:
first_negative_eigenvalue_index_dict[key] = np.where(eigenvalues_dict[key][0] < 0)[0][0] # find index of first negative eigenvalue
limit_point_loads.append(np.mean(
nondimensional_loads_dict[key][first_negative_eigenvalue_index_dict[key] - 1:first_negative_eigenvalue_index_dict[key] + 1])) # calculate nondimensional applied load at limit point as the mean between the last stable and first unstable load
# Plot bar chart with limit point loads
fig, ax = plt.subplots(figsize=(FIG_WIDTH_INCHES*1.5, FIG_HEIGHT_INCHES/2))
y_pos = np.arange(len(sol_105_input_name_dict.keys())) # position of bars
p = ax.barh(y_pos, limit_point_loads, height=0.5) # plot bars
ax.bar_label(p, padding=3, fmt="{:,.2f}") # add labels to bars
ax.set_yticks(y_pos, labels=tuple(sol_105_input_name_dict.keys())) # set labels of y-axis
ax.invert_yaxis() # labels read top-to-bottom
ax.set_xlabel("$P_\mathrm{lp}/P_\mathrm{design}$") # set label of x-axis
ax.set_xlim(right=1.7) # set limits of x-axis
# Save figure
plt.tight_layout() # Ensures proper spacing between subplots
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "LimitPointLoadBarChart.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Calculate percentage differences w.r.t. distributed non-follower forces
print(f"""Percentage differences of limit point loads w.r.t. distributed non-follower result:
- distributed follower forces --> {100*(limit_point_loads[1]-limit_point_loads[0])/limit_point_loads[0]:.1f}%
- non-follower forces applied to LRA --> {100*(limit_point_loads[2]-limit_point_loads[0])/limit_point_loads[0]:.1f}%
""")
# Compare deformation under distributed non-follower forces vs non-follower forces applied to LRA under limit point load.
# In[7]:
# Plot deformation under distributed non-follower forces
key = "distributed non-follower forces"
print(f"Deformation under {key}")
clim = [-.02, .02] # color limits for deformation plot
plt.rcParams.update({'font.size': DEFAULT_FONT_SIZE*(1/.7)})
fig, ax, cbar = plot_utils.plot_static_deformation(
op2=sol_106_op2_dict[key], subcase_id=FIRST_SUBCASE_ID, load_step=first_negative_eigenvalue_index_dict[key], clim=clim,
displacement_component='rx', colormap='sunset', length_unit='m', unit_scale_factor=1e-3)
adjust_3d_plot(ax, cbar)
ax.set_zticks([0])
cbar.remove()
plt.show()
BBOX_NO_COLORBAR = fig.bbox_inches.from_bounds(.8, .5, 4., 3.9) # create bounding box for figure
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, f"deformation_{key.replace(' ', '_')}.png"),
format='png', bbox_inches=BBOX_NO_COLORBAR, pad_inches=0, dpi=500)
# Plot deformation under non-follower forces applied to LRA
key = "non-follower forces applied to LRA"
print(f"Deformation under {key}")
fig, ax, cbar = plot_utils.plot_static_deformation(
op2=sol_106_op2_dict[key], subcase_id=FIRST_SUBCASE_ID, load_step=first_negative_eigenvalue_index_dict[key], clim=clim,
displacement_component='rx', colormap='sunset', length_unit='m', unit_scale_factor=1e-3)
adjust_3d_plot(ax, cbar)
ax.set_zticks([0])
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, f"deformation_{key.replace(' ', '_')}.png"),
format='png', bbox_inches=BBOX_COLORBAR, pad_inches=0, dpi=500)
# Restore default font size
plt.rcParams.update({'font.size': DEFAULT_FONT_SIZE})
# Compare deformation before and after snap-through for the case with distributed non-follower forces.
# In[8]:
# Increase font size because figures are rendered side by side in the paper
plt.rcParams.update({'font.size': DEFAULT_FONT_SIZE*(1/.49)})
# Plot deformation just before limit point
key = "distributed non-follower forces"
print("Deformation before snap:")
amplification_factor = 50 # amplification factor for displacements
clim = [-.025, .025] # color limits for deformation plot
fig, ax, cbar = plot_utils.plot_static_deformation(
op2=sol_106_op2_dict[key], subcase_id=FIRST_SUBCASE_ID, load_step=first_negative_eigenvalue_index_dict[key], displacement_component='rx',
colormap='sunset', length_unit='m', unit_scale_factor=1e-3, clim=clim, displacement_amplification_factor=amplification_factor) # plot buckling mode converting from mm to m
adjust_3d_plot(ax)
cbar.remove()
ax.set_xlim(0, W*1e-3)
ax.set_ylim(0, 4*H*1e-3)
ax.set_zlim(-H/2*1e-3, H*1e-3)
ax.set_box_aspect([ub - lb for lb, ub in (getattr(ax, f"get_{a}lim")() for a in 'xyz')])
plt.axis('off')
ax.view_init(40, -20)
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "BeforeSnap.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Plot deformation just after snap
skip = 10 # number of increments to skip for selection of applied load after snap
subsequent_values = nondimensional_loads_dict[key][first_negative_eigenvalue_index_dict[key] + skip:] # consider only applied loads after snap
closest_index_in_subsequent = np.argmin(np.abs(limit_point_loads[0] - subsequent_values)) # find index of closest value to applied load at first limit point
closest_index = first_negative_eigenvalue_index_dict[key] + skip + closest_index_in_subsequent # sum found index to index of first negative eigenvalue
print("Deformation just after snap")
fig, ax, cbar = plot_utils.plot_static_deformation(
op2=sol_106_op2_dict[key], subcase_id=FIRST_SUBCASE_ID, load_step=closest_index + 1, displacement_component='rx', colormap='sunset', length_unit='m',
unit_scale_factor=1e-3, displacement_amplification_factor=amplification_factor, clim=clim) # plot buckling mode converting from mm to m
adjust_3d_plot(ax)
cbar.ax.set_position(cbar.ax.get_position().shrunk(1.0, .82)) # decrease colorbar size
cbar.ax.set_position(cbar.ax.get_position().translated(0, .14)) # move colorbar upwards
ax.set_xlim(0, W*1e-3)
ax.set_ylim(0, 4*H*1e-3)
ax.set_zlim(-H/2*1e-3, H*1e-3)
ax.set_box_aspect([ub - lb for lb, ub in (getattr(ax, f"get_{a}lim")() for a in 'xyz')])
plt.axis('off')
ax.view_init(40, -20)
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "AfterSnap.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Restore default font size
plt.rcParams.update({'font.size': DEFAULT_FONT_SIZE})
# ### Optimization with distributed non-follower forces
#
# Plot history of optimization with linear constraints.
# In[9]:
from resources import optimization_utils
recorder_filename = "sol_105_non_follower_linear_opt.sql"
recorder_filepath = os.path.join(ANALYSIS_DIRECTORY_PATH, recorder_filename)
fig, sol_105_opt_histories = optimization_utils.plot_optimization_history(recorder_filepath) # plot optimization history
mass_reduction = 100*(sol_105_opt_histories['nastran_solver.mass'][-1, 0] - sol_105_opt_histories['nastran_solver.mass'][0, 0])/\
sol_105_opt_histories['nastran_solver.mass'][0, 0]
print(f"Mass reduction: {mass_reduction:.1f}%")
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "NonFollowerLinearOptHistory.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Plot history of optimization with nonlinear constraints.
# In[10]:
recorder_filename = "sol_106_non_follower_nonlinear_opt.sql"
recorder_filepath = os.path.join(ANALYSIS_DIRECTORY_PATH, recorder_filename)
fig, sol_106_opt_histories = optimization_utils.plot_optimization_history(recorder_filepath)
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "NonFollowerNonlinearOptHistory.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Find final mass at last feasible iteration.
# In[11]:
feasible_design_index = np.where(sol_106_opt_histories['nastran_solver.ks_stability'][:, 0] <= 0)[0][-1]
initial_mass = sol_105_opt_histories["nastran_solver.mass"][0, 0]*1e3
final_masses = {"linear constraint": (sol_105_opt_histories["nastran_solver.mass"][-1, 0]*1e3,),
"nonlinear constraint": (sol_106_opt_histories["nastran_solver.mass"][feasible_design_index, 0]*1e3,)} # convert mass to kg
mass_reduction = 100*(sol_106_opt_histories['nastran_solver.mass'][feasible_design_index, 0] - sol_106_opt_histories['nastran_solver.mass'][0, 0])/\
sol_106_opt_histories['nastran_solver.mass'][0, 0]
print(f"Mass reduction: {mass_reduction:.1f}%")
# Plot bar chat of final thicknesses.
# In[12]:
# Define function to plot bar chart of final thicknesses
def plot_thickness_bar_chart(sol_105_opt_histories, sol_106_opt_histories, figure_filename, iter_index=-1):
# Set up data for the bar chart
optimizations = ("linear constraint", "nonlinear constraint")
final_thicknesses = {
"$t_\mathrm{root}$": (sol_105_opt_histories["interp.t_cp"][-1, 0], sol_106_opt_histories["interp.t_cp"][iter_index, 0]),
"$t_\mathrm{tip}$": (sol_105_opt_histories["interp.t_cp"][-1, 1], sol_106_opt_histories["interp.t_cp"][iter_index, 1])
}
# Plot bar chart with final thicknesses
y_pos = np.arange(len(optimizations)) # the label locations
height = 0.35 # the height of the bars
multiplier = .5 # multiplier to separate bars
fig, ax = plt.subplots(figsize=(FIG_WIDTH_INCHES*1.5, FIG_HEIGHT_INCHES/2)) # create figure and axis
for attribute, values in final_thicknesses.items(): # iterate over attributes and values
offset = height * multiplier # offset to separate bars
rects = ax.barh(y_pos + offset, values, height, label=attribute) # plot bars
ax.bar_label(rects, padding=3, fmt="{:,.1f}") # add labels to bars
multiplier += 1 # increase multiplier to separate bars
# Add some text for labels, title and custom x-axis tick labels, etc.
ax.set_xlabel("$t$, mm")
ax.invert_yaxis() # labels read top-to-bottom
ax.set_yticks(y_pos + height, optimizations)
ax.set_xlim(right=8.6) # set limits of x-axis
# Save figure
plt.tight_layout() # ensures proper spacing between subplots
ax.legend(bbox_to_anchor=(0.02, 1.), ncols=2, frameon=False) # add legend after tight layout
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, figure_filename + '.svg'), format='svg', bbox_inches='tight', pad_inches=0)
# Plot bar chart with final thicknesses
figure_name = "Non-followerThicknesses"
plot_thickness_bar_chart(sol_105_opt_histories, sol_106_opt_histories, figure_name)
# Comparison of nonlinear structural stability response of initial structure vs linearly optimized structure vs nonlinearly optimized structure.
# In[13]:
# Initialize dictionary with names of input files
input_name_dict = {"initial structure": "sol_106_33120_elements",
"linearly optimized structure": "sol_106_non_follower_linear_opt",
"nonlinearly optimized structure": "sol_106_non_follower_nonlinear_opt"}
# Plot load-displacement diagrams
fig, axes, stable_lines, eigenvalues_dict, sol_106_op2_dict, nondimensional_applied_loads_dict = plot_load_displacement_diagram(input_name_dict)
sol_105_input_name = "sol_105_non_follower_nonlinear_opt"
sol_105_op2 = read_op2(os.path.join(ANALYSIS_DIRECTORY_PATH, sol_105_input_name + '.op2'), load_geometry=True, debug=None)
buckling_load_factor = sol_105_op2.eigenvectors[SECOND_SUBCASE_ID].eigrs[0]
axes[0].axhline(y=buckling_load_factor, color=GLASS_CEILING_COLOR, linestyle='-.') # plot glass ceiling
axes[1].axhline(y=buckling_load_factor, color=GLASS_CEILING_COLOR, linestyle='-.') # plot glass ceiling
glass_ceiling_line = Line2D([0], [0], linestyle='-.', color=GLASS_CEILING_COLOR, label="linear buckling of nonlinearly optimized structure")
# Set plot appearance for the first subplot
axes[0].set_xlabel(f"$u_{{z,\,{max_displacement_node_id:d}}}/w$, \%")
axes[0].set_ylabel("$P/P_\mathrm{design}$")
axes[0].grid(visible=True)
# Set plot appearance for the second subplot
axes[1].set_xlabel("$u_{z,\,\mathrm{tip}}/l$, \%")
axes[1].grid(visible=True)
# Save figure
plt.tight_layout() # ensures proper spacing between subplots
axes[0].legend(handles=stable_lines + [glass_ceiling_line], loc="upper left",
ncols=2, bbox_to_anchor=(-.03, 1.3), frameon=False) # add legend after tight_layout
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "Non-followerLoadDisplacementDiagram.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Compare deformation at design load of linearly vs nonlinearly optimized structure.
# In[14]:
def plot_deformation_comparison(nondimensional_applied_loads_dict, sol_106_op2_dict, label):
# Plot deformation of linearly optimized structure
key = "linearly optimized structure"
print(f"Deformation of {key}")
clim = [-.022, .022] # color limits for deformation plot
increment_number = np.argmin(np.abs(nondimensional_applied_loads_dict[key] - 1)) + 1 # find number of closest converged increment to design load
plt.rcParams.update({'font.size': DEFAULT_FONT_SIZE*(1/.7)})
fig, ax, cbar = plot_utils.plot_static_deformation(
op2=sol_106_op2_dict[key], subcase_id=FIRST_SUBCASE_ID, load_step=increment_number, clim=clim,
displacement_component='rx', colormap='sunset', length_unit='m', unit_scale_factor=1e-3)
adjust_3d_plot(ax, cbar)
ax.set_zticks([0])
cbar.remove()
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, f"{label}_deformation_{key.replace(' ', '_')}.png"),
format='png', bbox_inches=BBOX_NO_COLORBAR, pad_inches=0, dpi=500)
# Plot deformation of nonlinearly optimized structure
key = "nonlinearly optimized structure"
print(f"Deformation of {key}")
fig, ax, cbar = plot_utils.plot_static_deformation(
op2=sol_106_op2_dict[key], subcase_id=FIRST_SUBCASE_ID, clim=clim, displacement_component='rx', colormap='sunset', length_unit='m', unit_scale_factor=1e-3)
adjust_3d_plot(ax, cbar)
ax.set_zticks([0])
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, f"{label}_deformation_{key.replace(' ', '_')}.png"),
format='png', bbox_inches=BBOX_COLORBAR, pad_inches=0, dpi=500)
# Restore default font size
plt.rcParams.update({'font.size': DEFAULT_FONT_SIZE})
label = "non-follower"
plot_deformation_comparison(nondimensional_applied_loads_dict, sol_106_op2_dict, label)
# ### Optimization with follower forces
#
# Plot history of optimization with linear constraints.
# In[15]:
recorder_filename = "sol_105_follower_linear_opt.sql"
recorder_filepath = os.path.join(ANALYSIS_DIRECTORY_PATH, recorder_filename)
fig, sol_105_opt_histories = optimization_utils.plot_optimization_history(recorder_filepath) # plot optimization history
final_masses["linear constraint"] = final_masses["linear constraint"] + (sol_105_opt_histories["nastran_solver.mass"][-1, 0]*1e3,) # convert mass to kg
mass_reduction = 100*(sol_105_opt_histories['nastran_solver.mass'][-1, 0] - sol_105_opt_histories['nastran_solver.mass'][0, 0])/\
sol_105_opt_histories['nastran_solver.mass'][0, 0]
print(f"Mass reduction: {mass_reduction:.1f}%")
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "FollowerLinearOptHistory.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Plot history of optimization with nonlinear constraints.
# In[16]:
recorder_filename = "sol_106_follower_nonlinear_opt.sql"
recorder_filepath = os.path.join(ANALYSIS_DIRECTORY_PATH, recorder_filename)
fig, sol_106_opt_histories = optimization_utils.plot_optimization_history(recorder_filepath) # plot optimization history
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "FollowerNonlinearOptHistory.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Find final mass at last feasible iteration.
# In[17]:
feasible_design_index = np.where(sol_106_opt_histories['nastran_solver.ks_stability'][:, 0] <= 0)[0][-1]
final_masses["nonlinear constraint"] = final_masses["nonlinear constraint"] +\
(sol_106_opt_histories["nastran_solver.mass"][feasible_design_index, 0]*1e3,) # convert mass to kg
mass_reduction = 100*(sol_106_opt_histories['nastran_solver.mass'][feasible_design_index, 0] -\
sol_106_opt_histories['nastran_solver.mass'][0, 0])/sol_106_opt_histories['nastran_solver.mass'][0, 0]
print(f"Mass reduction: {mass_reduction:.1f}%")
# Plot bar chat of final thicknesses.
# In[18]:
figure_name = "FollowerForcesThicknesses"
plot_thickness_bar_chart(sol_105_opt_histories, sol_106_opt_histories, figure_name)
# Comparison of nonlinear structural stability response of initial structure vs linearly optimized structure vs nonlinearly optimized structure.
# In[19]:
# Initialize dictionary with names of input files
input_name_dict = {"initial structure": "sol_106_follower_linear_opt_start",
"linearly optimized structure": "sol_106_follower_linear_opt",
"nonlinearly optimized structure": "sol_106_follower_nonlinear_opt"}
# Plot load-displacement diagrams
fig, axes, stable_lines, eigenvalues_dict, sol_106_op2_dict, nondimensional_applied_loads_dict = plot_load_displacement_diagram(input_name_dict)
sol_105_input_name = "sol_105_follower_nonlinear_opt"
sol_105_op2 = read_op2(os.path.join(ANALYSIS_DIRECTORY_PATH, sol_105_input_name + '.op2'), load_geometry=True, debug=None)
buckling_load_factor = sol_105_op2.eigenvectors[SECOND_SUBCASE_ID].eigrs[0]
axes[0].axhline(y=buckling_load_factor, color=GLASS_CEILING_COLOR, linestyle='-.') # plot glass ceiling
axes[1].axhline(y=buckling_load_factor, color=GLASS_CEILING_COLOR, linestyle='-.') # plot glass ceiling
glass_ceiling_line = Line2D([0], [0], linestyle='-.', color=GLASS_CEILING_COLOR, label="linear buckling of nonlinearly optimized structure")
# Set plot appearance for the first subplot
axes[0].set_xlabel(f"$u_{{z,\,{max_displacement_node_id:d}}}/w$, \%")
axes[0].set_ylabel("$P/P_\mathrm{design}$")
axes[0].grid(visible=True)
# Set plot appearance for the second subplot
axes[1].set_xlabel("$u_{z,\,\mathrm{tip}}/l$, \%")
axes[1].grid(visible=True)
# Save figure
plt.tight_layout() # ensures proper spacing between subplots
axes[0].legend(handles=stable_lines + [glass_ceiling_line], loc="upper left",
ncols=2, bbox_to_anchor=(-.03, 1.3), frameon=False) # add legend after tight_layout
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "FollowerForcesLoadDisplacementDiagram.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Compare deformation at design load of linearly vs nonlinearly optimized structure.
# In[20]:
label = "follower"
plot_deformation_comparison(nondimensional_applied_loads_dict, sol_106_op2_dict, label)
# ### Optimization with non-follower forces applied to load reference axis
#
# Plot history of optimization with linear constraints.
# In[21]:
recorder_filename = "sol_105_lra_linear_opt.sql"
recorder_filepath = os.path.join(ANALYSIS_DIRECTORY_PATH, recorder_filename)
fig, sol_105_opt_histories = optimization_utils.plot_optimization_history(recorder_filepath) # plot optimization history
final_masses["linear constraint"] = final_masses["linear constraint"] + (sol_105_opt_histories["nastran_solver.mass"][-1, 0]*1e3,) # convert mass to kg
mass_reduction = 100*(sol_105_opt_histories['nastran_solver.mass'][-1, 0] - sol_105_opt_histories['nastran_solver.mass'][0, 0])/\
sol_105_opt_histories['nastran_solver.mass'][0, 0]
print(f"Mass reduction: {mass_reduction:.1f}%")
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "LraLinearOptHistory.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Plot history of optimization with nonlinear constraints.
# In[22]:
recorder_filename = "sol_106_lra_nonlinear_opt.sql"
recorder_filepath = os.path.join(ANALYSIS_DIRECTORY_PATH, recorder_filename)
fig, sol_106_opt_histories = optimization_utils.plot_optimization_history(recorder_filepath) # plot optimization history
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "LraNonlinearOptHistory.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Find final mass at last feasible iteration.
# In[23]:
feasible_design_index = np.where(sol_106_opt_histories['nastran_solver.ks_stability'][:, 0] <= 0)[0][-1]
final_masses["nonlinear constraint"] = final_masses["nonlinear constraint"] +\
(sol_106_opt_histories["nastran_solver.mass"][feasible_design_index, 0]*1e3,) # convert mass to kg
mass_reduction = 100*(sol_106_opt_histories['nastran_solver.mass'][feasible_design_index, 0] -\
sol_106_opt_histories['nastran_solver.mass'][0, 0])/sol_106_opt_histories['nastran_solver.mass'][0, 0]
print(f"Mass reduction: {mass_reduction:.1f}%")
# Plot bar chat of final thicknesses.
# In[24]:
figure_name = "LRAThicknesses"
plot_thickness_bar_chart(sol_105_opt_histories, sol_106_opt_histories, figure_name, feasible_design_index)
# Comparison of nonlinear structural stability response of initial structure vs linearly optimized structure vs nonlinearly optimized structure.
# In[25]:
# Initialize dictionary with names of input files
input_name_dict = {"initial structure": "sol_106_lra_linear_opt_start",
"linearly optimized structure": "sol_106_lra_linear_opt",
"nonlinearly optimized structure": "sol_106_lra_nonlinear_opt"}
# Plot load-displacement diagrams
fig, axes, stable_lines, eigenvalues_dict, sol_106_op2_dict, nondimensional_applied_loads_dict = plot_load_displacement_diagram(input_name_dict)
sol_105_input_name = "sol_105_lra_nonlinear_opt"
sol_105_op2 = read_op2(os.path.join(ANALYSIS_DIRECTORY_PATH, sol_105_input_name + '.op2'), load_geometry=True, debug=None)
buckling_load_factor = sol_105_op2.eigenvectors[SECOND_SUBCASE_ID].eigrs[0]
axes[0].axhline(y=buckling_load_factor, color=GLASS_CEILING_COLOR, linestyle='-.') # plot glass ceiling
axes[1].axhline(y=buckling_load_factor, color=GLASS_CEILING_COLOR, linestyle='-.') # plot glass ceiling
glass_ceiling_line = Line2D([0], [0], linestyle='-.', color=GLASS_CEILING_COLOR, label="linear buckling of nonlinearly optimized structure")
# Set plot appearance for the first subplot
axes[0].set_xlabel(f"$u_{{z,\,{max_displacement_node_id:d}}}/w$, \%")
axes[0].set_ylabel("$P/P_\mathrm{design}$")
axes[0].grid(visible=True)
# Set plot appearance for the second subplot
axes[1].set_xlabel("$u_{z,\,\mathrm{tip}}/l$, \%")
axes[1].grid(visible=True)
# Save figure
plt.tight_layout() # ensures proper spacing between subplots
axes[0].legend(handles=stable_lines + [glass_ceiling_line], loc="upper left",
ncols=2, bbox_to_anchor=(-.03, 1.3), frameon=False) # add legend after tight_layout
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "LRALoadDisplacementDiagram.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Compare deformation at design load of linearly vs nonlinearly optimized structure.
# In[26]:
label = "lra"
plot_deformation_comparison(nondimensional_applied_loads_dict, sol_106_op2_dict, label)
# ### Comparative discussion
#
# Plot bar chart of final masses.
# In[27]:
# Set up data for the bar chart
optimizations = ("distributed non-follower forces", "distributed follower forces", "non-follower forces applied to LRA")
# Plot bar chart with final thicknesses
y_pos = np.arange(len(optimizations)) # the label locations
height = 0.35 # the height of the bars
multiplier = .5 # multiplier to separate bars
fig, ax = plt.subplots(figsize=(FIG_WIDTH_INCHES*1.5, FIG_HEIGHT_INCHES/1.5)) # create figure and axis
for attribute, values in final_masses.items(): # iterate over attributes and values
offset = height * multiplier # offset to separate bars
rects = ax.barh(y_pos + offset, values, height, label=attribute) # plot bars
ax.bar_label(rects, padding=3, fmt="{:,.1e}") # add labels to bars
multiplier += 1 # increase multiplier to separate bars
# Add some text for labels, title and custom x-axis tick labels, etc.
ax.set_xlabel("$m$, kg")
ax.invert_yaxis() # labels read top-to-bottom
ax.set_yticks(y_pos + height, optimizations)
ax.set_xlim(right=4.9e3) # set limits of x-axis
# Save figure
plt.tight_layout() # ensures proper spacing between subplots
ax.legend(bbox_to_anchor=(0.05, 1.), ncols=2, frameon=False) # add legend after tight layout
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "FinalMassesBarChart.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Calculate percentage differences w.r.t. initial mass
key = "nonlinear constraint"
print(f"""Percentage differences of final masses w.r.t. initial mass:
- distributed non-follower forces: {100*(final_masses[key][0] - initial_mass)/initial_mass:.1f}%
- distributed follower forces: {100*(final_masses[key][1] - initial_mass)/initial_mass:.1f}%
- non-follower forces applied to LRA: {100*(final_masses[key][2] - initial_mass)/initial_mass:.1f}%
""")
# ## Appendix
#
# ***
#
# Plot mesh convergence study.
# In[28]:
from resources import box_beam_utils
# Define additional geometric parameters
no_stiffeners = 2 # number of stiffeners
h_s = H/10 # height of stiffeners [mm]
stiffeners_x_locations = np.linspace(0, W, no_stiffeners + 2)[1:-1] # [mm] stiffeners x-coordinates
stiffeners_spacing = W/(no_stiffeners + 1) # [mm] stiffeners spacing
ribs_spacing = stiffeners_spacing*1.4 # [mm] ribs spacing
no_ribs = round(L/ribs_spacing) + 1 # number of ribs
ribs_y_locations = np.linspace(0, L, no_ribs) # [mm] ribs y-coordinates
# Define shell elements' lengths to be used for the mesh convergence study and print them to screen
shell_element_lengths = np.geomspace(H/2, h_s/8, 10) # [mm]
np.set_printoptions(precision=3, suppress=True)
print("Prescribed length of shell elements for mesh convergence study [mm]:")
print(shell_element_lengths)
# Initialize arrays with number of elements, number of degrees of freedom and linear buckling loads
dofs = np.empty(np.shape(shell_element_lengths), dtype=int)
linear_buckling_loads = np.empty(np.shape(shell_element_lengths))
# Iterate through the different target element lengths
for count, element_length in enumerate(shell_element_lengths):
# Generate mesh
mesh = box_beam_utils.mesh_box_beam_reinforced_with_ribs_and_stiffeners(
width=W, height=H, ribs_y_coordinates=ribs_y_locations, stiffeners_x_coordinates=stiffeners_x_locations,
stiffeners_height=h_s, element_length=element_length)
nodes_connectivity_matrix = mesh.faces.reshape(-1, 5)[:, 1:]
# Read op2 file
input_name = f"sol_105_{len(nodes_connectivity_matrix)}_elements"
op2_filepath = os.path.join(ANALYSIS_DIRECTORY_PATH, input_name + '.op2')
sol_105_op2 = read_op2(op2_filename=op2_filepath, load_geometry=True, debug=None)
# Find number of degrees of freedom and critical buckling load
constrained_nodes_ids = next(iter(sol_105_op2.spcs.values()))[0].node_ids
dofs[count] = (len(sol_105_op2.nodes) - len(constrained_nodes_ids))*6
linear_buckling_loads[count] = sol_105_op2.eigenvectors[SECOND_SUBCASE_ID].eigrs[0]
# Plot buckling loads vs degrees of freedom with a log scale along the x-axis
fig, ax1 = plt.subplots(figsize=(FIG_WIDTH_INCHES, FIG_HEIGHT_INCHES)) # create a figure and set of axes
buckling_load_line = ax1.semilogx(dofs, linear_buckling_loads, 'o-')
# Create a twin set of axes to plot percentage difference vs degrees of freedom
ax2 = ax1.twinx()
percentage_diff = (linear_buckling_loads/linear_buckling_loads[-1]-1)*100
percentage_diff_line = ax2.plot(dofs, percentage_diff, 'o-')
# Set axes labels and grid
ax1.set_xlabel("Degrees of freedom")
ax1.set_ylabel("$P_\mathrm{SOL\,105}$, N")
ax2.set_ylabel("Difference w.r.t. finest mesh, \%")
ax1.grid(True)
ax2.grid(True)
# Show plot and save figure
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "MeshConvergenceStudy.svg"), format='svg', bbox_inches='tight', pad_inches=0)
# Plot mesh convergence verification.
# In[29]:
# Define dictionary with names of input files
input_name_dict = {"original mesh": "33120_elements",
"refined mesh": "108276_elements"}
# Initialize dictionaries to store ids of nodes where max displacement occurs and tip nodes
root_node_id_dict = {} # dictionary to store ids of nodes where max displacement occurs
tip_node_id_dict = {} # dictionary to store ids of tip nodes
# Create 3D plot of equilibrium diagram
fig = plt.figure() # create a figure
ax_3d = plt.axes(projection="3d") # create 3D axes
stable_lines = [] # list to store proxy artists for the legend
# Iterate over the different meshes
for count, key in enumerate(input_name_dict):
# Read op2 file of linear buckling analysis
op2_filepath = os.path.join(ANALYSIS_DIRECTORY_PATH, f"sol_105_{input_name_dict[key]}.op2")
sol_105_op2 = read_op2(op2_filename=op2_filepath, load_geometry=True, debug=None)
# Find node where max displacement occurs
max_displacement_index = np.argmax(np.linalg.norm(sol_105_op2.eigenvectors[SECOND_SUBCASE_ID].data[0, :, 0:3], axis=1)) # find index of max displacement magnitude
root_node_id_dict[key] = sol_105_op2.eigenvectors[SECOND_SUBCASE_ID].node_gridtype[max_displacement_index, 0] # find id of node with max displacement magnitude
# Find tip node id
nodes_xyz_array = np.array([node.xyz for node in sol_105_op2.nodes.values()]) # get nodes' coordinates
tip_node_id_dict[key] = np.argmin(np.linalg.norm(nodes_xyz_array - tip_node_xyz, axis=1)) + 1 # find id of tip node
# Read op2 file of nonlinear analysis
op2_filepath = os.path.join(ANALYSIS_DIRECTORY_PATH, f"sol_106_{input_name_dict[key]}.op2")
sol_106_op2 = read_op2(op2_filename=op2_filepath, load_geometry=True, debug=None)
# Read load-displacement history
_, loads, displacements = pynastran_utils.read_load_displacement_history_from_op2(
op2=sol_106_op2, node_ids=[root_node_id_dict[key], tip_node_id_dict[key]])
# Read eigenvalues
f06_filepath = os.path.join(ANALYSIS_DIRECTORY_PATH, f"sol_106_{input_name_dict[key]}.f06") # path of f06 file
eigenvalues = pynastran_utils.read_kllrh_lowest_eigenvalues_from_f06(f06_filepath) # read eigenvalues from f06 file
# Nondimensionalize loads and displacements
nondimensional_loads = loads[FIRST_SUBCASE_ID][:, Z_COMPONENT_INDEX]/design_load
nondimensional_displacements = {"root": displacements[root_node_id_dict[key]][FIRST_SUBCASE_ID][:, Z_COMPONENT_INDEX]/W,
"tip": displacements[tip_node_id_dict[key]][FIRST_SUBCASE_ID][:, Z_COMPONENT_INDEX]/L}
# Plot load-displacement curve
plot_utils.plot_3d_load_displacements_stability(axes=ax_3d,
displacements1=nondimensional_displacements["root"]*100,
displacements2=nondimensional_displacements["tip"]*100,
loads=nondimensional_loads,
eigenvalues=eigenvalues,
marker=MARKERS[count], color=COLORS[count])
stable_lines.append(Line2D([0], [0], linestyle='-', marker=MARKERS[count], color=COLORS[count], label=key)) # create proxy artist for the legend
# Add proxy artist for unstable lines and create legend
fig.legend(handles=stable_lines + [unstable_line], loc="upper center", bbox_to_anchor=(0.5, 0.8))
# Set axes labels and grid
ax_3d.set_xlabel("$u_{z,\,root}/w$, \%")
ax_3d.set_ylabel("$u_{z,\,tip}/l$, \%")
ax_3d.set_zlabel("$P/P_\mathrm{design}$")
ax_3d.grid(visible=True)
# Show plot and save figure
plt.show()
fig.savefig(os.path.join(FIGURES_DIRECTORY_PATH, "MeshConvergenceVerification.svg"), format='svg', bbox_inches='tight', pad_inches=0)