Research-Stack/5-Applications/tools-scripts/semi_jack/semi_jack_vibration.py

383 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# ==============================================================================
# COPYRIGHT NO ONE EVERYWHERE LLC (WYOMING HOLDING COMPANY)
# PROJECT: SOVEREIGN STACK
# This artifact is entirely proprietary and cryptographically proven.
# Open-Source usage requires explicit permission from Brandon Scott Schneider.
# ==============================================================================
"""
semi_jack_vibration.py — Vibration matrix analysis for Semi-Jack geometry
Assembles the global stiffness K, geometric stiffness K_geo (from atmospheric
pressure), and mass matrix M for a 3D pin-jointed truss.
Atmospheric pressure contribution:
Each strut acts as a cylinder under external pressure P_atm.
Net compressive axial force = P_atm × A_cross_section.
This modifies K via the geometric (stress-stiffening) matrix K_geo.
Under compression K_geo is negative → reduces effective stiffness → lowers ω.
Eigenvalue problem:
(K + K_geo) u = ω² M u
Natural frequencies: f_n = ω_n / (2π)
Boundary conditions:
Leaf nodes (no children) = fixed to ground (zero displacement).
Root + internal nodes = free.
Usage:
.venv-eng/bin/python 5-Applications/scripts/semi_jack_vibration.py
.venv-eng/bin/python 5-Applications/scripts/semi_jack_vibration.py --json 5-Applications/out/sovereign_jenga/quantum_annealed/merkle_tree_nspace.json
.venv-eng/bin/python 5-Applications/scripts/semi_jack_vibration.py --no-atm # vacuum comparison
"""
from __future__ import annotations
import argparse
import json
import math
import sys
from pathlib import Path
from typing import List, Set, Tuple
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from math_harness_compat import xp, AnyArray
from scipy.linalg import eigh
# ── Physical constants ─────────────────────────────────────────────────────────
ATM_PRESSURE_PA = 101_325.0 # Pa — standard atmosphere (ISO 2533)
G_MS2 = 9.80665 # m/s² — standard gravity
# ── Material: SLS PA12 (SI units throughout) ──────────────────────────────────
E_PA12_PA = 1_700e6 # Young's modulus, Pa
RHO_PA12_KG_M3 = 1_010.0 # density, kg/m³
# ── Geometry loader ────────────────────────────────────────────────────────────
def load_geometry(path: Path):
data = json.loads(path.read_text())
nodes = {n["id"]: n for n in data["nodes"]}
edges = [(e[0], e[1]) for e in data["edges"]]
# Identify leaf nodes (appear only as children, never as parents)
parents = {e[0] for e in edges}
all_ids = set(nodes.keys())
leaves = all_ids - parents
return nodes, edges, leaves
# ── Element matrices (3-DOF pin-jointed truss element) ────────────────────────
# DOFs per node: [ux, uy, uz]. Element connects node i (DOFs 0-2) to j (DOFs 3-5).
def direction_cosines(ni: dict, nj: dict) -> Tuple[float, float, float, float]:
dx = (nj["x"] - ni["x"]) * 1e-3 # mm → m
dy = (nj["y"] - ni["y"]) * 1e-3
dz = (nj["z"] - ni["z"]) * 1e-3
L = math.sqrt(dx**2 + dy**2 + dz**2)
if L < 1e-12:
return 0.0, 0.0, 0.0, 0.0
return dx/L, dy/L, dz/L, L
def element_stiffness(
ni: dict, nj: dict, radius_m: float
) -> Tuple[AnyArray, float, float]:
"""
6×6 elastic stiffness matrix in global coords.
Returns (k_global, L_m, A_m2).
"""
lx, ly, lz, L = direction_cosines(ni, nj)
if L < 1e-12:
return xp.zeros((6,6)), 0.0, 0.0
A = math.pi * radius_m**2
EA_over_L = E_PA12_PA * A / L
# Direction cosine vector
d = xp.array([lx, ly, lz])
# Local-to-global: k = EA/L * [d; -d] ⊗ [d; -d]
dext = xp.concatenate([d, -d])
k_global = EA_over_L * xp.outer(dext, dext)
return k_global, L, A
def element_geo_stiffness(
ni: dict, nj: dict, radius_m: float, axial_force_N: float
) -> AnyArray:
"""
6×6 geometric stiffness matrix for axial pre-load P.
For a truss element: k_geo = P/L * [I -I; -I I] projected onto
the transverse plane (perpendicular to strut axis).
Negative P (compression) → negative k_geo → softens structure.
"""
lx, ly, lz, L = direction_cosines(ni, nj)
if L < 1e-12:
return xp.zeros((6,6))
d = xp.array([lx, ly, lz])
I3 = xp.eye(3)
# Transverse projector: I - d⊗d
Pt = I3 - xp.outer(d, d)
k_geo_3 = (axial_force_N / L) * Pt
k_geo = xp.zeros((6, 6))
k_geo[0:3, 0:3] = k_geo_3
k_geo[3:6, 3:6] = k_geo_3
k_geo[0:3, 3:6] = -k_geo_3
k_geo[3:6, 0:3] = -k_geo_3
return k_geo
def element_mass(
ni: dict, nj: dict, radius_m: float
) -> AnyArray:
"""
6×6 consistent mass matrix in global coords.
m = ρAL/6 * [2I I; I 2I] (consistent formulation for truss)
"""
_, L, A = element_stiffness(ni, nj, radius_m)
if L < 1e-12:
return xp.zeros((6,6))
m_total = RHO_PA12_KG_M3 * A * L
I3 = xp.eye(3)
m = xp.zeros((6, 6))
m[0:3, 0:3] = 2/6 * m_total * I3
m[3:6, 3:6] = 2/6 * m_total * I3
m[0:3, 3:6] = 1/6 * m_total * I3
m[3:6, 0:3] = 1/6 * m_total * I3
return m
# ── Global assembly ────────────────────────────────────────────────────────────
def assemble(
nodes: dict,
edges: List[Tuple[int,int]],
leaves: Set[int],
radius_mm: float,
swl_N: float,
with_atm: bool,
) -> Tuple[AnyArray, AnyArray, List[int], dict]:
"""
Assemble global K, K_geo, M.
Returns (K_total, M, free_dofs, diagnostics).
"""
node_ids = sorted(nodes.keys())
id_to_idx = {nid: i for i, nid in enumerate(node_ids)}
n_nodes = len(node_ids)
n_dofs = n_nodes * 3
radius_m = radius_mm * 1e-3
A_m2 = math.pi * radius_m**2
# Atmospheric pressure compressive force on each strut end
# F_atm = P_atm × A_cross (acts inward = compression = negative)
atm_force_N = -ATM_PRESSURE_PA * A_m2 if with_atm else 0.0
K = xp.zeros((n_dofs, n_dofs))
M = xp.zeros((n_dofs, n_dofs))
diag = {
"atm_force_per_strut_N": abs(atm_force_N),
"n_struts": len(edges),
"radius_mm": radius_mm,
"A_mm2": A_m2 * 1e6,
}
# Identify root (no parent in edge list)
child_ids = {e[1] for e in edges}
root_id = next(nid for nid in node_ids if nid not in child_ids)
# Root force distribution: each direct child carries swl_N * load_frac
root_force = nodes[root_id].get("F", swl_N)
for p_id, c_id in edges:
ni = nodes[p_id]
nj = nodes[c_id]
k_el, L_m, _area = element_stiffness(ni, nj, radius_m)
# Structural axial force in strut (from applied SWL)
child_F = nj.get("F", 0.0)
frac = child_F / max(root_force, 1e-9)
strut_V = swl_N * frac # vertical component
lx, ly, lz, _L = direction_cosines(ni, nj)
cos_ang = abs(lz) if L_m > 1e-12 else 1.0
strut_axial = strut_V / max(cos_ang, 1e-6) # along strut axis
# Atmospheric compressive pre-load
total_axial_N = -strut_axial + atm_force_N # compression = negative
k_geo = element_geo_stiffness(ni, nj, radius_m, total_axial_N)
m_el = element_mass(ni, nj, radius_m)
# DOF indices
ri = id_to_idx[p_id] * 3
rj = id_to_idx[c_id] * 3
idx = [ri, ri+1, ri+2, rj, rj+1, rj+2]
for a, ga in enumerate(idx):
for b, gb in enumerate(idx):
K[ga, gb] += k_el[a, b] + k_geo[a, b]
M[ga, gb] += m_el[a, b]
# ── Boundary conditions: fix leaf nodes ───────────────────────────────────
fixed_dofs: Set[int] = set()
for leaf_id in leaves:
base = id_to_idx[leaf_id] * 3
fixed_dofs.update([base, base+1, base+2])
all_dofs = list(range(n_dofs))
free_dofs = [d for d in all_dofs if d not in fixed_dofs]
return K, M, free_dofs, diag
# ── Eigenvalue solve ───────────────────────────────────────────────────────────
def natural_frequencies(
K: AnyArray,
M: AnyArray,
free_dofs: List[int],
n_modes: int = 10,
) -> AnyArray:
"""
Solve generalised eigenvalue problem on free DOFs.
Returns natural frequencies in Hz.
"""
Kf = K[xp.ix_(free_dofs, free_dofs)]
Mf = M[xp.ix_(free_dofs, free_dofs)]
if Kf.shape[0] == 0:
return xp.array([])
# Regularise: add small diagonal to M to avoid singularity
Mf += xp.eye(Mf.shape[0]) * 1e-18
n_req = min(n_modes, Kf.shape[0] - 1)
if n_req < 1:
return xp.array([])
try:
# eigh for symmetric matrices → real eigenvalues
eigenvalues, _ = eigh(Kf, Mf, subset_by_index=[0, n_req-1])
# ω² = eigenvalue, clip negatives (rigid-body / numerical noise)
omega2 = xp.clip(eigenvalues, 0, None)
return xp.sqrt(omega2) / (2 * math.pi) # Hz
except Exception as e:
print(f" [eigensolve warning] {e}", file=sys.stderr)
return xp.array([])
# ── Report ─────────────────────────────────────────────────────────────────────
def run(
json_path: Path,
radius_mm: float,
swl_N: float,
n_modes: int,
with_atm: bool,
):
nodes, edges, leaves = load_geometry(json_path)
print(f"\n {'='*66}")
print(f" Semi-Jack Vibration Matrix Analysis")
print(f" {'='*66}")
print(f" Geometry : {json_path.name}")
print(f" Nodes : {len(nodes)} Edges: {len(edges)} Leaves: {len(leaves)}")
print(f" Radius : {radius_mm} mm SWL: {swl_N:.1f} N ({swl_N/G_MS2:.2f} kg)")
print(f" Atm P : {'101325 Pa (standard atmosphere)' if with_atm else 'OFF (vacuum)'}")
print(f" Material : SLS PA12 E={E_PA12_PA/1e6:.0f} MPa ρ={RHO_PA12_KG_M3} kg/m³")
print()
K, M, free_dofs, diag = assemble(
nodes, edges, leaves, radius_mm, swl_N, with_atm
)
print(f" System size : {K.shape[0]} DOFs ({len(free_dofs)} free after BCs)")
print(f" Strut area : {diag['A_mm2']:.3f} mm²")
if with_atm:
print(f" Atm load/strut: {diag['atm_force_per_strut_N']:.4f} N compressive")
print()
freqs = natural_frequencies(K, M, free_dofs, n_modes)
if len(freqs) == 0:
print(" No free DOFs — structure is fully constrained.")
return freqs
print(f" NATURAL FREQUENCIES (first {len(freqs)} modes)")
print(f" {'-'*50}")
for i, f in enumerate(freqs):
label = ""
if i == 0: label = " ← fundamental"
if f < 20: label += " ⚠ near audible resonance"
print(f" Mode {i+1:3d}: {f:12.2f} Hz{label}")
print()
print(f" Fundamental period : {1/freqs[0]*1000:.3f} ms" if freqs[0] > 0 else "")
return freqs
def compare(json_path: Path, radius_mm: float, swl_N: float, n_modes: int):
"""Run with and without atmosphere, show delta."""
print("\n Running vacuum baseline...")
f_vac = run(json_path, radius_mm, swl_N, n_modes, with_atm=False)
print("\n Running with standard atmosphere (101325 Pa)...")
f_atm = run(json_path, radius_mm, swl_N, n_modes, with_atm=True)
if len(f_vac) == 0 or len(f_atm) == 0:
return
n = min(len(f_vac), len(f_atm))
print(f"\n {'='*66}")
print(f" ATMOSPHERIC PRESSURE EFFECT ON VIBRATION MODES")
print(f" {'='*66}")
print(f" {'Mode':6s} {'Vacuum Hz':>12s} {'Atm Hz':>12s} {'Delta Hz':>12s} {'Delta %':>10s}")
print(f" {'-'*56}")
for i in range(n):
delta = f_atm[i] - f_vac[i]
delta_pc = 100.0 * delta / max(f_vac[i], 1e-9)
flag = " ↓ softened" if delta < -0.01 * f_vac[i] else ""
print(
f" {i+1:6d} {f_vac[i]:12.2f} {f_atm[i]:12.2f} "
f"{delta:12.2f} {delta_pc:10.3f}%{flag}"
)
print(f" {'='*66}\n")
def main():
ap = argparse.ArgumentParser(description="Semi-Jack vibration matrix analysis")
ap.add_argument("--json", default="5-Applications/out/sovereign_jenga/quantum_annealed/merkle_tree_nspace.json")
ap.add_argument("--radius", type=float, default=4.52, help="Tubule radius mm")
ap.add_argument("--swl", type=float, default=104.0, help="SWL in Newtons")
ap.add_argument("--modes", type=int, default=10, help="Number of modes")
ap.add_argument("--no-atm", action="store_true", help="Vacuum only (no atmosphere)")
ap.add_argument("--no-compare", action="store_true", help="Skip side-by-side comparison")
args = ap.parse_args()
path = Path(args.json)
if not path.exists():
path = Path(__file__).parent.parent / args.json
if not path.exists():
print(f"ERROR: {args.json} not found", file=sys.stderr)
sys.exit(1)
if args.no_atm:
run(path, args.radius, args.swl, args.modes, with_atm=False)
elif args.no_compare:
run(path, args.radius, args.swl, args.modes, with_atm=True)
else:
compare(path, args.radius, args.swl, args.modes)
if __name__ == "__main__":
main()