"""Mechatron-canonical FEA setup helper.
Reads bolt_patterns and load_cases from a Mechatron `graph.json` (the
canonical source-of-truth) and produces structured inputs the FEA solver
needs: per-bolt world coordinates, spring stiffness lookup, load case
attach resolution.
Replaces the v4-v7 pattern of hand-rolled `bolt_inventory.json` +
`canonical.json` side files. After this lands, a FEA script does::
from yapcad.package.analysis.mechatron_fea_setup import prepare
setup = prepare(graph_path="path/to/assembly/graph.json",
load_case_id="LC-004a")
# setup.bolts: list of Bolt entries (world coords + axis + spec)
# setup.spring_k: {(bolt_idx): (k_axial_N_per_m, k_shear_N_per_m)}
# setup.load_attach: resolved attach info (part, position_mm, direction_unit)
# setup.load_case: raw LoadCase dict for reference
History: created 2026-05-20 as Phase 3+4 of the FEA → Mechatron
integration plan.
"""
from __future__ import annotations
import json
import math
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
# ---------------------------------------------------------------------------
# Bolt-spec stiffness library
# ---------------------------------------------------------------------------
#
# Maps canonical bolt_spec strings → (k_axial_N_per_m, k_shear_N_per_m)
# for use in v7+ bolt-spring MPC formulation.
#
# Stiffness is calculated as k = E*A/L for axial, and k_shear ≈ G*A/L
# with G ≈ 0.4*E for steel. Bolt clamping is through PETG-CF flanges
# (~4-6 mm thick each); typical L ≈ 12 mm grip.
#
# These values can be over-ridden per load case via the graph.json
# `interfaces[].bolt_pattern.stiffness_override_N_per_m` field if needed.
_E_STEEL_8_8 = 200e9 # Pa
_G_STEEL_8_8 = 80e9 # Pa
_TYPICAL_GRIP_L_M = 0.012 # 12 mm = ~6 mm + ~6 mm flange stack-up
def _bolt_area_m2(diameter_in_mm: float) -> float:
"""Cross-section area of a bolt shaft in m²."""
return math.pi * (diameter_in_mm * 1e-3) ** 2 / 4.0
# Canonical name → (shaft diameter mm, length mm, grade)
_BOLT_SPEC_GEOMETRY = {
"1-4-20-x-1in-button-head-shcs": (6.35, 25.4, "8.8"),
"1-4-20-x-1-25in-button-head-shcs": (6.35, 31.75, "8.8"),
"1-4-20-button-head-shcs": (6.35, 25.4, "8.8"), # default length
"m5-x-16-shcs": (5.00, 16.0, "8.8"),
"m5-x-20-shcs": (5.00, 20.0, "8.8"),
"m6-x-20-shcs": (6.00, 20.0, "8.8"),
}
[docs]
def stiffness_for_bolt_spec(bolt_spec: str) -> Tuple[float, float]:
"""Return (k_axial_N_per_m, k_shear_N_per_m) for a bolt spec.
Uses the canonical _BOLT_SPEC_GEOMETRY table. Unknown specs fall
back to M5 stiffness with a warning.
"""
if bolt_spec.startswith("PLACEHOLDER"):
# Placeholder spec — return zero stiffness so the FEA setup
# treats this bolt as "not yet specified" and skips it.
return (0.0, 0.0)
geo = _BOLT_SPEC_GEOMETRY.get(bolt_spec)
if geo is None:
# Default to M5 if unknown — log via stderr (no hard requirement on logging here)
import sys
sys.stderr.write(
f"[mechatron_fea_setup] unknown bolt_spec {bolt_spec!r}, defaulting to M5\n"
)
geo = (5.0, 20.0, "8.8")
d_mm, l_mm, grade = geo
A = _bolt_area_m2(d_mm)
L = _TYPICAL_GRIP_L_M
k_axial = _E_STEEL_8_8 * A / L
k_shear = _G_STEEL_8_8 * A / L
return (k_axial, k_shear)
# ---------------------------------------------------------------------------
# Result dataclasses
# ---------------------------------------------------------------------------
[docs]
@dataclass
class Bolt:
"""One bolt in the assembly, world coordinates.
`parent_world` / `child_world` give the head- and nut-side
positions; the bolt axis is the unit vector from parent to child
(or `access_direction` if available).
"""
interface_id: str
bolt_index: int
parent_part: str
child_part: str
parent_world_mm: Tuple[float, float, float]
child_world_mm: Tuple[float, float, float]
axis_unit: Tuple[float, float, float]
bolt_spec: str
pcd_mm: float
clock_deg: float # 0-360°, computed from parent_world
k_axial_N_per_m: float
k_shear_N_per_m: float
[docs]
@dataclass
class LoadAttachResolved:
"""A LoadCase resolved against the assembly: where the force is applied,
in the FEA mesh frame, on which part(s)."""
load_case_id: str
part: str # "_assembly" for whole-stack, else specific part_id
interface_id: Optional[str]
position_mm: Tuple[float, float, float] # vehicle frame (X = axial-fwd)
direction_unit: Tuple[float, float, float] # in `coordinate_frame`
magnitude_n: float
coordinate_frame: str # "vehicle" or "mesh"
bolt_index: Optional[int]
clock_deg: Optional[float]
raw: Dict[str, Any] # full LoadCase dict for reference
[docs]
@dataclass
class FeaSetup:
"""Everything an FEA script needs, sourced from mechatron graph.json."""
graph_path: str
bolts: List[Bolt] = field(default_factory=list)
load_case_id: Optional[str] = None
load_attach: Optional[LoadAttachResolved] = None
raw_load_case: Optional[Dict[str, Any]] = None
raw_interfaces: List[Dict[str, Any]] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Coordinate-frame transforms
# ---------------------------------------------------------------------------
#
# Dashboard / vehicle frame: X = axial-fwd, Y = per-RH-rule, Z = launch-rail-side
# FEA mesh frame: Z = axial-fwd, X/Y = radial
#
# Transform: (x_dash, y_dash, z_dash) → (y_dash, z_dash, x_dash)
[docs]
def vehicle_to_mesh_position(pos: Sequence[float]) -> Tuple[float, float, float]:
"""Vehicle frame (X=axial) → FEA mesh frame (Z=axial)."""
return (float(pos[1]), float(pos[2]), float(pos[0]))
[docs]
def vehicle_to_mesh_direction(direction: Sequence[float]) -> Tuple[float, float, float]:
"""Same swap for direction vectors."""
return (float(direction[1]), float(direction[2]), float(direction[0]))
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
[docs]
def load_graph(graph_path: str | Path) -> Dict[str, Any]:
"""Load and validate a mechatron graph.json."""
p = Path(graph_path).expanduser()
if not p.exists():
raise FileNotFoundError(f"graph.json not found: {p}")
g = json.loads(p.read_text())
if "interfaces" not in g:
raise ValueError(f"{p}: missing 'interfaces' field — not a valid graph.json")
return g
[docs]
def list_load_cases(graph: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Return all LoadCase dicts from the graph."""
return list(graph.get("load_cases", []))
[docs]
def get_load_case(graph: Dict[str, Any], lc_id: str) -> Dict[str, Any]:
"""Return one LoadCase by id, or raise KeyError."""
for lc in graph.get("load_cases", []):
if lc.get("id") == lc_id:
return lc
raise KeyError(f"LoadCase {lc_id!r} not in graph (have: {[lc.get('id') for lc in graph.get('load_cases', [])]})")
[docs]
def get_interface(graph: Dict[str, Any], iface_id: str) -> Dict[str, Any]:
"""Return one Interface by id, or raise KeyError."""
for iface in graph.get("interfaces", []):
if iface.get("id") == iface_id:
return iface
raise KeyError(f"Interface {iface_id!r} not in graph")
[docs]
def list_interfaces_with_bolt_patterns(graph: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Return only interfaces that have a bolt_pattern attached.
Skips bolt_patterns with PLACEHOLDER bolt_spec — those are flagged
as needing engineering input before being usable.
"""
out = []
for iface in graph.get("interfaces", []):
bp = iface.get("bolt_pattern")
if not bp:
continue
if bp.get("bolt_spec", "").startswith("PLACEHOLDER"):
continue
out.append(iface)
return out
# ---------------------------------------------------------------------------
# Per-part bolt spec loader (2026-05-27 rework)
# ---------------------------------------------------------------------------
#
# The canonical source of truth for per-bolt orientation moved from
# graph.json's `bolt_pattern.access_direction` (which is single-vector
# per interface and was sloppily set to ±Z for every joint) to
# `bolts_per_part.yaml`, which carries per-bolt-group `orientation:
# axial|radial` plus per-hole datums on the owner part.
#
# `_load_bolts_per_part_spec(graph_path)` looks for the YAML next to
# graph.json (it lives at <designs>/assembly/bolts_per_part.yaml while
# graph.json lives at <designs>/assembly/graph.json/graph.json), parses
# it, and returns the inner `bolts_per_part` dict (or {} when absent).
#
# The generator then prefers this spec when available, otherwise falls
# back to the legacy access_direction behavior so older graphs still
# work.
def _load_bolts_per_part_spec(graph_path: str | Path) -> Dict[str, Any]:
"""Locate and load bolts_per_part.yaml relative to graph_path.
Search order (first hit wins):
1. <graph_dir>/bolts_per_part.yaml
2. <graph_dir>/../bolts_per_part.yaml (graph_dir is graph.json/, parent is assembly/)
3. <graph_dir>/../../bolts_per_part.yaml
Returns the inner ``bolts_per_part`` mapping (owner-part-id -> group-name
-> group-dict). Returns {} when the file is missing, PyYAML is not
available, or the file fails to parse.
"""
p = Path(graph_path).expanduser().resolve()
candidates = [
p.parent / "bolts_per_part.yaml",
p.parent.parent / "bolts_per_part.yaml",
p.parent.parent.parent / "bolts_per_part.yaml",
]
spec_path = None
for c in candidates:
if c.is_file():
spec_path = c
break
if spec_path is None:
return {}
try:
import yaml # type: ignore
except ImportError:
import sys
sys.stderr.write(
"[mechatron_fea_setup] PyYAML not installed; bolts_per_part.yaml ignored\n"
)
return {}
try:
data = yaml.safe_load(spec_path.read_text(encoding="utf-8")) or {}
except Exception as e:
import sys
sys.stderr.write(f"[mechatron_fea_setup] failed to parse {spec_path}: {e}\n")
return {}
return data.get("bolts_per_part", {}) or {}
# ---------------------------------------------------------------------------
# Kinematic chain → world transforms for every part in the graph
# ---------------------------------------------------------------------------
def _qmul(a: Sequence[float], b: Sequence[float]) -> List[float]:
"""Hamilton product, quaternions in (x, y, z, w) order."""
ax, ay, az, aw = a
bx, by, bz, bw = b
return [
aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz,
]
def _qrot(q: Sequence[float], v: Sequence[float]) -> List[float]:
"""Rotate 3-vector v by unit quaternion q in (x, y, z, w) form."""
x, y, z, w = q[0], q[1], q[2], q[3]
vx, vy, vz = v[0], v[1], v[2]
tx = 2.0 * (y * vz - z * vy)
ty = 2.0 * (z * vx - x * vz)
tz = 2.0 * (x * vy - y * vx)
return [
vx + w * tx + (y * tz - z * ty),
vy + w * ty + (z * tx - x * tz),
vz + w * tz + (x * ty - y * tx),
]
# ---------------------------------------------------------------------------
# Bolt generation — per-hole datum aware
# ---------------------------------------------------------------------------
def _index_parts(graph: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
return {p.get("id", ""): p for p in graph.get("parts", [])}
def _find_datum(part: Dict[str, Any], name: str) -> Optional[Dict[str, Any]]:
for d in part.get("datums", []):
if d.get("name") == name:
return d
return None
def _datum_origin(d: Dict[str, Any]) -> List[float]:
o = d.get("origin") or {}
return [float(o.get("x", 0.0)), float(o.get("y", 0.0)), float(o.get("z", 0.0))]
def _datum_direction(d: Dict[str, Any]) -> Optional[List[float]]:
dd = d.get("direction")
if not isinstance(dd, dict):
return None
v = [float(dd.get("x", 0.0)), float(dd.get("y", 0.0)), float(dd.get("z", 0.0))]
n = math.sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2)
if n < 1e-9:
return None
return [v[0] / n, v[1] / n, v[2] / n]
def _find_yaml_group_for_iface(
spec: Dict[str, Any],
iface: Dict[str, Any],
) -> Optional[Tuple[str, str, Dict[str, Any]]]:
"""Look up the bolts_per_part.yaml group matching this interface.
Returns (owner_part_id, group_name, group_spec_dict) or None when no
match is found. The owner can be either the parent or the child of
the interface (per the YAML ownership rule). Matching is by
``mates_to.part == <the other side>`` since the same owner-part may
own multiple bolt groups (e.g. upper-thrust has `bot_to_boattail`
and `top_to_fuel_tank`).
"""
if not spec:
return None
pp = iface.get("parent_part", "")
cp = iface.get("child_part", "")
def _match(owner: str, mated: str) -> Optional[Tuple[str, str, Dict[str, Any]]]:
owner_block = spec.get(owner) or {}
if not isinstance(owner_block, dict):
return None
for gname, g in owner_block.items():
if not isinstance(g, dict):
continue
mt = g.get("mates_to") or {}
if mt.get("part") == mated:
return (owner, gname, g)
return None
# Owner could be either side. Try parent-as-owner first then child-as-owner.
return _match(pp, cp) or _match(cp, pp)
[docs]
def generate_bolts_for_interface(
iface: Dict[str, Any],
parent_world_origin_mm: Optional[Tuple[float, float, float]] = None,
child_world_origin_mm: Optional[Tuple[float, float, float]] = None,
*,
parts_by_id: Optional[Dict[str, Dict[str, Any]]] = None,
world_transforms: Optional[Dict[str, Tuple[List[float], List[float]]]] = None,
bolts_per_part_spec: Optional[Dict[str, Any]] = None,
) -> List[Bolt]:
"""Generate per-bolt entries for an interface's bolt_pattern.
Two code paths:
1. **Preferred** (post-2026-05-27): when ``parts_by_id`` +
``world_transforms`` are supplied, derive each bolt's axis from
the parent part's per-hole datums (``top_bolt_hole_N`` /
``bot_bolt_hole_N``) transformed through the kinematic chain.
The per-hole ``direction`` field in graph.json carries the true
radial-or-axial bolt axis per hole, so this path produces
correct orientations for both joint types.
When ``bolts_per_part_spec`` is also supplied AND a matching
group exists, additionally use the YAML's ``orientation``,
``head_axial_offset_mm`` / ``head_radial_offset_mm``, and
``shank_length_mm`` to derive the head-anchor and shank-tip
endpoints. ``parent_world_mm`` becomes the head anchor (where
the bolt enters the joint), ``child_world_mm`` becomes the
shank tip (where it exits), and the connector line spans the
physical bolt length.
2. **Legacy fallback** (used when ``parts_by_id`` is absent):
evenly distribute bolts around PCD using
``bolt_pattern.access_direction`` and ``world_z_mm``. Same
behavior as the pre-2026-05-27 code. Note that this path emits
all axes as ±Z, since it distributes bolts purely by PCD +
``access_direction`` and has no per-hole radial datum data. Use
the preferred per-hole-datum path (supply ``parts_by_id`` +
``world_transforms``) when correct radial-joint axes are needed.
Args:
iface: the Interface dict from graph.json (must have bolt_pattern)
parent_world_origin_mm: legacy override (used only in fallback path)
child_world_origin_mm: legacy override (used only in fallback path)
parts_by_id: optional {part_id: part_dict} index from graph.json
world_transforms: optional {part_id: ([tx,ty,tz], [qx,qy,qz,qw])} from
``solve_world_transforms(graph)``
bolts_per_part_spec: optional bolts_per_part.yaml inner dict
from ``_load_bolts_per_part_spec(graph_path)``
Returns:
list of Bolt entries with world coords, axis, spec, stiffness.
"""
bp = iface.get("bolt_pattern")
if not bp:
return []
pcd_mm = float(bp["pcd_mm"])
n_bolts = int(bp["n_bolts"])
bolt_spec = str(bp["bolt_spec"])
access_dir = bp.get("access_direction", [0.0, 0.0, 1.0])
k_axial, k_shear = stiffness_for_bolt_spec(bolt_spec)
pid = iface.get("parent_part", "")
cid = iface.get("child_part", "")
iid = iface.get("id", "?")
# ---- Preferred path: per-hole datums + kinematic chain ----------------
if parts_by_id is not None and world_transforms is not None:
bolts = _generate_bolts_from_datums(
iface=iface,
bp=bp,
parts_by_id=parts_by_id,
world_transforms=world_transforms,
bolts_per_part_spec=bolts_per_part_spec or {},
n_bolts=n_bolts,
pcd_mm=pcd_mm,
bolt_spec=bolt_spec,
k_axial=k_axial,
k_shear=k_shear,
)
if bolts:
return bolts
# If we couldn't find datums, fall through to legacy path.
# ---- Legacy fallback: PCD + access_direction --------------------------
# Default to bolt_pattern's own world_z_mm if no override given.
if parent_world_origin_mm is None:
wz = bp.get("world_z_mm")
parent_world_origin_mm = (0.0, 0.0, float(wz)) if wz is not None else (0.0, 0.0, 0.0)
if child_world_origin_mm is None:
child_world_origin_mm = parent_world_origin_mm
r_mm = pcd_mm / 2.0
bolts: List[Bolt] = []
ad = (float(access_dir[0]), float(access_dir[1]), float(access_dir[2]))
norm = math.sqrt(ad[0] ** 2 + ad[1] ** 2 + ad[2] ** 2) or 1.0
axis = (ad[0] / norm, ad[1] / norm, ad[2] / norm)
for i in range(n_bolts):
theta = 2 * math.pi * i / n_bolts
x = r_mm * math.cos(theta)
y = r_mm * math.sin(theta)
z = 0.0
parent_world = (
parent_world_origin_mm[0] + x,
parent_world_origin_mm[1] + y,
parent_world_origin_mm[2] + z,
)
child_world = (
child_world_origin_mm[0] + x,
child_world_origin_mm[1] + y,
child_world_origin_mm[2] + z,
)
clock_deg = math.degrees(math.atan2(y, x)) % 360.0
bolts.append(Bolt(
interface_id=iid,
bolt_index=i,
parent_part=pid,
child_part=cid,
parent_world_mm=parent_world,
child_world_mm=child_world,
axis_unit=axis,
bolt_spec=bolt_spec,
pcd_mm=pcd_mm,
clock_deg=clock_deg,
k_axial_N_per_m=k_axial,
k_shear_N_per_m=k_shear,
))
return bolts
def _generate_bolts_from_datums(
*,
iface: Dict[str, Any],
bp: Dict[str, Any],
parts_by_id: Dict[str, Dict[str, Any]],
world_transforms: Dict[str, Tuple[List[float], List[float]]],
bolts_per_part_spec: Dict[str, Any],
n_bolts: int,
pcd_mm: float,
bolt_spec: str,
k_axial: float,
k_shear: float,
) -> List[Bolt]:
"""Per-hole-datum bolt generator. Returns [] if datums are missing.
Owner-side hole datums carry the per-bolt axis directly via their
``direction`` field — for axial joints it's ±Z, for radial joints
it's the inward radial unit vector at that clock angle. The world
transform of the owner part places the hole in vehicle frame.
Endpoints:
parent_world_mm = head anchor (owner-side hole entry, optionally
offset outward by head_radial_offset_mm or
offset axially by head_axial_offset_mm per YAML)
child_world_mm = shank tip = parent_world + shank_length * axis
"""
pp = iface.get("parent_part", "")
cp = iface.get("child_part", "")
iid = iface.get("id", "?")
# Decide owner part and which hole-datum prefix to read.
# The interface's `origin` Z indicates which side of the parent the
# joint sits on: if origin.z > 0 (e.g. boattail.top at z=330) we use
# ``top_bolt_hole_N``, else ``bot_bolt_hole_N``. Parent is the owner
# by default; if a YAML group exists and names a different owner we
# honor that.
yaml_match = _find_yaml_group_for_iface(bolts_per_part_spec, iface)
owner_id: str
hole_prefix: str
yaml_group: Dict[str, Any]
orientation_kind: str
if yaml_match is not None:
owner_id, _gname, yaml_group = yaml_match
# bolt_circle_ref tells us 'top' or 'bot'
bcr = str(yaml_group.get("bolt_circle_ref", ""))
hole_prefix = "top_bolt_hole" if "top" in bcr else "bot_bolt_hole"
orientation_kind = str(yaml_group.get("orientation", "")).lower()
else:
# No YAML match: default to parent-as-owner; infer top/bot from iface.
owner_id = pp
# Inspect parent's top vs bot circle z to pick the side closer to the interface origin.
owner_part0 = parts_by_id.get(owner_id, {})
iface_origin = iface.get("origin") or [0.0, 0.0, 0.0]
try:
iface_oz = float(iface_origin[2])
except (TypeError, ValueError, IndexError):
iface_oz = 0.0
top_circle = _find_datum(owner_part0, "top_bolt_circle")
bot_circle = _find_datum(owner_part0, "bot_bolt_circle")
top_z = _datum_origin(top_circle)[2] if top_circle else None
bot_z = _datum_origin(bot_circle)[2] if bot_circle else None
if top_z is not None and (bot_z is None or abs(iface_oz - top_z) <= abs(iface_oz - bot_z)):
hole_prefix = "top_bolt_hole"
else:
hole_prefix = "bot_bolt_hole"
yaml_group = {}
orientation_kind = ""
owner_part = parts_by_id.get(owner_id)
if owner_part is None:
return []
owner_transform = world_transforms.get(owner_id)
if owner_transform is None:
# Owner not in kinematic chain; can't place world coords.
return []
owner_t, owner_q = owner_transform
# Collect available per-hole datums.
holes: List[Tuple[int, List[float], List[float]]] = [] # (idx, origin_local, dir_local)
for i in range(max(n_bolts, 64)):
d = _find_datum(owner_part, f"{hole_prefix}_{i}")
if d is None:
if i < n_bolts:
# Missing a hole we expected; bail out and let caller fall back.
# But only if we have NO holes at all — partial sets are still useful.
pass
continue
origin_local = _datum_origin(d)
dir_local = _datum_direction(d)
if dir_local is None:
# Hole datum lacks direction; skip.
continue
holes.append((i, origin_local, dir_local))
# If we didn't find any holes, fall back to the legacy path.
if not holes:
return []
# Sort by index, take first n_bolts present.
holes.sort(key=lambda h: h[0])
# Decide axial vs radial if YAML didn't tell us. Inspect first hole.
if not orientation_kind:
first_dir = holes[0][2]
orientation_kind = "axial" if abs(first_dir[2]) > 0.8 else "radial"
# Pull offsets from YAML (or sensible defaults).
head_axial_offset = float(yaml_group.get("head_axial_offset_mm", 0.0))
head_radial_offset = float(yaml_group.get("head_radial_offset_mm", 0.0))
shank_length_mm = float(yaml_group.get("shank_length_mm", 0.0))
if shank_length_mm <= 0.01:
# Derive from bolt spec geometry table.
geo = _BOLT_SPEC_GEOMETRY.get(bolt_spec)
shank_length_mm = float(geo[1]) if geo else 25.4
bolts: List[Bolt] = []
for idx, origin_local, dir_local in holes:
# The owner-part hole direction is the bolt INSERTION axis in
# the part-local frame (it points the way the shank goes).
# For radial bolts this points INWARD toward the part's Z axis;
# for axial bolts it's ±Z.
insert_local = dir_local # already unit vector
# Head anchor offset (LOCAL frame):
# * radial: head sits OUTBOARD of the hole origin, i.e. opposite
# the inward insertion direction. Step ‘head_radial_offset’
# along -insert_local.
# * axial: head sits offset along +insert_local by
# head_axial_offset (negative offset puts head on the opposite
# side). Convention matches the dashboard's bolt loader so
# the FEA and bolt overlays line up.
if orientation_kind == "radial":
head_anchor_local = [
origin_local[0] - head_radial_offset * insert_local[0],
origin_local[1] - head_radial_offset * insert_local[1],
origin_local[2] - head_radial_offset * insert_local[2],
]
else:
head_anchor_local = [
origin_local[0] + head_axial_offset * insert_local[0],
origin_local[1] + head_axial_offset * insert_local[1],
origin_local[2] + head_axial_offset * insert_local[2],
]
# Shank tip in LOCAL frame = head anchor + shank_length * insert_local.
shank_tip_local = [
head_anchor_local[0] + shank_length_mm * insert_local[0],
head_anchor_local[1] + shank_length_mm * insert_local[1],
head_anchor_local[2] + shank_length_mm * insert_local[2],
]
# Transform LOCAL → WORLD using owner part's solved (t, q).
ha_rot = _qrot(owner_q, head_anchor_local)
head_anchor_world = (
owner_t[0] + ha_rot[0],
owner_t[1] + ha_rot[1],
owner_t[2] + ha_rot[2],
)
st_rot = _qrot(owner_q, shank_tip_local)
shank_tip_world = (
owner_t[0] + st_rot[0],
owner_t[1] + st_rot[1],
owner_t[2] + st_rot[2],
)
# Axis is the insertion direction rotated into world.
ax_rot = _qrot(owner_q, insert_local)
an = math.sqrt(ax_rot[0] ** 2 + ax_rot[1] ** 2 + ax_rot[2] ** 2) or 1.0
axis_world = (ax_rot[0] / an, ax_rot[1] / an, ax_rot[2] / an)
# Clock from hole_origin_local (atan2 of XY components).
clock_deg = math.degrees(math.atan2(origin_local[1], origin_local[0])) % 360.0
bolts.append(Bolt(
interface_id=iid,
bolt_index=idx,
parent_part=pp,
child_part=cp,
parent_world_mm=head_anchor_world,
child_world_mm=shank_tip_world,
axis_unit=axis_world,
bolt_spec=bolt_spec,
pcd_mm=pcd_mm,
clock_deg=clock_deg,
k_axial_N_per_m=k_axial,
k_shear_N_per_m=k_shear,
))
if len(bolts) >= n_bolts:
break
return bolts
[docs]
def resolve_load_attach(load_case: Dict[str, Any]) -> LoadAttachResolved:
"""Convert a LoadCase dict to a resolved attach descriptor.
Does NOT compute world coordinates of bolt-targeted loads (those need
interface origins from graph.json). Just packages the LoadCase fields
into a struct the FEA solver can consume directly.
"""
attach = load_case.get("attach", {})
pos = attach.get("position", [0.0, 0.0, 0.0])
direction = load_case.get("direction", [1.0, 0.0, 0.0])
return LoadAttachResolved(
load_case_id=load_case["id"],
part=attach.get("part", "_assembly"),
interface_id=attach.get("interface"),
position_mm=(float(pos[0]), float(pos[1]), float(pos[2])),
direction_unit=(float(direction[0]), float(direction[1]), float(direction[2])),
magnitude_n=float(load_case.get("magnitude_n") or 0.0),
coordinate_frame=load_case.get("coordinate_frame", "vehicle"),
bolt_index=attach.get("bolt_index"),
clock_deg=attach.get("clock_deg"),
raw=dict(load_case),
)
[docs]
def prepare(
graph_path: str | Path,
load_case_id: Optional[str] = None,
*,
interface_origins_mm: Optional[Dict[str, Tuple[float, float, float]]] = None,
) -> FeaSetup:
"""One-shot FEA setup from a mechatron graph.json.
Reads the graph, resolves the load case (if given), enumerates all
structural bolts with stiffness, and returns a FeaSetup struct.
Args:
graph_path: path to graph.json (mechatron canonical)
load_case_id: e.g. "LC-004a" — if None, no load attach is resolved
interface_origins_mm: optional override map {iface_id: (x,y,z) world}.
If not given, bolts are returned in interface-local coords with
origin (0,0,0); the FEA setup is responsible for combining with
the assembly transforms from the graph's interface solve.
Returns:
FeaSetup with bolts, load_attach (if requested), and raw refs.
"""
g = load_graph(graph_path)
setup = FeaSetup(graph_path=str(graph_path))
# 2026-05-27: enable the preferred per-hole-datum path.
# Build the parts index + kinematic-chain world transforms + load
# bolts_per_part.yaml so generate_bolts_for_interface() can produce
# correctly oriented per-bolt axes (radial joints no longer come out
# as ±Z).
parts_by_id = _index_parts(g)
world_transforms = solve_world_transforms(g)
bolts_per_part_spec = _load_bolts_per_part_spec(graph_path)
# Enumerate bolts. Origin resolution (legacy fallback path only):
# 1. Explicit override in interface_origins_mm wins (caller may override)
# 2. Else read `bolt_pattern.world_z_mm` from the graph (preferred since
# 2026-05-20 reconciliation — graph.json now carries mate-solve Z)
# 3. Else (0,0,0) and emit warning
interface_origins_mm = interface_origins_mm or {}
for iface in list_interfaces_with_bolt_patterns(g):
if iface["id"] in interface_origins_mm:
origin = interface_origins_mm[iface["id"]]
else:
origin = None # generate_bolts_for_interface() will read world_z_mm from bp
setup.bolts.extend(generate_bolts_for_interface(
iface,
parent_world_origin_mm=origin,
parts_by_id=parts_by_id,
world_transforms=world_transforms,
bolts_per_part_spec=bolts_per_part_spec,
))
setup.raw_interfaces = list_interfaces_with_bolt_patterns(g)
# Resolve load case if requested
if load_case_id is not None:
lc = get_load_case(g, load_case_id)
setup.load_case_id = load_case_id
setup.load_attach = resolve_load_attach(lc)
setup.raw_load_case = lc
return setup