feat: add adaptive isogrid tool — project foundations

- Python Brain: density field, constrained Delaunay triangulation,
  pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
  per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
This commit is contained in:
2026-02-16 00:01:35 +00:00
parent cf82de4f06
commit 4bec4063a5
16 changed files with 2124 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
# Adaptive Isogrid — Plate Lightweighting Tool
**Status:** Foundation / Pre-Implementation
**Architecture:** Python Brain + NX Hands + Atomizer Manager
## What It Does
Takes a plate with holes → generates an optimally lightweighted isogrid pattern → produces manufacturing-ready geometry. Isogrid density varies across the plate based on hole importance, edge proximity, and optimization-driven meta-parameters.
## Architecture
| Component | Role | Runtime |
|-----------|------|---------|
| **Python Brain** | Density field → Constrained Delaunay → rib profile | ~1-3 sec |
| **NX Hands** | Import profile → mesh → AFEM merge → Nastran solve → extract results | ~60-90 sec |
| **Atomizer Manager** | Optuna TPE sampling → objective evaluation → convergence | 500-2000 trials |
### Key Insight: Assembly FEM with Superposed Models
- **Model A** (permanent): Spider elements at holes + edge BC nodes. All loads/BCs applied here.
- **Model B** (variable): 2D shell mesh of ribbed plate. Rebuilt each iteration.
- **Node merge** at fixed interface locations connects them reliably every time.
Loads and BCs never need re-association. Only the rib pattern changes.
## Directory Structure
```
adaptive-isogrid/
├── README.md
├── requirements.txt
├── docs/
│ └── technical-spec.md # Full architecture spec
├── src/
│ ├── brain/ # Python geometry generator
│ │ ├── __init__.py
│ │ ├── density_field.py # η(x) evaluation
│ │ ├── triangulation.py # Constrained Delaunay + refinement
│ │ ├── pocket_profiles.py # Pocket inset + filleting
│ │ ├── profile_assembly.py # Final plate - pockets - holes
│ │ └── validation.py # Manufacturing constraint checks
│ ├── nx/ # NXOpen journal scripts
│ │ ├── extract_geometry.py # One-time: face → geometry.json
│ │ ├── build_interface_model.py # One-time: Model A + spiders
│ │ └── iteration_solve.py # Per-trial: rebuild Model B + solve
│ └── atomizer_study.py # Atomizer/Optuna integration
└── tests/
└── test_geometries/ # Sample geometry.json files
```
## Implementation Phases
1. **Python Brain standalone** (1-2 weeks) — geometry generator with matplotlib viz
2. **NX extraction + AFEM setup** (1-2 weeks) — one-time project setup scripts
3. **NX iteration script** (1-2 weeks) — per-trial mesh/solve/extract loop
4. **Atomizer integration** (1 week) — wire objective function + study management
5. **Validation + first real project** (1-2 weeks) — production run on client plate
## Quick Start (Phase 1)
```bash
cd tools/adaptive-isogrid
pip install -r requirements.txt
python -m src.brain --geometry tests/test_geometries/sample_bracket.json --params default
```
## Parameter Space
15 continuous parameters optimized by Atomizer (Optuna TPE):
- Density field: η₀, α, R₀, κ, p, β, R_edge
- Spacing: s_min, s_max
- Rib thickness: t_min, t₀, γ
- Manufacturing: w_frame, r_f, d_keep
See `docs/technical-spec.md` for full formulation.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
numpy>=1.24
scipy>=1.10
shapely>=2.0
triangle>=20230923
matplotlib>=3.7

View File

View File

@@ -0,0 +1,48 @@
"""
Atomizer/Optuna integration for adaptive isogrid optimization.
Wires: parameter sampling → Python Brain → NX Hands → result extraction.
"""
# Parameter space definition
PARAM_SPACE = {
# Density field
'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
'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
'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
'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)'},
}
# Default parameters for standalone brain testing
DEFAULT_PARAMS = {
'eta_0': 0.1,
'alpha': 1.0,
'R_0': 30.0,
'kappa': 1.0,
'p': 2.0,
'beta': 0.3,
'R_edge': 15.0,
's_min': 12.0,
's_max': 35.0,
't_min': 2.5,
't_0': 3.0,
'gamma': 1.0,
'w_frame': 8.0,
'r_f': 1.5,
'd_keep': 1.5,
'min_pocket_radius': 1.5,
}

View File

@@ -0,0 +1,8 @@
"""
Adaptive Isogrid — Python Brain
Density field evaluation → Constrained Delaunay triangulation →
Rib thickness computation → Pocket profile generation → Validation.
Takes geometry.json + parameters → outputs rib_profile.json
"""

View File

@@ -0,0 +1,146 @@
"""
Density field η(x) — maps every point on the plate to [0, 1].
η(x) = clamp(0, 1, η₀ + α·I(x) + β·E(x))
Where:
I(x) = Σᵢ wᵢ · exp(-(dᵢ(x)/Rᵢ)^p) — hole influence
E(x) = exp(-(d_edge(x)/R_edge)^p_edge) — edge reinforcement
"""
import numpy as np
from shapely.geometry import Polygon, Point, LinearRing
def compute_hole_influence(x, y, holes, params):
"""
Compute hole influence term I(x) at point (x, y).
I(x) = Σᵢ wᵢ · exp(-(dᵢ(x) / Rᵢ)^p)
Parameters
----------
x, y : float
Query point coordinates.
holes : list[dict]
Hole definitions from geometry.json.
params : dict
Must contain: R_0, kappa, p
"""
R_0 = params['R_0']
kappa = params['kappa']
p = params['p']
influence = 0.0
for hole in holes:
w = hole['weight']
if w <= 0:
continue
# Distance to hole
if hole.get('is_circular', True):
cx, cy = hole['center']
d = np.sqrt((x - cx)**2 + (y - cy)**2)
else:
# Non-circular: distance to boundary polygon
ring = LinearRing(hole['boundary'])
d = Point(x, y).distance(ring)
# Influence radius scales with hole weight
R_i = R_0 * (1.0 + kappa * w)
# Exponential decay
influence += w * np.exp(-(d / R_i)**p)
return influence
def compute_edge_influence(x, y, outer_boundary, params):
"""
Compute edge reinforcement term E(x) at point (x, y).
E(x) = exp(-(d_edge(x) / R_edge)^p)
"""
R_edge = params['R_edge']
p = params['p'] # reuse same decay exponent (could be separate in v2)
ring = LinearRing(outer_boundary)
d_edge = Point(x, y).distance(ring)
return np.exp(-(d_edge / R_edge)**p)
def evaluate_density(x, y, geometry, params):
"""
Evaluate the combined density field η(x, y).
η(x) = clamp(0, 1, η₀ + α·I(x) + β·E(x))
Returns
-------
float : density value in [0, 1]
"""
eta_0 = params['eta_0']
alpha = params['alpha']
beta = params['beta']
I = compute_hole_influence(x, y, geometry['holes'], params)
E = compute_edge_influence(x, y, geometry['outer_boundary'], params)
eta = eta_0 + alpha * I + beta * E
return np.clip(eta, 0.0, 1.0)
def evaluate_density_grid(geometry, params, resolution=2.0):
"""
Evaluate density field on a regular grid covering the plate.
Useful for visualization (heatmap).
Parameters
----------
geometry : dict
Plate geometry with outer_boundary and holes.
params : dict
Density field parameters.
resolution : float
Grid spacing in mm.
Returns
-------
X, Y, eta : ndarray
Meshgrid coordinates and density values.
"""
boundary = np.array(geometry['outer_boundary'])
x_min, y_min = boundary.min(axis=0)
x_max, y_max = boundary.max(axis=0)
xs = np.arange(x_min, x_max + resolution, resolution)
ys = np.arange(y_min, y_max + resolution, resolution)
X, Y = np.meshgrid(xs, ys)
plate_poly = Polygon(geometry['outer_boundary'])
eta = np.full_like(X, np.nan)
for i in range(X.shape[0]):
for j in range(X.shape[1]):
pt = Point(X[i, j], Y[i, j])
if plate_poly.contains(pt):
eta[i, j] = evaluate_density(X[i, j], Y[i, j], geometry, params)
return X, Y, eta
def density_to_spacing(eta, params):
"""Convert density η to local target triangle edge length s(x)."""
s_min = params['s_min']
s_max = params['s_max']
return s_max - (s_max - s_min) * eta
def density_to_rib_thickness(eta, params):
"""Convert density η to local rib thickness t(x)."""
t_min = params['t_min']
t_0 = params['t_0']
gamma = params['gamma']
t_max = t_0 * (1.0 + gamma) # max possible thickness
return np.clip(t_0 * (1.0 + gamma * eta), t_min, t_max)

View File

@@ -0,0 +1,176 @@
"""
Pocket profile generation — convert triangulation into manufacturable pocket cutouts.
Each triangle → inset by half-rib-thickness per edge → fillet corners → pocket polygon.
"""
import numpy as np
from shapely.geometry import Polygon
from shapely.ops import unary_union
from .density_field import evaluate_density, density_to_rib_thickness
def get_unique_edges(triangles):
"""Extract 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)
return edges
def get_triangle_edge_thicknesses(tri_vertices, tri_edges_thickness):
"""Get thickness for each edge of a triangle."""
return [tri_edges_thickness.get(e, 2.0) for e in tri_vertices]
def inset_triangle(p0, p1, p2, d0, d1, d2):
"""
Inset a triangle by distances d0, d1, d2 on each edge.
Edge 0: p0→p1, inset by d0
Edge 1: p1→p2, inset by d1
Edge 2: p2→p0, inset by d2
Returns inset triangle vertices or None if triangle collapses.
"""
def edge_normal_inward(a, b, opposite):
"""Unit inward normal of edge a→b (toward opposite vertex)."""
dx, dy = b[0] - a[0], b[1] - a[1]
length = np.sqrt(dx**2 + dy**2)
if length < 1e-10:
return np.array([0.0, 0.0])
# Two possible normals
n1 = np.array([-dy / length, dx / length])
n2 = np.array([dy / length, -dx / length])
# Pick the one pointing toward the opposite vertex
mid = (np.array(a) + np.array(b)) / 2.0
if np.dot(n1, np.array(opposite) - mid) > 0:
return n1
return n2
points = [np.array(p0), np.array(p1), np.array(p2)]
offsets = [d0, d1, d2]
# Compute inset lines for each edge
inset_lines = []
edges = [(0, 1, 2), (1, 2, 0), (2, 0, 1)]
for (i, j, k), d in zip(edges, offsets):
n = edge_normal_inward(points[i], points[j], points[k])
# Offset edge inward
a_off = points[i] + n * d
b_off = points[j] + n * d
inset_lines.append((a_off, b_off))
# Intersect consecutive inset lines to get new vertices
new_verts = []
for idx in range(3):
a1, b1 = inset_lines[idx]
a2, b2 = inset_lines[(idx + 1) % 3]
pt = line_intersection(a1, b1, a2, b2)
if pt is None:
return None
new_verts.append(pt)
# Check if triangle is valid (positive area)
area = triangle_area(new_verts[0], new_verts[1], new_verts[2])
if area < 0.1: # mm² — too small
return None
return new_verts
def line_intersection(a1, b1, a2, b2):
"""Find intersection of two lines (a1→b1) and (a2→b2)."""
d1 = b1 - a1
d2 = b2 - a2
cross = d1[0] * d2[1] - d1[1] * d2[0]
if abs(cross) < 1e-12:
return None # parallel
t = ((a2[0] - a1[0]) * d2[1] - (a2[1] - a1[1]) * d2[0]) / cross
return a1 + t * d1
def triangle_area(p0, p1, p2):
"""Signed area of triangle."""
return 0.5 * abs(
(p1[0] - p0[0]) * (p2[1] - p0[1]) -
(p2[0] - p0[0]) * (p1[1] - p0[1])
)
def generate_pockets(triangulation, geometry, params):
"""
Generate pocket profiles from triangulation.
Each triangle becomes a pocket (inset + filleted).
Small triangles are left solid (no pocket).
Returns
-------
list[dict] : pocket definitions with vertices and metadata.
"""
vertices = triangulation['vertices']
triangles = triangulation['triangles']
r_f = params['r_f']
min_pocket_radius = params.get('min_pocket_radius', 1.5)
# Precompute edge thicknesses
edge_thickness = {}
for tri in triangles:
for i in range(3):
edge = tuple(sorted([tri[i], tri[(i + 1) % 3]]))
if edge not in edge_thickness:
mid = (vertices[edge[0]] + vertices[edge[1]]) / 2.0
eta = evaluate_density(mid[0], mid[1], geometry, params)
edge_thickness[edge] = density_to_rib_thickness(eta, params)
pockets = []
for tri_idx, tri in enumerate(triangles):
p0 = vertices[tri[0]]
p1 = vertices[tri[1]]
p2 = vertices[tri[2]]
# Get edge thicknesses (half for inset)
e01 = tuple(sorted([tri[0], tri[1]]))
e12 = tuple(sorted([tri[1], tri[2]]))
e20 = tuple(sorted([tri[2], tri[0]]))
d0 = edge_thickness[e01] / 2.0
d1 = edge_thickness[e12] / 2.0
d2 = edge_thickness[e20] / 2.0
inset = inset_triangle(p0, p1, p2, d0, d1, d2)
if inset is None:
continue
# Check minimum pocket size via inscribed circle approximation
pocket_poly = Polygon(inset)
if not pocket_poly.is_valid or pocket_poly.is_empty:
continue
# Approximate inscribed radius
inscribed_r = pocket_poly.area / (pocket_poly.length / 2.0)
if inscribed_r < min_pocket_radius:
continue
# Apply fillet (buffer negative then positive = round inward corners)
fillet_amount = min(r_f, inscribed_r * 0.4) # don't over-fillet
if fillet_amount > 0.1:
filleted = pocket_poly.buffer(-fillet_amount).buffer(fillet_amount)
else:
filleted = pocket_poly
if filleted.is_empty or not filleted.is_valid:
continue
coords = list(filleted.exterior.coords)
pockets.append({
'triangle_index': tri_idx,
'vertices': coords,
'area': filleted.area,
})
return pockets

View File

@@ -0,0 +1,100 @@
"""
Profile assembly — combine plate boundary, pockets, and holes
into the final 2D ribbed plate profile for NX import.
Output: plate boundary - pockets - holes = ribbed plate (Shapely geometry)
"""
from shapely.geometry import Polygon
from shapely.ops import unary_union
def assemble_profile(geometry, pockets, params):
"""
Create the final 2D ribbed plate profile.
Plate boundary - pockets - holes = ribbed plate
Parameters
----------
geometry : dict
Plate geometry with outer_boundary and holes.
pockets : list[dict]
Pocket definitions from pocket_profiles.generate_pockets().
params : dict
Must contain w_frame (perimeter frame width).
Returns
-------
Shapely Polygon/MultiPolygon : the ribbed plate profile.
"""
plate = Polygon(geometry['outer_boundary'])
w_frame = params['w_frame']
# Inner boundary (frame inset)
if w_frame > 0:
inner_plate = plate.buffer(-w_frame)
else:
inner_plate = plate
if inner_plate.is_empty:
# Frame too wide for plate — return solid plate minus holes
ribbed = plate
for hole in geometry['holes']:
ribbed = ribbed.difference(Polygon(hole['boundary']))
return ribbed
# Union all pocket polygons
pocket_polys = []
for p in pockets:
poly = Polygon(p['vertices'])
if poly.is_valid and not poly.is_empty:
pocket_polys.append(poly)
if pocket_polys:
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.difference(clipped_pockets)
else:
ribbed = plate
# Subtract original hole boundaries
for hole in geometry['holes']:
hole_poly = Polygon(hole['boundary'])
ribbed = ribbed.difference(hole_poly)
return ribbed
def profile_to_json(ribbed_plate, params):
"""
Convert Shapely ribbed plate geometry to JSON-serializable dict
for NXOpen import.
"""
if ribbed_plate.is_empty:
return {'valid': False, 'reason': 'empty_geometry'}
# Separate exterior and interiors
if ribbed_plate.geom_type == 'MultiPolygon':
# Take the largest polygon (should be the plate)
largest = max(ribbed_plate.geoms, key=lambda g: g.area)
else:
largest = ribbed_plate
# Classify interiors as pockets or holes
outer_coords = list(largest.exterior.coords)
pocket_coords = []
hole_coords = []
for interior in largest.interiors:
coords = list(interior.coords)
pocket_coords.append(coords)
return {
'valid': True,
'outer_boundary': outer_coords,
'pockets': pocket_coords,
'parameters_used': params,
}

View File

@@ -0,0 +1,160 @@
"""
Constrained Delaunay triangulation with density-adaptive refinement.
Uses Shewchuk's Triangle library to generate adaptive mesh that
respects plate boundary, hole keepouts, and density-driven spacing.
"""
import numpy as np
import triangle as tr
from shapely.geometry import Polygon, LinearRing
from .density_field import evaluate_density, density_to_spacing
def offset_polygon(coords, distance, inward=True):
"""
Offset a polygon boundary by `distance`.
inward=True shrinks, inward=False expands.
Uses Shapely buffer (negative = inward for exterior ring).
"""
poly = Polygon(coords)
if inward:
buffered = poly.buffer(-distance)
else:
buffered = poly.buffer(distance)
if buffered.is_empty:
return coords # can't offset that much, return original
if hasattr(buffered, 'exterior'):
return list(buffered.exterior.coords)[:-1] # remove closing duplicate
return coords
def build_pslg(geometry, params):
"""
Build Planar Straight Line Graph for Triangle library.
Parameters
----------
geometry : dict
Plate geometry (outer_boundary, holes).
params : dict
Must contain d_keep (hole keepout multiplier).
Returns
-------
dict : Triangle-compatible PSLG with vertices, segments, holes.
"""
d_keep = params['d_keep']
vertices = []
segments = []
hole_markers = []
# Outer boundary (no offset needed — frame is handled in profile assembly)
outer = geometry['outer_boundary']
v_start = len(vertices)
vertices.extend(outer)
n = len(outer)
for i in range(n):
segments.append([v_start + i, v_start + (i + 1) % n])
# Each hole with keepout offset
for hole in geometry['holes']:
keepout_dist = d_keep * (hole.get('diameter', 10.0) or 10.0) / 2.0
hole_boundary = offset_polygon(hole['boundary'], keepout_dist, inward=False)
v_start = len(vertices)
vertices.extend(hole_boundary)
n_h = len(hole_boundary)
for i in range(n_h):
segments.append([v_start + i, v_start + (i + 1) % n_h])
# Marker inside hole tells Triangle to leave it empty
hole_markers.append(hole['center'])
result = {
'vertices': np.array(vertices, dtype=np.float64),
'segments': np.array(segments, dtype=np.int32),
}
if hole_markers:
result['holes'] = np.array(hole_markers, dtype=np.float64)
return result
def compute_triangle_areas(vertices, triangles):
"""Compute area of each triangle."""
v0 = vertices[triangles[:, 0]]
v1 = vertices[triangles[:, 1]]
v2 = vertices[triangles[:, 2]]
# Cross product / 2
areas = 0.5 * np.abs(
(v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) -
(v2[:, 0] - v0[:, 0]) * (v1[:, 1] - v0[:, 1])
)
return areas
def compute_centroids(vertices, triangles):
"""Compute centroid of each triangle."""
v0 = vertices[triangles[:, 0]]
v1 = vertices[triangles[:, 1]]
v2 = vertices[triangles[:, 2]]
return (v0 + v1 + v2) / 3.0
def generate_triangulation(geometry, params, max_refinement_passes=3):
"""
Generate density-adaptive constrained Delaunay triangulation.
Parameters
----------
geometry : dict
Plate geometry.
params : dict
Full parameter set (density field + spacing + manufacturing).
max_refinement_passes : int
Number of iterative refinement passes.
Returns
-------
dict : Triangle result with 'vertices' and 'triangles'.
"""
pslg = build_pslg(geometry, params)
# Initial triangulation with global max area
s_max = params['s_max']
global_max_area = (np.sqrt(3) / 4.0) * s_max**2
# Triangle options: p=PSLG, q30=min angle 30°, a=area constraint, D=conforming Delaunay
result = tr.triangulate(pslg, f'pq30Da{global_max_area:.1f}')
# Iterative refinement based on density field
for iteration in range(max_refinement_passes):
verts = result['vertices']
tris = result['triangles']
areas = compute_triangle_areas(verts, tris)
centroids = compute_centroids(verts, tris)
# Compute target area for each triangle based on density at centroid
target_areas = np.array([
(np.sqrt(3) / 4.0) * density_to_spacing(
evaluate_density(cx, cy, geometry, params), params
)**2
for cx, cy in centroids
])
# Check if all triangles satisfy constraints (20% tolerance)
if np.all(areas <= target_areas * 1.2):
break
# Set per-triangle max area and refine
result['triangle_max_area'] = target_areas
result = tr.triangulate(result, 'rpq30D')
return result

View File

@@ -0,0 +1,106 @@
"""
Manufacturing constraint validation for ribbed plate profiles.
Checks run after profile assembly to catch issues before NX import.
"""
import numpy as np
from shapely.geometry import Polygon
from shapely.validation import make_valid
def check_minimum_web(ribbed_plate, t_min):
"""
Check that no rib section is thinner than t_min.
Uses negative buffer: if buffering inward by t_min/2 leaves
the geometry connected, minimum web width is satisfied.
Returns True if minimum web width is OK.
"""
eroded = ribbed_plate.buffer(-t_min / 2.0)
if eroded.is_empty:
return False
# Check connectivity — should still be one piece
if eroded.geom_type == 'MultiPolygon':
# Some thin sections broke off — might be OK if they're tiny
main_area = max(g.area for g in eroded.geoms)
total_area = sum(g.area for g in eroded.geoms)
# If >95% of area is in one piece, it's fine
return main_area / total_area > 0.95
return True
def check_no_islands(ribbed_plate):
"""
Check that the ribbed plate has no floating islands
(disconnected solid regions).
Returns True if no islands found.
"""
if ribbed_plate.geom_type == 'Polygon':
return True
elif ribbed_plate.geom_type == 'MultiPolygon':
# Multiple polygons = islands. Only OK if one is dominant.
areas = [g.area for g in ribbed_plate.geoms]
return max(areas) / sum(areas) > 0.99
return False
def estimate_mass(ribbed_plate, params, density_kg_m3=2700.0):
"""
Estimate mass of the ribbed plate.
Parameters
----------
ribbed_plate : Shapely geometry
The ribbed plate profile.
params : dict
Must contain thickness info (from geometry).
density_kg_m3 : float
Material density (default: aluminum 6061).
Returns
-------
float : estimated mass in grams.
"""
area_mm2 = ribbed_plate.area
thickness_mm = params.get('thickness', 10.0)
volume_mm3 = area_mm2 * thickness_mm
volume_m3 = volume_mm3 * 1e-9
mass_kg = volume_m3 * density_kg_m3
return mass_kg * 1000.0 # grams
def validate_profile(ribbed_plate, params):
"""
Run all validation checks on the ribbed plate profile.
Returns
-------
tuple : (is_valid: bool, checks: dict)
"""
t_min = params['t_min']
# Fix any geometry issues
if not ribbed_plate.is_valid:
ribbed_plate = make_valid(ribbed_plate)
checks = {
'is_valid_geometry': ribbed_plate.is_valid,
'min_web_width': check_minimum_web(ribbed_plate, t_min),
'no_islands': check_no_islands(ribbed_plate),
'no_self_intersections': ribbed_plate.is_valid,
'mass_estimate_g': estimate_mass(ribbed_plate, params),
'area_mm2': ribbed_plate.area,
'num_interiors': len(list(ribbed_plate.interiors)) if ribbed_plate.geom_type == 'Polygon' else 0,
}
is_valid = all([
checks['is_valid_geometry'],
checks['min_web_width'],
checks['no_islands'],
checks['no_self_intersections'],
])
return is_valid, checks

View File

@@ -0,0 +1,6 @@
"""
Adaptive Isogrid — NX Hands
NXOpen journal scripts for geometry extraction, AFEM setup, and per-iteration solve.
These scripts run inside NX Simcenter via the NXOpen Python API.
"""

View File

@@ -0,0 +1,28 @@
"""
NXOpen script — Build Interface Model (Model A) for Assembly FEM.
ONE-TIME: Creates spider elements (RBE2/RBE3) at each hole + edge BC nodes.
Exports interface_nodes.json for the iteration script.
NOTE: Skeleton — requires NX environment for development.
See docs/technical-spec.md Section 4.2 for full pseudocode.
"""
def build_interface_model(geometry_json_path, fem_part):
"""
Build Model A: spider elements at each hole + edge BC nodes.
For each hole:
- Center node (master)
- N circumference nodes (~1 per 2mm of circumference)
- RBE2 (rigid) or RBE3 (distributing) spider
For plate boundary:
- Edge nodes at ~3mm spacing
Exports interface_nodes.json with all node IDs and coordinates.
"""
raise NotImplementedError(
"Develop inside NX Simcenter. See docs/technical-spec.md Section 4.2."
)

View File

@@ -0,0 +1,54 @@
"""
NXOpen script — Extract plate geometry from selected face.
ONE-TIME: Run inside NX. User selects plate face, assigns hole weights.
Exports geometry.json for the Python brain.
NOTE: This is pseudocode / skeleton. Actual NXOpen API calls need
NX environment to develop and test.
"""
# This script runs inside NX — NXOpen is available at runtime
# import NXOpen
# import json
# import math
def extract_plate_geometry(face, hole_weights):
"""
Extract plate geometry from an NX face.
Parameters
----------
face : NXOpen.Face
The selected plate face.
hole_weights : dict
{loop_index: weight} from user input.
Returns
-------
dict : geometry definition for export.
"""
raise NotImplementedError(
"This script must be developed and tested inside NX Simcenter. "
"See docs/technical-spec.md Section 2 for full pseudocode."
)
def sample_edge(edge, tolerance=0.1):
"""Sample edge curve as polyline with given chord tolerance."""
# NXOpen: edge.GetCurve(), evaluate at intervals
raise NotImplementedError
def fit_circle(points):
"""Fit a circle to boundary points. Returns (center, diameter)."""
# Least-squares circle fit
raise NotImplementedError
def export_geometry(geometry, filepath='geometry.json'):
"""Export geometry dict to JSON."""
import json
with open(filepath, 'w') as f:
json.dump(geometry, f, indent=2)

View File

@@ -0,0 +1,35 @@
"""
NXOpen script — Per-iteration Model B rebuild + solve + extract.
LOOP: Called by Atomizer for each trial.
1. Delete old Model B geometry + mesh
2. Import new 2D ribbed profile from rib_profile.json
3. Mesh with hard-point seeds at interface node locations
4. Merge nodes in Assembly FEM
5. Solve (Nastran)
6. Extract results → results.json
NOTE: Skeleton — requires NX environment for development.
See docs/technical-spec.md Section 4.3 for full pseudocode.
"""
def iteration_solve(profile_path, interface_nodes_path, afem_part):
"""
Single optimization iteration.
Returns dict with status, mass, stress/displacement fields.
"""
raise NotImplementedError(
"Develop inside NX Simcenter. See docs/technical-spec.md Section 4.3."
)
def extract_results(afem, solution):
"""
Extract field results from solved assembly FEM.
Only from Model B elements (plate mesh), ignoring spiders.
"""
raise NotImplementedError(
"Develop inside NX Simcenter. See docs/technical-spec.md Section 4.3."
)

View File

@@ -0,0 +1,49 @@
{
"plate_id": "sample_bracket",
"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.9], [44.8,53.7], [45.8,55.2], [47.1,56.3], [48.6,57.0], [50.2,57.0], [51.7,56.5], [53.0,55.4], [53.9,53.9], [54.4,52.1], [54.4,50.1], [54.0,48.3], [53.1,46.7], [51.8,45.6], [50.3,45.0], [48.6,45.0], [47.1,45.5], [45.8,46.6], [44.9,48.1]],
"weight": 1.0
},
{
"index": 1,
"center": [50, 250],
"diameter": 12.0,
"is_circular": true,
"boundary": [[44,250], [44.2,251.9], [44.8,253.7], [45.8,255.2], [47.1,256.3], [48.6,257.0], [50.2,257.0], [51.7,256.5], [53.0,255.4], [53.9,253.9], [54.4,252.1], [54.4,250.1], [54.0,248.3], [53.1,246.7], [51.8,245.6], [50.3,245.0], [48.6,245.0], [47.1,245.5], [45.8,246.6], [44.9,248.1]],
"weight": 1.0
},
{
"index": 2,
"center": [200, 150],
"diameter": 8.0,
"is_circular": true,
"boundary": [[196,150], [196.1,151.3], [196.5,152.5], [197.2,153.5], [198.1,154.2], [199.2,154.6], [200.4,154.6], [201.5,154.2], [202.4,153.4], [203.0,152.3], [203.3,151.0], [203.3,149.7], [203.0,148.5], [202.3,147.4], [201.4,146.6], [200.3,146.2], [199.1,146.2], [198.0,146.7], [197.2,147.5], [196.5,148.7]],
"weight": 0.3
},
{
"index": 3,
"center": [350, 50],
"diameter": 10.0,
"is_circular": true,
"boundary": [[345,50], [345.1,51.6], [345.6,53.1], [346.5,54.3], [347.6,55.2], [349.0,55.7], [350.5,55.7], [351.9,55.1], [353.0,54.1], [353.8,52.8], [354.2,51.3], [354.2,49.7], [353.8,48.2], [352.9,46.9], [351.7,46.0], [350.3,45.5], [348.8,45.5], [347.5,46.1], [346.5,47.1], [345.6,48.4]],
"weight": 0.7
},
{
"index": 4,
"center": [350, 250],
"diameter": 10.0,
"is_circular": true,
"boundary": [[345,250], [345.1,251.6], [345.6,253.1], [346.5,254.3], [347.6,255.2], [349.0,255.7], [350.5,255.7], [351.9,255.1], [353.0,254.1], [353.8,252.8], [354.2,251.3], [354.2,249.7], [353.8,248.2], [352.9,246.9], [351.7,246.0], [350.3,245.5], [348.8,245.5], [347.5,246.1], [346.5,247.1], [345.6,248.4]],
"weight": 0.7
}
]
}