Files
Atomizer/tools/adaptive-isogrid/docs/technical-spec.md

862 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, 200600 mm scale, 615 mm thick, 1630 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