Source code for yapcad.assembly.load_case

"""LoadCase: structural FEA load case attached to an Assembly.

A LoadCase carries the information an FEA setup needs to know **what force
is applied, where, in which direction, and against which interface** —
without the FEA author having to invent or hand-extract that data from
side-files.

Designed to round-trip through Mechatron's `LoadCase` graph entity
(see `crates/assembly-graph/src/types.rs`). The yapCAD dataclass lives
on the `Assembly` instance under `assembly.load_cases`.

History:
    Created 2026-05-20 in response to the Agentic-1 chute-deploy FEA
    iterations (v4→v7) where load case data was scattered across:
      - designs/assembly-dashboard-v2/index.html LOAD_CASES (hardcoded)
      - requirements/LOAD-CASES-DRAFT.md (draft notes)
      - specs/load-cases/canonical.json (jarvis-rich's hand extract)
    The lack of a single source of truth caused several iteration
    cycles to use wrong attach points / clock angles / part assignments.
    See Whiteboard message 20260520201704-46e1d134 for context.
"""

from __future__ import annotations

from dataclasses import dataclass, field, asdict
from typing import Any, Dict, List, Optional, Sequence


# Canonical coordinate frame enum.
COORDINATE_FRAME_VEHICLE = "vehicle"  # X = axial-fwd (dashboard convention)
COORDINATE_FRAME_MESH = "mesh"        # Z = axial-fwd (FEA mesh convention)
VALID_FRAMES = (COORDINATE_FRAME_VEHICLE, COORDINATE_FRAME_MESH)

# Canonical status values.
STATUS_CONFIRMED = "confirmed"
STATUS_ESTIMATED = "estimated"
STATUS_TRIBAL = "tribal"
STATUS_PLACEHOLDER = "placeholder"
VALID_STATUSES = (STATUS_CONFIRMED, STATUS_ESTIMATED, STATUS_TRIBAL, STATUS_PLACEHOLDER)

# Canonical load groups (mirrors dashboard LOAD_CASES.group).
GROUP_INERTIAL = "inertial"
GROUP_RECOVERY = "recovery"
GROUP_PRESSURE = "pressure"
GROUP_HANDLING = "handling"
GROUP_CUSTOM = "custom"
VALID_GROUPS = (GROUP_INERTIAL, GROUP_RECOVERY, GROUP_PRESSURE, GROUP_HANDLING, GROUP_CUSTOM)


[docs] @dataclass class LoadAttach: """Where a LoadCase applies its force/moment on the assembly. Resolution priority for FEA setup: 1. If `interface` is set, the load enters via that mechatron Interface (typically a bolted joint). The FEA solver looks up the interface's bolt_pattern and applies the load at the bolt nearest `clock_deg` (or at the specific `bolt_index` if given). 2. Otherwise, if `part` is set and != "_assembly", the load is applied at `position` (interpreted in the load case's coordinate_frame) on that part's surface DOFs. Useful for distributed nose-tip loads, engine thrust at engine-mount, etc. 3. If `part == "_assembly"`, the load is a whole-stack body force / inertial load. `position` is a notional centroid for visualization only; FEA applies the equivalent ρ·a body force on all parts. """ part: str # part_id, or "_assembly" for whole-stack inertial position: Sequence[float] = field(default_factory=lambda: [0.0, 0.0, 0.0]) interface: Optional[str] = None # mechatron interface id (preferred) clock_deg: Optional[float] = None # 0-360°, vehicle frame bolt_index: Optional[int] = None # 0..n_bolts-1 — overrides clock_deg if set
[docs] def to_dict(self) -> Dict[str, Any]: return { "part": self.part, "position": list(self.position), "interface": self.interface, "clock_deg": self.clock_deg, "bolt_index": self.bolt_index, }
[docs] @dataclass class LoadCase: """A structural load case applied to an Assembly. Round-trips through Mechatron's `LoadCase` type. """ id: str name: str group: str # one of VALID_GROUPS direction: Sequence[float] # unit vector in coordinate_frame attach: LoadAttach magnitude_n: Optional[float] = None # Force magnitude in Newtons magnitude_g: Optional[float] = None # Equivalent g-loading (if applicable) coordinate_frame: str = COORDINATE_FRAME_VEHICLE description: str = "" source: str = "" # where this load case came from status: str = STATUS_ESTIMATED warn: bool = False # surface a warning in the dashboard def __post_init__(self): if self.group not in VALID_GROUPS: raise ValueError( f"LoadCase.group must be one of {VALID_GROUPS}, got {self.group!r}" ) if self.coordinate_frame not in VALID_FRAMES: raise ValueError( f"LoadCase.coordinate_frame must be one of {VALID_FRAMES}, " f"got {self.coordinate_frame!r}" ) if self.status not in VALID_STATUSES: raise ValueError( f"LoadCase.status must be one of {VALID_STATUSES}, got {self.status!r}" ) if self.magnitude_n is None and self.magnitude_g is None: raise ValueError( f"LoadCase {self.id!r}: at least one of magnitude_n / magnitude_g must be set" ) if len(self.direction) != 3: raise ValueError( f"LoadCase {self.id!r}: direction must be 3-vector, got len {len(self.direction)}" )
[docs] def to_dict(self) -> Dict[str, Any]: d: Dict[str, Any] = { "id": self.id, "name": self.name, "group": self.group, "direction": list(self.direction), "attach": self.attach.to_dict(), "coordinate_frame": self.coordinate_frame, "description": self.description, "source": self.source, "status": self.status, } if self.magnitude_n is not None: d["magnitude_n"] = self.magnitude_n if self.magnitude_g is not None: d["magnitude_g"] = self.magnitude_g if self.warn: d["warn"] = True return d
[docs] @classmethod def from_dict(cls, data: Dict[str, Any]) -> "LoadCase": """Build from a dict (e.g. graph.json round-trip).""" attach_data = data.get("attach", {}) attach = LoadAttach( part=attach_data["part"], position=attach_data.get("position", [0.0, 0.0, 0.0]), interface=attach_data.get("interface"), clock_deg=attach_data.get("clock_deg"), bolt_index=attach_data.get("bolt_index"), ) return cls( id=data["id"], name=data["name"], group=data["group"], direction=data["direction"], attach=attach, magnitude_n=data.get("magnitude_n"), magnitude_g=data.get("magnitude_g"), coordinate_frame=data.get("coordinate_frame", COORDINATE_FRAME_VEHICLE), description=data.get("description", ""), source=data.get("source", ""), status=data.get("status", STATUS_ESTIMATED), warn=data.get("warn", False), )
[docs] @dataclass class BoltPattern: """A bolt circle attached to an Assembly interface. Round-trips through Mechatron's `BoltPattern` type (see `crates/assembly-graph/src/types.rs`). Fields match the mechatron schema; `bolt_spec` is a canonical string like "1-4-20-x-1in-button-head-shcs" that can be resolved against the cots-db MCP for stiffness, weight, COTS line item, etc. """ pcd_mm: float # Pitch circle DIAMETER in mm (not radius!) n_bolts: int bolt_spec: str access_direction: Sequence[float] # 3-vec; tool insertion direction def __post_init__(self): if self.pcd_mm <= 0: raise ValueError(f"BoltPattern.pcd_mm must be positive, got {self.pcd_mm}") if self.n_bolts <= 0: raise ValueError(f"BoltPattern.n_bolts must be positive, got {self.n_bolts}") if len(self.access_direction) != 3: raise ValueError( f"BoltPattern.access_direction must be 3-vector, got len {len(self.access_direction)}" )
[docs] def to_dict(self) -> Dict[str, Any]: return { "pcd_mm": float(self.pcd_mm), "n_bolts": int(self.n_bolts), "bolt_spec": str(self.bolt_spec), "access_direction": list(self.access_direction), }
[docs] @classmethod def from_dict(cls, data: Dict[str, Any]) -> "BoltPattern": return cls( pcd_mm=data["pcd_mm"], n_bolts=data["n_bolts"], bolt_spec=data["bolt_spec"], access_direction=data.get("access_direction", [0.0, 0.0, 1.0]), )