feat: Add Studio UI, intake system, and extractor improvements
Dashboard: - Add Studio page with drag-drop model upload and Claude chat - Add intake system for study creation workflow - Improve session manager and context builder - Add intake API routes and frontend components Optimization Engine: - Add CLI module for command-line operations - Add intake module for study preprocessing - Add validation module with gate checks - Improve Zernike extractor documentation - Update spec models with better validation - Enhance solve_simulation robustness Documentation: - Add ATOMIZER_STUDIO.md planning doc - Add ATOMIZER_UX_SYSTEM.md for UX patterns - Update extractor library docs - Add study-readme-generator skill Tools: - Add test scripts for extraction validation - Add Zernike recentering test Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,16 @@ from optimization_engine.extractors.extract_zernike_figure import (
|
||||
extract_zernike_figure_rms,
|
||||
)
|
||||
|
||||
# Displacement extraction
|
||||
from optimization_engine.extractors.extract_displacement import (
|
||||
extract_displacement,
|
||||
)
|
||||
|
||||
# Mass extraction from BDF
|
||||
from optimization_engine.extractors.extract_mass_from_bdf import (
|
||||
extract_mass_from_bdf,
|
||||
)
|
||||
|
||||
# Part mass and material extractor (from NX .prt files)
|
||||
from optimization_engine.extractors.extract_part_mass_material import (
|
||||
extract_part_mass_material,
|
||||
@@ -145,72 +155,76 @@ from optimization_engine.extractors.spec_extractor_builder import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Displacement extraction
|
||||
"extract_displacement",
|
||||
# Mass extraction (from BDF)
|
||||
"extract_mass_from_bdf",
|
||||
# Part mass & material (from .prt)
|
||||
'extract_part_mass_material',
|
||||
'extract_part_mass',
|
||||
'extract_part_material',
|
||||
'PartMassExtractor',
|
||||
"extract_part_mass_material",
|
||||
"extract_part_mass",
|
||||
"extract_part_material",
|
||||
"PartMassExtractor",
|
||||
# Stress extractors
|
||||
'extract_solid_stress',
|
||||
'extract_principal_stress',
|
||||
'extract_max_principal_stress',
|
||||
'extract_min_principal_stress',
|
||||
"extract_solid_stress",
|
||||
"extract_principal_stress",
|
||||
"extract_max_principal_stress",
|
||||
"extract_min_principal_stress",
|
||||
# Strain energy
|
||||
'extract_strain_energy',
|
||||
'extract_total_strain_energy',
|
||||
'extract_strain_energy_density',
|
||||
"extract_strain_energy",
|
||||
"extract_total_strain_energy",
|
||||
"extract_strain_energy_density",
|
||||
# SPC forces / reactions
|
||||
'extract_spc_forces',
|
||||
'extract_total_reaction_force',
|
||||
'extract_reaction_component',
|
||||
'check_force_equilibrium',
|
||||
"extract_spc_forces",
|
||||
"extract_total_reaction_force",
|
||||
"extract_reaction_component",
|
||||
"check_force_equilibrium",
|
||||
# Zernike (telescope mirrors) - Standard Z-only method
|
||||
'ZernikeExtractor',
|
||||
'extract_zernike_from_op2',
|
||||
'extract_zernike_filtered_rms',
|
||||
'extract_zernike_relative_rms',
|
||||
"ZernikeExtractor",
|
||||
"extract_zernike_from_op2",
|
||||
"extract_zernike_filtered_rms",
|
||||
"extract_zernike_relative_rms",
|
||||
# Zernike OPD (RECOMMENDED - uses actual geometry, no shape assumption)
|
||||
# Supports annular apertures via inner_radius parameter
|
||||
'ZernikeOPDExtractor',
|
||||
'extract_zernike_opd',
|
||||
'extract_zernike_opd_filtered_rms',
|
||||
'compute_zernike_coefficients_annular',
|
||||
"ZernikeOPDExtractor",
|
||||
"extract_zernike_opd",
|
||||
"extract_zernike_opd_filtered_rms",
|
||||
"compute_zernike_coefficients_annular",
|
||||
# Zernike Analytic (parabola-based with lateral displacement correction)
|
||||
'ZernikeAnalyticExtractor',
|
||||
'extract_zernike_analytic',
|
||||
'extract_zernike_analytic_filtered_rms',
|
||||
'compare_zernike_methods',
|
||||
"ZernikeAnalyticExtractor",
|
||||
"extract_zernike_analytic",
|
||||
"extract_zernike_analytic_filtered_rms",
|
||||
"compare_zernike_methods",
|
||||
# Backwards compatibility (deprecated)
|
||||
'ZernikeFigureExtractor',
|
||||
'extract_zernike_figure',
|
||||
'extract_zernike_figure_rms',
|
||||
"ZernikeFigureExtractor",
|
||||
"extract_zernike_figure",
|
||||
"extract_zernike_figure_rms",
|
||||
# Temperature (Phase 3 - thermal)
|
||||
'extract_temperature',
|
||||
'extract_temperature_gradient',
|
||||
'extract_heat_flux',
|
||||
'get_max_temperature',
|
||||
"extract_temperature",
|
||||
"extract_temperature_gradient",
|
||||
"extract_heat_flux",
|
||||
"get_max_temperature",
|
||||
# Modal mass (Phase 3 - dynamics)
|
||||
'extract_modal_mass',
|
||||
'extract_frequencies',
|
||||
'get_first_frequency',
|
||||
'get_modal_mass_ratio',
|
||||
"extract_modal_mass",
|
||||
"extract_frequencies",
|
||||
"get_first_frequency",
|
||||
"get_modal_mass_ratio",
|
||||
# Part introspection (Phase 4)
|
||||
'introspect_part',
|
||||
'get_expressions_dict',
|
||||
'get_expression_value',
|
||||
'print_introspection_summary',
|
||||
"introspect_part",
|
||||
"get_expressions_dict",
|
||||
"get_expression_value",
|
||||
"print_introspection_summary",
|
||||
# Custom extractor loader (Phase 5)
|
||||
'CustomExtractor',
|
||||
'CustomExtractorLoader',
|
||||
'CustomExtractorContext',
|
||||
'ExtractorSecurityError',
|
||||
'ExtractorValidationError',
|
||||
'load_custom_extractors',
|
||||
'execute_custom_extractor',
|
||||
'validate_custom_extractor',
|
||||
"CustomExtractor",
|
||||
"CustomExtractorLoader",
|
||||
"CustomExtractorContext",
|
||||
"ExtractorSecurityError",
|
||||
"ExtractorValidationError",
|
||||
"load_custom_extractors",
|
||||
"execute_custom_extractor",
|
||||
"validate_custom_extractor",
|
||||
# Spec extractor builder
|
||||
'SpecExtractorBuilder',
|
||||
'build_extractors_from_spec',
|
||||
'get_extractor_outputs',
|
||||
'list_available_builtin_extractors',
|
||||
"SpecExtractorBuilder",
|
||||
"build_extractors_from_spec",
|
||||
"get_extractor_outputs",
|
||||
"list_available_builtin_extractors",
|
||||
]
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
"""
|
||||
Extract mass from Nastran BDF/DAT file as fallback when OP2 doesn't have GRDPNT
|
||||
Extract mass from Nastran BDF/DAT file.
|
||||
|
||||
This module provides a simple wrapper around the BDFMassExtractor class.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
import re
|
||||
|
||||
from optimization_engine.extractors.bdf_mass_extractor import BDFMassExtractor
|
||||
|
||||
|
||||
def extract_mass_from_bdf(bdf_file: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract mass from Nastran BDF file by parsing material and element definitions.
|
||||
|
||||
This is a fallback when OP2 doesn't have PARAM,GRDPNT output.
|
||||
Extract mass from Nastran BDF file.
|
||||
|
||||
Args:
|
||||
bdf_file: Path to .dat or .bdf file
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'mass_kg': total mass in kg,
|
||||
'mass_g': total mass in grams,
|
||||
'method': 'bdf_calculation'
|
||||
'total_mass': mass in kg (primary key),
|
||||
'mass_kg': mass in kg,
|
||||
'mass_g': mass in grams,
|
||||
'cg': center of gravity [x, y, z],
|
||||
'num_elements': number of elements,
|
||||
'breakdown': mass by element type
|
||||
}
|
||||
"""
|
||||
bdf_file = Path(bdf_file)
|
||||
@@ -28,35 +32,23 @@ def extract_mass_from_bdf(bdf_file: Path) -> Dict[str, Any]:
|
||||
if not bdf_file.exists():
|
||||
raise FileNotFoundError(f"BDF file not found: {bdf_file}")
|
||||
|
||||
# Parse using pyNastran BDF reader
|
||||
from pyNastran.bdf.bdf import read_bdf
|
||||
extractor = BDFMassExtractor(str(bdf_file))
|
||||
result = extractor.extract_mass()
|
||||
|
||||
model = read_bdf(str(bdf_file), validate=False, xref=True, punch=False,
|
||||
encoding='utf-8', log=None, debug=False, mode='msc')
|
||||
# Add 'total_mass' as primary key for compatibility
|
||||
result["total_mass"] = result["mass_kg"]
|
||||
|
||||
# Calculate total mass by summing element masses
|
||||
# model.mass_properties() returns (mass, cg, inertia)
|
||||
mass_properties = model.mass_properties()
|
||||
mass_ton = mass_properties[0] # Mass in tons (ton-mm-sec)
|
||||
|
||||
# NX Nastran typically uses ton-mm-sec units
|
||||
mass_kg = mass_ton * 1000.0 # Convert tons to kg
|
||||
mass_g = mass_kg * 1000.0 # Convert kg to grams
|
||||
|
||||
return {
|
||||
'mass_kg': mass_kg,
|
||||
'mass_g': mass_g,
|
||||
'mass_ton': mass_ton,
|
||||
'method': 'bdf_calculation',
|
||||
'units': 'ton-mm-sec (converted to kg/g)'
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
bdf_file = Path(sys.argv[1])
|
||||
result = extract_mass_from_bdf(bdf_file)
|
||||
print(f"Mass from BDF: {result['mass_kg']:.6f} kg ({result['mass_g']:.3f} g)")
|
||||
print(f"CG: {result['cg']}")
|
||||
print(f"Elements: {result['num_elements']}")
|
||||
else:
|
||||
print(f"Usage: python {sys.argv[0]} <bdf_file>")
|
||||
|
||||
@@ -1,74 +1,86 @@
|
||||
"""
|
||||
Extract maximum von Mises stress from structural analysis
|
||||
Auto-generated by Atomizer Phase 3 - pyNastran Research Agent
|
||||
Extract maximum von Mises stress from structural analysis.
|
||||
|
||||
Pattern: solid_stress
|
||||
Element Type: CTETRA
|
||||
Result Type: stress
|
||||
API: model.ctetra_stress[subcase] or model.chexa_stress[subcase]
|
||||
Supports all solid element types (CTETRA, CHEXA, CPENTA, CPYRAM) and
|
||||
shell elements (CQUAD4, CTRIA3).
|
||||
|
||||
Unit Note: NX Nastran in kg-mm-s outputs stress in kPa. This extractor
|
||||
converts to MPa (divide by 1000) for engineering use.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, Optional
|
||||
import numpy as np
|
||||
from pyNastran.op2.op2 import OP2
|
||||
|
||||
|
||||
def extract_solid_stress(op2_file: Path, subcase: int = 1, element_type: str = 'ctetra'):
|
||||
"""Extract stress from solid elements."""
|
||||
from pyNastran.op2.op2 import OP2
|
||||
import numpy as np
|
||||
def extract_solid_stress(
|
||||
op2_file: Path,
|
||||
subcase: int = 1,
|
||||
element_type: Optional[str] = None,
|
||||
convert_to_mpa: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract maximum von Mises stress from solid elements.
|
||||
|
||||
model = OP2()
|
||||
Args:
|
||||
op2_file: Path to OP2 results file
|
||||
subcase: Subcase ID (default 1)
|
||||
element_type: Specific element type to check ('ctetra', 'chexa', etc.)
|
||||
If None, checks ALL solid element types and returns max.
|
||||
convert_to_mpa: If True, divide by 1000 to convert kPa to MPa (default True)
|
||||
|
||||
Returns:
|
||||
dict with 'max_von_mises' (in MPa if convert_to_mpa=True),
|
||||
'max_stress_element', and 'element_type'
|
||||
"""
|
||||
model = OP2(debug=False, log=None)
|
||||
model.read_op2(str(op2_file))
|
||||
|
||||
# Get stress object for element type
|
||||
# Different element types have different stress attributes
|
||||
stress_attr_map = {
|
||||
'ctetra': 'ctetra_stress',
|
||||
'chexa': 'chexa_stress',
|
||||
'cquad4': 'cquad4_stress',
|
||||
'ctria3': 'ctria3_stress'
|
||||
}
|
||||
# All solid element types to check
|
||||
solid_element_types = ["ctetra", "chexa", "cpenta", "cpyram"]
|
||||
shell_element_types = ["cquad4", "ctria3"]
|
||||
|
||||
stress_attr = stress_attr_map.get(element_type.lower())
|
||||
if not stress_attr:
|
||||
raise ValueError(f"Unknown element type: {element_type}")
|
||||
|
||||
# Access stress through op2_results container
|
||||
# pyNastran structure: model.op2_results.stress.cquad4_stress[subcase]
|
||||
stress_dict = None
|
||||
|
||||
if hasattr(model, 'op2_results') and hasattr(model.op2_results, 'stress'):
|
||||
stress_container = model.op2_results.stress
|
||||
if hasattr(stress_container, stress_attr):
|
||||
stress_dict = getattr(stress_container, stress_attr)
|
||||
|
||||
if stress_dict is None:
|
||||
raise ValueError(f"No {element_type} stress results in OP2. Available attributes: {[a for a in dir(model) if 'stress' in a.lower()]}")
|
||||
|
||||
# stress_dict is a dictionary with subcase IDs as keys
|
||||
available_subcases = list(stress_dict.keys())
|
||||
if not available_subcases:
|
||||
raise ValueError(f"No stress data found in OP2 file")
|
||||
|
||||
# Use the specified subcase or first available
|
||||
if subcase in available_subcases:
|
||||
actual_subcase = subcase
|
||||
# If specific element type requested, only check that one
|
||||
if element_type:
|
||||
element_types_to_check = [element_type.lower()]
|
||||
else:
|
||||
actual_subcase = available_subcases[0]
|
||||
# Check all solid types by default
|
||||
element_types_to_check = solid_element_types
|
||||
|
||||
stress = stress_dict[actual_subcase]
|
||||
if not hasattr(model, "op2_results") or not hasattr(model.op2_results, "stress"):
|
||||
raise ValueError("No stress results in OP2 file")
|
||||
|
||||
itime = 0
|
||||
stress_container = model.op2_results.stress
|
||||
|
||||
# Extract von Mises if available
|
||||
if stress.is_von_mises: # Property, not method
|
||||
# Different element types have von Mises at different column indices
|
||||
# Shell elements (CQUAD4, CTRIA3): 8 columns, von Mises at column 7
|
||||
# Solid elements (CTETRA, CHEXA): 10 columns, von Mises at column 9
|
||||
# Find max stress across all requested element types
|
||||
max_stress = 0.0
|
||||
max_stress_elem = 0
|
||||
max_stress_type = None
|
||||
|
||||
for elem_type in element_types_to_check:
|
||||
stress_attr = f"{elem_type}_stress"
|
||||
|
||||
if not hasattr(stress_container, stress_attr):
|
||||
continue
|
||||
|
||||
stress_dict = getattr(stress_container, stress_attr)
|
||||
if not stress_dict:
|
||||
continue
|
||||
|
||||
# Get subcase
|
||||
available_subcases = list(stress_dict.keys())
|
||||
if not available_subcases:
|
||||
continue
|
||||
|
||||
actual_subcase = subcase if subcase in available_subcases else available_subcases[0]
|
||||
stress = stress_dict[actual_subcase]
|
||||
|
||||
if not stress.is_von_mises:
|
||||
continue
|
||||
|
||||
# Determine von Mises column
|
||||
ncols = stress.data.shape[2]
|
||||
|
||||
if ncols == 8:
|
||||
# Shell elements - von Mises is last column
|
||||
von_mises_col = 7
|
||||
@@ -76,27 +88,37 @@ def extract_solid_stress(op2_file: Path, subcase: int = 1, element_type: str = '
|
||||
# Solid elements - von Mises is column 9
|
||||
von_mises_col = 9
|
||||
else:
|
||||
# Unknown format, try last column
|
||||
von_mises_col = ncols - 1
|
||||
|
||||
itime = 0
|
||||
von_mises = stress.data[itime, :, von_mises_col]
|
||||
max_stress = float(np.max(von_mises))
|
||||
elem_max = float(np.max(von_mises))
|
||||
|
||||
# Get element info
|
||||
element_ids = [eid for (eid, node) in stress.element_node]
|
||||
max_stress_elem = element_ids[np.argmax(von_mises)]
|
||||
if elem_max > max_stress:
|
||||
max_stress = elem_max
|
||||
element_ids = [eid for (eid, node) in stress.element_node]
|
||||
max_stress_elem = int(element_ids[np.argmax(von_mises)])
|
||||
max_stress_type = elem_type.upper()
|
||||
|
||||
return {
|
||||
'max_von_mises': max_stress,
|
||||
'max_stress_element': int(max_stress_elem)
|
||||
}
|
||||
else:
|
||||
raise ValueError("von Mises stress not available")
|
||||
if max_stress_type is None:
|
||||
raise ValueError(f"No stress results found for element types: {element_types_to_check}")
|
||||
|
||||
# Convert from kPa to MPa (NX kg-mm-s unit system outputs kPa)
|
||||
if convert_to_mpa:
|
||||
max_stress = max_stress / 1000.0
|
||||
|
||||
return {
|
||||
"max_von_mises": max_stress,
|
||||
"max_stress_element": max_stress_elem,
|
||||
"element_type": max_stress_type,
|
||||
"units": "MPa" if convert_to_mpa else "kPa",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
op2_file = Path(sys.argv[1])
|
||||
result = extract_solid_stress(op2_file)
|
||||
|
||||
@@ -473,23 +473,33 @@ def extract_displacements_by_subcase(
|
||||
ngt = darr.node_gridtype.astype(int)
|
||||
node_ids = ngt if ngt.ndim == 1 else ngt[:, 0]
|
||||
|
||||
# Try to identify subcase from subtitle or isubcase
|
||||
# Try to identify subcase from subtitle, label, or isubcase
|
||||
subtitle = getattr(darr, 'subtitle', None)
|
||||
op2_label = getattr(darr, 'label', None)
|
||||
isubcase = getattr(darr, 'isubcase', None)
|
||||
|
||||
# Extract numeric from subtitle
|
||||
label = None
|
||||
if isinstance(subtitle, str):
|
||||
import re
|
||||
# Extract numeric from subtitle first, then label, then isubcase
|
||||
import re
|
||||
subcase_id = None
|
||||
|
||||
# Priority 1: subtitle (e.g., "GRAVITY 20 DEG")
|
||||
if isinstance(subtitle, str) and subtitle.strip():
|
||||
m = re.search(r'-?\d+', subtitle)
|
||||
if m:
|
||||
label = m.group(0)
|
||||
subcase_id = m.group(0)
|
||||
|
||||
if label is None and isinstance(isubcase, int):
|
||||
label = str(isubcase)
|
||||
# Priority 2: label field (e.g., "90 SUBCASE 1")
|
||||
if subcase_id is None and isinstance(op2_label, str) and op2_label.strip():
|
||||
m = re.search(r'-?\d+', op2_label)
|
||||
if m:
|
||||
subcase_id = m.group(0)
|
||||
|
||||
if label:
|
||||
result[label] = {
|
||||
# Priority 3: isubcase number
|
||||
if subcase_id is None and isinstance(isubcase, int):
|
||||
subcase_id = str(isubcase)
|
||||
|
||||
if subcase_id:
|
||||
result[subcase_id] = {
|
||||
'node_ids': node_ids.astype(int),
|
||||
'disp': dmat.copy()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user