"""Geometric feasibility pre-flight checks for Hydrotech Beam. Validates trial design variable combinations against physical geometry constraints BEFORE sending to NX. Catches infeasible combos that would cause NX rebuild failures or physically impossible geometries. References: OPTIMIZATION_STRATEGY.md §4.2 — Hole overlap analysis Auditor review 2026-02-10 — Corrected spacing formula """ from __future__ import annotations from dataclasses import dataclass from typing import NamedTuple # --------------------------------------------------------------------------- # Fixed geometry constants (from NX introspection — CONTEXT.md) # --------------------------------------------------------------------------- BEAM_HALF_HEIGHT: float = 250.0 # mm — fixed, expression `beam_half_height` HOLE_SPAN: float = 4000.0 # mm — expression `p6`, total distribution length MIN_LIGAMENT: float = 30.0 # mm — minimum material between holes (mesh + structural) class FeasibilityResult(NamedTuple): """Result of a geometric feasibility check.""" feasible: bool reason: str ligament: float # mm — material between adjacent holes web_clearance: float # mm — clearance between hole edge and faces @dataclass(frozen=True) class DesignPoint: """A single design variable combination.""" beam_half_core_thickness: float # mm — DV1 beam_face_thickness: float # mm — DV2 holes_diameter: float # mm — DV3 hole_count: int # — DV4 def compute_ligament(holes_diameter: float, hole_count: int) -> float: """Compute ligament width (material between adjacent holes). The NX pattern places `n` holes across `hole_span` mm using `n-1` intervals (holes at both endpoints of the span). Formula (corrected per auditor review): spacing = hole_span / (hole_count - 1) ligament = spacing - holes_diameter Args: holes_diameter: Hole diameter in mm. hole_count: Number of holes (integer ≥ 2). Returns: Ligament width in mm. Negative means overlap. """ if hole_count < 2: # Single hole — no overlap possible, return large ligament return HOLE_SPAN spacing = HOLE_SPAN / (hole_count - 1) return spacing - holes_diameter def compute_web_clearance( beam_face_thickness: float, holes_diameter: float ) -> float: """Compute clearance between hole edge and face sheets. Total beam height = 2 × beam_half_height = 500 mm (fixed). Web clear height = total_height - 2 × face_thickness. Clearance = web_clear_height - holes_diameter. Args: beam_face_thickness: Face sheet thickness in mm. holes_diameter: Hole diameter in mm. Returns: Web clearance in mm. ≤ 0 means hole doesn't fit. """ total_height = 2.0 * BEAM_HALF_HEIGHT # 500 mm web_clear_height = total_height - 2.0 * beam_face_thickness return web_clear_height - holes_diameter def check_feasibility(point: DesignPoint) -> FeasibilityResult: """Run all geometric feasibility checks on a design point. Checks (in order): 1. Hole overlap — ligament between adjacent holes ≥ MIN_LIGAMENT 2. Web clearance — hole fits within the web (between face sheets) Args: point: Design variable combination to check. Returns: FeasibilityResult with feasibility status and diagnostic info. """ ligament = compute_ligament(point.holes_diameter, point.hole_count) web_clearance = compute_web_clearance( point.beam_face_thickness, point.holes_diameter ) # Check 1: Hole overlap if ligament < MIN_LIGAMENT: return FeasibilityResult( feasible=False, reason=( f"Hole overlap: ligament={ligament:.1f}mm < " f"{MIN_LIGAMENT}mm minimum " f"(d={point.holes_diameter}mm, n={point.hole_count})" ), ligament=ligament, web_clearance=web_clearance, ) # Check 2: Web clearance if web_clearance <= 0: return FeasibilityResult( feasible=False, reason=( f"Hole exceeds web: clearance={web_clearance:.1f}mm ≤ 0 " f"(face={point.beam_face_thickness}mm, " f"d={point.holes_diameter}mm)" ), ligament=ligament, web_clearance=web_clearance, ) return FeasibilityResult( feasible=True, reason="OK", ligament=ligament, web_clearance=web_clearance, ) def check_feasibility_from_values( beam_half_core_thickness: float, beam_face_thickness: float, holes_diameter: float, hole_count: int, ) -> FeasibilityResult: """Convenience wrapper — check feasibility from raw DV values. Args: beam_half_core_thickness: Core half-thickness in mm (DV1). beam_face_thickness: Face sheet thickness in mm (DV2). holes_diameter: Hole diameter in mm (DV3). hole_count: Number of holes (DV4). Returns: FeasibilityResult with feasibility status and diagnostic info. """ point = DesignPoint( beam_half_core_thickness=beam_half_core_thickness, beam_face_thickness=beam_face_thickness, holes_diameter=holes_diameter, hole_count=hole_count, ) return check_feasibility(point) # --------------------------------------------------------------------------- # Quick self-test # --------------------------------------------------------------------------- if __name__ == "__main__": # Baseline: should be feasible baseline = DesignPoint( beam_half_core_thickness=25.162, beam_face_thickness=21.504, holes_diameter=300.0, hole_count=10, ) result = check_feasibility(baseline) print(f"Baseline: {result}") assert result.feasible, f"Baseline should be feasible! Got: {result}" # Worst case: n=15, d=450 — should be infeasible (overlap) worst_overlap = DesignPoint( beam_half_core_thickness=25.0, beam_face_thickness=20.0, holes_diameter=450.0, hole_count=15, ) result = check_feasibility(worst_overlap) print(f"Worst overlap: {result}") assert not result.feasible, "n=15, d=450 should be infeasible" # Web clearance fail: face=40, d=450 web_fail = DesignPoint( beam_half_core_thickness=25.0, beam_face_thickness=40.0, holes_diameter=450.0, hole_count=5, ) result = check_feasibility(web_fail) print(f"Web clearance fail: {result}") assert not result.feasible, "face=40, d=450 should fail web clearance" print("\nAll self-tests passed ✓")