refactor: Major project cleanup and reorganization

## Removed Duplicate Directories
- Deleted old `dashboard/` (replaced by atomizer-dashboard)
- Deleted old `mcp_server/` Python tools (moved model_discovery to optimization_engine)
- Deleted `tests/mcp_server/` (obsolete tests)
- Deleted `launch_dashboard.bat` (old launcher)

## Consolidated Code
- Moved `mcp_server/tools/model_discovery.py` to `optimization_engine/model_discovery/`
- Updated import in `optimization_config_builder.py`
- Deleted stub `extract_mass.py` (use extract_mass_from_bdf instead)
- Deleted unused `intelligent_setup.py` and `hybrid_study_creator.py`
- Archived `result_extractors/` to `archive/deprecated/`

## Documentation Cleanup
- Deleted deprecated `docs/06_PROTOCOLS_DETAILED/` (14 files)
- Archived dated dev docs to `docs/08_ARCHIVE/sessions/`
- Archived old plans to `docs/08_ARCHIVE/plans/`
- Updated `docs/protocols/README.md` with SYS_15

## Skills Consolidation
- Archived redundant study creation skills to `.claude/skills/archive/`
- Kept `core/study-creation-core.md` as canonical

## Housekeeping
- Updated `.gitignore` to prevent `nul` and `_dat_run*.dat`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Antoine
2025-12-12 11:24:02 -05:00
parent 1bb201e0b7
commit d1261d62fd
58 changed files with 26 additions and 10731 deletions

View File

@@ -0,0 +1,66 @@
"""
Pluggable Result Extractor System
Base classes and implementations for extracting metrics from FEA results.
"""
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
from pathlib import Path
class ResultExtractor(ABC):
"""Base class for all result extractors."""
@abstractmethod
def extract(self, result_files: Dict[str, Path], config: Dict[str, Any]) -> Dict[str, float]:
"""
Extract metrics from FEA results.
Args:
result_files: Dictionary mapping file types to paths (e.g., {'op2': Path(...), 'f06': Path(...)})
config: Extractor-specific configuration parameters
Returns:
Dictionary mapping metric names to values
"""
pass
@property
@abstractmethod
def required_files(self) -> list[str]:
"""List of required file types (e.g., ['op2'], ['f06'], etc.)."""
pass
@property
def name(self) -> str:
"""Extractor name for registration."""
return self.__class__.__name__.replace("Extractor", "").lower()
# Registry of available extractors
_EXTRACTOR_REGISTRY: Dict[str, type[ResultExtractor]] = {}
def register_extractor(extractor_class: type[ResultExtractor]) -> type[ResultExtractor]:
"""Decorator to register an extractor."""
_EXTRACTOR_REGISTRY[extractor_class().name] = extractor_class
return extractor_class
def get_extractor(name: str) -> Optional[type[ResultExtractor]]:
"""Get extractor class by name."""
return _EXTRACTOR_REGISTRY.get(name)
def list_extractors() -> list[str]:
"""List all registered extractor names."""
return list(_EXTRACTOR_REGISTRY.keys())
__all__ = [
"ResultExtractor",
"register_extractor",
"get_extractor",
"list_extractors",
]

View File

@@ -0,0 +1,207 @@
"""
Result Extractors
Wrapper functions that integrate with the optimization runner.
These extract optimization metrics from NX Nastran result files.
"""
from pathlib import Path
from typing import Dict, Any
import sys
# Add project root to path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from optimization_engine.result_extractors.op2_extractor_example import (
extract_max_displacement,
extract_max_stress,
extract_mass
)
def mass_extractor(result_path: Path) -> Dict[str, float]:
"""
Extract mass metrics for optimization.
Args:
result_path: Path to .op2 file or directory containing results
Returns:
Dict with 'total_mass' and other mass-related metrics
"""
# If result_path is a directory, find the .op2 file
if result_path.is_dir():
op2_files = list(result_path.glob("*.op2"))
if not op2_files:
raise FileNotFoundError(f"No .op2 files found in {result_path}")
op2_path = op2_files[0] # Use first .op2 file
else:
op2_path = result_path
if not op2_path.exists():
raise FileNotFoundError(f"Result file not found: {op2_path}")
result = extract_mass(op2_path)
# Ensure total_mass key exists
if 'total_mass' not in result or result['total_mass'] is None:
raise ValueError(f"Could not extract total_mass from {op2_path}")
return result
def stress_extractor(result_path: Path) -> Dict[str, float]:
"""
Extract stress metrics for optimization.
Args:
result_path: Path to .op2 file or directory containing results
Returns:
Dict with 'max_von_mises' and other stress metrics
"""
# If result_path is a directory, find the .op2 file
if result_path.is_dir():
op2_files = list(result_path.glob("*.op2"))
if not op2_files:
raise FileNotFoundError(f"No .op2 files found in {result_path}")
op2_path = op2_files[0]
else:
op2_path = result_path
if not op2_path.exists():
raise FileNotFoundError(f"Result file not found: {op2_path}")
result = extract_max_stress(op2_path, stress_type='von_mises')
# Ensure max_von_mises key exists
if 'max_stress' in result:
result['max_von_mises'] = result['max_stress']
if 'max_von_mises' not in result or result['max_von_mises'] is None:
raise ValueError(f"Could not extract max_von_mises from {op2_path}")
return result
def displacement_extractor(result_path: Path) -> Dict[str, float]:
"""
Extract displacement metrics for optimization.
Args:
result_path: Path to .op2 file or directory containing results
Returns:
Dict with 'max_displacement' and other displacement metrics
"""
# If result_path is a directory, find the .op2 file
if result_path.is_dir():
op2_files = list(result_path.glob("*.op2"))
if not op2_files:
raise FileNotFoundError(f"No .op2 files found in {result_path}")
op2_path = op2_files[0]
else:
op2_path = result_path
if not op2_path.exists():
raise FileNotFoundError(f"Result file not found: {op2_path}")
result = extract_max_displacement(op2_path)
# Ensure max_displacement key exists
if 'max_displacement' not in result or result['max_displacement'] is None:
raise ValueError(f"Could not extract max_displacement from {op2_path}")
return result
def volume_extractor(result_path: Path) -> Dict[str, float]:
"""
Extract volume metrics for optimization.
Note: Volume is often not directly in OP2 files.
This is a placeholder that could be extended to:
- Calculate from mass and density
- Extract from .f06 file
- Query from NX model directly
Args:
result_path: Path to result files
Returns:
Dict with 'total_volume'
"""
# For now, estimate from mass (would need material density)
# This is a placeholder implementation
mass_result = mass_extractor(result_path)
# Assuming steel density ~7850 kg/m^3 = 7.85e-6 kg/mm^3
# volume (mm^3) = mass (kg) / density (kg/mm^3)
assumed_density = 7.85e-6 # kg/mm^3
if mass_result['total_mass']:
total_volume = mass_result['total_mass'] / assumed_density
else:
total_volume = None
return {
'total_volume': total_volume,
'note': 'Volume estimated from mass using assumed density'
}
# Registry of all available extractors
EXTRACTOR_REGISTRY = {
'mass_extractor': mass_extractor,
'stress_extractor': stress_extractor,
'displacement_extractor': displacement_extractor,
'volume_extractor': volume_extractor
}
def get_extractor(extractor_name: str):
"""
Get an extractor function by name.
Args:
extractor_name: Name of the extractor
Returns:
Extractor function
Raises:
ValueError: If extractor not found
"""
if extractor_name not in EXTRACTOR_REGISTRY:
available = ', '.join(EXTRACTOR_REGISTRY.keys())
raise ValueError(f"Unknown extractor: {extractor_name}. Available: {available}")
return EXTRACTOR_REGISTRY[extractor_name]
# Example usage
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python extractors.py <path_to_op2_file>")
print("\nAvailable extractors:")
for name in EXTRACTOR_REGISTRY.keys():
print(f" - {name}")
sys.exit(1)
result_path = Path(sys.argv[1])
print("="*60)
print("TESTING ALL EXTRACTORS")
print("="*60)
for extractor_name, extractor_func in EXTRACTOR_REGISTRY.items():
print(f"\n{extractor_name}:")
try:
result = extractor_func(result_path)
for key, value in result.items():
print(f" {key}: {value}")
except Exception as e:
print(f" Error: {e}")

View File

@@ -0,0 +1,73 @@
"""
Extract element forces from CBAR in Z direction from OP2
Auto-generated by Atomizer Phase 3 - pyNastran Research Agent
Pattern: cbar_force
Element Type: CBAR
Result Type: force
API: model.cbar_force[subcase]
"""
from pathlib import Path
from typing import Dict, Any
import numpy as np
from pyNastran.op2.op2 import OP2
def extract_cbar_force(op2_file: Path, subcase: int = 1, direction: str = 'Z'):
"""
Extract forces from CBAR elements.
Args:
op2_file: Path to OP2 file
subcase: Subcase ID
direction: Force direction ('X', 'Y', 'Z', 'axial', 'torque')
Returns:
Dict with force statistics
"""
from pyNastran.op2.op2 import OP2
import numpy as np
model = OP2()
model.read_op2(str(op2_file))
if not hasattr(model, 'cbar_force'):
raise ValueError("No CBAR force results in OP2")
force = model.cbar_force[subcase]
itime = 0
# CBAR force data structure:
# [bending_moment_a1, bending_moment_a2,
# bending_moment_b1, bending_moment_b2,
# shear1, shear2, axial, torque]
direction_map = {
'shear1': 4,
'shear2': 5,
'axial': 6,
'Z': 6, # Commonly axial is Z direction
'torque': 7
}
col_idx = direction_map.get(direction, direction_map.get(direction.lower(), 6))
forces = force.data[itime, :, col_idx]
return {
f'max_{direction}_force': float(np.max(np.abs(forces))),
f'avg_{direction}_force': float(np.mean(np.abs(forces))),
f'min_{direction}_force': float(np.min(np.abs(forces))),
'forces_array': forces.tolist()
}
if __name__ == '__main__':
# Example usage
import sys
if len(sys.argv) > 1:
op2_file = Path(sys.argv[1])
result = extract_cbar_force(op2_file)
print(f"Extraction result: {result}")
else:
print("Usage: python {sys.argv[0]} <op2_file>")

View File

@@ -0,0 +1,56 @@
"""
Extract displacement from OP2
Auto-generated by Atomizer Phase 3 - pyNastran Research Agent
Pattern: displacement
Element Type: General
Result Type: displacement
API: model.displacements[subcase]
"""
from pathlib import Path
from typing import Dict, Any
import numpy as np
from pyNastran.op2.op2 import OP2
def extract_displacement(op2_file: Path, subcase: int = 1):
"""Extract displacement results from OP2 file."""
from pyNastran.op2.op2 import OP2
import numpy as np
model = OP2()
model.read_op2(str(op2_file))
disp = model.displacements[subcase]
itime = 0 # static case
# Extract translation components
txyz = disp.data[itime, :, :3] # [tx, ty, tz]
# Calculate total displacement
total_disp = np.linalg.norm(txyz, axis=1)
max_disp = np.max(total_disp)
# Get node info
node_ids = [nid for (nid, grid_type) in disp.node_gridtype]
max_disp_node = node_ids[np.argmax(total_disp)]
return {
'max_displacement': float(max_disp),
'max_disp_node': int(max_disp_node),
'max_disp_x': float(np.max(np.abs(txyz[:, 0]))),
'max_disp_y': float(np.max(np.abs(txyz[:, 1]))),
'max_disp_z': float(np.max(np.abs(txyz[:, 2])))
}
if __name__ == '__main__':
# Example usage
import sys
if len(sys.argv) > 1:
op2_file = Path(sys.argv[1])
result = extract_displacement(op2_file)
print(f"Extraction result: {result}")
else:
print("Usage: python {sys.argv[0]} <op2_file>")

View File

@@ -0,0 +1,55 @@
"""
Extract expression value from NX .prt file
Used for extracting computed values like mass, volume, etc.
This extractor reads expressions using the .exp export method for accuracy.
"""
from pathlib import Path
from typing import Dict, Any
from optimization_engine.nx_updater import NXParameterUpdater
def extract_expression(prt_file: Path, expression_name: str):
"""
Extract an expression value from NX .prt file.
Args:
prt_file: Path to .prt file
expression_name: Name of expression to extract (e.g., 'p173' for mass)
Returns:
Dict with expression value and units
"""
updater = NXParameterUpdater(prt_file, backup=False)
expressions = updater.get_all_expressions(use_exp_export=True)
if expression_name not in expressions:
raise ValueError(f"Expression '{expression_name}' not found in {prt_file}")
expr_info = expressions[expression_name]
# If expression is a formula (value is None), we need to evaluate it
# For now, we'll raise an error if it's a formula - user should use the computed value
if expr_info['value'] is None and expr_info['formula'] is not None:
raise ValueError(
f"Expression '{expression_name}' is a formula: {expr_info['formula']}. "
f"This extractor requires a computed value, not a formula reference."
)
return {
expression_name: expr_info['value'],
f'{expression_name}_units': expr_info['units']
}
if __name__ == '__main__':
# Example usage
import sys
if len(sys.argv) > 2:
prt_file = Path(sys.argv[1])
expression_name = sys.argv[2]
result = extract_expression(prt_file, expression_name)
print(f"Extraction result: {result}")
else:
print(f"Usage: python {sys.argv[0]} <prt_file> <expression_name>")

View File

@@ -0,0 +1,127 @@
"""
Extract von Mises stress from solid and shell elements
Auto-generated by Atomizer Phase 3 - pyNastran Research Agent
Pattern: element_stress
Element Types: CTETRA, CHEXA, CPENTA (solids), CQUAD4, CTRIA3 (shells)
Result Type: stress
API: model.ctetra_stress[subcase], model.cquad4_stress[subcase], etc.
"""
from pathlib import Path
from typing import Dict, Any
import numpy as np
from pyNastran.op2.op2 import OP2
def extract_solid_stress(op2_file: Path, subcase: int = 1, element_type: str = 'auto'):
"""
Extract stress from solid or shell elements.
Args:
op2_file: Path to OP2 file
subcase: Subcase number (default 1)
element_type: Element type ('ctetra', 'chexa', 'cquad4', 'ctria3', or 'auto')
'auto' will detect available element types
Returns:
Dict with max von Mises stress and element info
"""
from pyNastran.op2.op2 import OP2
import numpy as np
model = OP2()
model.read_op2(str(op2_file))
# Auto-detect element type if requested
if element_type == 'auto':
# Try common element types in order
# pyNastran uses "stress.{element}_stress" as attribute names (with dot in name!)
possible_types = ['cquad4', 'ctria3', 'ctetra', 'chexa', 'cpenta']
element_type = None
for elem_type in possible_types:
stress_attr = f"stress.{elem_type}_stress"
try:
stress_dict = getattr(model, stress_attr, None)
if isinstance(stress_dict, dict) and subcase in stress_dict:
element_type = elem_type
break
except AttributeError:
continue
if element_type is None:
raise ValueError(f"No stress results found in OP2 for subcase {subcase}")
# Get stress object for element type
# pyNastran stores stress as "stress.{element}_stress" (e.g., "stress.cquad4_stress")
stress_attr = f"stress.{element_type}_stress"
try:
stress_dict = getattr(model, stress_attr)
except AttributeError:
raise ValueError(f"No {element_type} stress results in OP2")
if not isinstance(stress_dict, dict) or subcase not in stress_dict:
raise ValueError(f"Subcase {subcase} not found in {element_type} stress results")
stress = stress_dict[subcase]
itime = 0
# Extract von Mises
# For CQUAD4/CTRIA3 (shells): data shape is [ntimes, nelements, 8]
# Columns: [fiber_distance, oxx, oyy, txy, angle, omax, omin, von_mises]
# Column 7 (0-indexed) is von Mises
#
# For CTETRA/CHEXA (solids): Column 9 is von Mises
if element_type in ['cquad4', 'ctria3']:
# Shell elements - von Mises is at column 7
von_mises = stress.data[itime, :, 7]
else:
# Solid elements - von Mises is at column 9
von_mises = stress.data[itime, :, 9]
# Raw stress values from OP2 (in internal Nastran units)
max_stress_raw = float(np.max(von_mises))
min_stress_raw = float(np.min(von_mises))
avg_stress_raw = float(np.mean(von_mises))
# Convert to MPa
# For MN-MM unit system (UNITSYS=MN-MM), Nastran outputs stress with implied decimal
# The raw value needs to be divided by 1000 to get MPa
# Example: 131507.1875 (raw) = 131.507 MPa
max_stress_mpa = max_stress_raw / 1000.0
min_stress_mpa = min_stress_raw / 1000.0
avg_stress_mpa = avg_stress_raw / 1000.0
# Get element info
if hasattr(stress, 'element_node'):
element_ids = stress.element_node[:, 0] # First column is element ID
elif hasattr(stress, 'element'):
element_ids = stress.element
else:
element_ids = np.arange(len(von_mises))
max_stress_elem = int(element_ids[np.argmax(von_mises)])
return {
'max_von_mises': max_stress_mpa,
'min_von_mises': min_stress_mpa,
'avg_von_mises': avg_stress_mpa,
'max_stress_element': max_stress_elem,
'element_type': element_type,
'num_elements': len(von_mises),
'units': 'MPa'
}
if __name__ == '__main__':
# Example usage
import sys
if len(sys.argv) > 1:
op2_file = Path(sys.argv[1])
result = extract_solid_stress(op2_file)
print(f"Extraction result: {result}")
else:
print("Usage: python {sys.argv[0]} <op2_file>")

View File

@@ -0,0 +1,264 @@
"""
Example: Result Extraction from OP2 files using pyNastran
This shows how to extract optimization metrics from Nastran OP2 files.
Common metrics:
- Max displacement (for stiffness constraints)
- Max von Mises stress (for strength constraints)
- Mass (for minimization objectives)
"""
from pathlib import Path
from typing import Dict, Any
import numpy as np
def extract_max_displacement(op2_path: Path) -> Dict[str, Any]:
"""
Extract maximum displacement magnitude from OP2 file.
Args:
op2_path: Path to .op2 file
Returns:
Dictionary with max displacement, node ID, and components
"""
from pyNastran.op2.op2 import OP2
op2 = OP2()
op2.read_op2(str(op2_path))
# Get first subcase (usually the only one in static analysis)
subcase_id = list(op2.displacements.keys())[0]
displacements = op2.displacements[subcase_id]
# Extract node IDs and displacement data
node_ids = displacements.node_gridtype[:, 0].astype(int)
disp_data = displacements.data[0] # First (and usually only) timestep
# Calculate magnitude: sqrt(dx^2 + dy^2 + dz^2)
dx = disp_data[:, 0]
dy = disp_data[:, 1]
dz = disp_data[:, 2]
magnitudes = np.sqrt(dx**2 + dy**2 + dz**2)
# Find max
max_idx = np.argmax(magnitudes)
max_displacement = magnitudes[max_idx]
max_node_id = node_ids[max_idx]
return {
'max_displacement': float(max_displacement),
'max_node_id': int(max_node_id),
'dx': float(dx[max_idx]),
'dy': float(dy[max_idx]),
'dz': float(dz[max_idx]),
'units': 'mm', # NX typically uses mm
'subcase': subcase_id
}
def extract_max_stress(op2_path: Path, stress_type: str = 'von_mises') -> Dict[str, Any]:
"""
Extract maximum stress from OP2 file.
Args:
op2_path: Path to .op2 file
stress_type: 'von_mises' or 'max_principal'
Returns:
Dictionary with max stress, element ID, and location
"""
from pyNastran.op2.op2 import OP2
op2 = OP2(debug=False)
op2.read_op2(str(op2_path))
# Stress can be in different tables depending on element type
# Common: cquad4_stress, ctria3_stress, ctetra_stress, etc.
stress_tables = [
'cquad4_stress',
'ctria3_stress',
'ctetra_stress',
'chexa_stress',
'cpenta_stress',
'cbar_stress',
'cbeam_stress'
]
max_stress_overall = 0.0
max_element_id = None
max_element_type = None
# Try to get stress from different pyNastran API formats
for table_name in stress_tables:
stress_table = None
# Try format 1: Attribute name with dot (e.g., 'stress.chexa_stress')
# This is used in newer pyNastran versions
dotted_name = f'stress.{table_name}'
if hasattr(op2, dotted_name):
stress_table = getattr(op2, dotted_name)
# Try format 2: Nested attribute op2.stress.chexa_stress
elif hasattr(op2, 'stress') and hasattr(op2.stress, table_name):
stress_table = getattr(op2.stress, table_name)
# Try format 3: Direct attribute op2.chexa_stress (older pyNastran)
elif hasattr(op2, table_name):
stress_table = getattr(op2, table_name)
if stress_table:
subcase_id = list(stress_table.keys())[0]
stress_data = stress_table[subcase_id]
# Extract von Mises stress
# Note: Structure varies by element type
element_ids = stress_data.element_node[:, 0].astype(int)
if stress_type == 'von_mises':
# For solid elements (CHEXA, CTETRA, CPENTA): von Mises is at index 9
# For shell elements (CQUAD4, CTRIA3): von Mises is last column (-1)
if table_name in ['chexa_stress', 'ctetra_stress', 'cpenta_stress']:
# Solid elements: data shape is [itime, nnodes, 10]
# Index 9 is von_mises [oxx, oyy, ozz, txy, tyz, txz, o1, o2, o3, von_mises]
stresses = stress_data.data[0, :, 9]
else:
# Shell elements: von Mises is last column
stresses = stress_data.data[0, :, -1]
else:
# Max principal stress
if table_name in ['chexa_stress', 'ctetra_stress', 'cpenta_stress']:
stresses = stress_data.data[0, :, 6] # o1 (max principal)
else:
stresses = stress_data.data[0, :, -2]
max_stress_in_table = np.max(stresses)
if max_stress_in_table > max_stress_overall:
max_stress_overall = max_stress_in_table
max_idx = np.argmax(stresses)
max_element_id = element_ids[max_idx]
max_element_type = table_name.replace('_stress', '')
# CRITICAL: NX Nastran outputs stress in kPa (mN/mm²), convert to MPa
# 1 kPa = 0.001 MPa
max_stress_overall_mpa = max_stress_overall / 1000.0
return {
'max_stress': float(max_stress_overall_mpa),
'stress_type': stress_type,
'element_id': int(max_element_id) if max_element_id else None,
'element_type': max_element_type,
'units': 'MPa',
}
def extract_mass(op2_path: Path) -> Dict[str, Any]:
"""
Extract total mass from OP2 file.
Args:
op2_path: Path to .op2 file
Returns:
Dictionary with mass and center of gravity
"""
from pyNastran.op2.op2 import OP2
op2 = OP2()
op2.read_op2(str(op2_path))
# Mass is in grid_point_weight table
if hasattr(op2, 'grid_point_weight') and op2.grid_point_weight:
mass_data = op2.grid_point_weight
# Total mass
total_mass = mass_data.mass.sum()
# Center of gravity
cg = mass_data.cg
return {
'total_mass': float(total_mass),
'cg_x': float(cg[0]),
'cg_y': float(cg[1]),
'cg_z': float(cg[2]),
'units': 'kg'
}
else:
# Fallback: Mass not directly available
return {
'total_mass': None,
'note': 'Mass data not found in OP2 file. Ensure PARAM,GRDPNT,0 is in Nastran deck'
}
# Combined extraction function for optimization
def extract_all_results(op2_path: Path) -> Dict[str, Any]:
"""
Extract all common optimization metrics from OP2 file.
Args:
op2_path: Path to .op2 file
Returns:
Dictionary with all results
"""
results = {
'op2_file': str(op2_path),
'status': 'success'
}
try:
results['displacement'] = extract_max_displacement(op2_path)
except Exception as e:
results['displacement'] = {'error': str(e)}
try:
results['stress'] = extract_max_stress(op2_path)
except Exception as e:
results['stress'] = {'error': str(e)}
try:
results['mass'] = extract_mass(op2_path)
except Exception as e:
results['mass'] = {'error': str(e)}
return results
# Example usage
if __name__ == "__main__":
import sys
import json
if len(sys.argv) < 2:
print("Usage: python op2_extractor_example.py <path_to_op2_file>")
sys.exit(1)
op2_path = Path(sys.argv[1])
if not op2_path.exists():
print(f"Error: File not found: {op2_path}")
sys.exit(1)
print(f"Extracting results from: {op2_path}")
print("=" * 60)
results = extract_all_results(op2_path)
print("\nResults:")
print(json.dumps(results, indent=2))
# Summary
print("\n" + "=" * 60)
print("SUMMARY:")
if 'displacement' in results and 'max_displacement' in results['displacement']:
disp = results['displacement']
print(f" Max Displacement: {disp['max_displacement']:.6f} {disp['units']} at node {disp['max_node_id']}")
if 'stress' in results and 'max_stress' in results['stress']:
stress = results['stress']
print(f" Max {stress['stress_type']}: {stress['max_stress']:.2f} {stress['units']} in element {stress['element_id']}")
if 'mass' in results and 'total_mass' in results['mass'] and results['mass']['total_mass']:
mass = results['mass']
print(f" Total Mass: {mass['total_mass']:.6f} {mass['units']}")