31 KiB
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:
-
Select the plate face — click the top (or bottom) face of the plate. The tool reads this face's topology.
-
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.
-
Review — the tool displays the extracted boundary and holes overlaid on the model for visual confirmation.
-
Export — writes
geometry.jsoncontaining 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:
# 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
{
"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 importancep= 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)
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:
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:
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:
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.
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.
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:
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:
- 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)
- Python Brain generates isogrid profile inside sandbox boundary
- outputs
rib_profile_sandbox_n.json
- outputs
- NXOpen imports profile and replaces only sandbox geometry
- reserved faces remain unchanged
- Full plate remesh (single monolithic mesh)
- Solve existing Simcenter solution setup
- 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_SANDBOXattribute - 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.jsonusing 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:
{
"nodes_xy": [[...], [...]],
"stress_values": [...],
"disp_values": [...],
"strain_values": [...],
"mass": 0.0
}
4.5 Results Extraction Strategy
Two output backends are acceptable:
- Direct NXOpen post-processing API (preferred when available)
- 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
# 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
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:
- Generate isogrid from density field (hole-based only, no stress data)
- Run FEA → get stress field
- Regenerate isogrid from updated density field (now including stress)
- Run FEA → get updated stress field
- Check convergence (stress field stable?) → if not, repeat from 3
- 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
trianglelibrary) - 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.jsonfor Atomizer - End-to-end test: Python Brain → NX scripts →
results.jsonvs 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