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

31 KiB
Raw Blame History

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:

# 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 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)
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:

  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:
{
  "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

# 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:

  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