862 lines
31 KiB
Markdown
862 lines
31 KiB
Markdown
# Adaptive Isogrid Plate Lightweighting — Technical Specification
|
||
|
||
## System Architecture: "Python Brain + NX Hands + Atomizer Manager"
|
||
|
||
**Author:** Atomaste Solution
|
||
**Date:** February 2026
|
||
**Status:** Architecture Locked — Ready for Implementation
|
||
|
||
---
|
||
|
||
## 1. Project Summary
|
||
|
||
### What We're Building
|
||
|
||
A semi-automated tool that takes a plate with holes, generates an optimally lightweighted isogrid pattern, and produces a manufacturing-ready geometry. The isogrid density varies across the plate based on hole importance, edge proximity, and optimization-driven meta-parameters.
|
||
|
||
### Architecture Decision Record
|
||
|
||
After extensive brainstorming, the following decisions are locked:
|
||
|
||
| Decision | Choice | Rationale |
|
||
|---|---|---|
|
||
| Geometry generation | External Python (Constrained Delaunay) | Full access to scipy/triangle/gmsh, debuggable, fast |
|
||
| FEA strategy | Reserved-region monolithic remesh | Keep load/BC topology stable while allowing local rib updates |
|
||
| FEA solver | NX Simcenter + Nastran (2D shell) | Existing expertise, handles complex BCs, extensible to modal/buckling |
|
||
| NX role | Extract sandbox faces, reimport profile, remesh + solve | Reserved regions preserve associations; no assembly merge pipeline needed |
|
||
| Optimization | Atomizer (Optuna TPE), pure parametric v1 | One FEA per trial, ~2 min/iteration, stress feedback deferred to v2 |
|
||
| Geometry transfer | JSON-only round trip | Deterministic, scriptable, no DXF/STEP conversion drift |
|
||
| Plate type | Flat, 200–600 mm scale, 6–15 mm thick, 16–30 holes | Shell elements appropriate, fast solves |
|
||
|
||
### System Diagram
|
||
|
||
```
|
||
ONE-TIME SETUP
|
||
══════════════
|
||
User in NX:
|
||
├── Partition plate mid-surface into regions:
|
||
│ ├── sandbox face(s): ISOGRID_SANDBOX = sandbox_1, sandbox_2, ...
|
||
│ └── reserved regions: edges/functional zones to stay untouched
|
||
├── Assign hole weights (interactive UI or table)
|
||
└── Verify baseline solve and saved solution setup
|
||
|
||
OPTIMIZATION LOOP (~2 min/iteration)
|
||
════════════════════════════════════
|
||
Atomizer (Optuna TPE)
|
||
│
|
||
│ Samples meta-parameters
|
||
│ (η₀, α, β, p, R₀, κ, s_min, s_max, t_min, t₀, γ, w_frame, r_f, ...)
|
||
│
|
||
▼
|
||
NXOpen Extractor (~seconds)
|
||
├── Find all sandbox faces by attribute
|
||
├── Extract each sandbox loop set to local 2D
|
||
└── Write geometry_sandbox_n.json
|
||
│
|
||
▼
|
||
External Python — "The Brain" (~1-3 sec)
|
||
├── Load geometry_sandbox_n.json
|
||
├── Compute density field η(x)
|
||
├── Generate constrained Delaunay triangulation
|
||
├── Apply manufacturing constraints
|
||
└── Write rib_profile_sandbox_n.json
|
||
│
|
||
▼
|
||
NXOpen Import + Solve (~60-90 sec)
|
||
├── Replace sandbox geometry only
|
||
├── Keep reserved regions unchanged (loads/BCs persist)
|
||
├── Remesh full plate as monolithic model
|
||
├── Solve (Nastran)
|
||
└── Extract results.json (stress/disp/strain/mass)
|
||
│
|
||
▼
|
||
Atomizer updates surrogate, samples next trial
|
||
Repeat until convergence (~500-2000 trials)
|
||
```
|
||
|
||
### Key Architectural Insight: Why Reserved Regions
|
||
|
||
The plate lightweighting problem has a natural separation:
|
||
|
||
**What should stay fixed:** load/BC attachment topology around edges, holes, and functional interfaces.
|
||
|
||
**What should evolve:** the rib network inside the designable interior.
|
||
|
||
The reserved-region workflow maps directly to this separation. By only replacing
|
||
sandbox faces, all references in reserved zones remain valid while the interior
|
||
rib topology changes every iteration.
|
||
|
||
This means:
|
||
- Loads and BCs persist naturally because reserved geometry is untouched
|
||
- No multi-model coupling layer or interface node reconciliation workflow
|
||
- Full-plate remesh remains straightforward and robust
|
||
- Geometry extraction runs each iteration, enabling hole-position optimization
|
||
|
||
---
|
||
|
||
## 2. One-Time Setup: Geometry Extraction from NX
|
||
|
||
### 2.1 What the User Does
|
||
|
||
The user opens their plate model in NX and runs a setup script (NXOpen Python). The interaction is:
|
||
|
||
1. **Select the plate face** — click the top (or bottom) face of the plate. The tool reads this face's topology.
|
||
|
||
2. **Hole classification** — the tool lists all inner loops (holes) found on the face, showing each hole's center, diameter, and a preview highlight. The user assigns each hole a weight from 0.0 (ignore — just avoid it) to 1.0 (critical — maximum reinforcement). Grouping by class (A/B/C) is optional; raw per-hole weights work fine since they're not optimization variables.
|
||
|
||
3. **Review** — the tool displays the extracted boundary and holes overlaid on the model for visual confirmation.
|
||
|
||
4. **Export** — writes `geometry.json` containing everything the Python brain needs.
|
||
|
||
### 2.2 Geometry Extraction Logic (NXOpen)
|
||
|
||
The plate face in NX is a B-rep face bounded by edge loops. Extraction pseudocode:
|
||
|
||
```python
|
||
# NXOpen extraction script (runs inside NX)
|
||
import NXOpen
|
||
import json
|
||
|
||
def extract_plate_geometry(face, hole_weights):
|
||
"""
|
||
face: NXOpen.Face — the selected plate face
|
||
hole_weights: dict — {loop_index: weight} from user input
|
||
Returns: geometry dict for export
|
||
"""
|
||
# Get all edge loops on this face
|
||
loops = face.GetLoops()
|
||
|
||
geometry = {
|
||
'outer_boundary': None,
|
||
'holes': [],
|
||
'face_normal': None,
|
||
'thickness': None # can be read from plate body
|
||
}
|
||
|
||
for loop in loops:
|
||
edges = loop.GetEdges()
|
||
# Sample each edge as a polyline
|
||
points = []
|
||
for edge in edges:
|
||
# Get edge curve, sample at intervals
|
||
pts = sample_edge(edge, tolerance=0.1) # 0.1 mm chord tol
|
||
points.extend(pts)
|
||
|
||
if loop.IsOuter():
|
||
geometry['outer_boundary'] = points
|
||
else:
|
||
# Inner loop = hole
|
||
center, diameter = fit_circle(points) # for circular holes
|
||
hole_idx = len(geometry['holes'])
|
||
geometry['holes'].append({
|
||
'index': hole_idx,
|
||
'boundary': points, # actual boundary polyline
|
||
'center': center, # [x, y]
|
||
'diameter': diameter, # mm (None if non-circular)
|
||
'is_circular': is_circle(points, tolerance=0.5),
|
||
'weight': hole_weights.get(hole_idx, 0.0)
|
||
})
|
||
|
||
# Get plate thickness from body
|
||
geometry['thickness'] = measure_plate_thickness(face)
|
||
|
||
# Get face normal and establish XY coordinate system
|
||
geometry['face_normal'] = get_face_normal(face)
|
||
geometry['transform'] = get_face_to_xy_transform(face)
|
||
|
||
return geometry
|
||
|
||
def export_geometry(geometry, filepath='geometry.json'):
|
||
with open(filepath, 'w') as f:
|
||
json.dump(geometry, f, indent=2)
|
||
```
|
||
|
||
### 2.3 What geometry.json Contains
|
||
|
||
```json
|
||
{
|
||
"plate_id": "bracket_v3",
|
||
"units": "mm",
|
||
"thickness": 10.0,
|
||
"material": "AL6061-T6",
|
||
"outer_boundary": [[0,0], [400,0], [400,300], [0,300]],
|
||
"holes": [
|
||
{
|
||
"index": 0,
|
||
"center": [50, 50],
|
||
"diameter": 12.0,
|
||
"is_circular": true,
|
||
"boundary": [[44,50], [44.2,51.8], ...],
|
||
"weight": 1.0
|
||
},
|
||
{
|
||
"index": 1,
|
||
"center": [200, 150],
|
||
"diameter": 8.0,
|
||
"is_circular": true,
|
||
"boundary": [...],
|
||
"weight": 0.3
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Non-circular holes (slots, irregular cutouts) carry their full boundary polyline and weight, but `diameter` and `is_circular` are null/false. The density field uses point-to-polygon distance instead of point-to-center distance for these.
|
||
|
||
---
|
||
|
||
## 3. The Python Brain: Density Field + Geometry Generation
|
||
|
||
### 3.1 Density Field Formulation
|
||
|
||
The density field η(x) is the core of the method. It maps every point on the plate to a value between 0 (minimum density — remove maximum material) and 1 (maximum density — retain material).
|
||
|
||
**Hole influence term:**
|
||
|
||
```
|
||
I(x) = Σᵢ wᵢ · exp( -(dᵢ(x) / Rᵢ)^p )
|
||
```
|
||
|
||
Where:
|
||
- `dᵢ(x)` = distance from point x to hole i (center-to-point for circular, boundary-to-point for non-circular)
|
||
- `Rᵢ = R₀ · (1 + κ · wᵢ)` = influence radius, scales with hole importance
|
||
- `p` = decay exponent (controls transition sharpness)
|
||
- `wᵢ` = user-assigned hole weight (fixed, not optimized)
|
||
|
||
**Edge reinforcement term:**
|
||
|
||
```
|
||
E(x) = exp( -(d_edge(x) / R_edge)^p_edge )
|
||
```
|
||
|
||
Where `d_edge(x)` is the distance from x to the nearest plate boundary edge.
|
||
|
||
**Combined density field:**
|
||
|
||
```
|
||
η(x) = clamp(0, 1, η₀ + α · I(x) + β · E(x))
|
||
```
|
||
|
||
**Density to local target spacing:**
|
||
|
||
```
|
||
s(x) = s_max - (s_max - s_min) · η(x)
|
||
```
|
||
|
||
Where s(x) is the target triangle edge length at point x. High density → small spacing (more ribs). Low density → large spacing (fewer ribs).
|
||
|
||
**Density to local rib thickness:**
|
||
|
||
```
|
||
t(x) = clamp(t_min, t_max, t₀ · (1 + γ · η(x)))
|
||
```
|
||
|
||
Where t₀ is the nominal rib thickness and γ controls how much density affects thickness.
|
||
|
||
### 3.2 Geometry Generation: Constrained Delaunay Pipeline
|
||
|
||
The geometry generation pipeline converts the density field into a manufacturable 2D rib profile. The recommended approach uses Jonathan Shewchuk's Triangle library (Python binding: `triangle`) for constrained Delaunay triangulation with area constraints.
|
||
|
||
**Step 1 — Define the Planar Straight Line Graph (PSLG):**
|
||
|
||
The PSLG is the input to Triangle. It consists of:
|
||
- The outer boundary as a polygon (vertices + segments)
|
||
- Each hole boundary as a polygon (vertices + segments)
|
||
- Hole markers (points inside each hole, telling Triangle to leave these regions empty)
|
||
|
||
```python
|
||
import triangle
|
||
import numpy as np
|
||
|
||
def build_pslg(geometry, keepout_distance):
|
||
"""
|
||
Build PSLG from plate geometry.
|
||
keepout_distance: extra clearance around holes (mm)
|
||
"""
|
||
vertices = []
|
||
segments = []
|
||
holes_markers = []
|
||
|
||
# Outer boundary
|
||
outer = offset_inward(geometry['outer_boundary'], keepout_distance)
|
||
v_start = len(vertices)
|
||
vertices.extend(outer)
|
||
for i in range(len(outer)):
|
||
segments.append([v_start + i, v_start + (i+1) % len(outer)])
|
||
|
||
# Each hole boundary (offset outward for keepout)
|
||
for hole in geometry['holes']:
|
||
hole_boundary = offset_outward(hole['boundary'], keepout_distance)
|
||
v_start = len(vertices)
|
||
vertices.extend(hole_boundary)
|
||
for i in range(len(hole_boundary)):
|
||
segments.append([v_start + i, v_start + (i+1) % len(hole_boundary)])
|
||
holes_markers.append(hole['center']) # point inside hole
|
||
|
||
return {
|
||
'vertices': np.array(vertices),
|
||
'segments': np.array(segments),
|
||
'holes': np.array(holes_markers)
|
||
}
|
||
```
|
||
|
||
**Step 2 — Compute area constraints from density field:**
|
||
|
||
Triangle supports per-region area constraints via a callback or a maximum area parameter. For spatially varying area, we use an iterative refinement approach:
|
||
|
||
```python
|
||
def compute_max_area(x, y, params):
|
||
"""
|
||
Target triangle area at point (x,y) based on density field.
|
||
Smaller area = denser triangulation = more ribs.
|
||
"""
|
||
eta = evaluate_density_field(x, y, params)
|
||
s = params['s_max'] - (params['s_max'] - params['s_min']) * eta
|
||
# Area of equilateral triangle with side length s
|
||
target_area = (np.sqrt(3) / 4) * s**2
|
||
return target_area
|
||
```
|
||
|
||
**Step 3 — Run constrained Delaunay triangulation:**
|
||
|
||
```python
|
||
def generate_triangulation(pslg, params):
|
||
"""
|
||
Generate adaptive triangulation using Triangle library.
|
||
"""
|
||
# Initial triangulation with global max area
|
||
global_max_area = (np.sqrt(3) / 4) * params['s_max']**2
|
||
|
||
# Triangle options:
|
||
# 'p' = triangulate PSLG
|
||
# 'q30' = minimum angle 30° (quality mesh)
|
||
# 'a' = area constraint
|
||
# 'D' = conforming Delaunay
|
||
result = triangle.triangulate(pslg, f'pq30Da{global_max_area}')
|
||
|
||
# Iterative refinement based on density field
|
||
for iteration in range(3): # 2-3 refinement passes
|
||
# For each triangle, check if area exceeds local target
|
||
triangles = result['triangles']
|
||
vertices = result['vertices']
|
||
|
||
areas = compute_triangle_areas(vertices, triangles)
|
||
centroids = compute_centroids(vertices, triangles)
|
||
|
||
# Build per-triangle area constraints
|
||
max_areas = np.array([
|
||
compute_max_area(cx, cy, params)
|
||
for cx, cy in centroids
|
||
])
|
||
|
||
# If all triangles satisfy constraints, done
|
||
if np.all(areas <= max_areas * 1.2): # 20% tolerance
|
||
break
|
||
|
||
# Refine: set area constraint and re-triangulate
|
||
# Triangle supports this via the 'r' (refine) flag
|
||
result = triangle.triangulate(
|
||
result, f'rpq30Da',
|
||
# per-triangle area constraints via triangle_max_area
|
||
)
|
||
|
||
return result
|
||
```
|
||
|
||
**Step 4 — Extract ribs and compute thicknesses:**
|
||
|
||
```python
|
||
def extract_ribs(triangulation, params, geometry):
|
||
"""
|
||
Convert triangulation edges to rib definitions.
|
||
Each rib = (start_point, end_point, thickness, midpoint_density)
|
||
"""
|
||
vertices = triangulation['vertices']
|
||
triangles = triangulation['triangles']
|
||
|
||
# Get unique edges from triangle connectivity
|
||
edges = set()
|
||
for tri in triangles:
|
||
for i in range(3):
|
||
edge = tuple(sorted([tri[i], tri[(i+1)%3]]))
|
||
edges.add(edge)
|
||
|
||
ribs = []
|
||
for v1_idx, v2_idx in edges:
|
||
p1 = vertices[v1_idx]
|
||
p2 = vertices[v2_idx]
|
||
midpoint = (p1 + p2) / 2
|
||
|
||
# Skip edges on the boundary (these aren't interior ribs)
|
||
if is_boundary_edge(v1_idx, v2_idx, triangulation):
|
||
continue
|
||
|
||
# Compute local density and rib thickness
|
||
eta = evaluate_density_field(midpoint[0], midpoint[1], params)
|
||
thickness = compute_rib_thickness(eta, params)
|
||
|
||
ribs.append({
|
||
'start': p1.tolist(),
|
||
'end': p2.tolist(),
|
||
'midpoint': midpoint.tolist(),
|
||
'thickness': thickness,
|
||
'density': eta
|
||
})
|
||
|
||
return ribs
|
||
```
|
||
|
||
**Step 5 — Generate pocket profiles:**
|
||
|
||
Each triangle in the triangulation defines a pocket. The pocket profile is the triangle inset by half the local rib thickness on each edge, with fillet radii at corners.
|
||
|
||
```python
|
||
def generate_pocket_profiles(triangulation, ribs, params):
|
||
"""
|
||
For each triangle, compute the pocket outline
|
||
(triangle boundary inset by half-rib-width on each edge).
|
||
"""
|
||
vertices = triangulation['vertices']
|
||
triangles = triangulation['triangles']
|
||
|
||
pockets = []
|
||
for tri_idx, tri in enumerate(triangles):
|
||
# Get the three edge thicknesses
|
||
edge_thicknesses = get_triangle_edge_thicknesses(
|
||
tri, ribs, vertices
|
||
)
|
||
|
||
# Inset each edge by half its rib thickness
|
||
inset_polygon = inset_triangle(
|
||
vertices[tri[0]], vertices[tri[1]], vertices[tri[2]],
|
||
edge_thicknesses[0]/2, edge_thicknesses[1]/2, edge_thicknesses[2]/2
|
||
)
|
||
|
||
if inset_polygon is None:
|
||
# Triangle too small for pocket — skip (solid region)
|
||
continue
|
||
|
||
# Check minimum pocket size
|
||
inscribed_r = compute_inscribed_radius(inset_polygon)
|
||
if inscribed_r < params.get('min_pocket_radius', 1.5):
|
||
continue # pocket too small to manufacture
|
||
|
||
# Apply fillet to pocket corners
|
||
filleted = fillet_polygon(inset_polygon, params['r_f'])
|
||
|
||
pockets.append({
|
||
'triangle_index': tri_idx,
|
||
'vertices': filleted,
|
||
'area': polygon_area(filleted)
|
||
})
|
||
|
||
return pockets
|
||
```
|
||
|
||
**Step 6 — Assemble the ribbed plate profile:**
|
||
|
||
The final output is the plate boundary minus all pocket regions, plus the hole cutouts. This is a 2D profile that NX will mesh as shells.
|
||
|
||
```python
|
||
def assemble_profile(geometry, pockets, params):
|
||
"""
|
||
Create the final 2D ribbed plate profile.
|
||
Plate boundary - pockets - holes = ribbed plate
|
||
"""
|
||
from shapely.geometry import Polygon, MultiPolygon
|
||
from shapely.ops import unary_union
|
||
|
||
# Plate outline (with optional perimeter frame)
|
||
plate = Polygon(geometry['outer_boundary'])
|
||
|
||
# Inset plate by frame width
|
||
if params['w_frame'] > 0:
|
||
inner_plate = plate.buffer(-params['w_frame'])
|
||
else:
|
||
inner_plate = plate
|
||
|
||
# Union all pocket polygons
|
||
pocket_polys = [Polygon(p['vertices']) for p in pockets]
|
||
all_pockets = unary_union(pocket_polys)
|
||
|
||
# Clip pockets to inner plate (don't cut into frame)
|
||
clipped_pockets = all_pockets.intersection(inner_plate)
|
||
|
||
# Subtract pockets from plate
|
||
ribbed_plate = plate.difference(clipped_pockets)
|
||
|
||
# Subtract holes (with original hole boundaries)
|
||
for hole in geometry['holes']:
|
||
hole_poly = Polygon(hole['boundary'])
|
||
ribbed_plate = ribbed_plate.difference(hole_poly)
|
||
|
||
return ribbed_plate
|
||
```
|
||
|
||
**Step 7 — Validate and export:**
|
||
|
||
```python
|
||
def validate_and_export(ribbed_plate, params, output_path):
|
||
"""
|
||
Check manufacturability and export for NXOpen.
|
||
"""
|
||
checks = {
|
||
'min_web_width': check_minimum_web(ribbed_plate, params['t_min']),
|
||
'no_islands': check_no_floating_islands(ribbed_plate),
|
||
'no_self_intersections': ribbed_plate.is_valid,
|
||
'mass_estimate': estimate_mass(ribbed_plate, params),
|
||
}
|
||
|
||
valid = all([
|
||
checks['min_web_width'],
|
||
checks['no_islands'],
|
||
checks['no_self_intersections']
|
||
])
|
||
|
||
# Export as JSON (coordinate arrays for NXOpen)
|
||
profile_data = {
|
||
'valid': valid,
|
||
'checks': checks,
|
||
'outer_boundary': list(ribbed_plate.exterior.coords),
|
||
'pockets': [list(interior.coords)
|
||
for interior in ribbed_plate.interiors
|
||
if is_pocket(interior)], # pocket cutouts only
|
||
'hole_boundaries': [list(interior.coords)
|
||
for interior in ribbed_plate.interiors
|
||
if is_hole(interior)], # original hole cutouts
|
||
'mass_estimate': checks['mass_estimate'],
|
||
'num_pockets': len([i for i in ribbed_plate.interiors if is_pocket(i)]),
|
||
'parameters_used': params
|
||
}
|
||
|
||
with open(output_path, 'w') as f:
|
||
json.dump(profile_data, f)
|
||
|
||
return valid, checks
|
||
```
|
||
|
||
### 3.3 Manufacturing Constraint Summary
|
||
|
||
These constraints are enforced during geometry generation, not as FEA post-checks:
|
||
|
||
| Constraint | Value | Enforcement Point |
|
||
|---|---|---|
|
||
| Minimum rib width | t_min (param, ≥ 2.0 mm) | Rib thickness computation + validation |
|
||
| Minimum pocket inscribed radius | 1.5 mm (waterjet pierce requirement) | Pocket generation — skip small pockets |
|
||
| Corner fillet radius | r_f (param, ≥ 0.5 mm for waterjet, ≥ tool_radius for CNC) | Pocket profile filleting |
|
||
| Hole keepout | d_keep,hole (param, typically 1.5× hole diameter) | PSLG construction |
|
||
| Edge keepout / frame | w_frame (param) | Profile assembly |
|
||
| Minimum triangle quality | q_min = 30° minimum angle | Triangle library quality flag |
|
||
| No floating islands | — | Validation step |
|
||
| No self-intersections | — | Shapely validity check |
|
||
|
||
---
|
||
|
||
## 4. The NX Hands: Reserved-Region FEM Architecture
|
||
|
||
### 4.1 Core Concept: Sandbox + Reserved Regions
|
||
|
||
The FEA workflow uses direct reserved-region remeshing rather than interface-coupling constructs.
|
||
|
||
Instead, each plate mid-surface is explicitly partitioned in NX into:
|
||
|
||
- **Sandbox region(s)** — where the adaptive isogrid is allowed to change each iteration
|
||
- **Reserved regions** — geometry that must remain untouched (edges, functional features, local areas around critical holes/BC interfaces)
|
||
|
||
Each sandbox face is tagged with an NX user attribute:
|
||
|
||
- **Title:** `ISOGRID_SANDBOX`
|
||
- **Value:** `sandbox_1`, `sandbox_2`, ...
|
||
|
||
Multiple sandbox faces are supported on the same plate.
|
||
|
||
### 4.2 Iterative Geometry Round-Trip (JSON-only)
|
||
|
||
Per optimization iteration, NXOpen performs a full geometry round-trip for each sandbox:
|
||
|
||
1. **Extract current sandbox geometry** from NX into `geometry_sandbox_n.json`
|
||
- outer loop boundary
|
||
- inner loops (holes/cutouts)
|
||
- local 2D transform (face-local XY frame)
|
||
2. **Python Brain generates isogrid profile** inside sandbox boundary
|
||
- outputs `rib_profile_sandbox_n.json`
|
||
3. **NXOpen imports profile** and replaces only sandbox geometry
|
||
- reserved faces remain unchanged
|
||
4. **Full plate remesh** (single monolithic mesh)
|
||
5. **Solve existing Simcenter solution setup**
|
||
6. **Extract field + scalar results** to `results.json`
|
||
|
||
This JSON-only flow replaces DXF/STEP transfer for v1 and keeps CAD/CAE handoff deterministic.
|
||
|
||
### 4.3 Why Reserved-Region Is Robust
|
||
|
||
This architecture addresses the load/BC persistence problem by preserving topology where constraints live:
|
||
|
||
- Loads and boundary conditions are assigned to entities in reserved regions that do not change between iterations
|
||
- Only sandbox geometry is replaced, so reserved-region references remain valid
|
||
- The solver always runs on one connected, remeshed plate model (no multi-model merge operations)
|
||
- Hole positions can be optimized because geometry extraction happens **every iteration** from the latest NX model state
|
||
|
||
### 4.4 NXOpen Phase-2 Script Responsibilities
|
||
|
||
#### A) `extract_sandbox.py`
|
||
|
||
- Discover all faces with `ISOGRID_SANDBOX` attribute
|
||
- For each sandbox face:
|
||
- read outer + inner loops
|
||
- sample edges to polyline (chord tolerance 0.1 mm)
|
||
- fit circles on inner loops when circular
|
||
- project 3D points to face-local 2D
|
||
- write `geometry_sandbox_n.json` using Python Brain input schema
|
||
|
||
#### B) `import_profile.py`
|
||
|
||
- Read `rib_profile_sandbox_n.json`
|
||
- Rebuild NX curves from coordinate arrays
|
||
- Create/update sheet body for the sandbox zone
|
||
- Replace sandbox face geometry while preserving surrounding reserved faces
|
||
- Sew/unite resulting geometry into a watertight plate body
|
||
|
||
#### C) `solve_and_extract.py`
|
||
|
||
- Regenerate FE mesh for full plate
|
||
- Trigger solve using existing solution object(s)
|
||
- Extract from sandbox-associated nodes/elements:
|
||
- nodal Von Mises stress
|
||
- nodal displacement magnitude
|
||
- elemental strain
|
||
- total mass
|
||
- Write `results.json`:
|
||
|
||
```json
|
||
{
|
||
"nodes_xy": [[...], [...]],
|
||
"stress_values": [...],
|
||
"disp_values": [...],
|
||
"strain_values": [...],
|
||
"mass": 0.0
|
||
}
|
||
```
|
||
|
||
### 4.5 Results Extraction Strategy
|
||
|
||
Two output backends are acceptable:
|
||
|
||
1. **Direct NXOpen post-processing API** (preferred when available)
|
||
2. **Simcenter CSV export + parser** (robust fallback)
|
||
|
||
Both must produce consistent arrays for downstream optimization and optional stress-feedback loops.
|
||
|
||
---
|
||
|
||
## 5. Atomizer Integration
|
||
|
||
### 5.1 Parameter Space Definition
|
||
|
||
```python
|
||
# Atomizer/Optuna parameter space
|
||
PARAM_SPACE = {
|
||
# Density field parameters
|
||
'eta_0': {'type': 'float', 'low': 0.0, 'high': 0.4, 'desc': 'Baseline density offset'},
|
||
'alpha': {'type': 'float', 'low': 0.3, 'high': 2.0, 'desc': 'Hole influence scale'},
|
||
'R_0': {'type': 'float', 'low': 10.0, 'high': 100.0, 'desc': 'Base influence radius (mm)'},
|
||
'kappa': {'type': 'float', 'low': 0.0, 'high': 3.0, 'desc': 'Weight-to-radius coupling'},
|
||
'p': {'type': 'float', 'low': 1.0, 'high': 4.0, 'desc': 'Decay exponent'},
|
||
'beta': {'type': 'float', 'low': 0.0, 'high': 1.0, 'desc': 'Edge influence scale'},
|
||
'R_edge': {'type': 'float', 'low': 5.0, 'high': 40.0, 'desc': 'Edge influence radius (mm)'},
|
||
|
||
# Spacing parameters
|
||
's_min': {'type': 'float', 'low': 8.0, 'high': 20.0, 'desc': 'Min cell size (mm)'},
|
||
's_max': {'type': 'float', 'low': 25.0, 'high': 60.0, 'desc': 'Max cell size (mm)'},
|
||
|
||
# Rib thickness parameters
|
||
't_min': {'type': 'float', 'low': 2.0, 'high': 4.0, 'desc': 'Min rib thickness (mm)'},
|
||
't_0': {'type': 'float', 'low': 2.0, 'high': 6.0, 'desc': 'Nominal rib thickness (mm)'},
|
||
'gamma': {'type': 'float', 'low': 0.0, 'high': 3.0, 'desc': 'Density-thickness coupling'},
|
||
|
||
# Manufacturing / frame parameters
|
||
'w_frame': {'type': 'float', 'low': 3.0, 'high': 20.0, 'desc': 'Perimeter frame width (mm)'},
|
||
'r_f': {'type': 'float', 'low': 0.5, 'high': 3.0, 'desc': 'Pocket fillet radius (mm)'},
|
||
'd_keep': {'type': 'float', 'low': 1.0, 'high': 3.0, 'desc': 'Hole keepout multiplier (× diameter)'},
|
||
}
|
||
```
|
||
|
||
**Total: 15 continuous parameters.** This is a comfortable range for Optuna TPE. Can easily expand to 20-25 if needed (e.g., adding per-class influence overrides, smoothing length, separate edge/hole decay exponents).
|
||
|
||
### 5.2 Objective Function
|
||
|
||
```python
|
||
def objective(trial, geometry, sim_template):
|
||
# Sample parameters
|
||
params = {}
|
||
for name, spec in PARAM_SPACE.items():
|
||
params[name] = trial.suggest_float(name, spec['low'], spec['high'])
|
||
|
||
# --- Python Brain: generate geometry ---
|
||
profile_path = f'/tmp/isogrid_trial_{trial.number}.json'
|
||
valid, checks = generate_isogrid(geometry, params, profile_path)
|
||
|
||
if not valid:
|
||
# Geometry failed validation — penalize
|
||
return float('inf')
|
||
|
||
# --- NX Hands: mesh and solve ---
|
||
results = nx_import_and_solve(profile_path, sim_template)
|
||
|
||
if results['status'] != 'solved':
|
||
return float('inf')
|
||
|
||
# --- Evaluate ---
|
||
mass = results['mass']
|
||
max_stress = results['max_von_mises']
|
||
max_disp = results['max_displacement']
|
||
|
||
# Constraint penalties
|
||
penalty = 0.0
|
||
SIGMA_ALLOW = 150.0 # MPa (example for AL6061-T6 with SF)
|
||
DELTA_MAX = 0.1 # mm (example)
|
||
|
||
if max_stress > SIGMA_ALLOW:
|
||
penalty += 1e4 * ((max_stress / SIGMA_ALLOW) - 1.0) ** 2
|
||
if max_disp > DELTA_MAX:
|
||
penalty += 1e4 * ((max_disp / DELTA_MAX) - 1.0) ** 2
|
||
|
||
# Store fields for visualization (not used by optimizer)
|
||
trial.set_user_attr('stress_field', results.get('stress_field'))
|
||
trial.set_user_attr('displacement_field', results.get('displacement_field'))
|
||
trial.set_user_attr('mass', mass)
|
||
trial.set_user_attr('max_stress', max_stress)
|
||
trial.set_user_attr('max_disp', max_disp)
|
||
trial.set_user_attr('num_pockets', checks.get('num_pockets', 0))
|
||
|
||
return mass + penalty
|
||
```
|
||
|
||
### 5.3 Convergence and Stopping
|
||
|
||
With ~2 min/iteration and 15 parameters, expect:
|
||
|
||
| Trials | Wall Time | Expected Outcome |
|
||
|---|---|---|
|
||
| 50 | ~1.5 hours | Random exploration, baseline understanding |
|
||
| 200 | ~7 hours | Surrogate learning, good solutions emerging |
|
||
| 500 | ~17 hours | Near-optimal, diminishing returns starting |
|
||
| 1000 | ~33 hours | Refined optimum, convergence likely |
|
||
| 2000 | ~67 hours | Exhaustive, marginal improvement |
|
||
|
||
**Recommendation:** Start with 500 trials overnight. Review results. If the Pareto front is still moving, extend to 1000.
|
||
|
||
---
|
||
|
||
## 6. V2 Roadmap: Stress-Feedback Enhancement
|
||
|
||
Once v1 is running and producing good results, the stress-feedback enhancement adds the FEA stress/displacement fields as inputs to the density field:
|
||
|
||
```
|
||
η(x) = clamp(0, 1, η₀ + α·I(x) + β·E(x) + λ·S_prev(x))
|
||
```
|
||
|
||
Where `S_prev(x)` is the normalized, smoothed stress field from the previous FEA of the same trial. This creates a local feedback loop within each Atomizer trial:
|
||
|
||
1. Generate isogrid from density field (hole-based only, no stress data)
|
||
2. Run FEA → get stress field
|
||
3. Regenerate isogrid from updated density field (now including stress)
|
||
4. Run FEA → get updated stress field
|
||
5. Check convergence (stress field stable?) → if not, repeat from 3
|
||
6. Report final metrics to Atomizer
|
||
|
||
Atomizer then optimizes the meta-parameters including λ (stress feedback strength) and a smoothing kernel size for the stress field.
|
||
|
||
**New parameters for v2:** λ (stress feedback weight), σ_smooth (stress field smoothing kernel size, mm), n_inner_max (max inner iterations).
|
||
|
||
This is more expensive (2-5× FEA per trial) but produces designs that are truly structurally adapted, not just geometrically adapted.
|
||
|
||
---
|
||
|
||
## 7. Implementation Sequence
|
||
|
||
### Phase 1 — Python Brain Standalone (1-2 weeks)
|
||
|
||
Build and test the geometry generator independently of NX:
|
||
|
||
- Density field evaluation with exponential kernel
|
||
- Constrained Delaunay triangulation (using `triangle` library)
|
||
- Rib thickening and pocket profile generation (using `shapely`)
|
||
- Manufacturing constraint validation
|
||
- Matplotlib visualization (density field heatmap, rib pattern overlay, pocket profiles)
|
||
- Test with 3-5 different plate geometries and 50+ parameter sets
|
||
|
||
**Deliverable:** A Python module that takes `geometry.json` + parameters → outputs `rib_profile.json` + visualization plots.
|
||
|
||
### Phase 2 — NX Sandbox Extraction + Profile Import Scripts (1-2 weeks)
|
||
|
||
Build the NXOpen scripts for reserved-region geometry handling:
|
||
|
||
- Sandbox discovery via NX attribute (`ISOGRID_SANDBOX = sandbox_n`)
|
||
- Per-sandbox extraction script → `geometry_sandbox_n.json`
|
||
- Local 2D projection + loop sampling (outer + inner loops)
|
||
- Rib profile import script (`rib_profile_sandbox_n.json`)
|
||
- Sandbox-only geometry replacement + sew/unite with reserved regions
|
||
- End-to-end JSON round-trip validation on multi-sandbox plates
|
||
|
||
**Deliverable:** Complete NX geometry round-trip pipeline (extract/import) with reserved regions preserved.
|
||
|
||
### Phase 3 — NX Iteration Solve + Results Extraction (1-2 weeks)
|
||
|
||
Build the NXOpen per-iteration analysis script:
|
||
|
||
- Extract sandbox geometry every iteration (supports moving/optimized holes)
|
||
- Import regenerated rib profile into sandbox region(s)
|
||
- Remesh the **full** plate as one monolithic model
|
||
- Trigger existing Simcenter solution setup
|
||
- Extract nodal stress/displacement + elemental strain + mass
|
||
- Serialize standardized `results.json` for Atomizer
|
||
- End-to-end test: Python Brain → NX scripts → `results.json` vs manual benchmark
|
||
|
||
**Deliverable:** Production-ready per-iteration NX pipeline with stable result export for optimization.
|
||
|
||
### Phase 4 — Atomizer Integration (1 week)
|
||
|
||
Wire Atomizer to orchestrate the pipeline:
|
||
|
||
- Parameter sampling → Python Brain → NX journal trigger → result extraction
|
||
- Objective function with constraint penalties
|
||
- Study creation, execution, result logging
|
||
- Failed iteration handling (geometry validation failures, solve failures, merge warnings)
|
||
- Convergence monitoring (plot best mass vs. trial number)
|
||
|
||
**Deliverable:** Full automated optimization loop, ready for production runs.
|
||
|
||
### Phase 5 — Validation + First Real Project (1-2 weeks)
|
||
|
||
Run on an actual client plate:
|
||
|
||
- Full optimization campaign (500+ trials)
|
||
- Compare optimized mass vs. original solid plate and vs. uniform isogrid
|
||
- Manufacturing review (waterjet quote/feasibility from shop)
|
||
- Verify optimal design with refined mesh / higher-fidelity analysis
|
||
- Iterate on parameter bounds and manufacturing constraints based on feedback
|
||
|
||
**Deliverable:** First optimized plate design, manufacturing-ready.
|
||
|
||
---
|
||
|
||
## Appendix A: Python Dependencies
|
||
|
||
```
|
||
numpy >= 1.24
|
||
scipy >= 1.10
|
||
shapely >= 2.0
|
||
triangle >= 20230923 # Python binding for Shewchuk's Triangle
|
||
matplotlib >= 3.7
|
||
```
|
||
|
||
Optional for v2: `gmsh` (alternative mesher), `plotly` (interactive viz).
|
||
|
||
## Appendix B: Key Reference Material
|
||
|
||
- Shewchuk, J.R. "Triangle: A Two-Dimensional Quality Mesh Generator" — the engine behind the constrained Delaunay step
|
||
- NASA CR-124075 "Isogrid Design Handbook" — classical isogrid design equations
|
||
- Optuna documentation — TPE sampler configuration and multi-objective support
|
||
- NXOpen Python API Reference — for geometry creation and Simcenter automation
|