Skip to content

Commit 7af305a

Browse files
authored
Add files via upload
1 parent 2bcfcc7 commit 7af305a

2 files changed

Lines changed: 436 additions & 0 deletions

File tree

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
#!/usr/bin/env python3
2+
"""Independent linear 3-D frame screening model for ASTERION FCTA-1 V0.8.
3+
4+
This is an engineering cross-check, not a substitute for an ANSYS Mechanical
5+
model. It uses Euler-Bernoulli beam elements, ideal rigid joints, linear elastic
6+
material, circular tubes, and simplified lumped masses.
7+
"""
8+
from __future__ import annotations
9+
10+
import argparse
11+
import csv
12+
import json
13+
import math
14+
from pathlib import Path
15+
from typing import Dict, Iterable, Tuple
16+
17+
import numpy as np
18+
import pandas as pd
19+
from scipy.linalg import eigh
20+
from scipy.sparse import coo_matrix, csc_matrix
21+
from scipy.sparse.linalg import spsolve
22+
23+
DOF_PER_NODE = 6
24+
G0 = 9.80665
25+
26+
27+
def _rotation_matrix(p1: np.ndarray, p2: np.ndarray) -> Tuple[np.ndarray, float]:
28+
delta = p2 - p1
29+
length = float(np.linalg.norm(delta))
30+
if length <= 1e-12:
31+
raise ValueError("Zero-length element")
32+
ex = delta / length
33+
reference = np.array([0.0, 0.0, 1.0])
34+
if abs(float(np.dot(ex, reference))) > 0.90:
35+
reference = np.array([0.0, 1.0, 0.0])
36+
ey = np.cross(reference, ex)
37+
ey /= np.linalg.norm(ey)
38+
ez = np.cross(ex, ey)
39+
rotation = np.vstack((ex, ey, ez))
40+
return rotation, length
41+
42+
43+
def _local_stiffness(E: float, G: float, A: float, I: float, J: float, L: float) -> np.ndarray:
44+
k = np.zeros((12, 12), dtype=float)
45+
a = E * A / L
46+
t = G * J / L
47+
by = E * I
48+
bz = E * I
49+
50+
k[0, 0] = k[6, 6] = a
51+
k[0, 6] = k[6, 0] = -a
52+
k[3, 3] = k[9, 9] = t
53+
k[3, 9] = k[9, 3] = -t
54+
55+
# Bending in local x-y plane (v, rz), about local z.
56+
ids = [1, 5, 7, 11]
57+
block = bz * np.array([
58+
[12/L**3, 6/L**2, -12/L**3, 6/L**2],
59+
[ 6/L**2, 4/L, -6/L**2, 2/L],
60+
[-12/L**3, -6/L**2, 12/L**3, -6/L**2],
61+
[ 6/L**2, 2/L, -6/L**2, 4/L],
62+
])
63+
k[np.ix_(ids, ids)] += block
64+
65+
# Bending in local x-z plane (w, ry), about local y.
66+
ids = [2, 4, 8, 10]
67+
block = by * np.array([
68+
[12/L**3, -6/L**2, -12/L**3, -6/L**2],
69+
[-6/L**2, 4/L, 6/L**2, 2/L],
70+
[-12/L**3, 6/L**2, 12/L**3, 6/L**2],
71+
[-6/L**2, 2/L, 6/L**2, 4/L],
72+
])
73+
k[np.ix_(ids, ids)] += block
74+
return k
75+
76+
77+
def _transform(rotation: np.ndarray) -> np.ndarray:
78+
T = np.zeros((12, 12), dtype=float)
79+
for start in (0, 3, 6, 9):
80+
T[start:start+3, start:start+3] = rotation
81+
return T
82+
83+
84+
def _node_dofs(index: int) -> np.ndarray:
85+
start = index * DOF_PER_NODE
86+
return np.arange(start, start + DOF_PER_NODE, dtype=int)
87+
88+
89+
def load_model(root: Path):
90+
model_dir = root / "analysis/ansys/v0_8/model"
91+
nodes = pd.read_csv(model_dir / "optimized_beam_nodes.csv")
92+
elements = pd.read_csv(model_dir / "optimized_beam_elements.csv")
93+
sections = pd.read_csv(model_dir / "optimized_beam_sections.csv").set_index("section_id")
94+
material = pd.read_csv(model_dir / "material_properties.csv").iloc[0]
95+
remote = pd.read_csv(model_dir / "remote_mass_definitions.csv")
96+
return nodes, elements, sections, material, remote
97+
98+
99+
def assemble(root: Path):
100+
nodes, elements, sections, material, remote = load_model(root)
101+
id_to_index = {int(nid): i for i, nid in enumerate(nodes.node_id)}
102+
coords = nodes[["x_mm", "y_mm", "z_mm"]].to_numpy(float) / 1000.0
103+
ndof = len(nodes) * DOF_PER_NODE
104+
rows, cols, values = [], [], []
105+
mass_diag = np.zeros(ndof, dtype=float)
106+
107+
E = float(material.elastic_modulus_MPa) * 1e6
108+
nu = float(material.poisson_ratio)
109+
G = E / (2.0 * (1.0 + nu))
110+
rho = float(material.density_kg_m3)
111+
112+
element_cache = []
113+
for row in elements.itertuples(index=False):
114+
i = id_to_index[int(row.node_1)]
115+
j = id_to_index[int(row.node_2)]
116+
p1, p2 = coords[i], coords[j]
117+
R, L = _rotation_matrix(p1, p2)
118+
section = sections.loc[int(row.section_id)]
119+
A = float(section.area_mm2) * 1e-6
120+
I = float(section.I_mm4) * 1e-12
121+
J = float(section.J_mm4) * 1e-12
122+
k_local = _local_stiffness(E, G, A, I, J, L)
123+
T = _transform(R)
124+
k_global = T.T @ k_local @ T
125+
dofs = np.concatenate((_node_dofs(i), _node_dofs(j)))
126+
rr, cc = np.meshgrid(dofs, dofs, indexing="ij")
127+
rows.extend(rr.ravel().tolist())
128+
cols.extend(cc.ravel().tolist())
129+
values.extend(k_global.ravel().tolist())
130+
131+
beam_mass = rho * A * L
132+
for node_index in (i, j):
133+
d = _node_dofs(node_index)
134+
mass_diag[d[0:3]] += beam_mass / 2.0
135+
# Positive approximate rotary inertia for a robust screening modal model.
136+
mass_diag[d[3:6]] += beam_mass * L * L / 24.0
137+
element_cache.append((row, i, j, R, L, k_local, T, A, I, J))
138+
139+
K = coo_matrix((values, (rows, cols)), shape=(ndof, ndof)).tocsc()
140+
141+
# Place non-structural mass at the nearest structural node. ANSYS V0.5 uses
142+
# reviewed remote-point couplings instead; this is only a screening model.
143+
for row in remote.itertuples(index=False):
144+
xyz = np.array([float(row.x_mm), float(row.y_mm), float(row.z_mm)]) / 1000.0
145+
idx = int(np.argmin(np.linalg.norm(coords - xyz, axis=1)))
146+
m = float(row.mass_kg)
147+
d = _node_dofs(idx)
148+
mass_diag[d[0:3]] += m
149+
mass_diag[d[3:6]] += max(0.01, m * 0.25)
150+
151+
mass_diag[mass_diag <= 0.0] = 1e-9
152+
return nodes, elements, sections, material, coords, id_to_index, K, mass_diag, element_cache
153+
154+
155+
def constraints(nodes: pd.DataFrame, mode: str) -> np.ndarray:
156+
if mode == "aft_fixed":
157+
idx = np.where(np.isclose(nodes.x_mm.to_numpy(float), -21000.0))[0]
158+
elif mode == "stabilised":
159+
# Engineering-minimum stabilisation at three aft nodes. Do not use this
160+
# support set for final flight load-path claims.
161+
aft = nodes[np.isclose(nodes.x_mm, -21000.0)].sort_values("node_id")
162+
idx = aft.index.to_numpy()[:3]
163+
else:
164+
raise ValueError(f"Unknown constraint mode: {mode}")
165+
fixed = np.concatenate([_node_dofs(int(i)) for i in idx])
166+
return np.unique(fixed)
167+
168+
169+
def build_load(nodes: pd.DataFrame, coords: np.ndarray, case: str) -> Tuple[np.ndarray, str]:
170+
F = np.zeros(len(nodes) * DOF_PER_NODE, dtype=float)
171+
group = nodes.group.astype(str)
172+
173+
if case == "LC-STR-01":
174+
ids = np.where(group == "DOCK_FRAME")[0]
175+
for i in ids:
176+
F[_node_dofs(i)[0]] += -25000.0 / len(ids)
177+
support = "aft_fixed"
178+
elif case == "LC-STR-02":
179+
ids = np.where(group == "PROP_MOUNT")[0]
180+
for i in ids:
181+
F[_node_dofs(i)[0]] += 12000.0 / len(ids)
182+
support = "aft_fixed"
183+
elif case == "LC-STR-03":
184+
ids = np.where(group.isin(["RING1_OUTER", "RING2_OUTER"]))[0]
185+
for i in ids:
186+
radial = np.array([0.0, coords[i, 1], coords[i, 2]])
187+
radial /= np.linalg.norm(radial)
188+
F[_node_dofs(i)[0:3]] += 2433.19 * radial
189+
support = "aft_fixed"
190+
elif case == "LC-STR-04":
191+
ids = np.where(group == "RING1_OUTER")[0]
192+
target = ids[int(np.argmax(coords[ids, 1]))]
193+
radial = np.array([0.0, coords[target, 1], coords[target, 2]])
194+
radial /= np.linalg.norm(radial)
195+
F[_node_dofs(target)[0:3]] += 500.0 * 2.43319 * radial
196+
support = "aft_fixed"
197+
elif case == "LC-STR-05":
198+
for ring_group in ("RING1_OUTER", "RING2_OUTER"):
199+
ids = np.where(group == ring_group)[0]
200+
radius = np.mean(np.sqrt(coords[ids, 1]**2 + coords[ids, 2]**2))
201+
force_each = 6484.25 / (radius * len(ids))
202+
for i in ids:
203+
y, z = coords[i, 1], coords[i, 2]
204+
tangent = np.array([0.0, -z, y])
205+
tangent /= np.linalg.norm(tangent)
206+
F[_node_dofs(i)[0:3]] += force_each * tangent
207+
support = "aft_fixed"
208+
elif case == "LC-STR-06":
209+
# Combined powered-ring operation: propulsion + both ring centrifugal loads.
210+
ids = np.where(group == "PROP_MOUNT")[0]
211+
for i in ids:
212+
F[_node_dofs(i)[0]] += 12000.0 / len(ids)
213+
ids = np.where(group.isin(["RING1_OUTER", "RING2_OUTER"]))[0]
214+
for i in ids:
215+
radial = np.array([0.0, coords[i, 1], coords[i, 2]])
216+
radial /= np.linalg.norm(radial)
217+
F[_node_dofs(i)[0:3]] += 2433.19 * radial
218+
support = "aft_fixed"
219+
elif case == "LC-STR-07":
220+
ids = np.where(group == "DOCK_FRAME")[0]
221+
for i in ids:
222+
F[_node_dofs(i)[0]] += -25000.0 / len(ids)
223+
F[_node_dofs(i)[1]] += 2500.0 / len(ids)
224+
support = "aft_fixed"
225+
elif case == "LC-STR-08":
226+
# Balanced 120 s braking of counter-rotating rings. Opposite torques cancel globally.
227+
for ring_group, sign in (("RING1_OUTER", 1.0), ("RING2_OUTER", -1.0)):
228+
ids = np.where(group == ring_group)[0]
229+
radius = np.mean(np.sqrt(coords[ids, 1]**2 + coords[ids, 2]**2))
230+
force_each = 6484.25 / (radius * len(ids))
231+
for i in ids:
232+
y, z = coords[i, 1], coords[i, 2]
233+
tangent = np.array([0.0, -z, y])
234+
tangent /= np.linalg.norm(tangent)
235+
F[_node_dofs(i)[0:3]] += sign * force_each * tangent
236+
support = "aft_fixed"
237+
elif case == "LC-STR-09":
238+
# Single-ring braking fault with a controlled 180 s stop.
239+
ids = np.where(group == "RING1_OUTER")[0]
240+
radius = np.mean(np.sqrt(coords[ids, 1]**2 + coords[ids, 2]**2))
241+
force_each = 4322.83 / (radius * len(ids))
242+
for i in ids:
243+
y, z = coords[i, 1], coords[i, 2]
244+
tangent = np.array([0.0, -z, y])
245+
tangent /= np.linalg.norm(tangent)
246+
F[_node_dofs(i)[0:3]] += force_each * tangent
247+
support = "aft_fixed"
248+
else:
249+
raise ValueError(f"Unknown load case {case}")
250+
return F, support
251+
252+
253+
def solve_static(K: csc_matrix, F: np.ndarray, fixed: np.ndarray) -> np.ndarray:
254+
all_dofs = np.arange(K.shape[0])
255+
free = np.setdiff1d(all_dofs, fixed)
256+
u = np.zeros(K.shape[0], dtype=float)
257+
u[free] = spsolve(K[free][:, free], F[free])
258+
return u
259+
260+
261+
def element_results(u: np.ndarray, cache, material, sections) -> pd.DataFrame:
262+
E = float(material.elastic_modulus_MPa) * 1e6
263+
yield_pa = float(material.yield_strength_MPa) * 1e6
264+
rows = []
265+
for row, i, j, R, L, k_local, T, A, I, J in cache:
266+
dofs = np.concatenate((_node_dofs(i), _node_dofs(j)))
267+
u_local = T @ u[dofs]
268+
forces = k_local @ u_local
269+
c = float(sections.loc[int(row.section_id)].outer_diameter_mm) / 2000.0
270+
values = []
271+
for offset in (0, 6):
272+
axial = forces[offset + 0]
273+
torsion = forces[offset + 3]
274+
my = forces[offset + 4]
275+
mz = forces[offset + 5]
276+
sigma = abs(axial) / A + c * math.hypot(my, mz) / I
277+
tau = abs(torsion) * c / J
278+
vm = math.sqrt(sigma * sigma + 3.0 * tau * tau)
279+
values.append((axial, torsion, my, mz, vm))
280+
critical = max(values, key=lambda x: x[4])
281+
axial_min = min(values[0][0], values[1][0])
282+
pcr = math.pi**2 * E * I / (L**2)
283+
buckling_factor = pcr / abs(axial_min) if axial_min < -1e-6 else math.inf
284+
rows.append({
285+
"element_id": int(row.element_id),
286+
"group": str(row.group),
287+
"section_id": int(row.section_id),
288+
"length_m": L,
289+
"axial_force_N": critical[0],
290+
"torsion_Nm": critical[1],
291+
"bending_My_Nm": critical[2],
292+
"bending_Mz_Nm": critical[3],
293+
"screening_von_mises_MPa": critical[4] / 1e6,
294+
"yield_factor": yield_pa / critical[4] if critical[4] > 1e-9 else math.inf,
295+
"member_euler_factor": buckling_factor,
296+
})
297+
return pd.DataFrame(rows)
298+
299+
300+
def modal_supported(K: csc_matrix, mass_diag: np.ndarray, fixed: np.ndarray, count: int = 12):
301+
all_dofs = np.arange(K.shape[0])
302+
free = np.setdiff1d(all_dofs, fixed)
303+
Kff = K[free][:, free].toarray()
304+
Mff = np.diag(mass_diag[free])
305+
# Dense subset eigensolution is stable for this small screening model.
306+
eigvals, eigvecs = eigh(Kff, Mff, subset_by_index=[0, min(count + 8, len(free)-1)])
307+
positive = eigvals[eigvals > 1e-5]
308+
frequencies = np.sqrt(positive) / (2.0 * math.pi)
309+
return frequencies[:count]
310+
311+
312+
def main() -> int:
313+
parser = argparse.ArgumentParser()
314+
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[3])
315+
parser.add_argument("--output", type=Path, default=None)
316+
args = parser.parse_args()
317+
root = args.root.resolve()
318+
output = (args.output or (root / "calculations/v0_8/python_screening")).resolve()
319+
output.mkdir(parents=True, exist_ok=True)
320+
321+
nodes, elements, sections, material, coords, id_to_index, K, mass_diag, cache = assemble(root)
322+
summaries = []
323+
displacement_rows = []
324+
for case in ["LC-STR-01","LC-STR-02","LC-STR-03","LC-STR-04","LC-STR-06","LC-STR-07","LC-STR-08","LC-STR-09"]:
325+
F, support = build_load(nodes, coords, case)
326+
fixed = constraints(nodes, support)
327+
u = solve_static(K, F, fixed)
328+
nodal = u.reshape((-1, DOF_PER_NODE))
329+
trans = nodal[:, :3]
330+
mag = np.linalg.norm(trans, axis=1)
331+
critical_node = int(np.argmax(mag))
332+
eres = element_results(u, cache, material, sections)
333+
critical_element = eres.loc[eres.screening_von_mises_MPa.idxmax()]
334+
finite_buckling = eres[np.isfinite(eres.member_euler_factor)]
335+
min_buckling = float(finite_buckling.member_euler_factor.min()) if len(finite_buckling) else math.inf
336+
summaries.append({
337+
"load_case_id": case,
338+
"support_model": support,
339+
"result_source": "Independent Python Euler-Bernoulli frame screening",
340+
"max_translation_mm": float(mag[critical_node] * 1000.0),
341+
"critical_node_id": int(nodes.iloc[critical_node].node_id),
342+
"max_screening_von_mises_MPa": float(critical_element.screening_von_mises_MPa),
343+
"critical_element_id": int(critical_element.element_id),
344+
"minimum_yield_factor": float(critical_element.yield_factor),
345+
"minimum_member_euler_factor": min_buckling,
346+
})
347+
for i, node in nodes.iterrows():
348+
displacement_rows.append({
349+
"load_case_id": case,
350+
"node_id": int(node.node_id),
351+
"x_m": float(node.x_mm / 1000.0),
352+
"ux_mm": float(trans[i, 0] * 1000.0),
353+
"uy_mm": float(trans[i, 1] * 1000.0),
354+
"uz_mm": float(trans[i, 2] * 1000.0),
355+
"translation_mm": float(mag[i] * 1000.0),
356+
})
357+
eres.to_csv(output / f"{case.lower()}_element_screening.csv", index=False)
358+
359+
modal_fixed = constraints(nodes, "aft_fixed")
360+
frequencies = modal_supported(K, mass_diag, modal_fixed, count=12)
361+
modal_df = pd.DataFrame({
362+
"mode": np.arange(1, len(frequencies) + 1),
363+
"frequency_Hz": frequencies,
364+
"source": "Independent Python frame screening; approximate lumped mass",
365+
})
366+
modal_df.to_csv(output / "supported_modal_screening.csv", index=False)
367+
368+
summary_df = pd.DataFrame(summaries)
369+
summary_df.to_csv(output / "static_screening_summary.csv", index=False)
370+
pd.DataFrame(displacement_rows).to_csv(output / "nodal_displacements.csv", index=False)
371+
372+
report = {
373+
"status": "screening_only",
374+
"solver": "custom linear 3-D Euler-Bernoulli frame",
375+
"nodes": int(len(nodes)),
376+
"elements": int(len(elements)),
377+
"structural_tube_mass_kg": float(sections.estimated_mass_kg.sum()),
378+
"approximate_total_lumped_mass_kg": float(mass_diag.reshape((-1, 6))[:, 0].sum()),
379+
"static_cases": summaries,
380+
"supported_modal_frequencies_Hz": [float(v) for v in frequencies],
381+
"limitations": [
382+
"Ideal rigid beam joints",
383+
"No shear deformation or warping",
384+
"Simplified nearest-node placement for subsystem mass",
385+
"Approximate positive rotary lumped mass",
386+
"No contact, geometric nonlinearity, joint flexibility or imperfections",
387+
"ANSYS Mechanical results are required for final portfolio claims",
388+
],
389+
}
390+
(output / "screening_report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
391+
print(json.dumps(report, indent=2))
392+
return 0
393+
394+
395+
if __name__ == "__main__":
396+
raise SystemExit(main())

0 commit comments

Comments
 (0)