From 10b08ee73c225802a8d05dd37755b007ad71526c Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Fri, 5 Sep 2025 17:10:18 -0400 Subject: [PATCH 01/17] Adding medium_transport example. Started the example script for a generic Medium Transport. Included code to plot the station geometry in a grid. This took way too long... --- examples/medium_transport.py | 129 +++++++++++++++++++++++ sample_input_out_files/ctrl_inputs.yaml | 35 ------ sample_input_out_files/print_inputs.yaml | 4 - src/hyperstruct/__init__.py | 37 +++++-- 4 files changed, 156 insertions(+), 49 deletions(-) create mode 100644 examples/medium_transport.py delete mode 100644 sample_input_out_files/ctrl_inputs.yaml delete mode 100644 sample_input_out_files/print_inputs.yaml diff --git a/examples/medium_transport.py b/examples/medium_transport.py new file mode 100644 index 0000000..6db3594 --- /dev/null +++ b/examples/medium_transport.py @@ -0,0 +1,129 @@ +"""This script builds components for an example Medium Transport aircraft. + +All the values in this file are taken from publicly available data +on the Lockheed C-130J-30. However, instead of matching the fuselage shape +exactly, a rounded rectangle will be used. + +Assumptions are made for material properties, and material data taken +from either SWEEP documentation or Matweb. + +Unit system is lbf, in, s + +References: +https://en.wikipedia.org/wiki/Lockheed_C-130_Hercules +https://man.fas.org/dod-101/sys/ac/c-130.htm +https://www.lockheedmartin.com/content/dam/lockheed-martin/aero/documents/sustainment/csc/service-news/sn-mag-v1-v10/V2N1.pdf +""" + +import matplotlib.pyplot as plt + +from hyperstruct import Material +from hyperstruct import Station + + +# from hyperstruct.fuselage import Cover +# from hyperstruct.fuselage import ForcedCrippling +# from hyperstruct.fuselage import Fuselage +# from hyperstruct.fuselage import MajorFrame + + +# Some global variables for reference +FUSELAGE_LENGTH = 12 * (97 + 15) + 9 +# Ignore the floor break transitions, since we're not matching the exact shape +# we'll turn this into a rounded rectangle of equivalent length and width. +FUSELAGE_DIAMETER = 12 * 14.17 +CENTER_FUSE_Z_REF = 12 * 7 +# A dict of FS locations where Major Frames exist and shapes change +_w, _d, _z, _r = [ + FUSELAGE_DIAMETER, + FUSELAGE_DIAMETER, + CENTER_FUSE_Z_REF, + 0.45 * FUSELAGE_DIAMETER, +] +FS_DICT = { + 93: [ + "Nose Station, and NLG Bay Start", + 12 * 2 + 10, + 12 * 3 + 2, + 0.5 * CENTER_FUSE_Z_REF, + 13, + ], + 165: ["FWD Fuse, and NLG Bay End", _w, _d, _z, _r], + 245: ["FWD Fuse Mate", _w, _d, _z, _r], + 497: ["MLG Bay Start, Wing Fwd Spar", _w, _d, _z, _r], + 620: ["MLG Bay End, Wing Aft Spar", _w, _d, _z, _r], + 737: ["Empennage Mate", _w, 0.9 * _d, _z + 0.1 * _d, _r], + 941: ["Ramp Cutout End", _w, 0.5 * _d, _z + 0.5 * _d, 0.5 * _r], + 1071: [ + "Vert and Horz Stabilizer Fwd Spar", + 0.7 * _w, + 0.3 * _d, + _z + 0.7 * _d, + 0.3 * _r, + ], + 1200: ["Tail Station", 0.5 * _w, 0.2 * _d, _z + 0.7 * _d, 0.2 * _r], +} +# + +# Build the stations geometry +stations = [] +for fs, values in FS_DICT.items(): + name, width, depth, z_ref, radius = values + stations.append( + Station( + orientation="FS", + name=name, + number=fs, + width=width, + depth=depth, + vertical_centroid=z_ref, + radius=radius, + ) + ) + +# Plot the geometry +fig, axs = plt.subplots( + nrows=3, + ncols=3, + figsize=(9, 9), + layout="constrained", + gridspec_kw=dict(hspace=0, wspace=0), + sharex="col", + sharey="row", + subplot_kw=dict(box_aspect=1), +) +axs = axs.flatten() +square_side = 1.1 * _d + _z +xlim = (-square_side / 2, square_side / 2) +ylim = (0, square_side) +# print(f"x-axis width = {xlim[1]-xlim[0]:.2f}") +# print(f"y-axis depth = {ylim[1]-ylim[0]:.2f}") + +for i, station in enumerate(stations): + station.show(display=False, ax=axs[i], xlim=xlim, ylim=ylim) + +for ax in fig.get_axes(): + ax.label_outer() + +fig.suptitle( + "Station Diagrams for Generic Medium Transport", fontfamily="serif", fontsize=16 +) +plt.show() + + +# A basic aluminum material +# 2024-T3, Sheet, A-basis +al2024 = Material( + rho=0.1, + E=10.5e6, + E_c=10.6e6, + nu=0.33, + F_tu=64e3, + F_ty=42.1e3, + F_cy=48.3e3, + F_su=41.0e3, + F_bru=10.04e3, + F_bry=89.0e3, + F_en=20.0e3, + db_r=116, +) diff --git a/sample_input_out_files/ctrl_inputs.yaml b/sample_input_out_files/ctrl_inputs.yaml deleted file mode 100644 index f0e8b9f..0000000 --- a/sample_input_out_files/ctrl_inputs.yaml +++ /dev/null @@ -1,35 +0,0 @@ ---- -# This is an example input file, see ADA002852, Table 4 -# for source documentation. -air_vehicle_class: fighter -wing_type: fixed -vertical_tail_type: single -load_calc_options: basic -vehicle_load_calcs: all -calc_fuselage_loads: true -calc_wing_loads: true -calc_horz_tail_loads: true -calc_vert_tail_loads: true -calc_pos_maneuver: true -calc_neg_maneuver: true -calc_flaps_down_maneuver: true -calc_flaps_down_land: true -calc_pos_vertical_gusts: true -calc_neg_vertical_gusts: true -calc_lateral_gusts: true -calc_pitch_accel: true -calc_yaw_accel: true -wing_fatigue_spectra_calcs: all -wing_construction: metal -horz_tail_construction: metal -vert_tail_construction: metal -exec_airloads: true -exec_wing_empennage: true -exec_fuselage: true -exec_landing_gear: true -exec_horz_tail: true -exec_vert_tail: true -exec_air_induc_sys: true -exec_fatigue: true -exec_outputs: true -modify_subsequent_cases: false diff --git a/sample_input_out_files/print_inputs.yaml b/sample_input_out_files/print_inputs.yaml deleted file mode 100644 index c83d72d..0000000 --- a/sample_input_out_files/print_inputs.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# TODO ---- -# This is an example input file, see ADA002852, Table 3 -# for source documentation. diff --git a/src/hyperstruct/__init__.py b/src/hyperstruct/__init__.py index 927554b..86d7c2a 100644 --- a/src/hyperstruct/__init__.py +++ b/src/hyperstruct/__init__.py @@ -279,21 +279,32 @@ def curvature(self) -> float: raise NotImplementedError def show( - self, coords: Optional[List[Tuple[float, float]]] = None, display: bool = True - ) -> Tuple[Figure, Axes]: + self, + coords: Optional[List[Tuple[float, float]]] = None, + display: bool = True, + ax: Axes = None, + xlim: None | Tuple[float, float] = None, + ylim: None | Tuple[float, float] = None, + ) -> None | Tuple[Figure, Axes]: """Plot the station shape for a visual check. This method just uses matplotlib to draw the shape on a plot. It will select the appropriate shape (Artist) object, based on the Station properties, and put in a figure on it's own. """ - fig, ax = plt.subplots() - lower_y = 0.0 if self.vertical_centroid >= 0 else self.vertical_centroid - ax.set( - xlim=(-1.1 * self.width, 1.1 * self.width), - ylim=(lower_y, 1.1 * self.depth + self.vertical_centroid), - aspect="equal", - ) + if not ax: + # If a specific axes object is passed, use that axes, + # otherwise, we need to instantiate the axes + fig, ax = plt.subplots() + + if not xlim: + xlim = (-1.1 * self.width, 1.1 * self.width) + + if not ylim: + lower_y = 0.0 if self.vertical_centroid >= 0 else self.vertical_centroid + ylim = (lower_y, 1.1 * self.depth + self.vertical_centroid) + + ax.set(xlim=xlim, ylim=ylim) if self.is_ellipse: obj = Ellipse( @@ -370,10 +381,16 @@ def show( markersize=4, ) + _ = ax.set_title( + label=f"FS {self.number}, {self.name}", fontfamily="serif", fontsize="small" + ) if display: plt.show() - return (fig, ax) + if ax: + return None + else: + return (fig, ax) def _quadratic_sol( self, m: float, q: float, p: float From 32e693570be6f144c814ab43eb848ef864151296 Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Fri, 19 Sep 2025 14:53:32 -0400 Subject: [PATCH 02/17] Adding a couple function arguments. This makes the methods more flexible. --- src/hyperstruct/__init__.py | 8 +++++--- src/hyperstruct/fuselage.py | 11 ++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/hyperstruct/__init__.py b/src/hyperstruct/__init__.py index 86d7c2a..c94dfd3 100644 --- a/src/hyperstruct/__init__.py +++ b/src/hyperstruct/__init__.py @@ -282,7 +282,7 @@ def show( self, coords: Optional[List[Tuple[float, float]]] = None, display: bool = True, - ax: Axes = None, + axes: Axes = None, xlim: None | Tuple[float, float] = None, ylim: None | Tuple[float, float] = None, ) -> None | Tuple[Figure, Axes]: @@ -292,10 +292,12 @@ def show( It will select the appropriate shape (Artist) object, based on the Station properties, and put in a figure on it's own. """ - if not ax: + if not axes: # If a specific axes object is passed, use that axes, # otherwise, we need to instantiate the axes fig, ax = plt.subplots() + else: + ax = axes if not xlim: xlim = (-1.1 * self.width, 1.1 * self.width) @@ -387,7 +389,7 @@ def show( if display: plt.show() - if ax: + if axes: return None else: return (fig, ax) diff --git a/src/hyperstruct/fuselage.py b/src/hyperstruct/fuselage.py index 2f1863d..8693bf5 100644 --- a/src/hyperstruct/fuselage.py +++ b/src/hyperstruct/fuselage.py @@ -1426,7 +1426,7 @@ class MajorFrame(Component): min_gauge: float = field(default=0.040, metadata={"unit": "inch"}) """Manufacturing requirement for minimum gauge thickness, default 0.040[in].""" - def show(self, show_coords: bool = False, save: bool = False) -> Tuple[Any, Any]: + def show(self, show_coords: bool = False, save: bool = False, display: bool=False) -> Tuple[Any, Any]: """Plot the frame and applied loads.""" if show_coords: # coords = [(row[5], row[6]) for row in self.cuts] @@ -1468,7 +1468,7 @@ def show(self, show_coords: bool = False, save: bool = False) -> Tuple[Any, Any] "color": "red", }, ) - _ = ax.annotate(abs(vertical), xy=v_tip) + _ = ax.annotate(f"{abs(vertical):.2f}", xy=v_tip) if horizontal != 0.0: _ = ax.annotate( "", @@ -1481,7 +1481,7 @@ def show(self, show_coords: bool = False, save: bool = False) -> Tuple[Any, Any] }, ) anchor = "left" if horizontal > 0 else "right" - _ = ax.annotate(abs(horizontal), xy=h_tip, ha=anchor) + _ = ax.annotate(f"{abs(horizontal):.2f}", xy=h_tip, ha=anchor) if moment != 0.0: z_a = z - self.geom.depth / 10 z_b = z + self.geom.depth / 10 @@ -1510,7 +1510,7 @@ def show(self, show_coords: bool = False, save: bool = False) -> Tuple[Any, Any] ) moment_text = (y_moment, z) anchor = "left" if moment > 0 else "right" - _ = ax.annotate(abs(moment), xy=moment_text, ha=anchor) + _ = ax.annotate(f"{abs(moment):.2f}", xy=moment_text, ha=anchor) _ = ax.set_xlabel("Butt Line, $BL$", fontfamily="serif") _ = ax.set_ylabel("Water Line, $WL$", fontfamily="serif") @@ -1537,7 +1537,8 @@ def show(self, show_coords: bool = False, save: bool = False) -> Tuple[Any, Any] if save: fig.savefig(f"FS{self.fs_loc}_geom_loads.png") - plt.show() + if display: + plt.show() return fig, ax From b6273ef47400c831012888d15ddc83324db4f263 Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Fri, 19 Sep 2025 14:55:24 -0400 Subject: [PATCH 03/17] Expanding the Medium Transport Example. Putting the loads onto frames, and building up the fuselage. --- examples/medium_transport.py | 160 +++++++++++++++++++++++++++++++++-- 1 file changed, 154 insertions(+), 6 deletions(-) diff --git a/examples/medium_transport.py b/examples/medium_transport.py index 6db3594..0f1114b 100644 --- a/examples/medium_transport.py +++ b/examples/medium_transport.py @@ -16,15 +16,16 @@ """ import matplotlib.pyplot as plt +import numpy as np from hyperstruct import Material from hyperstruct import Station - +from hyperstruct import composite_cg # from hyperstruct.fuselage import Cover # from hyperstruct.fuselage import ForcedCrippling -# from hyperstruct.fuselage import Fuselage -# from hyperstruct.fuselage import MajorFrame +from hyperstruct.fuselage import Fuselage +from hyperstruct.fuselage import MajorFrame # Some global variables for reference @@ -32,7 +33,7 @@ # Ignore the floor break transitions, since we're not matching the exact shape # we'll turn this into a rounded rectangle of equivalent length and width. FUSELAGE_DIAMETER = 12 * 14.17 -CENTER_FUSE_Z_REF = 12 * 7 +CENTER_FUSE_Z_REF = 12 * 7 + 20 # A dict of FS locations where Major Frames exist and shapes change _w, _d, _z, _r = [ FUSELAGE_DIAMETER, @@ -40,6 +41,7 @@ CENTER_FUSE_Z_REF, 0.45 * FUSELAGE_DIAMETER, ] + FS_DICT = { 93: [ "Nose Station, and NLG Bay Start", @@ -100,7 +102,7 @@ # print(f"y-axis depth = {ylim[1]-ylim[0]:.2f}") for i, station in enumerate(stations): - station.show(display=False, ax=axs[i], xlim=xlim, ylim=ylim) + station.show(display=False, axes=axs[i], xlim=xlim, ylim=ylim) for ax in fig.get_axes(): ax.label_outer() @@ -108,8 +110,76 @@ fig.suptitle( "Station Diagrams for Generic Medium Transport", fontfamily="serif", fontsize=16 ) -plt.show() +# plt.show() + + +# An initial estimate at weight distribution based on CG target +target_weight = 73000 +point_weights = np.array( + [ + # Weight , FS + [1200, 93], + [6500, 165], + [9000, 245], + [15000, 497], + [16000, 620], + [11000, 737], + [7600, 941], + [4200, 1071], + [2500, 1200], + ] +) +_w, _cg = composite_cg(point_weights) +print(f"Target Weight = {target_weight:d} [lbs]") +print(f" Total Weight = {_w:d} [lbs]") +print(f" CG = {_cg:.2f} [in]") + + +# +# Landing Gear Loads Calculation +# Taxi, WC=73k, 1.5g +# +FNZ0 = 1.5 +XNGG = 240 +XMGG = 600 +XCG = 596 +DGW = target_weight +Rmg = FNZ0 * DGW * (XCG - XNGG) / (XMGG - XNGG) +Rng = FNZ0 * DGW - Rmg +# Sink Speed is 10 ft/s, convert to in/s +# SSPD = 12 * 10.0 +# Shock Strut Stroke +# STKE = 26 +# g = 386 # in/s2 + +# Assume no Airloads for now +PZN = 0 # Forebody lift +XCPN = 170 # Forebody Center of pressure +PZWB = 0 # Wing outer panel lift +XCPW = 620 # Coord of outer wing panel +PZBW = 0 # Body lift in presence of wing +XCPB = 600 # Coord of of body lift +PZH = 0 # Horz Tail Lift +XCPH = 1200 # Coord of hTail lift + +# Vehicle Pitch Inertia, simplified. See pg 56 fo ADA002867 +TIYY = np.sum(point_weights[:, 0] * (XCG - point_weights[:, 1]) ** 2) +print(f"Vehicle pitch inertia = {TIYY:.3e}") + +print(f"\nLoads for {FNZ0}g Taxi:") +print(f" Rmg = {Rmg:.3e} [lbs]") +print(f" Rng = {Rng:.3e} [lbs]") +print(f"FNZO = {FNZ0:.2f} [g]") +diff = FNZ0 * DGW - Rmg - Rng +print( + f"Balance of Vertical Forces: {FNZ0:.1f}*{DGW:.2e} - {Rmg:.2e} - {Rng:.2e} = {diff:.1f}" +) + + +# +# Turn the Stations and Loads into Frames +# # A basic aluminum material # 2024-T3, Sheet, A-basis @@ -127,3 +197,81 @@ F_en=20.0e3, db_r=116, ) + + +inertia_loads = FNZ0 * point_weights[:, 0].flatten() + +gear_loads = [ + # y, z, V, H, M + np.array( + [ # FS 165 + [20.0, 17.0, Rng / 2, 0.0, 0.0], # NLG LH Mount + [-20.0, 17.0, Rng / 2, 0.0, 0.0], # NLG RH Mount + ] + ), + np.array( + [ # FS 620 + [FUSELAGE_DIAMETER / 2, 47.0, Rmg / 2, 0.0, 0.0], # MLG LH Mount + [-FUSELAGE_DIAMETER / 2, 47.0, Rmg / 2, 0.0, 0.0], # MLG RH Mount + ] + ), +] + +frames = {} +for station in stations: + if station.number == 165: + load = gear_loads[0] + elif station.number == 620: + load = gear_loads[1] + else: + load = None + + frames[station.number] = MajorFrame( + material=al2024, + fs_loc=station.number, + loads=load, + geom=station, + fd=6.0, + ) + +nlg_frame = frames.pop(165) +mlg_frame = frames.pop(620) +# Don't need all the frames since only 2 of them have loads +frames = (nlg_frame, mlg_frame) +fig1, ax1 = nlg_frame.show() +fig2, ax2 = mlg_frame.show() + + +# +# Compile the frames and stations into a fuselage +# +w_fus = np.column_stack( + (point_weights[:, 1], FNZ0 * point_weights[:, 0], np.zeros((len(point_weights),))) +) +w_fc = np.zeros(3) +p_air = np.zeros(3) +ext_loads = [row[:, 2].sum() for row in gear_loads] +p_ext = np.column_stack( + (np.array([[165], [620]]), np.transpose(ext_loads), np.zeros((2,))) +) +print(w_fus) +print("") +print(p_ext) + +fuse = Fuselage(stations=stations, major_frames=frames) +loads = fuse.net_loads(w_fus, w_fc, p_air, p_ext) +fig, (ax1, ax2) = fuse.vmt_diagram(w_fus, w_fc, p_air, p_ext) + +print(" FS , P , M_ext , V , M_int") +print(loads) + +print("\n\n") +x, v, m = fuse.lookup_loads(x=500, loads=loads) +print(x) +print(v) +print(m) + +_ = ax1.plot(x, v, marker="^", color="k") +_ = ax2.plot(x, m, marker="^", color="k") + +plt.show() From 4275bfd109f590eecd7358ca6094f6ddf426e698 Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Mon, 6 Apr 2026 21:00:17 -0400 Subject: [PATCH 04/17] Functional 1.5g Taxi VMT Debugged the loads for the transport example. We've got basic shear-moment diagrams! --- examples/medium_transport.py | 43 ++++++++++++++++++++---------------- src/hyperstruct/fuselage.py | 26 ++++++++++++++++------ 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/examples/medium_transport.py b/examples/medium_transport.py index 0f1114b..5ee4cc7 100644 --- a/examples/medium_transport.py +++ b/examples/medium_transport.py @@ -118,15 +118,15 @@ point_weights = np.array( [ # Weight , FS - [1200, 93], - [6500, 165], - [9000, 245], - [15000, 497], - [16000, 620], - [11000, 737], - [7600, 941], - [4200, 1071], - [2500, 1200], + [-1200, 93], + [-6500, 165], + [-9000, 245], + [-15000, 497], + [-16000, 620], + [-11000, 737], + [-7600, 941], + [-4200, 1071], + [-2500, 1200], ] ) _w, _cg = composite_cg(point_weights) @@ -168,12 +168,13 @@ print(f"Vehicle pitch inertia = {TIYY:.3e}") print(f"\nLoads for {FNZ0}g Taxi:") +print(25 * "-") print(f" Rmg = {Rmg:.3e} [lbs]") print(f" Rng = {Rng:.3e} [lbs]") print(f"FNZO = {FNZ0:.2f} [g]") diff = FNZ0 * DGW - Rmg - Rng print( - f"Balance of Vertical Forces: {FNZ0:.1f}*{DGW:.2e} - {Rmg:.2e} - {Rng:.2e} = {diff:.1f}" + f"Balance of Vertical Forces: {FNZ0:.1f}*{DGW:.2e} - {Rmg:.2e} - {Rng:.2e} = {diff:.1f}\n" ) @@ -250,26 +251,30 @@ ) w_fc = np.zeros(3) p_air = np.zeros(3) -ext_loads = [row[:, 2].sum() for row in gear_loads] +ext_loads = [arr[:, 2].sum() for arr in gear_loads] p_ext = np.column_stack( (np.array([[165], [620]]), np.transpose(ext_loads), np.zeros((2,))) ) +print("Fuselage Frame Weights:") +print(25 * "-") print(w_fus) -print("") +print(11 * " " + f"{np.sum(w_fus[:, 1]):.2f}") +print("\nFuselage Frame Loads:") +print(25 * "-") print(p_ext) +print(17 * " " + f"{np.sum(p_ext[:, 1]):.2f}") fuse = Fuselage(stations=stations, major_frames=frames) loads = fuse.net_loads(w_fus, w_fc, p_air, p_ext) fig, (ax1, ax2) = fuse.vmt_diagram(w_fus, w_fc, p_air, p_ext) -print(" FS , P , M_ext , V , M_int") -print(loads) +with np.printoptions(precision=3): + print(" FS , P , M_ext , V , M_int") + print(loads) + print("\n\n") -print("\n\n") -x, v, m = fuse.lookup_loads(x=500, loads=loads) -print(x) -print(v) -print(m) +x, v, m = fuse.lookup_loads(x=400, loads=loads) +print(f"FS{x}: V={v / 1000:.1f}[kip], M={m:.2e}[in-lbs]") _ = ax1.plot(x, v, marker="^", color="k") _ = ax2.plot(x, m, marker="^", color="k") diff --git a/src/hyperstruct/fuselage.py b/src/hyperstruct/fuselage.py index 8693bf5..d4dbad1 100644 --- a/src/hyperstruct/fuselage.py +++ b/src/hyperstruct/fuselage.py @@ -1426,7 +1426,9 @@ class MajorFrame(Component): min_gauge: float = field(default=0.040, metadata={"unit": "inch"}) """Manufacturing requirement for minimum gauge thickness, default 0.040[in].""" - def show(self, show_coords: bool = False, save: bool = False, display: bool=False) -> Tuple[Any, Any]: + def show( + self, show_coords: bool = False, save: bool = False, display: bool = False + ) -> Tuple[Any, Any]: """Plot the frame and applied loads.""" if show_coords: # coords = [(row[5], row[6]) for row in self.cuts] @@ -2190,18 +2192,27 @@ def net_loads( # March along the fuselage, and pull together the applied loads into 1 source # For each load station, append the applied forces and moments into a matrix loads = np.vstack((w_fus, w_fc, p_air, p_ext)) - loads.sort(axis=0) + # Sorting the array by the FS (the 0th column) + row_idx = np.argsort(loads[:, 0]) + loads = loads[row_idx] + # print("\nNET LOADS FUNCTION DEBUG") + # print("Loads Array:") + # print(" [ FS, Load, Moment ]") + # print(loads) # March through the applied loads matrix, # add cumulative shear, and calculate cumulative moment shears = np.cumsum(loads[:, 1]) loads = np.column_stack((loads, shears)) + # print(f"Stacked Loads Array:") moments = [] x = 0 for row in loads: - load = row[1] + load = row[3] moment = row[2] - + # Cumulative internal moment is equal to the previous moment, + # plus any point moment, plus the internal shear + # multiplied by the incremental distance. moments.append(moment + load * (row[0] - x)) x = row[0] @@ -2209,17 +2220,18 @@ def net_loads( # Start it at zero with no loads at origin (free tip) loads = np.column_stack((loads, moments)) - loads = np.insert(loads, obj=0, values=np.zeros(5, dtype=float), axis=0) + # with np.printoptions(precision=3): + # print(loads) # Verify static equilibrium if shears[-1] != 0: raise ArithmeticError( - f"Static Equilibrium has been violated! {np.sum(shears):.2f} != 0.0" + f"Shear Static Equilibrium has been violated! {np.sum(shears):.2f} != 0.0" ) if moments[-1] != 0: raise ArithmeticError( - f"Static Equilibrium has been violated! {np.sum(moments):.2f} != 0.0" + f"Moment Static Equilibrium has been violated! {np.sum(moments):.2f} != 0.0" ) # Return the final arrays of internal shears and moments From e0e8220117bbcc097aa4db5480bcb5dd31982a50 Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Sat, 16 May 2026 11:25:51 -0400 Subject: [PATCH 05/17] Some major work on the example script. Fixed the net loads function to properly balance on my example case. --- examples/medium_transport.py | 34 +++++++++++++++----- src/hyperstruct/__init__.py | 1 + src/hyperstruct/fuselage.py | 60 +++++++++++++++++++++++------------- 3 files changed, 66 insertions(+), 29 deletions(-) diff --git a/examples/medium_transport.py b/examples/medium_transport.py index 5ee4cc7..107dcf4 100644 --- a/examples/medium_transport.py +++ b/examples/medium_transport.py @@ -28,6 +28,9 @@ from hyperstruct.fuselage import MajorFrame +# from hyperstruct.fuselage import MinorFrame + + # Some global variables for reference FUSELAGE_LENGTH = 12 * (97 + 15) + 9 # Ignore the floor break transitions, since we're not matching the exact shape @@ -137,12 +140,12 @@ # # Landing Gear Loads Calculation -# Taxi, WC=73k, 1.5g +# Taxi, WC=73k, 2.0g # -FNZ0 = 1.5 -XNGG = 240 -XMGG = 600 -XCG = 596 +FNZ0 = 2.0 +XNGG = 165 +XMGG = 620 +XCG = _cg DGW = target_weight Rmg = FNZ0 * DGW * (XCG - XNGG) / (XMGG - XNGG) Rng = FNZ0 * DGW - Rmg @@ -225,7 +228,7 @@ elif station.number == 620: load = gear_loads[1] else: - load = None + load = np.zeros((2, 5)) frames[station.number] = MajorFrame( material=al2024, @@ -267,9 +270,10 @@ fuse = Fuselage(stations=stations, major_frames=frames) loads = fuse.net_loads(w_fus, w_fc, p_air, p_ext) fig, (ax1, ax2) = fuse.vmt_diagram(w_fus, w_fc, p_air, p_ext) +_ = fig.suptitle(f"{FNZ0:.1f}g Taxi, WC=73kip, xCG={XCG:.0f}[in]") with np.printoptions(precision=3): - print(" FS , P , M_ext , V , M_int") + print(" FS , P , M_ext , V , M_int") print(loads) print("\n\n") @@ -277,6 +281,20 @@ print(f"FS{x}: V={v / 1000:.1f}[kip], M={m:.2e}[in-lbs]") _ = ax1.plot(x, v, marker="^", color="k") -_ = ax2.plot(x, m, marker="^", color="k") +_ = ax2.plot(x, m, marker="^", color="k", label="Analysis Point") +_ = ax2.legend() plt.show() + + +# +# S I Z I N G +# +# The synthesis method currently doesn't do anything... +# fuse.synthesis() + +# Major Frames +print("Major Frame Sizing:") +for frame in frames: + frame.synthesis() + print(f" FS {frame.fs_loc}: {frame.weight:.1f}[lbf]") diff --git a/src/hyperstruct/__init__.py b/src/hyperstruct/__init__.py index c94dfd3..fd21528 100644 --- a/src/hyperstruct/__init__.py +++ b/src/hyperstruct/__init__.py @@ -19,6 +19,7 @@ __version__ = version("hyperstruct") +# TODO: We should have an inverse version of this for smearing weights def composite_cg(masses: List[Tuple[float, float]]) -> Tuple[float, float]: """Calculate the cg of a combined set of masses, along a single axis. diff --git a/src/hyperstruct/fuselage.py b/src/hyperstruct/fuselage.py index d4dbad1..b599a2a 100644 --- a/src/hyperstruct/fuselage.py +++ b/src/hyperstruct/fuselage.py @@ -2118,7 +2118,7 @@ def synthesis(self) -> None: Frames, and Longitudinal Members (Stringers/Longerons) form the basic structural grid work that resists vehicle shear and bending loads. Covers are thin sheets which are efficient in resisting shear and tension loads, - but inefficient in resitting compresison loads. Stiffening members, Minor + but inefficient in resisting compresison loads. Stiffening members, Minor Frames, and Stringers/Longerons are used to provide the cpability for resisting compression loads. @@ -2153,7 +2153,12 @@ def synthesis(self) -> None: # def net_loads( - self, w_fus: ArrayLike, w_fc: ArrayLike, p_air: ArrayLike, p_ext: ArrayLike + self, + w_fus: ArrayLike, + w_fc: ArrayLike, + p_air: ArrayLike, + p_ext: ArrayLike, + debug: bool = False, ) -> ArrayLike: """Calculates net (ultimate) vertical shear and bending. [FLDNT]. @@ -2173,6 +2178,7 @@ def net_loads( w_fc (ArrayLike): Distributed fuselage content weights (nonstructural) p_air (ArrayLike): Distributed body airloads p_ext (ArrayLike): External forces at the support frames + debug (bool): Print debugging information Returns: ArrayLike: Loads matrix with cols [FS, Force, Moment, Shear, Bending] @@ -2195,43 +2201,55 @@ def net_loads( # Sorting the array by the FS (the 0th column) row_idx = np.argsort(loads[:, 0]) loads = loads[row_idx] - # print("\nNET LOADS FUNCTION DEBUG") - # print("Loads Array:") - # print(" [ FS, Load, Moment ]") - # print(loads) + # Generating an array with unique FS values + unique_x, inverse_indices = np.unique(loads[:, 0], return_inverse=True) + sum_p = np.bincount(inverse_indices, weights=loads[:, 1]) + sum_m = np.bincount(inverse_indices, weights=loads[:, 2]) + loads = np.column_stack((unique_x, sum_p, sum_m)) + # We've combined all the external loads now. + # Time to remove the zero row for net loads computation. + loads = loads[1:] + + if debug: + print("\nNET LOADS FUNCTION DEBUG") + print("Loads Array:") + print(" [ FS, Load, Moment ]") + print(loads) # March through the applied loads matrix, # add cumulative shear, and calculate cumulative moment shears = np.cumsum(loads[:, 1]) loads = np.column_stack((loads, shears)) - # print(f"Stacked Loads Array:") moments = [] x = 0 - for row in loads: - load = row[3] - moment = row[2] - # Cumulative internal moment is equal to the previous moment, - # plus any point moment, plus the internal shear - # multiplied by the incremental distance. - moments.append(moment + load * (row[0] - x)) + for i, row in enumerate(loads): + if i == 0: + # First entry won't have previous moment, so it's zero + moments.append(0) + else: + # Cumulative internal moment is equal to the previous moment, + # plus any point moment, plus the internal cumulative shear + # multiplied by the incremental distance. + prev_m = moments[i - 1] + point_moment = row[2] + load = loads[i - 1][3] + moments.append(prev_m + point_moment + load * (row[0] - x)) + x = row[0] moments = np.array(moments) # Start it at zero with no loads at origin (free tip) loads = np.column_stack((loads, moments)) - # with np.printoptions(precision=3): - # print(loads) - # Verify static equilibrium if shears[-1] != 0: raise ArithmeticError( - f"Shear Static Equilibrium has been violated! {np.sum(shears):.2f} != 0.0" + f"Shear Static Equilibrium has been violated! {shears[-1]:.2f} != 0.0" ) - if moments[-1] != 0: + if not np.isclose(moments[-1], 0.0): raise ArithmeticError( - f"Moment Static Equilibrium has been violated! {np.sum(moments):.2f} != 0.0" + f"Moment Static Equilibrium has been violated! {moments[-1]:.2f} != 0.0" ) # Return the final arrays of internal shears and moments @@ -2266,7 +2284,7 @@ def vmt_diagram( fig, (ax1, ax2) = plt.subplots(nrows=2, sharex=True, figsize=(11, 5)) _ = ax1.plot(loads[:, 0], loads[:, 3]) - _ = ax2.plot(loads[:, 0], loads[:, 4]) + _ = ax2.plot(loads[:, 0], loads[:, 4], color="darkorange") # _ = ax1.set_xlabel("Fuselage Station, $FS$, [in]") _ = ax1.set_ylabel("Vertical Shear, $V$, [lbs]", fontfamily="serif") From d9e765d94f54be456781a7bc8e6e34e6c37b84f1 Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Sat, 16 May 2026 11:59:54 -0400 Subject: [PATCH 06/17] Set the cut_geometry to provide mean values for input stations. --- src/hyperstruct/fuselage.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/hyperstruct/fuselage.py b/src/hyperstruct/fuselage.py index b599a2a..8901ec4 100644 --- a/src/hyperstruct/fuselage.py +++ b/src/hyperstruct/fuselage.py @@ -886,8 +886,6 @@ def post_buckled( self, d: float, h: float, - frame_material: Material, - long_material: Material, D: float, M: float, Z: float, @@ -896,6 +894,7 @@ def post_buckled( RC: float, f_s: float, f_scr: float, + long_material: Material | None = None, ) -> float: """Thickness required for post-buckled strength.""" # Pass everything directly through to the ForcedCrippling class. @@ -910,7 +909,7 @@ def post_buckled( c=c, b=b, construction=construction, - frame_material=frame_material, + frame_material=self.material, cover_material=cover_material, long_material=long_material, t_r=t_r, @@ -2106,7 +2105,16 @@ def cut_geometry(self, start: Station, end: Station) -> Station: None """ _ = end - return start + interpolated = Station( + orientation=start.orientation, + name=start.name, + number=np.mean([start.number, end.number]), + width=np.mean([start.width, end.width]), + depth=np.mean([start.depth, end.depth]), + vertical_centroid=np.mean([start.vertical_centroid, end.vertical_centroid]), + radius=np.mean([start.radius, end.radius]), + ) + return interpolated def synthesis(self) -> None: """The full multistations synthesis loop. @@ -2129,7 +2137,7 @@ def synthesis(self) -> None: Pressure bulkhead design criteria and sizing are also evaluated independently for local considerations. - Several assumptions have bene made to minimize the multiplicity of + Several assumptions have been made to minimize the multiplicity of variables and thus simplify the synthesis process. 1. The shell is assumed to be composed of only four (4) sectors: upper, lower, and two symmetric sides. From 4adae846afba605182b7188d863e71ffbff93799 Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Sat, 23 May 2026 10:34:56 -0400 Subject: [PATCH 07/17] Adding new LoadCase class. --- src/hyperstruct/__init__.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/hyperstruct/__init__.py b/src/hyperstruct/__init__.py index fd21528..7e05d40 100644 --- a/src/hyperstruct/__init__.py +++ b/src/hyperstruct/__init__.py @@ -13,6 +13,7 @@ from matplotlib.patches import Circle from matplotlib.patches import Ellipse from matplotlib.patches import FancyBboxPatch +from numpy.typing import ArrayLike from scipy.special import ellipeinc @@ -106,15 +107,6 @@ class Component: material: Material """material the cover is made of.""" - def synthesis(self) -> None: - """The sizing method. - - The sizing method collects all sizing routines and executes them - in the order of the `routines` list. - """ - # This doesn't work. It's just a placeholder. - pass - @dataclass class Station: @@ -643,3 +635,24 @@ def get_coords(self, theta: float, debug: bool = False) -> Tuple[float, float]: y = r * np.cos(theta) + self.vertical_centroid return (float(x), float(y)) + + +@dataclass +class LoadCase: + """Loads representing a single LoadCase along a beam. + + A LoadCase is just a pre-formatted numpy array with column assumptions, and some metadata. + The columns of the LoadCase.loads array are: + Station [in], + Applied Beam Shear [lbf], + Applied Moment [in-lbf], + Internal Beam Shear [lbf], + Internal Moment [in-lbf] + + Note that only a single directional load is supported and assumed. For all components except + the Vertical Stabilizer, this is vertical (z). For the Vertical Stabilizer, this horizontal (y). + """ + + loads: ArrayLike + lcid: int = None + name: str = None From 6df97ec1a78ea09f0f43493361c1727ac15da1b7 Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Sat, 23 May 2026 11:36:12 -0400 Subject: [PATCH 08/17] Adding the search and shell sizing routines. Still very much WIP, but committing now to save a snapshot. --- src/hyperstruct/fuselage.py | 218 +++++++++++++++++++++++++++++++----- 1 file changed, 192 insertions(+), 26 deletions(-) diff --git a/src/hyperstruct/fuselage.py b/src/hyperstruct/fuselage.py index 8901ec4..753c519 100644 --- a/src/hyperstruct/fuselage.py +++ b/src/hyperstruct/fuselage.py @@ -11,6 +11,7 @@ # from typing import Dict from typing import Any +from typing import NamedTuple from typing import Tuple import matplotlib.pyplot as plt @@ -24,6 +25,7 @@ from scipy.optimize import minimize_scalar from hyperstruct import Component +from hyperstruct import LoadCase from hyperstruct import Material from hyperstruct import Station @@ -755,6 +757,45 @@ def acoustic_fatigue(self) -> Tuple[float, float]: return (float(f_l * t_l), float(f_c * t_c)) + def sizing(self) -> dict: + """Calculates Cover thicknesses required. [FCOVER]. + + Sizing calculates the thickness required for the current "snapshot" + condition. This assumes you've provided all the required instance + variables for sizing, including load (V), area moment (Q), and area + moment of inertia (I). + + The Upper and Lower portions are calculated separately, + but will always be the same. Upper and lower criteria include + pressure design, local panel flutter, and acoustic fatigue. The side + panels include checks against shear strength. + + Returns: + dict: results dictionary for upper, lower, and side covers + """ + # Upper + upper_t = {} + upper_t["pressure"] = self.thickness_pressure() + upper_t["panel_flutter"] = self.panel_flutter(mach=None, altitude=None) + upper_t["acoustic"] = self.acoustic_fatigue() + # Lower + lower_t = {} + lower_t["pressure"] = self.thickness_pressure() + lower_t["panel_flutter"] = self.panel_flutter(mach=None, altitude=None) + lower_t["acoustic"] = self.acoustic_fatigue() + # Side + side_t = {} + side_t["pressure"] = self.thickness_pressure() + side_t["shear"] = self.field_thickness_block_shear() + side_t["net_section"] = self.land_thickness_net_section() + side_t["post_buckled"] = self.field_thickness_postbuckled() + side_t["panel_flutter"] = self.panel_flutter(mach=None, altitude=None) + side_t["acoustic"] = self.acoustic_fatigue() + + results = {"upper": upper_t, "lower": lower_t, "side": side_t} + + return results + @dataclass class MinorFrame(Component): @@ -786,6 +827,15 @@ class MinorFrame(Component): t_r: float = 0.0 """cap flange thickness.""" + M: float | None = None + """Bending moment of the section.""" + + frame_spacing: float | None = None + """Frame spacing, if known.""" + + diameter: float | None = None + """The fuselage diameter at the cut.""" + @property def t_w(self) -> float: """Web thickness.""" @@ -822,31 +872,26 @@ def rho(self) -> float: ** 0.5 ) - def general_stability(self, L: float, D: float, M: float) -> float: + def general_stability(self) -> float: """Thickness to avoid general instability. The thickness that provides frame stiffness sufficient to prevent general instability failure is solved via the Shanley equation. - Args: - L: Frame Spacing - D: Fuselage Diameter - M: Bending moment at the cut - Returns: A float of Flange thickness. """ c_f = 1 / 16000 - numerator = c_f * M * D**2 + numerator = c_f * self.M * self.diameter**2 denominator = ( self.material.E_c - * L + * self.frame_spacing * (self.b * self.c**2 + 2 * self.b**3 * self.c / 3 + self.c**3 / 24) ) return float(numerator / denominator) - def acoustic_fatigue(self, d: float) -> float: + def acoustic_fatigue(self) -> float: """Thickness requirements based on acoustic fatigue. Assumptions are: @@ -866,9 +911,6 @@ def acoustic_fatigue(self, d: float) -> float: decibel level is then increased by 30, which represents jet noise instead of a purely random spectrum. - Args: - d: Support spacing (frame spacing) - Returns: A float of Flange thickness. """ @@ -878,7 +920,7 @@ def acoustic_fatigue(self, d: float) -> float: db_oa = db_r + 30 P = 2.9e-9 * 10 ** (db_oa / 20) # Note: K_c is directly hardcoded to 7.025, per Fig. 20 of ADA002867 - t_r = 7.025 * d**0.5 * P**2 / self.material.F_en + t_r = 7.025 * self.frame_spacing**0.5 * P**2 / self.material.F_en return float(t_r) @@ -887,7 +929,6 @@ def post_buckled( d: float, h: float, D: float, - M: float, Z: float, sum_z_sq: float, t_c: float, @@ -918,11 +959,39 @@ def post_buckled( # directly, we only need the first value in the returned tuple. # No further analysis is necessary. t, _, _ = check.forced_crippling( - D=D, M=M, Z=Z, sum_z_sq=sum_z_sq, t_c=t_c, RC=RC, f_s=f_s, f_scr=f_scr + D=self.diameter, + M=self.M, + Z=Z, + sum_z_sq=sum_z_sq, + t_c=t_c, + RC=RC, + f_s=f_s, + f_scr=f_scr, ) return float(t) + def sizing(self) -> dict: + """Calculates the MinorFrame sizing. [MINFR].""" + upper = { + "general_stability": self.general_stability(), + "acoustic_fatigue": self.acoustic_fatigue(), + } + lower = { + "general_stability": self.general_stability(), + "acoustic_fatigue": self.acoustic_fatigue(), + } + side = { + "general_stability": self.general_stability(), + "acoustic_fatigue": self.acoustic_fatigue(), + # TODO: How do we simplify the post_buckled method...Fucking diagonal tension... FML + "forced_crippling": self.post_buckled(stufffffffff), + } + + results = {"upper": upper, "lower": lower, "side": side} + + return results + @dataclass class Longeron(Component): @@ -947,6 +1016,9 @@ class Longeron(Component): k: float """Inner flange proportion of web height.""" + M: float | None = None + """Bending moment at the cut.""" + @property def area(self) -> float: """Cross-sectional area.""" @@ -983,7 +1055,6 @@ def area_effective(self) -> float: def bending_strength( self, - M_ext: float, t: float, d: float, I_t: float, @@ -1048,11 +1119,12 @@ def bending_strength( M_sl = 0.0 # Moment reacted by longerons - M_l = 0.5 * M_ext - M_c - M_s - M_sl + M_l = 0.5 * self.M - M_c - M_s - M_sl # Longeron area to resist this moment A_l = M_l * 0.5 * d / (f_max_l * self.i_xx / self.area) + # TODO: # Now the compression sector is evaluated # Effectiveness of cover in compression is based on Peery curved panel buckling # F_CCR = ( 9*(t_c/R)**(5/3) + 0.16*(t_c/L)**(1.3) + K_c*np.pi**2/(12*(1-nu_c**2)) ) * cover_e @@ -2091,6 +2163,19 @@ class Fuselage: major_frames: Tuple[MajorFrame] """A set of MajorFrames with load introduction points.""" + loadcase: LoadCase + """A single loadcase to evaluate.""" + + # TODO: Do we want these as instance variables? + cover_model: Cover + """The Cover model to use.""" + + long_model: Longeron + """The Longeron/Stinger model to use.""" + + frame_model: MinorFrame + """The MinorFrame model to use.""" + def cut_geometry(self, start: Station, end: Station) -> Station: """Interpolate the geometry between the described stations. @@ -2116,10 +2201,8 @@ def cut_geometry(self, start: Station, end: Station) -> Station: ) return interpolated - def synthesis(self) -> None: - """The full multistations synthesis loop. - - Mocking this up for now, to outline the roadmap for all methods. + def synthesis(self, frame_spacing: float | None = None) -> None: + """The full multistation synthesis loop. [FUSSHL]. Geometry definitions and constraints, loads, and design criteria are all parameters evaluated in the synthesis of shell members. Covers, Minor @@ -2146,19 +2229,35 @@ def synthesis(self) -> None: 3. The side sector is designed to resist only vertical shear load, and stringers in this sector are sized to satisfy minimum area and cover support requirements. + + Args: + frame_spacing (Float|None) : Provide frame spacing or search for min frame spacing by leaving blank. Default None. """ - for k, v in enumerate(self.stations): + # Doing the MajorFrame weight first. + for frame in self.major_frames: + frame.synthesis() + + for k, station in enumerate(self.stations): if k == len(self.stations): # the last station in the tuple is the tail geometry, # so no synthesis cut aft of the tail section. continue else: # interpolate the geometry between the current station and the next - geom = self.cut_geometry(v, self.stations[k + 1]) + geom = self.cut_geometry(station, self.stations[k + 1]) - # This is nonsense stuff to pass pre-commit. - _ = geom - # + _, cut_shear, cut_bending = self.lookup_loads(geom.number, self.loads) + + # Should we use a provided spacing or conduct a frame spacing search? + if not frame_spacing: + cut_results = self.frame_search(V=cut_shear, M=cut_bending, geom=geom) + else: + cut_results = self.size_shell( + V=cut_shear, M=cut_bending, geom=geom, frame_spacing=frame_spacing + ) + + # TODO: Not implemented + _ = self.size_station() def net_loads( self, @@ -2346,3 +2445,70 @@ def lookup_loads(self, x: float, loads: ArrayLike) -> Tuple[float, float, float] v = np.interp(x, xp=xp, fp=fp_v) m = np.interp(x, xp=xp, fp=fp_m) return (x, np.float64(v), np.float64(m)) + + def stringer_search(self): + """Search for weight-optimum stringer/longeron spacing. [LONGS].""" + # TODO: We need to calculate these at each longeron search step + self.cover_model.Q = self.get_Q() + self.cover_modelcover.I = self.get_I() + + pass + + def frame_search( + self, + min_spacing: float, + V: float, + M: float, + geom: Station, + ) -> NamedTuple: + """Search for weight-optimum frame spacing. [FPANEL]. + + Frame spacing starts with a pre-determined maximum, based on the + shell dimensions, and an initial minimum. Spacing is increased until + the lumped weight of covers, minor frames, and longitudinal members + indicates an upward trend. This increase of weight, or an optimum less + than the initial spacing abbreviates the search. + """ + max_spacing = max(geom.depth, geom.width) / 2 + + for spacing in np.linspace(min_spacing, max_spacing, num=10): + results_obj = self.size_shell(V=V, M=M, frame_spacing=spacing, geom=geom) + if spacing > min_spacing: + # The first iteration won't have previous results + if results_obj.weight > previous_results.weight: + break + + previous_results = results_obj + + return previous_results + + def size_shell( + self, + V: float, + M: float, + frame_spacing: float, + geom: Station, + ) -> NamedTuple: + """Conducts analysis point sizing of Fuselage shell structure. + + This method sizes shell structure at a single point. + """ + # Set the instance variables for our cut loads + self.cover_model.V = V + self.cover_model.L = frame_spacing + self.cover_model.D = long_spacing + + self.frame_model.M = M + self.frame_model.frame_spacing = frame_spacing + self.frame_model.diameter = np.mean([geom.depth, geom.width]) + + self.long_model.M = M + # TODO: What else does the longeorn model need? + + # Run the class sizing routines + self.cover_model.sizing() + self.frame_model.sizing() + self.long_model.sizing() + + # Caculate the weight and build the results object + pass From 6bbe30145c27028b0827399b1dfaab34b363a7ac Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Mon, 25 May 2026 11:52:18 -0400 Subject: [PATCH 09/17] Working on programmatic synthesis still. Added size_shell weight calculations. Swapped all the component classes to use instance variables for sizing methods. Added new longeron_coords method to Fuselage, for the First Moment of Area method. --- src/hyperstruct/fuselage.py | 180 ++++++++++++++++++++++++++++-------- 1 file changed, 141 insertions(+), 39 deletions(-) diff --git a/src/hyperstruct/fuselage.py b/src/hyperstruct/fuselage.py index 753c519..9cbd872 100644 --- a/src/hyperstruct/fuselage.py +++ b/src/hyperstruct/fuselage.py @@ -443,6 +443,15 @@ class Cover(Component): RC: float = 0 """radius of curvature.""" + F_allow: float | None = None + """fatigue allowable stress for pressurization (25% Fty if not provided).""" + + mach: float | None = None + """loadcase airspeed Mach number.""" + + altitude: float | None = None + """loadcase altitude, in thousands of feet.""" + V: float = 0 """total vertical shear at the cut.""" @@ -579,7 +588,7 @@ def land_thickness_net_section(self) -> float: else: return float(self.q / (self.c_r * self.material.F_su)) - def thickness_pressure(self, F_allow: Any = None) -> Tuple[float, float]: + def thickness_pressure(self) -> Tuple[float, float]: """Thicknesses based on cover pressure. A required thickness is evaluated to resist hoop stress, @@ -593,15 +602,13 @@ def thickness_pressure(self, F_allow: Any = None) -> Tuple[float, float]: from zero to peak pressure 20,000 times during the vehicle's useful life, with a stress concentration factor of 4.0. - Args: - F_allow: Allowable stress (25% yield, if not provided) Returns: A tuple of (Land thickness, Field thickness). """ b = min(self.D, self.L) - if not F_allow: - F_allow = self.material.F_ty / 4.0 + if not self.F_allow: + self.F_allow = self.material.F_ty / 4.0 # TODO: Lookup the vehicle-level load factors for the CG Nz_plus = 6 @@ -631,13 +638,13 @@ def thickness_pressure(self, F_allow: Any = None) -> Tuple[float, float]: P_2 = P_o + rho * Nz_2 * h # Simple Hoop Stress - t_1 = P_1 * self.RC / F_allow - t_2 = P_2 * self.RC / F_allow + t_1 = P_1 * self.RC / self.F_allow + t_2 = P_2 * self.RC / self.F_allow # Strip Theory Edge thickness - t_3 = (1.646 * b * P_1**0.894 * self.material.E**0.394) / F_allow**1.288 + t_3 = (1.646 * b * P_1**0.894 * self.material.E**0.394) / self.F_allow**1.288 # Strip Theory Midspan thickness - t_4 = self.material.E**1.984 * (1.3769 * b * P_1**2.484) / F_allow**4.467 + t_4 = self.material.E**1.984 * (1.3769 * b * P_1**2.484) / self.F_allow**4.467 t_min = min(t_1, t_2, t_3, t_4) @@ -646,7 +653,7 @@ def thickness_pressure(self, F_allow: Any = None) -> Tuple[float, float]: else: return (float(t_min), float(t_min)) - def panel_flutter(self, mach: float, altitude: float) -> float: + def panel_flutter(self) -> float: """Evaluate baseline thickness to avoid local panel flutter. The baseline thickness to make local panel flutter highly improbable @@ -658,13 +665,11 @@ def panel_flutter(self, mach: float, altitude: float) -> float: over for all Mach and altitudes corresponding to the flight envelope conditions of the aircraft. - Args: - mach: Mach Number - altitude: Altitude (in thousands of feet) - Returns: A float of Field Thickness. """ + mach = self.mach + altitude = self.altitude # Dynamic pressures based on standard day atmosphere. # Dynamic Pressure, q, in [psf] # Altitudes must be measured in [ft] @@ -973,26 +978,19 @@ def post_buckled( def sizing(self) -> dict: """Calculates the MinorFrame sizing. [MINFR].""" - upper = { - "general_stability": self.general_stability(), - "acoustic_fatigue": self.acoustic_fatigue(), - } - lower = { - "general_stability": self.general_stability(), - "acoustic_fatigue": self.acoustic_fatigue(), - } - side = { + results = { "general_stability": self.general_stability(), "acoustic_fatigue": self.acoustic_fatigue(), # TODO: How do we simplify the post_buckled method...Fucking diagonal tension... FML "forced_crippling": self.post_buckled(stufffffffff), } - results = {"upper": upper, "lower": lower, "side": side} - return results +# TODO: We need to revisit the Longerons. Compressive portion is completely +# blank, and the area property and sizing methods just don't smell right. +# For now, we're just going for something functional. @dataclass class Longeron(Component): """Fuselage longitudinal member component. @@ -1063,7 +1061,7 @@ def bending_strength( A_s: float, I_a: float, ) -> float: - """Thickness required to satisfy bending strength. + """Area required to satisfy bending strength. Longitudinal member sizing is dependent on the contribution of all copmonents that resist bending loads. This method accounts for the difference @@ -1080,13 +1078,13 @@ def bending_strength( fiber stress according to the relationship of vertical coordinate versus extreme fiber coordinate. - Bending moment is assumed to be reactied by an internal coupled force system. + Bending moment is assumed to be reacted by an internal coupled force system. Thus, in the case of down-bending, the uper half of the shell sustains tension loads, and the lower half, compression loads; half of the moment is reacted in each half. Covers are totally effective in the tension sector of the fuselage. Cutouts eliminate cover contributions; proximity to cutouts degrade the effectiveness of the cover. The width of cutouts at other synthesis cuts - combined with longitudinal displacement and hsear lag slope of 2 to 1 is used + combined with longitudinal displacement and shear lag slope of 2 to 1 is used to determine the apparent effective width. Args: @@ -1186,6 +1184,16 @@ def post_buckled( return 1.0 + def sizing(self) -> float: + """Calculates the weight/in of the member.""" + area_1 = self.bending_strength(stufff) + area_2 = self.post_buckled(stuffffffffff) + + w_1 = self.material.rho * area_1 + w_2 = self.material.rho * area_2 + + return max(w_1, w_2) + # TODO: Bulkheads need major overhaul! This is where the internal geometry # routines come in! It's functional for now and will provide a weight value, @@ -2163,6 +2171,9 @@ class Fuselage: major_frames: Tuple[MajorFrame] """A set of MajorFrames with load introduction points.""" + construction: str + """Construction method. ('stringer' or 'longeron')""" + loadcase: LoadCase """A single loadcase to evaluate.""" @@ -2176,6 +2187,37 @@ class Fuselage: frame_model: MinorFrame """The MinorFrame model to use.""" + def longeron_coords( + self, station: Station, phi: float | None = None, d: float | None = None + ) -> Tuple[float]: + """Vertical and horizontal coordinates of the longerons in a section. + + Four primary longerons are assumed for longeron construction fuselages. + These elements may be located by either the angular definition (phi), + or as a fraction of the total fuselage section depth. + + If the user provides a phi and d value, the d value will be ignored. + + Args: + station (Station): The station geometry. + phi (float | None, optional): Clockwise angle. Defaults to None. + d (float | None, optional): Fraction of total depth. Defaults to None. + + Returns: + Tuple[float]: Coordinates (y, z) of longeron centroid + """ + if not phi: + return station.get_coords(phi) + elif not d: + if d >= 1: + y = station.wo + else: + zz = d * station.depth / 2 - station.doo + y = station.wo + np.sqrt(station.radius**2 - zz**2) + return (y, d * station.depth / 2) + else: + return (0, 0) + def cut_geometry(self, start: Station, end: Station) -> Station: """Interpolate the geometry between the described stations. @@ -2201,6 +2243,23 @@ def cut_geometry(self, start: Station, end: Station) -> Station: ) return interpolated + def get_Q(self, geom: Station, **kwargs) -> float: + """Calculate the first moment of area for the section. + + The first moment of area calculation assumes all areas are lumped + into the bending element centroids. To calculate, simply sum all + the areas by their vertical distance. We calculate this value for + the midpoint of the fuselage, where the maximum value will be. + """ + if self.construction == "longeron": + y, z = self.longeron_coords(geom, **kwargs) + return z * self.long_model.area + elif self.construction == "stringer": + # TODO: + raise NotImplementedError() + else: + raise ValueError("Construction method must be 'stringer' or 'longeron'!") + def synthesis(self, frame_spacing: float | None = None) -> None: """The full multistation synthesis loop. [FUSSHL]. @@ -2245,18 +2304,25 @@ def synthesis(self, frame_spacing: float | None = None) -> None: else: # interpolate the geometry between the current station and the next geom = self.cut_geometry(station, self.stations[k + 1]) + chunk_length = self.stations[k + 1].number - station.number _, cut_shear, cut_bending = self.lookup_loads(geom.number, self.loads) # Should we use a provided spacing or conduct a frame spacing search? if not frame_spacing: - cut_results = self.frame_search(V=cut_shear, M=cut_bending, geom=geom) + cut_results = self.frame_search( + V=cut_shear, M=cut_bending, geom=geom, chunk_length=chunk_length + ) else: cut_results = self.size_shell( - V=cut_shear, M=cut_bending, geom=geom, frame_spacing=frame_spacing + V=cut_shear, + M=cut_bending, + geom=geom, + frame_spacing=frame_spacing, + chunk_length=chunk_length, ) - # TODO: Not implemented + # TODO: Not implemented. Is this different that size_shell()? _ = self.size_station() def net_loads( @@ -2446,10 +2512,10 @@ def lookup_loads(self, x: float, loads: ArrayLike) -> Tuple[float, float, float] m = np.interp(x, xp=xp, fp=fp_m) return (x, np.float64(v), np.float64(m)) - def stringer_search(self): + def stringer_search(self, **kwargs): """Search for weight-optimum stringer/longeron spacing. [LONGS].""" # TODO: We need to calculate these at each longeron search step - self.cover_model.Q = self.get_Q() + self.cover_model.Q = self.get_Q(**kwargs) self.cover_modelcover.I = self.get_I() pass @@ -2460,6 +2526,7 @@ def frame_search( V: float, M: float, geom: Station, + chunk_length: float, ) -> NamedTuple: """Search for weight-optimum frame spacing. [FPANEL]. @@ -2472,7 +2539,9 @@ def frame_search( max_spacing = max(geom.depth, geom.width) / 2 for spacing in np.linspace(min_spacing, max_spacing, num=10): - results_obj = self.size_shell(V=V, M=M, frame_spacing=spacing, geom=geom) + results_obj = self.size_shell( + V=V, M=M, frame_spacing=spacing, geom=geom, chunk_length=chunk_length + ) if spacing > min_spacing: # The first iteration won't have previous results if results_obj.weight > previous_results.weight: @@ -2488,6 +2557,7 @@ def size_shell( M: float, frame_spacing: float, geom: Station, + chunk_length: float, ) -> NamedTuple: """Conducts analysis point sizing of Fuselage shell structure. @@ -2497,18 +2567,50 @@ def size_shell( self.cover_model.V = V self.cover_model.L = frame_spacing self.cover_model.D = long_spacing + self.cover_model.mach = mach + self.cover_model.altitude = altitude self.frame_model.M = M self.frame_model.frame_spacing = frame_spacing self.frame_model.diameter = np.mean([geom.depth, geom.width]) self.long_model.M = M - # TODO: What else does the longeorn model need? # Run the class sizing routines - self.cover_model.sizing() - self.frame_model.sizing() - self.long_model.sizing() + cover_results = self.cover_model.sizing() + frame_results = self.frame_model.sizing() + long_weight = self.long_model.sizing() # Caculate the weight and build the results object - pass + # All weights are total lbs for this chunk + cover_rho = self.cover_model.material.rho + upper_s = geom.upper_panel + lower_s = geom.lower_panel + side_s = geom.side_panel + cover_upper_weight = ( + upper_s * cover_rho * max(cover_results["upper"].values()) * chunk_length + ) + cover_lower_weight = ( + lower_s * cover_rho * max(cover_results["lower"].values()) * chunk_length + ) + cover_side_weight = ( + side_s * cover_rho * max(cover_results["side"].values()) * chunk_length + ) + + perimeter = geom.upper_panel + geom.lower_panel + 2 * geom.side_panel + # With thickness calculated from sizing, area is automatically updated for us. + single_weight = ( + self.frame_model.material.rho * self.frame_model.area * perimeter + ) + frame_weight = single_weight * chunk_length / frame_spacing + + long_weight = long_weight * perimeter / long_spacing * chunk_length + + weights = { + "covers_upper": cover_upper_weight, + "covers_lower": cover_lower_weight, + "covers_side": cover_side_weight, + "minor_frames": frame_weight, + "longerons": long_weight, + } + return weights From 9750a2eb81e7162ca872d23064e1847ca08b7cf6 Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Sat, 27 Jun 2026 10:12:58 -0400 Subject: [PATCH 10/17] Snapshot. Still a heavy WIP and I'm not sure what all has changed since I last commited. Just sending this up as a new starting point. --- examples/medium_transport.py | 29 ++- src/hyperstruct/__init__.py | 6 +- src/hyperstruct/fuselage.py | 369 +++++++++++++++++++++++++++++------ 3 files changed, 333 insertions(+), 71 deletions(-) diff --git a/examples/medium_transport.py b/examples/medium_transport.py index 107dcf4..4fc7060 100644 --- a/examples/medium_transport.py +++ b/examples/medium_transport.py @@ -18,14 +18,14 @@ import matplotlib.pyplot as plt import numpy as np -from hyperstruct import Material +from hyperstruct import Material, LoadCase from hyperstruct import Station from hyperstruct import composite_cg # from hyperstruct.fuselage import Cover # from hyperstruct.fuselage import ForcedCrippling from hyperstruct.fuselage import Fuselage -from hyperstruct.fuselage import MajorFrame +from hyperstruct.fuselage import MajorFrame, MinorFrame, Cover, Longeron # from hyperstruct.fuselage import MinorFrame @@ -267,7 +267,24 @@ print(p_ext) print(17 * " " + f"{np.sum(p_ext[:, 1]):.2f}") -fuse = Fuselage(stations=stations, major_frames=frames) +cover_model = Cover( + material=al2024, milled=False, L=30, D=20, R=1, RC=25 +) +long_model = Longeron( + material=al2024, b=2.0, t_s=0.1, k=0.8 +) +frame_model = MinorFrame( + material=al2024, c=4.0, b=3.0, construction="longeron" +) + +fuse = Fuselage( + stations=stations, + major_frames=frames, + construction="longeron", + cover_model=cover_model, + long_model=long_model, + frame_model=frame_model +) loads = fuse.net_loads(w_fus, w_fc, p_air, p_ext) fig, (ax1, ax2) = fuse.vmt_diagram(w_fus, w_fc, p_air, p_ext) _ = fig.suptitle(f"{FNZ0:.1f}g Taxi, WC=73kip, xCG={XCG:.0f}[in]") @@ -290,11 +307,13 @@ # # S I Z I N G # -# The synthesis method currently doesn't do anything... -# fuse.synthesis() + +lc = LoadCase(fuse_loads=loads, lcid=31, name="3g Taxi", mach=0.1, altitude=0.0) # Major Frames print("Major Frame Sizing:") for frame in frames: frame.synthesis() print(f" FS {frame.fs_loc}: {frame.weight:.1f}[lbf]") + +fuse.synthesis(loadcase=lc) \ No newline at end of file diff --git a/src/hyperstruct/__init__.py b/src/hyperstruct/__init__.py index 7e05d40..ba7ffd0 100644 --- a/src/hyperstruct/__init__.py +++ b/src/hyperstruct/__init__.py @@ -642,7 +642,7 @@ class LoadCase: """Loads representing a single LoadCase along a beam. A LoadCase is just a pre-formatted numpy array with column assumptions, and some metadata. - The columns of the LoadCase.loads array are: + The columns of the LoadCase.fuse_loads array are: Station [in], Applied Beam Shear [lbf], Applied Moment [in-lbf], @@ -653,6 +653,8 @@ class LoadCase: the Vertical Stabilizer, this is vertical (z). For the Vertical Stabilizer, this horizontal (y). """ - loads: ArrayLike + fuse_loads: ArrayLike lcid: int = None name: str = None + mach: float | None = None + altitude: float | None = None diff --git a/src/hyperstruct/fuselage.py b/src/hyperstruct/fuselage.py index 9cbd872..a774340 100644 --- a/src/hyperstruct/fuselage.py +++ b/src/hyperstruct/fuselage.py @@ -8,6 +8,7 @@ from copy import copy from dataclasses import dataclass from dataclasses import field +from collections import namedtuple # from typing import Dict from typing import Any @@ -30,6 +31,24 @@ from hyperstruct import Station +@dataclass +class ShellContext: + """Context object passing shell evaluation properties to components.""" + geom: Station + V: float + M: float + long_spacing: float + frame_spacing: float + cover_material: Material | None = None + long_material: Material | None = None + t_c: float | None = None + RC: float | None = None + f_s: float | None = None + f_scr: float | None = None + sum_z_sq: float | None = None + Z: float | None = None + + @dataclass class ForcedCrippling: """This is a dedicated analysis class for Forced Crippling. @@ -461,6 +480,9 @@ class Cover(Component): I: float = 0 """area moment of inertia of the bending elements.""" + context: ShellContext | None = None + """Sizing context.""" + @property def c_r(self) -> float: """Rivet Factor. @@ -651,7 +673,7 @@ def thickness_pressure(self) -> Tuple[float, float]: if self.milled: return (float(t_3), float(t_min)) else: - return (float(t_min), float(t_min)) + return float(t_min) def panel_flutter(self) -> float: """Evaluate baseline thickness to avoid local panel flutter. @@ -702,6 +724,9 @@ def panel_flutter(self) -> float: ) elif mach > 2.0: FM = np.sqrt(mach**2 - 1) + else: + # Mach is below supersonic. Panel Flutter isn't a concern. + FM = 10000 # Length/Width parameter LW = self.L / self.D @@ -760,7 +785,9 @@ def acoustic_fatigue(self) -> Tuple[float, float]: f_l = 1.0794 + 0.000143 * x_l - 0.076475 * (1 / x_l) - 0.29969 * np.log(x_l) f_c = 1.0794 + 0.000143 * x_c - 0.076475 * (1 / x_c) - 0.29969 * np.log(x_c) - return (float(f_l * t_l), float(f_c * t_c)) + # We return the max thickness for conservatism + # TODO: Do we want to handle a more complex weight estimate and return both in the future? + return max(float(f_l * t_l), float(f_c * t_c)) def sizing(self) -> dict: """Calculates Cover thicknesses required. [FCOVER]. @@ -781,12 +808,12 @@ def sizing(self) -> dict: # Upper upper_t = {} upper_t["pressure"] = self.thickness_pressure() - upper_t["panel_flutter"] = self.panel_flutter(mach=None, altitude=None) + upper_t["panel_flutter"] = self.panel_flutter() upper_t["acoustic"] = self.acoustic_fatigue() # Lower lower_t = {} lower_t["pressure"] = self.thickness_pressure() - lower_t["panel_flutter"] = self.panel_flutter(mach=None, altitude=None) + lower_t["panel_flutter"] = self.panel_flutter() lower_t["acoustic"] = self.acoustic_fatigue() # Side side_t = {} @@ -794,7 +821,7 @@ def sizing(self) -> dict: side_t["shear"] = self.field_thickness_block_shear() side_t["net_section"] = self.land_thickness_net_section() side_t["post_buckled"] = self.field_thickness_postbuckled() - side_t["panel_flutter"] = self.panel_flutter(mach=None, altitude=None) + side_t["panel_flutter"] = self.panel_flutter() side_t["acoustic"] = self.acoustic_fatigue() results = {"upper": upper_t, "lower": lower_t, "side": side_t} @@ -841,6 +868,9 @@ class MinorFrame(Component): diameter: float | None = None """The fuselage diameter at the cut.""" + context: ShellContext | None = None + """Sizing context.""" + @property def t_w(self) -> float: """Web thickness.""" @@ -929,29 +959,26 @@ def acoustic_fatigue(self) -> float: return float(t_r) - def post_buckled( - self, - d: float, - h: float, - D: float, - Z: float, - sum_z_sq: float, - t_c: float, - RC: float, - f_s: float, - f_scr: float, - long_material: Material | None = None, - ) -> float: + def post_buckled(self) -> float: """Thickness required for post-buckled strength.""" + if not self.context: + return 0.0 + + ctx = self.context + if not ctx.t_c or ctx.f_s is None or ctx.f_scr is None: + return 0.0 + # Pass everything directly through to the ForcedCrippling class. construction = self.construction t_r = self.t_r b = self.b c = self.c - cover_material = self.material + cover_material = ctx.cover_material or self.material + long_material = ctx.long_material or self.material + check = ForcedCrippling( - d=d, - h=h, + d=ctx.frame_spacing, + h=ctx.long_spacing, c=c, b=b, construction=construction, @@ -964,14 +991,14 @@ def post_buckled( # directly, we only need the first value in the returned tuple. # No further analysis is necessary. t, _, _ = check.forced_crippling( - D=self.diameter, - M=self.M, - Z=Z, - sum_z_sq=sum_z_sq, - t_c=t_c, - RC=RC, - f_s=f_s, - f_scr=f_scr, + D=self.diameter or 0.0, + M=self.M or 0.0, + Z=ctx.Z or 0.0, + sum_z_sq=ctx.sum_z_sq or 1.0, + t_c=ctx.t_c, + RC=ctx.RC or 0.0, + f_s=ctx.f_s, + f_scr=ctx.f_scr, ) return float(t) @@ -981,8 +1008,7 @@ def sizing(self) -> dict: results = { "general_stability": self.general_stability(), "acoustic_fatigue": self.acoustic_fatigue(), - # TODO: How do we simplify the post_buckled method...Fucking diagonal tension... FML - "forced_crippling": self.post_buckled(stufffffffff), + "forced_crippling": self.post_buckled(), } return results @@ -1017,6 +1043,9 @@ class Longeron(Component): M: float | None = None """Bending moment at the cut.""" + context: ShellContext | None = None + """Sizing context.""" + @property def area(self) -> float: """Cross-sectional area.""" @@ -1186,6 +1215,7 @@ def post_buckled( def sizing(self) -> float: """Calculates the weight/in of the member.""" + # TODO: area_1 = self.bending_strength(stufff) area_2 = self.post_buckled(stuffffffffff) @@ -2174,10 +2204,6 @@ class Fuselage: construction: str """Construction method. ('stringer' or 'longeron')""" - loadcase: LoadCase - """A single loadcase to evaluate.""" - - # TODO: Do we want these as instance variables? cover_model: Cover """The Cover model to use.""" @@ -2187,8 +2213,11 @@ class Fuselage: frame_model: MinorFrame """The MinorFrame model to use.""" + loadcase: LoadCase | None = None + """A single loadcase to evaluate.""" + def longeron_coords( - self, station: Station, phi: float | None = None, d: float | None = None + self, station: Station, phi: float | None = None, d: float = 1.0 ) -> Tuple[float]: """Vertical and horizontal coordinates of the longerons in a section. @@ -2201,22 +2230,22 @@ def longeron_coords( Args: station (Station): The station geometry. phi (float | None, optional): Clockwise angle. Defaults to None. - d (float | None, optional): Fraction of total depth. Defaults to None. + d (float, optional): Fraction of total depth. Defaults to 1.0. Returns: Tuple[float]: Coordinates (y, z) of longeron centroid """ - if not phi: + if phi: + # if phi provided go ahead and use that. Easy. return station.get_coords(phi) - elif not d: + else: if d >= 1: y = station.wo else: zz = d * station.depth / 2 - station.doo y = station.wo + np.sqrt(station.radius**2 - zz**2) return (y, d * station.depth / 2) - else: - return (0, 0) + def cut_geometry(self, start: Station, end: Station) -> Station: """Interpolate the geometry between the described stations. @@ -2243,24 +2272,83 @@ def cut_geometry(self, start: Station, end: Station) -> Station: ) return interpolated - def get_Q(self, geom: Station, **kwargs) -> float: + def get_Q(self, geom: Station, ds: float | None = None, **kwargs) -> float: """Calculate the first moment of area for the section. The first moment of area calculation assumes all areas are lumped into the bending element centroids. To calculate, simply sum all the areas by their vertical distance. We calculate this value for the midpoint of the fuselage, where the maximum value will be. + + Note: This is intended to be calculated exclusive of sizing. An + inherent assumption is that exact internal loads distributions are + not required. That would be out of scope. """ if self.construction == "longeron": y, z = self.longeron_coords(geom, **kwargs) return z * self.long_model.area elif self.construction == "stringer": - # TODO: - raise NotImplementedError() + # Area moments are simply the summation of vertical and lateral coordinates. + # We only use the vertical because we only calculate effects from vertical bending. + # Therefore, Q = ALU * sum(z-coords) + ALS * sum(z-coords) + # + TCU * sum(z-coords * ds) + TCS * sum(z-coords * ds) + # ALU = Area of each upper stringer + # ALS = Area of each side stringer + # TCU = thickness of upper cover + # TCS = thickness of side cover + + # ASSUMPTION: All stringers are the same area. + perimeter = geom.upper_panel + geom.lower_panel + 2 * geom.side_panel + # Quarter the total stringers are in the upper quadrant + num_stringers = perimeter / ds / 4 + + # TODO: This isn't quite right... Close enough for weights sizing? + d_theta = 90 / num_stringers + + # This could be a numpy array instead of a loop. + z_coords = [] + theta = d_theta / 2 + for _ in range(num_stringers): + y, z = geom.get_coords(theta) + z_coords.append(z) + theta += d_theta + + Q_alu = self.long_model.area * np.sum(z_coords) + Q_als = self.long_model.area * np.sum(z_coords) + Q_tcu = self.cover_model.t_c * np.sum(z_coords * ds) + Q_tcs = self.cover_model.t_c * np.sum(z_coords * ds) + + return (Q_alu + Q_als + Q_tcu + Q_tcs) else: raise ValueError("Construction method must be 'stringer' or 'longeron'!") + + def get_I(self, geom: Station, **kwargs) -> float: + """Calculate the second moment of area for the section. - def synthesis(self, frame_spacing: float | None = None) -> None: + The second moment of area is calculated similarly to the first moment + of area. Sum all contributions from bending elements and covers, using + their centroids and a lumped area assumption. + + Note: This is intended to be calculated exclusive of sizing. An + inherent assumption is that exact internal loads distributions are + not required. That would be out of scope. + + Args: + geom (Station): The station geometry. + + Returns: + float: The second moment of area for the upper quadrant. + """ + if self.construction == "longeron": + y, z = self.longeron_coords(geom, **kwargs) + return z**2 * self.long_model.area + elif self.construction == "stringer": + # TODO: I guess do this eventually... + raise NotImplementedError("Tell the author he's an idiot and forgot to do this.") + else: + raise ValueError("Construction method must be 'stringer' or 'longeron'!") + + def synthesis(self, loadcase: LoadCase, stringer_spacing: float | None = None, frame_spacing: float | None = None) -> None: """The full multistation synthesis loop. [FUSSHL]. Geometry definitions and constraints, loads, and design criteria are all @@ -2290,6 +2378,8 @@ def synthesis(self, frame_spacing: float | None = None) -> None: cover support requirements. Args: + loadcase (LoadCase): The loadcase to evaluate. + stringer_spacing (Float|None) : Provide stringer spacing or search for min frame spacing by leaving blank. Default None. frame_spacing (Float|None) : Provide frame spacing or search for min frame spacing by leaving blank. Default None. """ # Doing the MajorFrame weight first. @@ -2306,20 +2396,45 @@ def synthesis(self, frame_spacing: float | None = None) -> None: geom = self.cut_geometry(station, self.stations[k + 1]) chunk_length = self.stations[k + 1].number - station.number - _, cut_shear, cut_bending = self.lookup_loads(geom.number, self.loads) - - # Should we use a provided spacing or conduct a frame spacing search? - if not frame_spacing: + _, cut_shear, cut_bending = self.lookup_loads(geom.number, loadcase.fuse_loads) + + # Should we use a provided spacing or conduct a search? + if not stringer_spacing: + # A full-up nested search + if self.construction == "longeron": + # Note, this is a fraction of the section depth, not a unit + # Since we only assume 4 primary longerons, this is kinda useless. + stringer_min_spacing = 0.75 + # Hardcoding a lower value for the search routine + frame_spacing = 2.0 + else: + # Start with a lot of stringers in the upper panel + stringer_min_spacing = geom.upper_panel / 48 + + cut_results = self.stringer_search( + start=stringer_min_spacing, + geom=geom, + frame_spacing=frame_spacing, + V=cut_shear, + M=cut_bending, + chunk_length=chunk_length, + loadcase=loadcase, + d=0.9 + ) + elif not frame_spacing: + # Search on frame spacing only cut_results = self.frame_search( - V=cut_shear, M=cut_bending, geom=geom, chunk_length=chunk_length + V=cut_shear, M=cut_bending, geom=geom, chunk_length=chunk_length, loadcase=loadcase ) else: + # Don't need a search cut_results = self.size_shell( V=cut_shear, M=cut_bending, geom=geom, frame_spacing=frame_spacing, chunk_length=chunk_length, + loadcase=loadcase ) # TODO: Not implemented. Is this different that size_shell()? @@ -2512,21 +2627,66 @@ def lookup_loads(self, x: float, loads: ArrayLike) -> Tuple[float, float, float] m = np.interp(x, xp=xp, fp=fp_m) return (x, np.float64(v), np.float64(m)) - def stringer_search(self, **kwargs): - """Search for weight-optimum stringer/longeron spacing. [LONGS].""" - # TODO: We need to calculate these at each longeron search step - self.cover_model.Q = self.get_Q(**kwargs) - self.cover_modelcover.I = self.get_I() + def stringer_search( + self, + start: float, + geom: Station, + frame_spacing: float, + V: float, + M: float, + chunk_length: float, + loadcase: LoadCase, + **kwargs + ) -> NamedTuple: + """Search for weight-optimum stringer/longeron spacing. [LONGS]. + + For longeron construction, the routine locates the primary longerons. + The longeron position data are either defined at the local cuts + or by a general position data. - pass + Args: + start (float) : The starting spacing. + geom (Station): The station geometry. + frame_spacing (float): Frame spacing, inches. + V (float): Beam shear load at the cut. + M (float): Beam bending moment at the cut. + chunk_length (float): Chunk length from the synthesis routine. + loadcase (LoadCase): The loadcase from synthesis. + + Returns: + NamedTuple: weight results + """ + max_spacing = max(geom.depth, geom.width) / 2 + previous_results = None + for spacing in np.linspace(start, max_spacing, num=10): + # Do some stuff on the spacing. + results_obj = self.frame_search( + min_spacing=frame_spacing, V=V, M=M, long_spacing=spacing, geom=geom, chunk_length=chunk_length, loadcase=loadcase + ) + + # Update constituent models that depend on spacing. + self.cover_model.Q = self.get_Q(**kwargs) + self.cover_model.I = self.get_I(**kwargs) + + # Evaluate the performance criteria + if spacing > start: + # The first iteration won't have previous results + if results_obj.weight > previous_results.weight: + break + + previous_results = results_obj + + return previous_results def frame_search( self, min_spacing: float, V: float, M: float, + long_spacing: float, geom: Station, chunk_length: float, + loadcase: LoadCase ) -> NamedTuple: """Search for weight-optimum frame spacing. [FPANEL]. @@ -2535,12 +2695,24 @@ def frame_search( the lumped weight of covers, minor frames, and longitudinal members indicates an upward trend. This increase of weight, or an optimum less than the initial spacing abbreviates the search. + + Args: + min_spacing (float): Frame spacing, inches. + V (float): Beam shear load at the cut. + M (float): Beam bending moment at the cut. + geom (Station): The station geometry. + chunk_length (float): Chunk length from the synthesis routine. + loadcase (LoadCase): The loadcase from synthesis. + + Returns: + NamedTuple: weight results """ - max_spacing = max(geom.depth, geom.width) / 2 + # Is 10x min an appropriate ceiling? So 2 to 20 or 6 to 60? Probably overkill if anything. + max_spacing = 10 * min_spacing - for spacing in np.linspace(min_spacing, max_spacing, num=10): + for spacing in np.linspace(min_spacing, max_spacing, num=20): results_obj = self.size_shell( - V=V, M=M, frame_spacing=spacing, geom=geom, chunk_length=chunk_length + V=V, M=M, long_spacing=long_spacing, frame_spacing=spacing, geom=geom, chunk_length=chunk_length, loadcase=loadcase ) if spacing > min_spacing: # The first iteration won't have previous results @@ -2555,20 +2727,34 @@ def size_shell( self, V: float, M: float, + long_spacing: float, frame_spacing: float, geom: Station, chunk_length: float, - ) -> NamedTuple: + loadcase: LoadCase + ) -> NamedTuple: """Conducts analysis point sizing of Fuselage shell structure. This method sizes shell structure at a single point. - """ + + Args: + V (float): Beam shear load at the cut. + M (float): Beam bending moment at the cut. + long_spacing (float): Longeron/Stringer spacing. + frame_spacing (float): MinorFrame spacing. + geom (Station): Station geometry. + chunk_length (float): The chunk length from the synthesis routine. + loadcase (LoadCase): The loadcase from synthesis. + + Returns: + NamedTuple: The results object with weight breakdown. + """ # Set the instance variables for our cut loads self.cover_model.V = V self.cover_model.L = frame_spacing self.cover_model.D = long_spacing - self.cover_model.mach = mach - self.cover_model.altitude = altitude + self.cover_model.mach = loadcase.mach + self.cover_model.altitude = loadcase.altitude self.frame_model.M = M self.frame_model.frame_spacing = frame_spacing @@ -2576,8 +2762,58 @@ def size_shell( self.long_model.M = M + context = ShellContext( + geom=geom, + V=V, + M=M, + long_spacing=long_spacing, + frame_spacing=frame_spacing, + cover_material=self.cover_model.material, + long_material=self.long_model.material, + ) + self.cover_model.context = context + self.frame_model.context = context + self.long_model.context = context + # Run the class sizing routines cover_results = self.cover_model.sizing() + + # Populate context with cover results for subsequent sizing routines + try: + # If we ever swap some cover methods to return both + # field and land thickness, we'll get a type error here. + t_c = max(cover_results["side"].values()) + except TypeError as err: + # If we do that, let's catch it and help future-us understand what went wrong. + print(cover_results) + print(cover_results["side"].values()) + raise err + + context.t_c = t_c + context.RC = self.cover_model.RC + + q = self.cover_model.q + context.f_s = q / t_c if t_c > 0 else 0.0 + + # Calculate critical shear buckling strength + f_scr = ( + self.cover_model.k_s + * np.pi**2 + * self.cover_model.material.E + / (12 * (1 - self.cover_model.material.nu**2)) + * (t_c / min(long_spacing, frame_spacing)) ** 2 + ) + context.f_scr = f_scr + + context.Z = geom.depth / 2 + + if self.construction == "longeron": + _, z = self.longeron_coords(geom) + context.sum_z_sq = 4 * z**2 + else: + context.sum_z_sq = 1.0 # placeholder for stringer + raise NotImplementedError("The sum_z_sq context for stringers isn't currently calculated! Further development needed.") + frame_results = self.frame_model.sizing() long_weight = self.long_model.sizing() @@ -2613,4 +2849,9 @@ def size_shell( "minor_frames": frame_weight, "longerons": long_weight, } - return weights + total_weight = sum(weights.values()) + + ResultsObj = namedtuple("Results", ["weights_dict", "weight"]) + results = ResultsObj(weights, total_weight) + + return results From dda93a87ecba852ebdff6894cffecce1bad9cb3f Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Sat, 27 Jun 2026 11:34:01 -0400 Subject: [PATCH 11/17] Functional Fuselage Synthesis! Finally!! We've got a full-up routine hacked together! --- examples/medium_transport.py | 17 +++++++++++++-- src/hyperstruct/fuselage.py | 41 +++++++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/examples/medium_transport.py b/examples/medium_transport.py index 4fc7060..d14f663 100644 --- a/examples/medium_transport.py +++ b/examples/medium_transport.py @@ -17,6 +17,7 @@ import matplotlib.pyplot as plt import numpy as np +from rich import print from hyperstruct import Material, LoadCase from hyperstruct import Station @@ -301,7 +302,7 @@ _ = ax2.plot(x, m, marker="^", color="k", label="Analysis Point") _ = ax2.legend() -plt.show() +# plt.show() # @@ -316,4 +317,16 @@ frame.synthesis() print(f" FS {frame.fs_loc}: {frame.weight:.1f}[lbf]") -fuse.synthesis(loadcase=lc) \ No newline at end of file +results = fuse.synthesis(loadcase=lc) + +print(results) + +x = [float(x[0]) for x in results] +y = [float(x[1].weight) for x in results] +fig, ax = plt.subplots(figsize=(7,4)) +_ = ax.bar(x, y, width=100, color='k') +_ = ax.set_xlabel("Fuselage Station, $FS$, [in]") +_ = ax.set_ylabel("Weight, $W$, [lbs]") +_ = fig.suptitle(f"{FNZ0:.1f}g Taxi, xCG={XCG:.0f}[in]") + +plt.show() \ No newline at end of file diff --git a/src/hyperstruct/fuselage.py b/src/hyperstruct/fuselage.py index a774340..fbd9715 100644 --- a/src/hyperstruct/fuselage.py +++ b/src/hyperstruct/fuselage.py @@ -1117,7 +1117,6 @@ def bending_strength( to determine the apparent effective width. Args: - M_ext: External moment at the cut t: cover thickness d: fuselage depth I_t: cover moment of inertia, as a function of thickness @@ -1215,9 +1214,26 @@ def post_buckled( def sizing(self) -> float: """Calculates the weight/in of the member.""" - # TODO: - area_1 = self.bending_strength(stufff) - area_2 = self.post_buckled(stuffffffffff) + ctx = self.context + # Centroidal cover inertia is 1/12 b*h^3 + # Effective width is twice the longeron flange width. + cover_A = 2 * self.b * ctx.t_c + cover_I = 2 * self.b * ctx.t_c ** 3 / 12 + # Parallel axis theorem to get inertia of cover about centroid of longeron + cover_I = cover_I + cover_A * (self.b / 2) ** 2 + + area_1 = self.bending_strength( + t=ctx.t_c, + d=ctx.geom.depth, + I_t=cover_I, + l_p=ctx.geom.upper_panel, + # TODO: No cutouts currently supported! + rtu=0.0, + A_s=self.area_effective, + I_a=self.area_effective + ) + # area_2 = self.post_buckled(stuffffffffff) + area_2 = 0.0 w_1 = self.material.rho * area_1 w_2 = self.material.rho * area_2 @@ -2348,7 +2364,7 @@ def get_I(self, geom: Station, **kwargs) -> float: else: raise ValueError("Construction method must be 'stringer' or 'longeron'!") - def synthesis(self, loadcase: LoadCase, stringer_spacing: float | None = None, frame_spacing: float | None = None) -> None: + def synthesis(self, loadcase: LoadCase, stringer_spacing: float | None = None, frame_spacing: float | None = None) -> list: """The full multistation synthesis loop. [FUSSHL]. Geometry definitions and constraints, loads, and design criteria are all @@ -2381,13 +2397,17 @@ def synthesis(self, loadcase: LoadCase, stringer_spacing: float | None = None, f loadcase (LoadCase): The loadcase to evaluate. stringer_spacing (Float|None) : Provide stringer spacing or search for min frame spacing by leaving blank. Default None. frame_spacing (Float|None) : Provide frame spacing or search for min frame spacing by leaving blank. Default None. + + Returns: + list of tuples (cut_location: float, cut_results: NamedTuple) """ # Doing the MajorFrame weight first. for frame in self.major_frames: frame.synthesis() + results = [] for k, station in enumerate(self.stations): - if k == len(self.stations): + if k == len(self.stations)-1: # the last station in the tuple is the tail geometry, # so no synthesis cut aft of the tail section. continue @@ -2437,8 +2457,9 @@ def synthesis(self, loadcase: LoadCase, stringer_spacing: float | None = None, f loadcase=loadcase ) - # TODO: Not implemented. Is this different that size_shell()? - _ = self.size_station() + results.append((geom.number, cut_results)) + + return results def net_loads( self, @@ -2665,8 +2686,8 @@ def stringer_search( ) # Update constituent models that depend on spacing. - self.cover_model.Q = self.get_Q(**kwargs) - self.cover_model.I = self.get_I(**kwargs) + self.cover_model.Q = self.get_Q(geom=geom, **kwargs) + self.cover_model.I = self.get_I(geom=geom, **kwargs) # Evaluate the performance criteria if spacing > start: From d3e7cad80c3262c9acaea364fa3f1cea4ae78b53 Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Mon, 6 Jul 2026 20:30:55 -0400 Subject: [PATCH 12/17] Debugging. Added logging for debug, and found the root of cover pressure unreasonable results. Multiplied by gravitational acceleration instead of dividing, and had some unreasonable defaults. --- examples/medium_transport.py | 39 ++++---- src/hyperstruct/__init__.py | 3 + src/hyperstruct/fuselage.py | 176 ++++++++++++++++++++++++----------- 3 files changed, 144 insertions(+), 74 deletions(-) diff --git a/examples/medium_transport.py b/examples/medium_transport.py index d14f663..f39b176 100644 --- a/examples/medium_transport.py +++ b/examples/medium_transport.py @@ -19,14 +19,18 @@ import numpy as np from rich import print -from hyperstruct import Material, LoadCase +from hyperstruct import LoadCase +from hyperstruct import Material from hyperstruct import Station from hyperstruct import composite_cg # from hyperstruct.fuselage import Cover # from hyperstruct.fuselage import ForcedCrippling +from hyperstruct.fuselage import Cover from hyperstruct.fuselage import Fuselage -from hyperstruct.fuselage import MajorFrame, MinorFrame, Cover, Longeron +from hyperstruct.fuselage import Longeron +from hyperstruct.fuselage import MajorFrame +from hyperstruct.fuselage import MinorFrame # from hyperstruct.fuselage import MinorFrame @@ -141,9 +145,9 @@ # # Landing Gear Loads Calculation -# Taxi, WC=73k, 2.0g +# Taxi, WC=73k, 3.0g # -FNZ0 = 2.0 +FNZ0 = 3.0 XNGG = 165 XMGG = 620 XCG = _cg @@ -268,23 +272,17 @@ print(p_ext) print(17 * " " + f"{np.sum(p_ext[:, 1]):.2f}") -cover_model = Cover( - material=al2024, milled=False, L=30, D=20, R=1, RC=25 -) -long_model = Longeron( - material=al2024, b=2.0, t_s=0.1, k=0.8 -) -frame_model = MinorFrame( - material=al2024, c=4.0, b=3.0, construction="longeron" -) +cover_model = Cover(material=al2024, milled=False, L=30, D=20, R=0, RC=25) +long_model = Longeron(material=al2024, b=2.0, t_s=0.1, k=0.8) +frame_model = MinorFrame(material=al2024, c=4.0, b=3.0, construction="longeron") fuse = Fuselage( - stations=stations, + stations=stations, major_frames=frames, construction="longeron", cover_model=cover_model, long_model=long_model, - frame_model=frame_model + frame_model=frame_model, ) loads = fuse.net_loads(w_fus, w_fc, p_air, p_ext) fig, (ax1, ax2) = fuse.vmt_diagram(w_fus, w_fc, p_air, p_ext) @@ -309,7 +307,9 @@ # S I Z I N G # -lc = LoadCase(fuse_loads=loads, lcid=31, name="3g Taxi", mach=0.1, altitude=0.0) +lc = LoadCase( + fuse_loads=loads, lcid=31, name="3g Taxi", mach=0.1, altitude=0.0, cg_x=XCG +) # Major Frames print("Major Frame Sizing:") @@ -323,10 +323,11 @@ x = [float(x[0]) for x in results] y = [float(x[1].weight) for x in results] -fig, ax = plt.subplots(figsize=(7,4)) -_ = ax.bar(x, y, width=100, color='k') +fig, ax = plt.subplots(figsize=(7, 4)) +_ = ax.bar(x, y, width=100, color="k") _ = ax.set_xlabel("Fuselage Station, $FS$, [in]") _ = ax.set_ylabel("Weight, $W$, [lbs]") _ = fig.suptitle(f"{FNZ0:.1f}g Taxi, xCG={XCG:.0f}[in]") -plt.show() \ No newline at end of file + +# plt.show() diff --git a/src/hyperstruct/__init__.py b/src/hyperstruct/__init__.py index ba7ffd0..aceb437 100644 --- a/src/hyperstruct/__init__.py +++ b/src/hyperstruct/__init__.py @@ -658,3 +658,6 @@ class LoadCase: name: str = None mach: float | None = None altitude: float | None = None + cg_x: float | None = None + cg_y: float | None = None + cg_z: float | None = None diff --git a/src/hyperstruct/fuselage.py b/src/hyperstruct/fuselage.py index fbd9715..52d3fec 100644 --- a/src/hyperstruct/fuselage.py +++ b/src/hyperstruct/fuselage.py @@ -5,10 +5,11 @@ This file contains all global variables, classes, and functions related to fuselage weight synthesis. """ +import logging +from collections import namedtuple from copy import copy from dataclasses import dataclass from dataclasses import field -from collections import namedtuple # from typing import Dict from typing import Any @@ -23,6 +24,7 @@ from matplotlib.lines import Line2D from matplotlib.patches import FancyArrowPatch from numpy.typing import ArrayLike +from rich.logging import RichHandler from scipy.optimize import minimize_scalar from hyperstruct import Component @@ -31,9 +33,26 @@ from hyperstruct import Station +FORMAT = "%(asctime)s %(message)s" +logging.basicConfig( + level="DEBUG", + format=FORMAT, + datefmt="[%X]", + handlers=[ + RichHandler( + markup=True, rich_tracebacks=True, tracebacks_suppress=["matplotlib", "PIL"] + ) + ], +) +logger = logging.getLogger(__name__) +nolog = logging.getLogger("PIL").propagate = False +nolog = logging.getLogger("matplotlib").propagate = False + + @dataclass class ShellContext: """Context object passing shell evaluation properties to components.""" + geom: Station V: float M: float @@ -628,21 +647,29 @@ def thickness_pressure(self) -> Tuple[float, float]: Returns: A tuple of (Land thickness, Field thickness). """ + logger.debug("COVER COMPONENT SIZING") b = min(self.D, self.L) if not self.F_allow: self.F_allow = self.material.F_ty / 4.0 # TODO: Lookup the vehicle-level load factors for the CG + logger.debug("Load Factors at Aircraft CG:") Nz_plus = 6 Nz_minus = -3 + logger.debug(f"Nz_plus = {Nz_plus:.0f}, Nz_minus = {Nz_minus:.0f}") # TODO: Lookup the vehicle-level pitch accelerations # Assume the pitch acceleration units are radians per second per second - Q_dot = 6.9 + # As a default value, we'll use a completely made up number that AI tells me + # is roughly the order of magnitude for a combat maneuver in a transport plane. + Q_dot = 0.349 # 386.0886 [in/s2] is the gravitational acceleration - Nz_1 = Nz_plus + Q_dot * self.R * 386.0886 - Nz_2 = Nz_minus + Q_dot * self.R * 386.0886 + Nz_1 = Nz_plus + Q_dot * self.R / 386.0886 + Nz_2 = Nz_minus - Q_dot * self.R / 386.0886 + logger.debug("Load Factors at Cover:") + logger.debug(f"R = {self.R:.2f} (distance from CG to cut)") + logger.debug(f"Nz_plus = {Nz_1:.1f}, Nz_minus = {Nz_2:.1f}") # TODO: Lookup the fluid density from the vehicle # Should this default to air for cabin fluid? @@ -658,10 +685,17 @@ def thickness_pressure(self) -> Tuple[float, float]: P_1 = P_o + rho * Nz_1 * h P_2 = P_o + rho * Nz_2 * h + logger.debug("Pressure Values:") + logger.debug(f" P_1 = {P_1:.2f}") + logger.debug(f" P_2 = {P_2:.2f}") + logger.debug(f" RC = {self.RC:.2f}") # Simple Hoop Stress t_1 = P_1 * self.RC / self.F_allow t_2 = P_2 * self.RC / self.F_allow + logger.debug(" Hoop Stress:") + logger.debug(f" t_1 = {t_1:.3f}") + logger.debug(f" t_2 = {t_2:.3f}") # Strip Theory Edge thickness t_3 = (1.646 * b * P_1**0.894 * self.material.E**0.394) / self.F_allow**1.288 @@ -975,7 +1009,7 @@ def post_buckled(self) -> float: c = self.c cover_material = ctx.cover_material or self.material long_material = ctx.long_material or self.material - + check = ForcedCrippling( d=ctx.frame_spacing, h=ctx.long_spacing, @@ -1218,7 +1252,7 @@ def sizing(self) -> float: # Centroidal cover inertia is 1/12 b*h^3 # Effective width is twice the longeron flange width. cover_A = 2 * self.b * ctx.t_c - cover_I = 2 * self.b * ctx.t_c ** 3 / 12 + cover_I = 2 * self.b * ctx.t_c**3 / 12 # Parallel axis theorem to get inertia of cover about centroid of longeron cover_I = cover_I + cover_A * (self.b / 2) ** 2 @@ -1230,7 +1264,7 @@ def sizing(self) -> float: # TODO: No cutouts currently supported! rtu=0.0, A_s=self.area_effective, - I_a=self.area_effective + I_a=self.area_effective, ) # area_2 = self.post_buckled(stuffffffffff) area_2 = 0.0 @@ -2262,7 +2296,6 @@ def longeron_coords( y = station.wo + np.sqrt(station.radius**2 - zz**2) return (y, d * station.depth / 2) - def cut_geometry(self, start: Station, end: Station) -> Station: """Interpolate the geometry between the described stations. @@ -2320,7 +2353,7 @@ def get_Q(self, geom: Station, ds: float | None = None, **kwargs) -> float: # TODO: This isn't quite right... Close enough for weights sizing? d_theta = 90 / num_stringers - + # This could be a numpy array instead of a loop. z_coords = [] theta = d_theta / 2 @@ -2334,10 +2367,10 @@ def get_Q(self, geom: Station, ds: float | None = None, **kwargs) -> float: Q_tcu = self.cover_model.t_c * np.sum(z_coords * ds) Q_tcs = self.cover_model.t_c * np.sum(z_coords * ds) - return (Q_alu + Q_als + Q_tcu + Q_tcs) + return Q_alu + Q_als + Q_tcu + Q_tcs else: raise ValueError("Construction method must be 'stringer' or 'longeron'!") - + def get_I(self, geom: Station, **kwargs) -> float: """Calculate the second moment of area for the section. @@ -2360,11 +2393,18 @@ def get_I(self, geom: Station, **kwargs) -> float: return z**2 * self.long_model.area elif self.construction == "stringer": # TODO: I guess do this eventually... - raise NotImplementedError("Tell the author he's an idiot and forgot to do this.") + raise NotImplementedError( + "Tell the author he's an idiot and forgot to do this." + ) else: raise ValueError("Construction method must be 'stringer' or 'longeron'!") - def synthesis(self, loadcase: LoadCase, stringer_spacing: float | None = None, frame_spacing: float | None = None) -> list: + def synthesis( + self, + loadcase: LoadCase, + stringer_spacing: float | None = None, + frame_spacing: float | None = None, + ) -> list: """The full multistation synthesis loop. [FUSSHL]. Geometry definitions and constraints, loads, and design criteria are all @@ -2407,7 +2447,7 @@ def synthesis(self, loadcase: LoadCase, stringer_spacing: float | None = None, f results = [] for k, station in enumerate(self.stations): - if k == len(self.stations)-1: + if k == len(self.stations) - 1: # the last station in the tuple is the tail geometry, # so no synthesis cut aft of the tail section. continue @@ -2416,7 +2456,9 @@ def synthesis(self, loadcase: LoadCase, stringer_spacing: float | None = None, f geom = self.cut_geometry(station, self.stations[k + 1]) chunk_length = self.stations[k + 1].number - station.number - _, cut_shear, cut_bending = self.lookup_loads(geom.number, loadcase.fuse_loads) + _, cut_shear, cut_bending = self.lookup_loads( + geom.number, loadcase.fuse_loads + ) # Should we use a provided spacing or conduct a search? if not stringer_spacing: @@ -2428,23 +2470,27 @@ def synthesis(self, loadcase: LoadCase, stringer_spacing: float | None = None, f # Hardcoding a lower value for the search routine frame_spacing = 2.0 else: - # Start with a lot of stringers in the upper panel - stringer_min_spacing = geom.upper_panel / 48 - + # Start with a lot of stringers in the upper panel + stringer_min_spacing = geom.upper_panel / 48 + cut_results = self.stringer_search( - start=stringer_min_spacing, - geom=geom, - frame_spacing=frame_spacing, - V=cut_shear, - M=cut_bending, - chunk_length=chunk_length, + start=stringer_min_spacing, + geom=geom, + frame_spacing=frame_spacing, + V=cut_shear, + M=cut_bending, + chunk_length=chunk_length, loadcase=loadcase, - d=0.9 + d=0.9, ) elif not frame_spacing: # Search on frame spacing only cut_results = self.frame_search( - V=cut_shear, M=cut_bending, geom=geom, chunk_length=chunk_length, loadcase=loadcase + V=cut_shear, + M=cut_bending, + geom=geom, + chunk_length=chunk_length, + loadcase=loadcase, ) else: # Don't need a search @@ -2454,7 +2500,7 @@ def synthesis(self, loadcase: LoadCase, stringer_spacing: float | None = None, f geom=geom, frame_spacing=frame_spacing, chunk_length=chunk_length, - loadcase=loadcase + loadcase=loadcase, ) results.append((geom.number, cut_results)) @@ -2553,12 +2599,12 @@ def net_loads( # Verify static equilibrium if shears[-1] != 0: raise ArithmeticError( - f"Shear Static Equilibrium has been violated! {shears[-1]:.2f} != 0.0" + f"Shear Static Equilibrium has been violated! {shears[-1]:.3f} != 0.0" ) - if not np.isclose(moments[-1], 0.0): + if not np.isclose(moments[-1], 0.0, atol=0.001): raise ArithmeticError( - f"Moment Static Equilibrium has been violated! {moments[-1]:.2f} != 0.0" + f"Moment Static Equilibrium has been violated! {moments[-1]:.3f} != 0.0" ) # Return the final arrays of internal shears and moments @@ -2649,21 +2695,21 @@ def lookup_loads(self, x: float, loads: ArrayLike) -> Tuple[float, float, float] return (x, np.float64(v), np.float64(m)) def stringer_search( - self, - start: float, - geom: Station, - frame_spacing: float, - V: float, - M: float, - chunk_length: float, - loadcase: LoadCase, - **kwargs - ) -> NamedTuple: + self, + start: float, + geom: Station, + frame_spacing: float, + V: float, + M: float, + chunk_length: float, + loadcase: LoadCase, + **kwargs, + ) -> NamedTuple: """Search for weight-optimum stringer/longeron spacing. [LONGS]. - + For longeron construction, the routine locates the primary longerons. The longeron position data are either defined at the local cuts - or by a general position data. + or by a general position data. Args: start (float) : The starting spacing. @@ -2682,7 +2728,13 @@ def stringer_search( for spacing in np.linspace(start, max_spacing, num=10): # Do some stuff on the spacing. results_obj = self.frame_search( - min_spacing=frame_spacing, V=V, M=M, long_spacing=spacing, geom=geom, chunk_length=chunk_length, loadcase=loadcase + min_spacing=frame_spacing, + V=V, + M=M, + long_spacing=spacing, + geom=geom, + chunk_length=chunk_length, + loadcase=loadcase, ) # Update constituent models that depend on spacing. @@ -2707,7 +2759,7 @@ def frame_search( long_spacing: float, geom: Station, chunk_length: float, - loadcase: LoadCase + loadcase: LoadCase, ) -> NamedTuple: """Search for weight-optimum frame spacing. [FPANEL]. @@ -2733,7 +2785,13 @@ def frame_search( for spacing in np.linspace(min_spacing, max_spacing, num=20): results_obj = self.size_shell( - V=V, M=M, long_spacing=long_spacing, frame_spacing=spacing, geom=geom, chunk_length=chunk_length, loadcase=loadcase + V=V, + M=M, + long_spacing=long_spacing, + frame_spacing=spacing, + geom=geom, + chunk_length=chunk_length, + loadcase=loadcase, ) if spacing > min_spacing: # The first iteration won't have previous results @@ -2752,8 +2810,8 @@ def size_shell( frame_spacing: float, geom: Station, chunk_length: float, - loadcase: LoadCase - ) -> NamedTuple: + loadcase: LoadCase, + ) -> NamedTuple: """Conducts analysis point sizing of Fuselage shell structure. This method sizes shell structure at a single point. @@ -2769,13 +2827,14 @@ def size_shell( Returns: NamedTuple: The results object with weight breakdown. - """ + """ # Set the instance variables for our cut loads self.cover_model.V = V self.cover_model.L = frame_spacing self.cover_model.D = long_spacing self.cover_model.mach = loadcase.mach self.cover_model.altitude = loadcase.altitude + self.cover_model.R = abs(geom.number - loadcase.cg_x) self.frame_model.M = M self.frame_model.frame_spacing = frame_spacing @@ -2799,6 +2858,11 @@ def size_shell( # Run the class sizing routines cover_results = self.cover_model.sizing() + logger.debug(f"Sizing station {geom.orientation}{geom.number} results:") + logger.debug(50 * "-") + logger.debug("Covers:") + logger.debug(cover_results) + # Populate context with cover results for subsequent sizing routines try: # If we ever swap some cover methods to return both @@ -2809,13 +2873,13 @@ def size_shell( print(cover_results) print(cover_results["side"].values()) raise err - + context.t_c = t_c context.RC = self.cover_model.RC - + q = self.cover_model.q context.f_s = q / t_c if t_c > 0 else 0.0 - + # Calculate critical shear buckling strength f_scr = ( self.cover_model.k_s @@ -2825,15 +2889,17 @@ def size_shell( * (t_c / min(long_spacing, frame_spacing)) ** 2 ) context.f_scr = f_scr - + context.Z = geom.depth / 2 - + if self.construction == "longeron": _, z = self.longeron_coords(geom) context.sum_z_sq = 4 * z**2 else: - context.sum_z_sq = 1.0 # placeholder for stringer - raise NotImplementedError("The sum_z_sq context for stringers isn't currently calculated! Further development needed.") + context.sum_z_sq = 1.0 # placeholder for stringer + raise NotImplementedError( + "The sum_z_sq context for stringers isn't currently calculated! Further development needed." + ) frame_results = self.frame_model.sizing() long_weight = self.long_model.sizing() From bcce798c861c6ae1726831d097471f6ce67c24f0 Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Mon, 6 Jul 2026 20:44:24 -0400 Subject: [PATCH 13/17] MinorFrame Weight. Setting instance MinorFrame flange thickness after sizing prior to calculating area. Added minimum gauge attribute with reasonable default. --- src/hyperstruct/fuselage.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/hyperstruct/fuselage.py b/src/hyperstruct/fuselage.py index 52d3fec..2b261b2 100644 --- a/src/hyperstruct/fuselage.py +++ b/src/hyperstruct/fuselage.py @@ -902,6 +902,9 @@ class MinorFrame(Component): diameter: float | None = None """The fuselage diameter at the cut.""" + min_gauge: float | None = 0.020 + """The minimum gauge thickness (default 0.020).""" + context: ShellContext | None = None """Sizing context.""" @@ -1043,6 +1046,7 @@ def sizing(self) -> dict: "general_stability": self.general_stability(), "acoustic_fatigue": self.acoustic_fatigue(), "forced_crippling": self.post_buckled(), + "min_gauge": self.min_gauge, } return results @@ -2902,6 +2906,10 @@ def size_shell( ) frame_results = self.frame_model.sizing() + logger.debug( + f"Setting MinorFrame flange thickness to {max(frame_results.values()):.3f}" + ) + self.frame_model.t_r = max(frame_results.values()) long_weight = self.long_model.sizing() # Caculate the weight and build the results object @@ -2921,11 +2929,14 @@ def size_shell( ) perimeter = geom.upper_panel + geom.lower_panel + 2 * geom.side_panel + logger.debug(f"Frame Perimeter = {perimeter:.2f}") # With thickness calculated from sizing, area is automatically updated for us. single_weight = ( self.frame_model.material.rho * self.frame_model.area * perimeter ) + logger.debug(f"Single Frame Weight = {single_weight:.2f}") frame_weight = single_weight * chunk_length / frame_spacing + logger.debug(f"Total Frame Weight = {frame_weight:.2f}") long_weight = long_weight * perimeter / long_spacing * chunk_length From 9efe19d35fa6cbe364b22fd54a05a3255d5ebc21 Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Mon, 6 Jul 2026 20:57:23 -0400 Subject: [PATCH 14/17] Adding skin min_gauge. --- src/hyperstruct/fuselage.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/hyperstruct/fuselage.py b/src/hyperstruct/fuselage.py index 2b261b2..99b9b1d 100644 --- a/src/hyperstruct/fuselage.py +++ b/src/hyperstruct/fuselage.py @@ -35,7 +35,7 @@ FORMAT = "%(asctime)s %(message)s" logging.basicConfig( - level="DEBUG", + level="INFO", format=FORMAT, datefmt="[%X]", handlers=[ @@ -499,6 +499,9 @@ class Cover(Component): I: float = 0 """area moment of inertia of the bending elements.""" + min_gauge: float | None = 0.020 + """minimum gauge thickness (default 0.020).""" + context: ShellContext | None = None """Sizing context.""" @@ -844,11 +847,13 @@ def sizing(self) -> dict: upper_t["pressure"] = self.thickness_pressure() upper_t["panel_flutter"] = self.panel_flutter() upper_t["acoustic"] = self.acoustic_fatigue() + upper_t["min_gauge"] = self.min_gauge # Lower lower_t = {} lower_t["pressure"] = self.thickness_pressure() lower_t["panel_flutter"] = self.panel_flutter() lower_t["acoustic"] = self.acoustic_fatigue() + lower_t["min_gauge"] = self.min_gauge # Side side_t = {} side_t["pressure"] = self.thickness_pressure() @@ -857,6 +862,7 @@ def sizing(self) -> dict: side_t["post_buckled"] = self.field_thickness_postbuckled() side_t["panel_flutter"] = self.panel_flutter() side_t["acoustic"] = self.acoustic_fatigue() + side_t["min_gauge"] = self.min_gauge results = {"upper": upper_t, "lower": lower_t, "side": side_t} @@ -2949,7 +2955,7 @@ def size_shell( } total_weight = sum(weights.values()) - ResultsObj = namedtuple("Results", ["weights_dict", "weight"]) - results = ResultsObj(weights, total_weight) + ResultsObj = namedtuple("Results", ["weights_dict", "weight", "segment_length"]) + results = ResultsObj(weights, total_weight, chunk_length) return results From bdf2ad58cdd1ea2a5c0ccaacc986385620c42f76 Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Sat, 25 Jul 2026 11:10:14 -0400 Subject: [PATCH 15/17] Cleaning up some docstrings and variable names. Just some administrative stuff to comply with darglint and flake8. Also commenting out the mypy sessions for local checks. We don't use mypy right now. --- .flake8 | 2 +- examples/medium_transport.py | 2 +- noxfile.py | 2 +- poetry.lock | 17 +++++++------- pyproject.toml | 1 + src/hyperstruct/fuselage.py | 26 ++++++++++++++++------ tests/test_fuselage.py | 43 ++++++++++++++++++++++++++++++------ 7 files changed, 67 insertions(+), 26 deletions(-) diff --git a/.flake8 b/.flake8 index 9f62595..b86bf85 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] select = B,B9,C,D,DAR,E,F,N,RST,S,W -ignore = E203,E501,RST201,RST203,RST301,W503,N803,N806 +ignore = E203,E501,RST201,RST203,RST301,W503,N803,N806,E741 max-line-length = 120 max-complexity = 13 docstring-convention = google diff --git a/examples/medium_transport.py b/examples/medium_transport.py index f39b176..8fd77ad 100644 --- a/examples/medium_transport.py +++ b/examples/medium_transport.py @@ -330,4 +330,4 @@ _ = fig.suptitle(f"{FNZ0:.1f}g Taxi, xCG={XCG:.0f}[in]") -# plt.show() +plt.show() diff --git a/noxfile.py b/noxfile.py index dc88a1b..061b8f9 100644 --- a/noxfile.py +++ b/noxfile.py @@ -29,7 +29,7 @@ nox.options.sessions = ( "pre-commit", "safety", - "mypy", + # "mypy", "tests", "typeguard", "xdoctest", diff --git a/poetry.lock b/poetry.lock index aa7dd64..571019e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1295,7 +1295,7 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, @@ -1508,7 +1508,7 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, @@ -2624,21 +2624,20 @@ docutils = ">=0.11,<1.0" [[package]] name = "rich" -version = "14.0.0" +version = "15.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false -python-versions = ">=3.8.0" -groups = ["dev"] +python-versions = ">=3.9.0" +groups = ["main", "dev"] markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" files = [ - {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, - {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, + {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, + {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, ] [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] @@ -3663,4 +3662,4 @@ tests-strict = ["pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)"] [metadata] lock-version = "2.1" python-versions = ">=3.10, <4.0" -content-hash = "f05cbb41168c8a184aa6c9f0307be1085b7bfd98321a86009afb3574fef97e76" +content-hash = "c1309fa4d56ba2f3d4ffc8668e056c6635d75512c25c76ea0b360fd7cbfd461c" diff --git a/pyproject.toml b/pyproject.toml index 3aee7df..fa6fb46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ scipy = ">=1.14.1, <2.0" matplotlib = ">=3.10.1, <4.0" pytest-check = ">=2.5.3, <3.0" pandas = ">=2.3.0, <3.0" +rich = "^15.0.0" [tool.poetry.requires-plugins] poetry-plugin-export = ">=1.8.0" diff --git a/src/hyperstruct/fuselage.py b/src/hyperstruct/fuselage.py index 99b9b1d..7dea73d 100644 --- a/src/hyperstruct/fuselage.py +++ b/src/hyperstruct/fuselage.py @@ -632,7 +632,7 @@ def land_thickness_net_section(self) -> float: else: return float(self.q / (self.c_r * self.material.F_su)) - def thickness_pressure(self) -> Tuple[float, float]: + def thickness_pressure(self) -> Tuple[float, float] | float: """Thicknesses based on cover pressure. A required thickness is evaluated to resist hoop stress, @@ -779,7 +779,7 @@ def panel_flutter(self) -> float: return float(t_b) - def acoustic_fatigue(self) -> Tuple[float, float]: + def acoustic_fatigue(self) -> Tuple[float, float] | float: """Thickness requirements based on acoustic fatigue. Assumptions are: @@ -2290,7 +2290,7 @@ def longeron_coords( Args: station (Station): The station geometry. phi (float | None, optional): Clockwise angle. Defaults to None. - d (float, optional): Fraction of total depth. Defaults to 1.0. + d (float): Fraction of total depth. Defaults to 1.0. Returns: Tuple[float]: Coordinates (y, z) of longeron centroid @@ -2331,7 +2331,7 @@ def cut_geometry(self, start: Station, end: Station) -> Station: ) return interpolated - def get_Q(self, geom: Station, ds: float | None = None, **kwargs) -> float: + def get_q(self, geom: Station, ds: float | None = None, **kwargs) -> float: """Calculate the first moment of area for the section. The first moment of area calculation assumes all areas are lumped @@ -2381,7 +2381,7 @@ def get_Q(self, geom: Station, ds: float | None = None, **kwargs) -> float: else: raise ValueError("Construction method must be 'stringer' or 'longeron'!") - def get_I(self, geom: Station, **kwargs) -> float: + def get_inertia(self, geom: Station, **kwargs) -> float: """Calculate the second moment of area for the section. The second moment of area is calculated similarly to the first moment @@ -2394,9 +2394,14 @@ def get_I(self, geom: Station, **kwargs) -> float: Args: geom (Station): The station geometry. + kwargs (dict): Keyword arguments. Returns: float: The second moment of area for the upper quadrant. + + Raises: + NotImplementedError: For Stringer construction I. + ValueError: If parent does not have `construction` attribute defined correctly. """ if self.construction == "longeron": y, z = self.longeron_coords(geom, **kwargs) @@ -2729,6 +2734,7 @@ def stringer_search( M (float): Beam bending moment at the cut. chunk_length (float): Chunk length from the synthesis routine. loadcase (LoadCase): The loadcase from synthesis. + kwargs (dict): Keyword arguments. Returns: NamedTuple: weight results @@ -2748,8 +2754,8 @@ def stringer_search( ) # Update constituent models that depend on spacing. - self.cover_model.Q = self.get_Q(geom=geom, **kwargs) - self.cover_model.I = self.get_I(geom=geom, **kwargs) + self.cover_model.Q = self.get_q(geom=geom, **kwargs) + self.cover_model.I = self.get_inertia(geom=geom, **kwargs) # Evaluate the performance criteria if spacing > start: @@ -2783,6 +2789,7 @@ def frame_search( min_spacing (float): Frame spacing, inches. V (float): Beam shear load at the cut. M (float): Beam bending moment at the cut. + long_spacing (float): Longeron spacing. geom (Station): The station geometry. chunk_length (float): Chunk length from the synthesis routine. loadcase (LoadCase): The loadcase from synthesis. @@ -2793,6 +2800,7 @@ def frame_search( # Is 10x min an appropriate ceiling? So 2 to 20 or 6 to 60? Probably overkill if anything. max_spacing = 10 * min_spacing + previous_results = None for spacing in np.linspace(min_spacing, max_spacing, num=20): results_obj = self.size_shell( V=V, @@ -2837,6 +2845,10 @@ def size_shell( Returns: NamedTuple: The results object with weight breakdown. + + Raises: + NotImplementedError: For stringer construction. + TypeError: If the Cover methods return multiple thickness values. """ # Set the instance variables for our cut loads self.cover_model.V = V diff --git a/tests/test_fuselage.py b/tests/test_fuselage.py index 57803e0..218a537 100644 --- a/tests/test_fuselage.py +++ b/tests/test_fuselage.py @@ -13,7 +13,9 @@ from hyperstruct.fuselage import Cover from hyperstruct.fuselage import ForcedCrippling from hyperstruct.fuselage import Fuselage +from hyperstruct.fuselage import Longeron from hyperstruct.fuselage import MajorFrame +from hyperstruct.fuselage import MinorFrame @pytest.fixture @@ -103,9 +105,36 @@ def b_frame(aluminum: Material, b_station: Station) -> Tuple[MajorFrame]: @pytest.fixture -def fuselage(a_station: Tuple[Station], b_frame: Tuple[MajorFrame]) -> Fuselage: +def basic_longeron(aluminum: Material) -> Longeron: + """Basic Longeron.""" + long_model = Longeron(material=aluminum, b=2.0, t_s=0.1, k=0.8) + return long_model + + +@pytest.fixture +def basic_frame(aluminum: Material) -> MinorFrame: + """Basic MinorFrame.""" + frame_model = MinorFrame(material=aluminum, c=4.0, b=3.0, construction="longeron") + return frame_model + + +@pytest.fixture +def fuselage( + a_station: Tuple[Station], + b_frame: Tuple[MajorFrame], + cover_model: Cover = unmilled_cover, + long_model: Longeron = basic_longeron, + frame_model: MinorFrame = basic_frame, +) -> Fuselage: """Build a Fuselage class.""" - fuse = Fuselage(stations=a_station, major_frames=b_frame) + fuse = Fuselage( + stations=a_station, + major_frames=b_frame, + construction="longeron", + cover_model=cover_model, + long_model=long_model, + frame_model=frame_model, + ) return fuse @@ -151,22 +180,22 @@ def test_unmilled_shear_and_net(unmilled_cover: Cover) -> None: def test_unmilled_pressure(unmilled_cover: Cover) -> None: """Test an unmilled cover.""" - t_l, t_c = unmilled_cover.thickness_pressure() + t_l = unmilled_cover.thickness_pressure() assert isinstance(t_l, float) - assert isinstance(t_c, float) def test_unmilled_flutter(unmilled_cover: Cover) -> None: """Test an unmilled cover.""" - t_c = unmilled_cover.panel_flutter(mach=1.3, altitude=5000) + unmilled_cover.mach = 1.3 + unmilled_cover.altitude = 5000 + t_c = unmilled_cover.panel_flutter() assert isinstance(t_c, float) def test_unmilled_acoustic(unmilled_cover: Cover) -> None: """Test an unmilled cover.""" - t_l, t_c = unmilled_cover.acoustic_fatigue() + t_l = unmilled_cover.acoustic_fatigue() assert isinstance(t_l, float) - assert isinstance(t_c, float) def test_diagonal_tension(diag_ten: ForcedCrippling) -> None: From e0385ad59053f6775e45dc12b7b362610609515b Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Sat, 25 Jul 2026 11:11:15 -0400 Subject: [PATCH 16/17] Bumping version. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fa6fb46..2e67b92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hyperstruct" -version = "0.0.10" +version = "0.1.0" description = "Hyperstruct" authors = ["Benjamin Crews "] license = "MIT" From 912ab7db454e0f2c3fa73f06d9c85e3e68cb2db7 Mon Sep 17 00:00:00 2001 From: Benjamin Crews Date: Sat, 25 Jul 2026 11:26:46 -0400 Subject: [PATCH 17/17] Forgot to comment out the mypy workflow on Ubuntu. --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 94fb219..1cb7e29 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: include: - { python: "3.12", os: "ubuntu-latest", session: "pre-commit" } # - { python: "3.12", os: "ubuntu-latest", session: "safety" } - - { python: "3.12", os: "ubuntu-latest", session: "mypy" } + # - { python: "3.12", os: "ubuntu-latest", session: "mypy" } # - { python: "3.11", os: "ubuntu-latest", session: "mypy" } # - { python: "3.10", os: "ubuntu-latest", session: "mypy" } - { python: "3.12", os: "ubuntu-latest", session: "tests" }