feat(isogrid): FEA stress field → 2D heatmap → adaptive density feedback

Closes the optimization loop: OP2 results → density field refinement.

**extract_stress_field_2d.py (new)**
- Reads OP2 (3D solid or 2D shell elements) + BDF via pyNastran
- Projects element centroids to 2D sandbox coords using geometry transform
- Averages stress through thickness (for solid 3D meshes)
- Normalises by sigma_yield to [0..1]
- save/load helpers (NPZ) for trial persistence

**stress_feedback.py (new)**
- StressFeedbackField: converts 2D stress scatter → smooth density modifier
- Gaussian blur (configurable radius, default 40mm) prevents oscillations
- RBF interpolator (thin-plate spline) for fast pointwise evaluation
- evaluate(x, y) returns S_stress ∈ [0..1]
- from_field() and from_npz() constructors

**density_field.py (modified)**
- evaluate_density() now accepts optional stress_field= argument
- Adaptive formula: η = η₀ + α·I + β·E + γ·S_stress
- gamma_stress param controls feedback gain (0.0 = pure parametric)
- Fully backward compatible (no stress_field = original behaviour)

Usage:
    field = extract_stress_field_2d(op2, bdf, geometry["transform"], sigma_yield=276.0)
    feedback = StressFeedbackField.from_field(field, blur_radius_mm=40.0)
    eta = evaluate_density(x, y, geometry, params, stress_field=feedback)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 11:13:28 -05:00
parent a9c40368d3
commit 6658de02f4
3 changed files with 623 additions and 10 deletions

View File

@@ -1,13 +1,22 @@
"""
Density field η(x) — maps every point on the plate to [0, 1].
η(x) = clamp(0, 1, η₀ + α·I(x) + β·E(x))
Base formula:
η(x) = clamp(0, 1, η₀ + α·I(x) + β·E(x))
With stress feedback (adaptive mode):
η(x) = clamp(0, 1, η₀ + α·I(x) + β·E(x) + γ·S_stress(x))
Where:
I(x) = Σᵢ wᵢ · exp(-(dᵢ(x)/Rᵢ)^p) — hole influence
E(x) = exp(-(d_edge(x)/R_edge)^p_edge) — edge reinforcement
I(x) = Σᵢ wᵢ · exp(-(dᵢ(x)/Rᵢ)^p) — hole influence
E(x) = exp(-(d_edge(x)/R_edge)^p) — edge reinforcement
S_stress(x) = normalised stress from previous FEA trial [0..1]
γ (gamma_stress) = stress feedback gain (Atomizer design variable)
Pass a StressFeedbackField instance as stress_field= to activate adaptive mode.
"""
from typing import Optional
import numpy as np
from shapely.geometry import Polygon, Point, LinearRing
@@ -70,24 +79,43 @@ def compute_edge_influence(x, y, outer_boundary, params):
return np.exp(-(d_edge / R_edge)**p)
def evaluate_density(x, y, geometry, params):
def evaluate_density(x, y, geometry, params, stress_field=None):
"""
Evaluate the combined density field η(x, y).
η(x) = clamp(0, 1, η₀ + α·I(x) + β·E(x))
Base: η = η₀ + α·I(x) + β·E(x)
Adaptive: η = η₀ + α·I(x) + β·E(x) + γ·S_stress(x)
Parameters
----------
x, y : float
geometry : dict
params : dict
Must contain eta_0, alpha, beta.
Optional: gamma_stress (default 0.0) for stress feedback gain.
stress_field : StressFeedbackField, optional
Pass a StressFeedbackField instance to enable adaptive mode.
If None, pure parametric mode (γ=0).
Returns
-------
float : density value in [0, 1]
"""
eta_0 = params['eta_0']
alpha = params['alpha']
beta = params['beta']
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
if stress_field is not None:
gamma = params.get('gamma_stress', 0.0)
if gamma > 0.0:
S = stress_field.evaluate(x, y)
eta += gamma * S
return np.clip(eta, 0.0, 1.0)

View File

@@ -0,0 +1,228 @@
"""
Stress Feedback Field — converts a 2D FEA stress field into a density modifier.
Extends the base density field:
η(x,y) = η₀ + α·I_hole + β·E_edge + γ·S_stress(x,y)
Where S_stress(x,y) is built from the previous trial's OP2 results:
- High stress → S_stress → 1 → more ribs → stress drops
- Low stress → S_stress → 0 → coarser mesh → lighter
Gaussian smoothing prevents local oscillations between iterations.
Usage:
from optimization_engine.extractors.extract_stress_field_2d import (
extract_stress_field_2d, load_stress_field
)
from src.brain.stress_feedback import StressFeedbackField
# After FEA extraction:
field = extract_stress_field_2d(op2, bdf, geometry["transform"], sigma_yield=276.0)
feedback = StressFeedbackField.from_field(field, blur_radius_mm=40.0)
# Inside density evaluation:
s = feedback.evaluate(x, y) # → [0..1]
"""
from __future__ import annotations
from typing import Any, Dict, Optional
import numpy as np
from scipy.interpolate import RBFInterpolator
from scipy.ndimage import gaussian_filter
class StressFeedbackField:
"""
Converts a 2D nodal stress field into a smooth density modifier S_stress(x,y).
The field is built by:
1. Normalising stress values to [0..1] via sigma_yield (or auto-max)
2. Applying Gaussian smoothing to suppress oscillations
3. Building a thin-plate-spline RBF interpolator for fast evaluation
"""
def __init__(
self,
nodes_2d: np.ndarray,
stress_norm: np.ndarray,
):
"""
Args:
nodes_2d: (N, 2) array of [u, v] sandbox coordinates (mm)
stress_norm: (N,) normalised stress values in [0..1]
"""
self._nodes = nodes_2d
self._values = np.clip(stress_norm, 0.0, 1.0)
# Build RBF interpolator (thin-plate spline, with light smoothing)
self._rbf = RBFInterpolator(
nodes_2d,
self._values,
kernel="thin_plate_spline",
smoothing=0.5, # regularisation — avoids overfitting noisy data
degree=1,
)
# ------------------------------------------------------------------
# Construction helpers
# ------------------------------------------------------------------
@classmethod
def from_field(
cls,
field: Dict[str, Any],
blur_radius_mm: float = 40.0,
sigma_yield: Optional[float] = None,
) -> "StressFeedbackField":
"""
Build from the dict returned by extract_stress_field_2d().
Args:
field: Output of extract_stress_field_2d()
blur_radius_mm: Gaussian blur radius in mm. Controls how far the
stress influence spreads. Typical: 12× s_max.
Larger → smoother, more stable; smaller → sharper.
sigma_yield: Override yield strength for normalisation.
Falls back to field["sigma_yield"] or field["max_stress"].
"""
nodes_2d = field["nodes_2d"]
# --- Normalise ---
if "stress_normalized" in field:
stress_norm = field["stress_normalized"].copy()
else:
sy = sigma_yield or field.get("sigma_yield") or field["max_stress"]
stress_norm = field["stress"] / sy
# --- Gaussian smoothing on a temporary grid, then re-sample ---
stress_norm = cls._smooth(nodes_2d, stress_norm, blur_radius_mm)
return cls(nodes_2d, stress_norm)
@classmethod
def from_npz(
cls,
npz_path,
blur_radius_mm: float = 40.0,
) -> "StressFeedbackField":
"""
Load directly from a saved .npz file (output of save_stress_field()).
"""
from optimization_engine.extractors.extract_stress_field_2d import load_stress_field
field = load_stress_field(npz_path)
return cls.from_field(field, blur_radius_mm=blur_radius_mm)
# ------------------------------------------------------------------
# Smoothing
# ------------------------------------------------------------------
@staticmethod
def _smooth(
nodes_2d: np.ndarray,
values: np.ndarray,
blur_radius_mm: float,
grid_res_mm: float = 5.0,
) -> np.ndarray:
"""
Apply Gaussian blur by:
1. Rasterising the scatter data to a temporary grid
2. Applying scipy gaussian_filter
3. Re-sampling back to original node locations
This avoids oscillations between high- and low-stress neighbours.
"""
if blur_radius_mm <= 0:
return values
u, v = nodes_2d[:, 0], nodes_2d[:, 1]
u_min, u_max = u.min(), u.max()
v_min, v_max = v.min(), v.max()
# Build grid
nu = max(int((u_max - u_min) / grid_res_mm) + 2, 10)
nv = max(int((v_max - v_min) / grid_res_mm) + 2, 10)
grid_u = np.linspace(u_min, u_max, nu)
grid_v = np.linspace(v_min, v_max, nv)
GU, GV = np.meshgrid(grid_u, grid_v)
# Scatter → grid via nearest-index binning
u_idx = np.clip(
np.round((u - u_min) / (u_max - u_min + 1e-9) * (nu - 1)).astype(int),
0, nu - 1,
)
v_idx = np.clip(
np.round((v - v_min) / (v_max - v_min + 1e-9) * (nv - 1)).astype(int),
0, nv - 1,
)
grid_vals = np.zeros((nv, nu))
grid_cnt = np.zeros((nv, nu))
np.add.at(grid_vals, (v_idx, u_idx), values)
np.add.at(grid_cnt, (v_idx, u_idx), 1)
# Fill empty cells with nearest-neighbour before blurring
mask = grid_cnt > 0
grid_vals[mask] /= grid_cnt[mask]
# Fill holes via a quick nearest-value propagation
if not mask.all():
from scipy.ndimage import distance_transform_edt
_, nearest = distance_transform_edt(~mask, return_indices=True)
grid_vals[~mask] = grid_vals[nearest[0][~mask], nearest[1][~mask]]
# Gaussian blur (sigma in grid cells)
sigma_cells = blur_radius_mm / grid_res_mm
blurred = gaussian_filter(grid_vals, sigma=sigma_cells, mode="nearest")
# Re-sample at original node positions (bilinear via index)
from scipy.interpolate import RegularGridInterpolator
interp = RegularGridInterpolator(
(grid_v, grid_u), blurred,
method="linear",
bounds_error=False,
fill_value=None,
)
smoothed = interp(np.column_stack([v, u]))
return np.clip(smoothed, 0.0, 1.0)
# ------------------------------------------------------------------
# Evaluation
# ------------------------------------------------------------------
def evaluate(self, x: float, y: float) -> float:
"""
Return S_stress(x, y) ∈ [0..1] at a single 2D sandbox point.
High return value → high stress in this region → density field
will increase → more ribs will be placed here.
"""
pt = np.array([[x, y]])
val = float(self._rbf(pt)[0])
return float(np.clip(val, 0.0, 1.0))
def evaluate_batch(self, points_2d: np.ndarray) -> np.ndarray:
"""
Evaluate at many points at once (faster than calling evaluate() in a loop).
Args:
points_2d: (N, 2) array of [u, v] sandbox coordinates
Returns:
(N,) array of S_stress values in [0..1]
"""
vals = self._rbf(points_2d)
return np.clip(vals, 0.0, 1.0)
# ------------------------------------------------------------------
# Diagnostics
# ------------------------------------------------------------------
def summary(self) -> Dict[str, float]:
return {
"n_nodes": int(len(self._values)),
"min": float(self._values.min()),
"max": float(self._values.max()),
"mean": float(self._values.mean()),
"pct_above_50": float((self._values > 0.5).mean()),
}