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:
2026-01-27 12:02:30 -05:00
parent 3193831340
commit a26914bbe8
56 changed files with 14173 additions and 646 deletions

View File

@@ -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",
]

View File

@@ -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>")

View 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)

View 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()
}