|
| 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