Implements the optimization study code for Phase 1 (LHS DoE) of the Hydrotech Beam structural optimization. Files added: - run_doe.py: Main entry point — Optuna study with SQLite persistence, Deb's feasibility rules, CSV/JSON export, Phase 1→2 gate check - sampling.py: 50-point LHS via scipy.stats.qmc with stratified integer sampling ensuring all 11 hole_count levels (5-15) are covered - geometric_checks.py: Pre-flight feasibility filter — hole overlap (corrected formula: span/(n-1) - d ≥ 30mm) and web clearance checks - nx_interface.py: NX automation module with stub solver for development and NXOpen template for Windows/dalidou integration - requirements.txt: optuna, scipy, numpy, pandas Key design decisions: - Baseline enqueued as Trial 0 (LAC lesson) - All 4 DV expression names from binary introspection (exact spelling) - Pre-flight geometric filter saves compute and prevents NX crashes - No surrogates (LAC lesson: direct FEA via TPE beats surrogate+L-BFGS) - SQLite persistence enables resume after interruption Tested end-to-end with stub solver: 51 trials, 12 geometric rejects, 39 solved, correct CSV/JSON output. Ref: OPTIMIZATION_STRATEGY.md, auditor review 2026-02-10
206 lines
6.6 KiB
Python
206 lines
6.6 KiB
Python
"""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 ✓")
|