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:
357
optimization_engine/extractors/extract_stress_field_2d.py
Normal file
357
optimization_engine/extractors/extract_stress_field_2d.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Extract a 2D von Mises stress field from OP2 results, projected onto the sandbox plane.
|
||||
|
||||
Works for both:
|
||||
- 3D solid meshes (CHEXA, CTETRA, CPENTA): averages stress through thickness
|
||||
- 2D shell meshes (CQUAD4, CTRIA3): directly maps to plane
|
||||
|
||||
The returned field is in the sandbox 2D coordinate system (u, v) matching the
|
||||
geometry_sandbox_N.json coordinate space — ready to feed directly into the
|
||||
Brain density field as S_stress(x, y).
|
||||
|
||||
Usage:
|
||||
from optimization_engine.extractors.extract_stress_field_2d import extract_stress_field_2d
|
||||
|
||||
field = extract_stress_field_2d(
|
||||
op2_file="path/to/results.op2",
|
||||
bdf_file="path/to/model.bdf",
|
||||
transform=geometry["transform"], # from geometry_sandbox_N.json
|
||||
)
|
||||
|
||||
# field["nodes_2d"] → (N, 2) array of [u, v] sandbox coords
|
||||
# field["stress"] → (N,) array of von Mises stress in MPa
|
||||
# field["max_stress"] → peak stress in MPa
|
||||
|
||||
Unit Note: NX Nastran in kg-mm-s outputs stress in kPa → divided by 1000 → MPa.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import numpy as np
|
||||
from pyNastran.bdf.bdf import BDF
|
||||
from pyNastran.op2.op2 import OP2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3D → 2D coordinate projection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _project_to_2d(
|
||||
xyz: np.ndarray,
|
||||
transform: Dict[str, Any],
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Project 3D points onto the sandbox plane using the geometry transform.
|
||||
|
||||
The transform (from geometry_sandbox_N.json) defines:
|
||||
- origin: 3D origin of the sandbox plane
|
||||
- x_axis: direction of sandbox U axis in 3D
|
||||
- y_axis: direction of sandbox V axis in 3D
|
||||
- normal: plate normal (thickness direction — discarded)
|
||||
|
||||
Inverse of import_profile.py's unproject_point_to_3d().
|
||||
|
||||
Args:
|
||||
xyz: (N, 3) array of 3D points
|
||||
transform: dict with 'origin', 'x_axis', 'y_axis', 'normal'
|
||||
|
||||
Returns:
|
||||
(N, 2) array of [u, v] sandbox coordinates
|
||||
"""
|
||||
origin = np.array(transform["origin"])
|
||||
x_axis = np.array(transform["x_axis"])
|
||||
y_axis = np.array(transform["y_axis"])
|
||||
|
||||
# Translate to origin
|
||||
rel = xyz - origin # (N, 3)
|
||||
|
||||
# Project onto sandbox axes
|
||||
u = rel @ x_axis # dot product with x_axis
|
||||
v = rel @ y_axis # dot product with y_axis
|
||||
|
||||
return np.column_stack([u, v])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Element centroid extraction from BDF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_element_centroids(bdf: BDF) -> Dict[int, np.ndarray]:
|
||||
"""
|
||||
Compute centroid for every element in the BDF model.
|
||||
|
||||
Returns:
|
||||
{element_id: centroid_xyz (3,)}
|
||||
"""
|
||||
node_xyz = {nid: np.array(node.xyz) for nid, node in bdf.nodes.items()}
|
||||
centroids = {}
|
||||
|
||||
for eid, elem in bdf.elements.items():
|
||||
try:
|
||||
nids = elem.node_ids
|
||||
pts = np.array([node_xyz[n] for n in nids if n in node_xyz])
|
||||
if len(pts) > 0:
|
||||
centroids[eid] = pts.mean(axis=0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return centroids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Von Mises stress extraction from OP2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_all_von_mises(
|
||||
model: OP2,
|
||||
subcase: int,
|
||||
convert_to_mpa: bool,
|
||||
) -> Dict[int, float]:
|
||||
"""
|
||||
Extract von Mises stress for every element across all solid + shell types.
|
||||
|
||||
Returns:
|
||||
{element_id: von_mises_stress}
|
||||
"""
|
||||
SOLID_TYPES = ["ctetra", "chexa", "cpenta", "cpyram"]
|
||||
SHELL_TYPES = ["cquad4", "ctria3"]
|
||||
ALL_TYPES = SOLID_TYPES + SHELL_TYPES
|
||||
|
||||
if not hasattr(model, "op2_results") or not hasattr(model.op2_results, "stress"):
|
||||
raise ValueError("No stress results found in OP2 file")
|
||||
|
||||
stress_container = model.op2_results.stress
|
||||
elem_stress: Dict[int, float] = {}
|
||||
|
||||
for elem_type in ALL_TYPES:
|
||||
attr = f"{elem_type}_stress"
|
||||
if not hasattr(stress_container, attr):
|
||||
continue
|
||||
|
||||
stress_dict = getattr(stress_container, attr)
|
||||
if not stress_dict:
|
||||
continue
|
||||
|
||||
available = list(stress_dict.keys())
|
||||
if not available:
|
||||
continue
|
||||
|
||||
sc = subcase if subcase in available else available[0]
|
||||
stress = stress_dict[sc]
|
||||
|
||||
if not stress.is_von_mises:
|
||||
continue
|
||||
|
||||
ncols = stress.data.shape[2]
|
||||
# Von Mises column: solid=9, shell=7
|
||||
vm_col = 9 if ncols >= 10 else 7 if ncols == 8 else ncols - 1
|
||||
|
||||
itime = 0
|
||||
von_mises = stress.data[itime, :, vm_col] # (n_elements,)
|
||||
|
||||
# element_node: list of (eid, node_id) pairs — may repeat for each node
|
||||
for i, (eid, _node) in enumerate(stress.element_node):
|
||||
vm = float(von_mises[i])
|
||||
# Keep max stress if element appears multiple times (e.g. corner nodes)
|
||||
if eid not in elem_stress or vm > elem_stress[eid]:
|
||||
elem_stress[eid] = vm
|
||||
|
||||
if not elem_stress:
|
||||
raise ValueError("No von Mises stress data found in OP2 file")
|
||||
|
||||
if convert_to_mpa:
|
||||
elem_stress = {eid: v / 1000.0 for eid, v in elem_stress.items()}
|
||||
|
||||
return elem_stress
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extractor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_stress_field_2d(
|
||||
op2_file: Path,
|
||||
bdf_file: Path,
|
||||
transform: Dict[str, Any],
|
||||
subcase: int = 1,
|
||||
convert_to_mpa: bool = True,
|
||||
sigma_yield: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract a 2D von Mises stress field projected onto the sandbox plane.
|
||||
|
||||
For 3D solid meshes: element centroids are projected to 2D, then stress
|
||||
values at the same (u, v) location are averaged through thickness.
|
||||
|
||||
For 2D shell meshes: centroids are directly in-plane, no averaging needed.
|
||||
|
||||
Args:
|
||||
op2_file: Path to NX Nastran OP2 results file
|
||||
bdf_file: Path to BDF model file (for geometry/node positions)
|
||||
transform: Sandbox plane transform dict from geometry_sandbox_N.json
|
||||
Keys: 'origin', 'x_axis', 'y_axis', 'normal'
|
||||
subcase: Subcase ID to extract (default: 1)
|
||||
convert_to_mpa: Divide by 1000 to convert NX kPa → MPa (default: True)
|
||||
sigma_yield: Optional yield strength in MPa. If provided, adds a
|
||||
'stress_normalized' field (0..1 scale) for density feedback.
|
||||
|
||||
Returns:
|
||||
dict with:
|
||||
'nodes_2d': (N, 2) ndarray — [u, v] in sandbox 2D coords (mm)
|
||||
'stress': (N,) ndarray — von Mises stress (MPa or kPa)
|
||||
'max_stress': float — peak stress value
|
||||
'mean_stress': float — mean stress value
|
||||
'percentile_95': float — 95th percentile (robust peak)
|
||||
'units': str — 'MPa' or 'kPa'
|
||||
'n_elements': int — number of elements with stress data
|
||||
'stress_normalized': (N,) ndarray — stress / sigma_yield (if provided)
|
||||
'sigma_yield': float — yield strength used (if provided)
|
||||
"""
|
||||
op2_file = Path(op2_file)
|
||||
bdf_file = Path(bdf_file)
|
||||
|
||||
# --- Load BDF geometry ---
|
||||
bdf = BDF(debug=False)
|
||||
bdf.read_bdf(str(bdf_file), xref=True)
|
||||
|
||||
centroids_3d = _get_element_centroids(bdf) # {eid: xyz}
|
||||
|
||||
# --- Load OP2 stress ---
|
||||
model = OP2(debug=False, log=None)
|
||||
model.read_op2(str(op2_file))
|
||||
|
||||
elem_stress = _get_all_von_mises(model, subcase, convert_to_mpa)
|
||||
|
||||
# --- Match elements: keep only those with both centroid and stress ---
|
||||
common_ids = sorted(set(centroids_3d.keys()) & set(elem_stress.keys()))
|
||||
if not common_ids:
|
||||
raise ValueError(
|
||||
f"No matching elements between BDF ({len(centroids_3d)} elements) "
|
||||
f"and OP2 ({len(elem_stress)} elements). Check that they are from the same model."
|
||||
)
|
||||
|
||||
xyz_arr = np.array([centroids_3d[eid] for eid in common_ids]) # (N, 3)
|
||||
stress_arr = np.array([elem_stress[eid] for eid in common_ids]) # (N,)
|
||||
|
||||
# --- Project 3D centroids → 2D sandbox coords ---
|
||||
nodes_2d = _project_to_2d(xyz_arr, transform) # (N, 2)
|
||||
|
||||
# --- For 3D solid meshes: average through-thickness duplicates ---
|
||||
# Elements at the same (u, v) xy-location but different thickness positions
|
||||
# get averaged to produce a single 2D stress value per location.
|
||||
uv_rounded = np.round(nodes_2d, decimals=1) # group within 0.1mm
|
||||
uv_tuples = [tuple(r) for r in uv_rounded]
|
||||
|
||||
unique_uvs: Dict[tuple, list] = {}
|
||||
for i, uv in enumerate(uv_tuples):
|
||||
unique_uvs.setdefault(uv, []).append(stress_arr[i])
|
||||
|
||||
uv_final = np.array([list(k) for k in unique_uvs.keys()])
|
||||
stress_final = np.array([np.mean(v) for v in unique_uvs.values()])
|
||||
|
||||
n_raw = len(stress_arr)
|
||||
n_averaged = len(stress_final)
|
||||
n_layers = round(n_raw / n_averaged) if n_averaged > 0 else 1
|
||||
|
||||
result = {
|
||||
"nodes_2d": uv_final,
|
||||
"stress": stress_final,
|
||||
"max_stress": float(np.max(stress_final)),
|
||||
"mean_stress": float(np.mean(stress_final)),
|
||||
"percentile_95": float(np.percentile(stress_final, 95)),
|
||||
"units": "MPa" if convert_to_mpa else "kPa",
|
||||
"n_elements": n_averaged,
|
||||
"n_raw_elements": n_raw,
|
||||
"n_thickness_layers": n_layers,
|
||||
}
|
||||
|
||||
if sigma_yield is not None:
|
||||
result["stress_normalized"] = stress_final / sigma_yield
|
||||
result["sigma_yield"] = sigma_yield
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Save / load helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def save_stress_field(field: Dict[str, Any], output_path: Path) -> None:
|
||||
"""
|
||||
Save extracted stress field to an NPZ file for fast reloading.
|
||||
|
||||
Usage:
|
||||
save_stress_field(field, "trial_0001/stress_field_2d.npz")
|
||||
"""
|
||||
output_path = Path(output_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
np.savez(
|
||||
str(output_path),
|
||||
nodes_2d=field["nodes_2d"],
|
||||
stress=field["stress"],
|
||||
stress_normalized=field.get("stress_normalized", np.array([])),
|
||||
max_stress=field["max_stress"],
|
||||
mean_stress=field["mean_stress"],
|
||||
percentile_95=field["percentile_95"],
|
||||
sigma_yield=field.get("sigma_yield", 0.0),
|
||||
n_elements=field["n_elements"],
|
||||
)
|
||||
|
||||
|
||||
def load_stress_field(npz_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Load a previously saved stress field.
|
||||
|
||||
Usage:
|
||||
field = load_stress_field("trial_0001/stress_field_2d.npz")
|
||||
"""
|
||||
data = np.load(str(npz_path), allow_pickle=False)
|
||||
field = {
|
||||
"nodes_2d": data["nodes_2d"],
|
||||
"stress": data["stress"],
|
||||
"max_stress": float(data["max_stress"]),
|
||||
"mean_stress": float(data["mean_stress"]),
|
||||
"percentile_95": float(data["percentile_95"]),
|
||||
"n_elements": int(data["n_elements"]),
|
||||
}
|
||||
if data["sigma_yield"] > 0:
|
||||
field["stress_normalized"] = data["stress_normalized"]
|
||||
field["sigma_yield"] = float(data["sigma_yield"])
|
||||
return field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import json
|
||||
|
||||
if len(sys.argv) < 4:
|
||||
print("Usage: python extract_stress_field_2d.py <op2> <bdf> <geometry_sandbox.json> [sigma_yield]")
|
||||
sys.exit(1)
|
||||
|
||||
op2 = Path(sys.argv[1])
|
||||
bdf = Path(sys.argv[2])
|
||||
geom = Path(sys.argv[3])
|
||||
sy = float(sys.argv[4]) if len(sys.argv) > 4 else None
|
||||
|
||||
with open(geom) as f:
|
||||
geometry = json.load(f)
|
||||
|
||||
field = extract_stress_field_2d(op2, bdf, geometry["transform"], sigma_yield=sy)
|
||||
|
||||
print(f"Extracted {field['n_elements']} elements "
|
||||
f"(from {field['n_raw_elements']} raw, {field['n_thickness_layers']} thickness layers)")
|
||||
print(f"Max stress: {field['max_stress']:.1f} {field['units']}")
|
||||
print(f"Mean stress: {field['mean_stress']:.1f} {field['units']}")
|
||||
print(f"95th pct: {field['percentile_95']:.1f} {field['units']}")
|
||||
|
||||
out = op2.with_suffix(".stress_field_2d.npz")
|
||||
save_stress_field(field, out)
|
||||
print(f"Saved to: {out}")
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
228
tools/adaptive-isogrid/src/brain/stress_feedback.py
Normal file
228
tools/adaptive-isogrid/src/brain/stress_feedback.py
Normal 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: 1–2× 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()),
|
||||
}
|
||||
Reference in New Issue
Block a user