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:
75
tools/adaptive-isogrid/README.md
Normal file
75
tools/adaptive-isogrid/README.md
Normal 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.
|
||||||
1128
tools/adaptive-isogrid/docs/technical-spec.md
Normal file
1128
tools/adaptive-isogrid/docs/technical-spec.md
Normal file
File diff suppressed because it is too large
Load Diff
5
tools/adaptive-isogrid/requirements.txt
Normal file
5
tools/adaptive-isogrid/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
numpy>=1.24
|
||||||
|
scipy>=1.10
|
||||||
|
shapely>=2.0
|
||||||
|
triangle>=20230923
|
||||||
|
matplotlib>=3.7
|
||||||
0
tools/adaptive-isogrid/src/__init__.py
Normal file
0
tools/adaptive-isogrid/src/__init__.py
Normal file
48
tools/adaptive-isogrid/src/atomizer_study.py
Normal file
48
tools/adaptive-isogrid/src/atomizer_study.py
Normal 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,
|
||||||
|
}
|
||||||
8
tools/adaptive-isogrid/src/brain/__init__.py
Normal file
8
tools/adaptive-isogrid/src/brain/__init__.py
Normal 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
|
||||||
|
"""
|
||||||
146
tools/adaptive-isogrid/src/brain/density_field.py
Normal file
146
tools/adaptive-isogrid/src/brain/density_field.py
Normal 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)
|
||||||
176
tools/adaptive-isogrid/src/brain/pocket_profiles.py
Normal file
176
tools/adaptive-isogrid/src/brain/pocket_profiles.py
Normal 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
|
||||||
100
tools/adaptive-isogrid/src/brain/profile_assembly.py
Normal file
100
tools/adaptive-isogrid/src/brain/profile_assembly.py
Normal 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,
|
||||||
|
}
|
||||||
160
tools/adaptive-isogrid/src/brain/triangulation.py
Normal file
160
tools/adaptive-isogrid/src/brain/triangulation.py
Normal 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
|
||||||
106
tools/adaptive-isogrid/src/brain/validation.py
Normal file
106
tools/adaptive-isogrid/src/brain/validation.py
Normal 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
|
||||||
6
tools/adaptive-isogrid/src/nx/__init__.py
Normal file
6
tools/adaptive-isogrid/src/nx/__init__.py
Normal 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.
|
||||||
|
"""
|
||||||
28
tools/adaptive-isogrid/src/nx/build_interface_model.py
Normal file
28
tools/adaptive-isogrid/src/nx/build_interface_model.py
Normal 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."
|
||||||
|
)
|
||||||
54
tools/adaptive-isogrid/src/nx/extract_geometry.py
Normal file
54
tools/adaptive-isogrid/src/nx/extract_geometry.py
Normal 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)
|
||||||
35
tools/adaptive-isogrid/src/nx/iteration_solve.py
Normal file
35
tools/adaptive-isogrid/src/nx/iteration_solve.py
Normal 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."
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user