Merge branch 'claude/project-summary-option-a-01At4mDLnfELXcMejPaKNhG5'

This commit is contained in:
2025-11-15 10:24:20 -05:00
18 changed files with 4550 additions and 1 deletions

View File

@@ -9,4 +9,4 @@ property of Atomaste. Unauthorized copying, modification, distribution, or use o
Software, via any medium, is strictly prohibited without prior written permission from
Atomaste.
For licensing inquiries, please contact: contact@atomaste.com
For licensing inquiries, please contact: antoine@atomaste.ca

View File

@@ -0,0 +1,180 @@
{
"design_variables": [
{
"name": "tip_thickness",
"type": "continuous",
"bounds": [
15.0,
25.0
],
"units": "mm",
"initial_value": 20.0
},
{
"name": "support_angle",
"type": "continuous",
"bounds": [
20.0,
40.0
],
"units": "degrees",
"initial_value": 30.0
}
],
"objectives": [
{
"name": "minimize_mass",
"description": "Minimize total mass (weight reduction)",
"extractor": "mass_extractor",
"metric": "total_mass",
"direction": "minimize",
"weight": 5.0
},
{
"name": "minimize_max_stress",
"description": "Minimize maximum von Mises stress",
"extractor": "stress_extractor",
"metric": "max_von_mises",
"direction": "minimize",
"weight": 10.0
}
],
"constraints": [
{
"name": "max_displacement_limit",
"description": "Maximum allowable displacement",
"extractor": "displacement_extractor",
"metric": "max_displacement",
"type": "upper_bound",
"limit": 1.0,
"units": "mm"
},
{
"name": "max_stress_limit",
"description": "Maximum allowable von Mises stress",
"extractor": "stress_extractor",
"metric": "max_von_mises",
"type": "upper_bound",
"limit": 200.0,
"units": "MPa"
}
],
"optimization_settings": {
"n_trials": 150,
"sampler": "TPE",
"n_startup_trials": 20
},
"model_info": {
"sim_file": "C:/Users/antoi/Documents/Atomaste/Atomizer/examples/bracket/Bracket_sim1.sim",
"solutions": [
{
"name": "Direct Frequency Response",
"type": "Direct Frequency Response",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Disable in Thermal Solution 2D",
"type": "Disable in Thermal Solution 2D",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Nonlinear Statics",
"type": "Nonlinear Statics",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Linear Statics",
"type": "Linear Statics",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "*Thermal-Flow Coupled Solution Parameters",
"type": "*Thermal-Flow Coupled Solution Parameters",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Thermal Solution Parameters",
"type": "Thermal Solution Parameters",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Disable in Thermal Solution 3D",
"type": "Disable in Thermal Solution 3D",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Modal Frequency Response",
"type": "Modal Frequency Response",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Direct Transient Response",
"type": "Direct Transient Response",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "-Flow-Structural Coupled Solution Parameters",
"type": "-Flow-Structural Coupled Solution Parameters",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Normal Modes",
"type": "Normal Modes",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Modal Transient Response",
"type": "Modal Transient Response",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "\"ObjectDisableInThermalSolution3D",
"type": "\"ObjectDisableInThermalSolution3D",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "1Pass Structural Contact Solution to Flow Solver",
"type": "1Pass Structural Contact Solution to Flow Solver",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "0Thermal-Structural Coupled Solution Parameters",
"type": "0Thermal-Structural Coupled Solution Parameters",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Design Optimization",
"type": "Design Optimization",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "DisableInThermalSolution",
"type": "DisableInThermalSolution",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "\"ObjectDisableInThermalSolution2D",
"type": "\"ObjectDisableInThermalSolution2D",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
}
]
}
}

80
examples/test_bracket.sim Normal file
View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Sample NX Simulation File for Testing -->
<!-- This is a simplified representation of an actual .sim file -->
<SimulationModel version="2412">
<Metadata>
<Name>test_bracket</Name>
<Description>Simple bracket structural analysis</Description>
<NXVersion>NX 2412</NXVersion>
<CreatedDate>2025-11-15</CreatedDate>
</Metadata>
<!-- Solution Definitions -->
<Solutions>
<Solution name="Structural Analysis 1" type="Static Structural" solver="NX Nastran">
<Description>Linear static analysis under load</Description>
<SolverSettings>
<SolverType>101</SolverType>
<LinearSolver>Direct</LinearSolver>
</SolverSettings>
</Solution>
</Solutions>
<!-- Expressions (Parametric Variables) -->
<Expressions>
<Expression name="wall_thickness" value="5.0" units="mm">
<Formula>5.0</Formula>
<Type>Dimension</Type>
</Expression>
<Expression name="hole_diameter" value="10.0" units="mm">
<Formula>10.0</Formula>
<Type>Dimension</Type>
</Expression>
<Expression name="rib_spacing" value="40.0" units="mm">
<Formula>40.0</Formula>
<Type>Dimension</Type>
</Expression>
<Expression name="material_density" value="2.7" units="g/cm^3">
<Formula>2.7</Formula>
<Type>Material Property</Type>
</Expression>
</Expressions>
<!-- FEM Model -->
<FEM>
<Mesh name="Bracket Mesh" element_size="2.5" node_count="8234" element_count="4521">
<ElementTypes>
<ElementType type="CQUAD4"/>
<ElementType type="CTRIA3"/>
</ElementTypes>
</Mesh>
<Materials>
<Material name="Aluminum 6061-T6" type="Isotropic">
<Property name="youngs_modulus" value="68.9e9" units="Pa"/>
<Property name="poissons_ratio" value="0.33" units=""/>
<Property name="density" value="2700" units="kg/m^3"/>
<Property name="yield_strength" value="276e6" units="Pa"/>
</Material>
</Materials>
<Loads>
<Load name="Applied Force" type="Force" magnitude="1000.0" units="N">
<Location>Top Face</Location>
<Direction>0 -1 0</Direction>
</Load>
</Loads>
<Constraints>
<Constraint name="Fixed Support" type="Fixed">
<Location>Bottom Holes</Location>
</Constraint>
</Constraints>
</FEM>
<!-- Linked Files -->
<LinkedFiles>
<PartFile>test_bracket.prt</PartFile>
<FemFile>test_bracket.fem</FemFile>
</LinkedFiles>
</SimulationModel>

281
mcp_server/tools/README.md Normal file
View File

@@ -0,0 +1,281 @@
# MCP Tools Documentation
This directory contains the MCP (Model Context Protocol) tools that enable LLM-driven optimization configuration for Atomizer.
## Available Tools
### 1. Model Discovery (`model_discovery.py`) ✅ IMPLEMENTED
**Purpose**: Parse Siemens NX .sim files to extract FEA model information.
**Function**: `discover_fea_model(sim_file_path: str) -> Dict[str, Any]`
**What it extracts**:
- **Solutions**: Analysis types (static, thermal, modal, etc.)
- **Expressions**: Parametric variables that can be optimized
- **FEM Info**: Mesh, materials, loads, constraints
- **Linked Files**: Associated .prt files and result files
**Usage Example**:
```python
from mcp_server.tools import discover_fea_model, format_discovery_result_for_llm
# Discover model
result = discover_fea_model("C:/Projects/Bracket/analysis.sim")
# Format for LLM
if result['status'] == 'success':
markdown_output = format_discovery_result_for_llm(result)
print(markdown_output)
# Access structured data
for expr in result['expressions']:
print(f"{expr['name']}: {expr['value']} {expr['units']}")
```
**Command Line Usage**:
```bash
python mcp_server/tools/model_discovery.py examples/test_bracket.sim
```
**Output Format**:
- **JSON**: Complete structured data for programmatic use
- **Markdown**: Human-readable format for LLM consumption
**Supported .sim File Versions**:
- NX 2412 (tested)
- Should work with NX 12.0+ (XML-based .sim files)
**Limitations**:
- Expression values are best-effort extracted from .sim XML
- For accurate values, the associated .prt file is parsed (binary parsing)
- Binary .prt parsing is heuristic-based and may miss some expressions
---
### 2. Build Optimization Config (`optimization_config.py`) ✅ IMPLEMENTED
**Purpose**: Generate `optimization_config.json` from user selections of objectives, constraints, and design variables.
**Functions**:
- `build_optimization_config(...)` - Create complete optimization configuration
- `list_optimization_options(sim_file_path)` - List all available options for a model
- `format_optimization_options_for_llm(options)` - Format options as Markdown
**What it does**:
- Discovers available design variables from the FEA model
- Lists available objectives (minimize mass, stress, displacement, volume)
- Lists available constraints (max stress, max displacement, mass limits)
- Builds a complete `optimization_config.json` based on user selections
- Validates that all selections are valid for the model
**Usage Example**:
```python
from mcp_server.tools import build_optimization_config, list_optimization_options
# Step 1: List available options
options = list_optimization_options("examples/bracket/Bracket_sim1.sim")
print(f"Available design variables: {len(options['available_design_variables'])}")
# Step 2: Build configuration
result = build_optimization_config(
sim_file_path="examples/bracket/Bracket_sim1.sim",
design_variables=[
{'name': 'tip_thickness', 'lower_bound': 15.0, 'upper_bound': 25.0},
{'name': 'support_angle', 'lower_bound': 20.0, 'upper_bound': 40.0}
],
objectives=[
{'objective_key': 'minimize_mass', 'weight': 5.0},
{'objective_key': 'minimize_max_stress', 'weight': 10.0}
],
constraints=[
{'constraint_key': 'max_displacement_limit', 'limit_value': 1.0},
{'constraint_key': 'max_stress_limit', 'limit_value': 200.0}
],
optimization_settings={
'n_trials': 150,
'sampler': 'TPE'
}
)
if result['status'] == 'success':
print(f"Config saved to: {result['config_file']}")
```
**Command Line Usage**:
```bash
python mcp_server/tools/optimization_config.py examples/bracket/Bracket_sim1.sim
```
**Available Objectives**:
- `minimize_mass`: Minimize total mass (weight reduction)
- `minimize_max_stress`: Minimize maximum von Mises stress
- `minimize_max_displacement`: Minimize maximum displacement (increase stiffness)
- `minimize_volume`: Minimize total volume (material usage)
**Available Constraints**:
- `max_stress_limit`: Maximum allowable von Mises stress
- `max_displacement_limit`: Maximum allowable displacement
- `min_mass_limit`: Minimum required mass (structural integrity)
- `max_mass_limit`: Maximum allowable mass (weight budget)
**Output**: Creates `optimization_config.json` with:
- Design variable definitions with bounds
- Multi-objective configuration with weights
- Constraint definitions with limits
- Optimization algorithm settings (trials, sampler)
---
### 3. Start Optimization (PLANNED)
**Purpose**: Launch optimization run with given configuration.
**Function**: `start_optimization(config_path: str, resume: bool = False) -> Dict[str, Any]`
---
### 4. Query Optimization Status (PLANNED)
**Purpose**: Get current status of running optimization.
**Function**: `query_optimization_status(session_id: str) -> Dict[str, Any]`
---
### 5. Extract Results (PLANNED)
**Purpose**: Parse FEA result files (OP2, F06, XDB) for optimization metrics.
**Function**: `extract_results(result_files: List[str], extractors: List[str]) -> Dict[str, Any]`
---
### 6. Run NX Journal (PLANNED)
**Purpose**: Execute NXOpen scripts via file-based communication.
**Function**: `run_nx_journal(journal_script: str, parameters: Dict) -> Dict[str, Any]`
---
## Testing
### Unit Tests
```bash
# Install pytest (if not already installed)
pip install pytest
# Run all MCP tool tests
pytest tests/mcp_server/tools/ -v
# Run specific test
pytest tests/mcp_server/tools/test_model_discovery.py -v
```
### Example Files
Example .sim files for testing are located in `examples/`:
- `test_bracket.sim`: Simple structural analysis with 4 expressions
---
## Development Guidelines
### Adding a New Tool
1. **Create module**: `mcp_server/tools/your_tool.py`
2. **Implement function**:
```python
def your_tool_name(param: str) -> Dict[str, Any]:
"""
Brief description.
Args:
param: Description
Returns:
Structured result dictionary
"""
try:
# Implementation
return {
'status': 'success',
'data': result
}
except Exception as e:
return {
'status': 'error',
'error_type': 'error_category',
'message': str(e),
'suggestion': 'How to fix'
}
```
3. **Add to `__init__.py`**:
```python
from .your_tool import your_tool_name
__all__ = [
# ... existing tools
"your_tool_name",
]
```
4. **Create tests**: `tests/mcp_server/tools/test_your_tool.py`
5. **Update documentation**: Add section to this README
---
## Error Handling
All MCP tools follow a consistent error handling pattern:
**Success Response**:
```json
{
"status": "success",
"data": { ... }
}
```
**Error Response**:
```json
{
"status": "error",
"error_type": "file_not_found | invalid_file | unexpected_error",
"message": "Detailed error message",
"suggestion": "Actionable suggestion for user"
}
```
---
## Integration with MCP Server
These tools are designed to be called by the MCP server and consumed by LLMs. The workflow is:
1. **LLM Request**: "Analyze my FEA model at C:/Projects/model.sim"
2. **MCP Server**: Calls `discover_fea_model()`
3. **Tool Returns**: Structured JSON result
4. **MCP Server**: Formats with `format_discovery_result_for_llm()`
5. **LLM Response**: Uses formatted data to answer user
---
## Future Enhancements
- [ ] Support for binary .sim file formats (older NX versions)
- [ ] Direct NXOpen integration for accurate expression extraction
- [ ] Support for additional analysis types (thermal, modal, etc.)
- [ ] Caching of parsed results for performance
- [ ] Validation of .sim file integrity
- [ ] Extraction of solver convergence settings
---
**Last Updated**: 2025-11-15
**Status**: Phase 1 (Model Discovery) ✅ COMPLETE | Phase 2 (Optimization Config Builder) ✅ COMPLETE

View File

@@ -12,10 +12,19 @@ Available tools:
"""
from typing import Dict, Any
from .model_discovery import discover_fea_model, format_discovery_result_for_llm
from .optimization_config import (
build_optimization_config,
list_optimization_options,
format_optimization_options_for_llm
)
__all__ = [
"discover_fea_model",
"format_discovery_result_for_llm",
"build_optimization_config",
"list_optimization_options",
"format_optimization_options_for_llm",
"start_optimization",
"query_optimization_status",
"extract_results",

View File

@@ -0,0 +1,621 @@
"""
MCP Tool: FEA Model Discovery
Parses Siemens NX .sim files to extract:
- Simulation solutions (structural, thermal, modal, etc.)
- Parametric expressions (design variables)
- FEM information (mesh, elements, materials)
- Linked part files
This tool enables LLM-driven optimization configuration by providing
structured information about what can be optimized in a given FEA model.
"""
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, Any, List, Optional
import json
import re
class SimFileParser:
"""
Parser for Siemens NX .sim (simulation) files.
IMPORTANT: Real NX .sim files are BINARY (not XML) in NX 12+.
The parser uses two approaches:
1. XML parsing for test/legacy files
2. Binary string extraction for real NX files
.sim files contain references to:
- Parent .prt file (geometry and expressions)
- Solution definitions (structural, thermal, etc.)
- FEM (mesh, materials, loads, constraints)
- Solver settings
"""
def __init__(self, sim_path: Path):
"""
Initialize parser with path to .sim file.
Args:
sim_path: Absolute path to .sim file
Raises:
FileNotFoundError: If sim file doesn't exist
ValueError: If file is not a valid .sim file
"""
self.sim_path = Path(sim_path)
if not self.sim_path.exists():
raise FileNotFoundError(f"Sim file not found: {sim_path}")
if self.sim_path.suffix.lower() != '.sim':
raise ValueError(f"Not a .sim file: {sim_path}")
self.tree = None
self.root = None
self.is_binary = False
self.sim_strings = [] # Extracted strings from binary file
self._parse_file()
def _parse_file(self):
"""
Parse the .sim file - handles both XML (test files) and binary (real NX files).
"""
# First, try XML parsing
try:
self.tree = ET.parse(self.sim_path)
self.root = self.tree.getroot()
self.is_binary = False
return
except ET.ParseError:
# Not XML, must be binary - this is normal for real NX files
pass
# Binary file - extract readable strings
try:
with open(self.sim_path, 'rb') as f:
content = f.read()
# Extract strings (sequences of printable ASCII characters)
# Minimum length of 4 to avoid noise
text_content = content.decode('latin-1', errors='ignore')
self.sim_strings = re.findall(r'[\x20-\x7E]{4,}', text_content)
self.is_binary = True
except Exception as e:
raise ValueError(f"Failed to parse .sim file (tried both XML and binary): {e}")
def extract_solutions(self) -> List[Dict[str, Any]]:
"""
Extract solution definitions from .sim file.
Returns:
List of solution dictionaries with type, name, solver info
"""
solutions = []
if not self.is_binary and self.root is not None:
# XML parsing
for solution_tag in ['Solution', 'AnalysisSolution', 'SimSolution']:
for elem in self.root.iter(solution_tag):
solution_info = {
'name': elem.get('name', 'Unknown'),
'type': elem.get('type', 'Unknown'),
'solver': elem.get('solver', 'NX Nastran'),
'description': elem.get('description', ''),
}
solutions.append(solution_info)
else:
# Binary parsing - look for solution type indicators
solution_types = {
'SOL 101': 'Linear Statics',
'SOL 103': 'Normal Modes',
'SOL 106': 'Nonlinear Statics',
'SOL 108': 'Direct Frequency Response',
'SOL 109': 'Direct Transient Response',
'SOL 111': 'Modal Frequency Response',
'SOL 112': 'Modal Transient Response',
'SOL 200': 'Design Optimization',
}
found_solutions = set()
for s in self.sim_strings:
for sol_id, sol_type in solution_types.items():
if sol_id in s:
found_solutions.add(sol_type)
# Also check for solution names in strings
for s in self.sim_strings:
if 'Solution' in s and len(s) < 50:
# Potential solution name
if any(word in s for word in ['Structural', 'Thermal', 'Modal', 'Static']):
found_solutions.add(s.strip())
for sol_name in found_solutions:
solutions.append({
'name': sol_name,
'type': sol_name,
'solver': 'NX Nastran',
'description': 'Extracted from binary .sim file'
})
# Default if nothing found
if not solutions:
solutions.append({
'name': 'Default Solution',
'type': 'Static Structural',
'solver': 'NX Nastran',
'description': 'Solution info could not be fully extracted from .sim file'
})
return solutions
def extract_expressions(self) -> List[Dict[str, Any]]:
"""
Extract expression references from .sim file.
Note: Actual expression values are stored in the .prt file.
This method extracts references and attempts to read from .prt if available.
Returns:
List of expression dictionaries with name, value, units
"""
expressions = []
# XML parsing - look for expression elements
if not self.is_binary and self.root is not None:
for expr_elem in self.root.iter('Expression'):
expr_info = {
'name': expr_elem.get('name', ''),
'value': expr_elem.get('value', None),
'units': expr_elem.get('units', ''),
'formula': expr_elem.text if expr_elem.text else None
}
if expr_info['name']:
expressions.append(expr_info)
# Try to read from associated .prt file (works for both XML and binary .sim)
# Try multiple naming patterns:
# 1. Same name as .sim: Bracket_sim1.prt
# 2. Base name: Bracket.prt
# 3. With _i suffix: Bracket_fem1_i.prt
prt_paths = [
self.sim_path.with_suffix('.prt'), # Bracket_sim1.prt
self.sim_path.parent / f"{self.sim_path.stem.split('_')[0]}.prt", # Bracket.prt
self.sim_path.parent / f"{self.sim_path.stem}_i.prt", # Bracket_sim1_i.prt
]
for prt_path in prt_paths:
if prt_path.exists():
prt_expressions = self._extract_prt_expressions(prt_path)
# Merge with existing, prioritizing .prt values
expr_dict = {e['name']: e for e in expressions}
for prt_expr in prt_expressions:
expr_dict[prt_expr['name']] = prt_expr
expressions = list(expr_dict.values())
break # Use first .prt file found
return expressions
def _extract_prt_expressions(self, prt_path: Path) -> List[Dict[str, Any]]:
"""
Extract expressions from associated .prt file.
.prt files are binary, but expression data is stored in readable sections.
NX expression format: #(Type [units]) name: value;
Args:
prt_path: Path to .prt file
Returns:
List of expression dictionaries
"""
expressions = []
try:
# Read as binary and search for text patterns
with open(prt_path, 'rb') as f:
content = f.read()
# Try to decode as latin-1 (preserves all byte values)
text_content = content.decode('latin-1', errors='ignore')
# Pattern 1: NX native format with variations:
# #(Number [mm]) tip_thickness: 20;
# (Number [mm]) p3: 10;
# *(Number [mm]) support_blend_radius: 10;
# ((Number [degrees]) support_angle: 30;
# Prefix can be: #(, *(, (, ((
nx_pattern = r'[#*\(]*\((\w+)\s*\[([^\]]*)\]\)\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)'
# Use set to avoid duplicates
expr_names_seen = set()
for match in re.finditer(nx_pattern, text_content):
expr_type, units, name, value = match.groups()
if name not in expr_names_seen:
expr_names_seen.add(name)
expressions.append({
'name': name,
'value': float(value),
'units': units,
'type': expr_type,
'source': 'prt_file_nx_format'
})
# Pattern 2: Find expression names from Root: references
# Format: Root:expression_name:
root_pattern = r'Root:([a-zA-Z_][a-zA-Z0-9_]{2,}):'
potential_expr_names = set()
for match in re.finditer(root_pattern, text_content):
name = match.group(1)
# Filter out common NX internal names
if name not in ['index', '%%Name', '%%ug_objects_for_', 'WorldModifier']:
if not name.startswith('%%'):
potential_expr_names.add(name)
# For names found in Root: but not in value patterns,
# mark as "found but value unknown"
for name in potential_expr_names:
if name not in expr_names_seen:
expressions.append({
'name': name,
'value': None,
'units': '',
'type': 'Unknown',
'source': 'prt_file_reference_only'
})
# Pattern 3: Fallback - simple name=value pattern
# Only use if no NX-format expressions found
if not expressions:
simple_pattern = r'([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)'
for match in re.finditer(simple_pattern, text_content):
name, value = match.groups()
# Filter out common false positives (short names, underscore-prefixed)
if len(name) > 3 and not name.startswith('_'):
# Additional filter: avoid Nastran keywords
if name.upper() not in ['PRINT', 'PUNCH', 'PLOT', 'BOTH', 'GRID', 'GAUSS']:
expressions.append({
'name': name,
'value': float(value),
'units': '',
'source': 'prt_file_simple_pattern'
})
except Exception as e:
# .prt parsing is best-effort, don't fail if it doesn't work
print(f"Warning: Could not extract expressions from .prt file: {e}")
return expressions
def extract_fem_info(self) -> Dict[str, Any]:
"""
Extract FEM (finite element model) information.
Returns:
Dictionary with mesh, material, and element info
"""
fem_info = {
'mesh': {},
'materials': [],
'element_types': [],
'loads': [],
'constraints': []
}
if not self.is_binary and self.root is not None:
# XML parsing
for mesh_elem in self.root.iter('Mesh'):
fem_info['mesh'] = {
'name': mesh_elem.get('name', 'Default Mesh'),
'element_size': mesh_elem.get('element_size', 'Unknown'),
'node_count': mesh_elem.get('node_count', 'Unknown'),
'element_count': mesh_elem.get('element_count', 'Unknown')
}
for mat_elem in self.root.iter('Material'):
material = {
'name': mat_elem.get('name', 'Unknown'),
'type': mat_elem.get('type', 'Isotropic'),
'properties': {}
}
for prop in ['youngs_modulus', 'poissons_ratio', 'density', 'yield_strength']:
if mat_elem.get(prop):
material['properties'][prop] = mat_elem.get(prop)
fem_info['materials'].append(material)
for elem_type in self.root.iter('ElementType'):
fem_info['element_types'].append(elem_type.get('type', 'Unknown'))
for load_elem in self.root.iter('Load'):
load = {
'name': load_elem.get('name', 'Unknown'),
'type': load_elem.get('type', 'Force'),
'magnitude': load_elem.get('magnitude', 'Unknown')
}
fem_info['loads'].append(load)
for constraint_elem in self.root.iter('Constraint'):
constraint = {
'name': constraint_elem.get('name', 'Unknown'),
'type': constraint_elem.get('type', 'Fixed'),
}
fem_info['constraints'].append(constraint)
else:
# Binary parsing - extract from .fem file if available
fem_path = self.sim_path.with_name(self.sim_path.stem.replace('_sim', '_fem') + '.fem')
if not fem_path.exists():
# Try alternative naming patterns
fem_path = self.sim_path.parent / f"{self.sim_path.stem.split('_')[0]}_fem1.fem"
if fem_path.exists():
fem_info = self._extract_fem_from_fem_file(fem_path)
else:
# Extract what we can from .sim strings
fem_info['note'] = 'Limited FEM info available from binary .sim file'
return fem_info
def _extract_fem_from_fem_file(self, fem_path: Path) -> Dict[str, Any]:
"""
Extract FEM information from .fem file.
Args:
fem_path: Path to .fem file
Returns:
Dictionary with FEM information
"""
fem_info = {
'mesh': {},
'materials': [],
'element_types': set(),
'loads': [],
'constraints': []
}
try:
with open(fem_path, 'rb') as f:
content = f.read()
text_content = content.decode('latin-1', errors='ignore')
# Look for mesh metadata
mesh_match = re.search(r'Mesh\s+(\d+)', text_content)
if mesh_match:
fem_info['mesh']['name'] = f"Mesh {mesh_match.group(1)}"
# Look for material names
for material_match in re.finditer(r'MAT\d+\s+([A-Za-z0-9_\-\s]+)', text_content):
mat_name = material_match.group(1).strip()
if mat_name and len(mat_name) > 2:
fem_info['materials'].append({
'name': mat_name,
'type': 'Unknown',
'properties': {}
})
# Look for element types (Nastran format: CQUAD4, CTRIA3, CTETRA, etc.)
element_pattern = r'\b(C[A-Z]{3,6}\d?)\b'
for elem_match in re.finditer(element_pattern, text_content):
elem_type = elem_match.group(1)
if elem_type.startswith('C') and len(elem_type) <= 8:
fem_info['element_types'].add(elem_type)
fem_info['element_types'] = list(fem_info['element_types'])
except Exception as e:
fem_info['note'] = f'Could not fully parse .fem file: {e}'
return fem_info
def get_linked_files(self) -> Dict[str, str]:
"""
Get paths to linked files (.prt, result files, etc.)
Returns:
Dictionary mapping file type to path
"""
linked_files = {}
# .prt file (geometry and expressions)
prt_path = self.sim_path.with_suffix('.prt')
if prt_path.exists():
linked_files['part_file'] = str(prt_path)
# Common result file locations
result_dir = self.sim_path.parent
sim_name = self.sim_path.stem
# Nastran result files
for ext in ['.op2', '.f06', '.f04', '.bdf']:
result_file = result_dir / f"{sim_name}{ext}"
if result_file.exists():
linked_files[f'result{ext}'] = str(result_file)
return linked_files
def discover_fea_model(sim_file_path: str) -> Dict[str, Any]:
"""
MCP Tool: Discover FEA Model
Analyzes a Siemens NX .sim file and extracts:
- Solutions (analysis types)
- Expressions (potential design variables)
- FEM information (mesh, materials, loads)
- Linked files
This is the primary tool for LLM-driven optimization setup.
Args:
sim_file_path: Absolute path to .sim file (Windows or Unix format)
Returns:
Structured dictionary with model information
Example:
>>> result = discover_fea_model("C:/Projects/Bracket/analysis.sim")
>>> print(result['expressions'])
[{'name': 'wall_thickness', 'value': 5.0, 'units': 'mm'}, ...]
"""
try:
# Normalize path (handle both Windows and Unix)
sim_path = Path(sim_file_path).resolve()
# Parse the .sim file
parser = SimFileParser(sim_path)
# Extract all components
result = {
'status': 'success',
'sim_file': str(sim_path),
'file_exists': sim_path.exists(),
'solutions': parser.extract_solutions(),
'expressions': parser.extract_expressions(),
'fem_info': parser.extract_fem_info(),
'linked_files': parser.get_linked_files(),
'metadata': {
'parser_version': '0.1.0',
'nx_version': 'NX 2412', # Can be extracted from .sim file in future
}
}
# Add summary statistics
result['summary'] = {
'solution_count': len(result['solutions']),
'expression_count': len(result['expressions']),
'material_count': len(result['fem_info']['materials']),
'load_count': len(result['fem_info']['loads']),
'constraint_count': len(result['fem_info']['constraints']),
}
return result
except FileNotFoundError as e:
return {
'status': 'error',
'error_type': 'file_not_found',
'message': str(e),
'suggestion': 'Check that the file path is absolute and the .sim file exists'
}
except ValueError as e:
return {
'status': 'error',
'error_type': 'invalid_file',
'message': str(e),
'suggestion': 'Ensure the file is a valid NX .sim file (not corrupted or encrypted)'
}
except Exception as e:
return {
'status': 'error',
'error_type': 'unexpected_error',
'message': str(e),
'suggestion': 'This may be an unsupported .sim file format. Please report this issue.'
}
def format_discovery_result_for_llm(result: Dict[str, Any]) -> str:
"""
Format discovery result for LLM consumption (Markdown).
This is used by the MCP server to present results to the LLM
in a clear, structured format.
Args:
result: Output from discover_fea_model()
Returns:
Markdown-formatted string
"""
if result['status'] != 'success':
return f"❌ **Error**: {result['message']}\n\n💡 {result['suggestion']}"
md = []
md.append(f"# FEA Model Analysis\n")
md.append(f"**File**: `{result['sim_file']}`\n")
# Solutions
md.append(f"## Solutions ({result['summary']['solution_count']})\n")
for sol in result['solutions']:
md.append(f"- **{sol['name']}** ({sol['type']}) - Solver: {sol['solver']}")
if sol['description']:
md.append(f" - {sol['description']}")
md.append("")
# Expressions (Design Variables)
md.append(f"## Expressions ({result['summary']['expression_count']})\n")
if result['expressions']:
md.append("| Name | Value | Units |")
md.append("|------|-------|-------|")
for expr in result['expressions']:
value = expr.get('value', 'N/A')
units = expr.get('units', '')
md.append(f"| `{expr['name']}` | {value} | {units} |")
else:
md.append("⚠️ No expressions found. Model may not be parametric.")
md.append("")
# FEM Information
fem = result['fem_info']
md.append(f"## FEM Information\n")
if fem['mesh']:
md.append(f"**Mesh**: {fem['mesh'].get('name', 'Unknown')}")
md.append(f"- Nodes: {fem['mesh'].get('node_count', 'Unknown')}")
md.append(f"- Elements: {fem['mesh'].get('element_count', 'Unknown')}")
md.append("")
if fem['materials']:
md.append(f"**Materials** ({len(fem['materials'])})")
for mat in fem['materials']:
md.append(f"- {mat['name']} ({mat['type']})")
md.append("")
if fem['loads']:
md.append(f"**Loads** ({len(fem['loads'])})")
for load in fem['loads']:
md.append(f"- {load['name']} ({load['type']})")
md.append("")
if fem['constraints']:
md.append(f"**Constraints** ({len(fem['constraints'])})")
for constraint in fem['constraints']:
md.append(f"- {constraint['name']} ({constraint['type']})")
md.append("")
# Linked Files
if result['linked_files']:
md.append(f"## Linked Files\n")
for file_type, file_path in result['linked_files'].items():
md.append(f"- **{file_type}**: `{file_path}`")
md.append("")
return "\n".join(md)
# For testing/debugging
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python model_discovery.py <path_to_sim_file>")
sys.exit(1)
sim_path = sys.argv[1]
result = discover_fea_model(sim_path)
if result['status'] == 'success':
print(format_discovery_result_for_llm(result))
print("\n" + "="*60)
print("JSON Output:")
print(json.dumps(result, indent=2))
else:
print(f"Error: {result['message']}")

View File

@@ -0,0 +1,368 @@
"""
MCP Tool: Build Optimization Configuration
Wraps the OptimizationConfigBuilder to create an MCP-compatible tool
that helps LLMs guide users through building optimization configurations.
This tool:
1. Discovers the FEA model (design variables)
2. Lists available objectives and constraints
3. Builds a complete optimization_config.json based on user selections
"""
from pathlib import Path
from typing import Dict, Any, List, Optional
import json
import sys
# Add project root to path for imports
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from optimization_engine.optimization_config_builder import OptimizationConfigBuilder
from mcp_server.tools.model_discovery import discover_fea_model
def build_optimization_config(
sim_file_path: str,
design_variables: List[Dict[str, Any]],
objectives: List[Dict[str, Any]],
constraints: Optional[List[Dict[str, Any]]] = None,
optimization_settings: Optional[Dict[str, Any]] = None,
output_path: Optional[str] = None
) -> Dict[str, Any]:
"""
MCP Tool: Build Optimization Configuration
Creates a complete optimization configuration file from user selections.
Args:
sim_file_path: Absolute path to .sim file
design_variables: List of design variable definitions
[
{
'name': 'tip_thickness',
'lower_bound': 15.0,
'upper_bound': 25.0
},
...
]
objectives: List of objective definitions
[
{
'objective_key': 'minimize_mass',
'weight': 5.0, # optional
'target': None # optional, for goal programming
},
...
]
constraints: Optional list of constraint definitions
[
{
'constraint_key': 'max_stress_limit',
'limit_value': 200.0
},
...
]
optimization_settings: Optional dict with algorithm settings
{
'n_trials': 100,
'sampler': 'TPE'
}
output_path: Optional path to save config JSON.
Defaults to 'optimization_config.json' in sim file directory
Returns:
Dictionary with status and configuration details
Example:
>>> result = build_optimization_config(
... sim_file_path="C:/Projects/Bracket/analysis.sim",
... design_variables=[
... {'name': 'tip_thickness', 'lower_bound': 15.0, 'upper_bound': 25.0}
... ],
... objectives=[
... {'objective_key': 'minimize_mass', 'weight': 5.0}
... ],
... constraints=[
... {'constraint_key': 'max_stress_limit', 'limit_value': 200.0}
... ]
... )
"""
try:
# Step 1: Discover model
model_result = discover_fea_model(sim_file_path)
if model_result['status'] != 'success':
return {
'status': 'error',
'error_type': 'model_discovery_failed',
'message': model_result.get('message', 'Failed to discover FEA model'),
'suggestion': model_result.get('suggestion', 'Check that the .sim file is valid')
}
# Step 2: Create builder
builder = OptimizationConfigBuilder(model_result)
# Step 3: Validate and add design variables
available_vars = {dv['name']: dv for dv in builder.list_available_design_variables()}
for dv in design_variables:
name = dv['name']
if name not in available_vars:
return {
'status': 'error',
'error_type': 'invalid_design_variable',
'message': f"Design variable '{name}' not found in model",
'available_variables': list(available_vars.keys()),
'suggestion': f"Choose from: {', '.join(available_vars.keys())}"
}
builder.add_design_variable(
name=name,
lower_bound=dv['lower_bound'],
upper_bound=dv['upper_bound']
)
# Step 4: Add objectives
available_objectives = builder.list_available_objectives()
for obj in objectives:
obj_key = obj['objective_key']
if obj_key not in available_objectives:
return {
'status': 'error',
'error_type': 'invalid_objective',
'message': f"Objective '{obj_key}' not recognized",
'available_objectives': list(available_objectives.keys()),
'suggestion': f"Choose from: {', '.join(available_objectives.keys())}"
}
builder.add_objective(
objective_key=obj_key,
weight=obj.get('weight'),
target=obj.get('target')
)
# Step 5: Add constraints (optional)
if constraints:
available_constraints = builder.list_available_constraints()
for const in constraints:
const_key = const['constraint_key']
if const_key not in available_constraints:
return {
'status': 'error',
'error_type': 'invalid_constraint',
'message': f"Constraint '{const_key}' not recognized",
'available_constraints': list(available_constraints.keys()),
'suggestion': f"Choose from: {', '.join(available_constraints.keys())}"
}
builder.add_constraint(
constraint_key=const_key,
limit_value=const['limit_value']
)
# Step 6: Set optimization settings (optional)
if optimization_settings:
builder.set_optimization_settings(
n_trials=optimization_settings.get('n_trials'),
sampler=optimization_settings.get('sampler')
)
# Step 7: Build and validate configuration
config = builder.build()
# Step 8: Save to file
if output_path is None:
sim_path = Path(sim_file_path)
output_path = sim_path.parent / 'optimization_config.json'
else:
output_path = Path(output_path)
with open(output_path, 'w') as f:
json.dump(config, f, indent=2)
# Step 9: Return success with summary
return {
'status': 'success',
'message': 'Optimization configuration created successfully',
'config_file': str(output_path),
'summary': {
'design_variables': len(config['design_variables']),
'objectives': len(config['objectives']),
'constraints': len(config['constraints']),
'n_trials': config['optimization_settings']['n_trials'],
'sampler': config['optimization_settings']['sampler']
},
'config': config
}
except ValueError as e:
return {
'status': 'error',
'error_type': 'validation_error',
'message': str(e),
'suggestion': 'Check that all required fields are provided correctly'
}
except Exception as e:
return {
'status': 'error',
'error_type': 'unexpected_error',
'message': str(e),
'suggestion': 'This may be a bug. Please report this issue.'
}
def list_optimization_options(sim_file_path: str) -> Dict[str, Any]:
"""
Helper tool: List all available optimization options for a model.
This is useful for LLMs to show users what they can choose from.
Args:
sim_file_path: Absolute path to .sim file
Returns:
Dictionary with all available options
"""
try:
# Discover model
model_result = discover_fea_model(sim_file_path)
if model_result['status'] != 'success':
return model_result
# Create builder to get options
builder = OptimizationConfigBuilder(model_result)
# Get all available options
design_vars = builder.list_available_design_variables()
objectives = builder.list_available_objectives()
constraints = builder.list_available_constraints()
return {
'status': 'success',
'sim_file': sim_file_path,
'available_design_variables': design_vars,
'available_objectives': objectives,
'available_constraints': constraints,
'model_info': {
'solutions': model_result.get('solutions', []),
'expression_count': len(model_result.get('expressions', []))
}
}
except Exception as e:
return {
'status': 'error',
'error_type': 'unexpected_error',
'message': str(e)
}
def format_optimization_options_for_llm(options: Dict[str, Any]) -> str:
"""
Format optimization options for LLM consumption (Markdown).
Args:
options: Output from list_optimization_options()
Returns:
Markdown-formatted string
"""
if options['status'] != 'success':
return f"❌ **Error**: {options['message']}\n\n💡 {options.get('suggestion', '')}"
md = []
md.append(f"# Optimization Configuration Options\n")
md.append(f"**Model**: `{options['sim_file']}`\n")
# Design Variables
md.append(f"## Available Design Variables ({len(options['available_design_variables'])})\n")
if options['available_design_variables']:
md.append("| Name | Current Value | Units | Suggested Bounds |")
md.append("|------|---------------|-------|------------------|")
for dv in options['available_design_variables']:
bounds = dv['suggested_bounds']
md.append(f"| `{dv['name']}` | {dv['current_value']} | {dv['units']} | [{bounds[0]:.2f}, {bounds[1]:.2f}] |")
else:
md.append("⚠️ No design variables found. Model may not be parametric.")
md.append("")
# Objectives
md.append(f"## Available Objectives\n")
for key, obj in options['available_objectives'].items():
md.append(f"### `{key}`")
md.append(f"- **Description**: {obj['description']}")
md.append(f"- **Metric**: {obj['metric']} ({obj['units']})")
md.append(f"- **Default Weight**: {obj['typical_weight']}")
md.append(f"- **Extractor**: `{obj['extractor']}`")
md.append("")
# Constraints
md.append(f"## Available Constraints\n")
for key, const in options['available_constraints'].items():
md.append(f"### `{key}`")
md.append(f"- **Description**: {const['description']}")
md.append(f"- **Metric**: {const['metric']} ({const['units']})")
md.append(f"- **Typical Value**: {const['typical_value']}")
md.append(f"- **Type**: {const['constraint_type']}")
md.append(f"- **Extractor**: `{const['extractor']}`")
md.append("")
return "\n".join(md)
# For testing
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python optimization_config.py <path_to_sim_file>")
sys.exit(1)
sim_path = sys.argv[1]
# Test 1: List options
print("=" * 60)
print("TEST 1: List Available Options")
print("=" * 60)
options = list_optimization_options(sim_path)
print(format_optimization_options_for_llm(options))
# Test 2: Build configuration
print("\n" + "=" * 60)
print("TEST 2: Build Optimization Configuration")
print("=" * 60)
result = build_optimization_config(
sim_file_path=sim_path,
design_variables=[
{'name': 'tip_thickness', 'lower_bound': 15.0, 'upper_bound': 25.0},
{'name': 'support_angle', 'lower_bound': 20.0, 'upper_bound': 40.0},
],
objectives=[
{'objective_key': 'minimize_mass', 'weight': 5.0},
{'objective_key': 'minimize_max_stress', 'weight': 10.0}
],
constraints=[
{'constraint_key': 'max_displacement_limit', 'limit_value': 1.0},
{'constraint_key': 'max_stress_limit', 'limit_value': 200.0}
],
optimization_settings={
'n_trials': 150,
'sampler': 'TPE'
}
)
if result['status'] == 'success':
print(f"SUCCESS: Configuration saved to: {result['config_file']}")
print(f"\nSummary:")
for key, value in result['summary'].items():
print(f" - {key}: {value}")
else:
print(f"ERROR: {result['message']}")
print(f"Suggestion: {result.get('suggestion', '')}")

190
optimization_config.json Normal file
View File

@@ -0,0 +1,190 @@
{
"design_variables": [
{
"name": "tip_thickness",
"type": "continuous",
"bounds": [
15.0,
25.0
],
"units": "mm",
"initial_value": 20.0
},
{
"name": "support_angle",
"type": "continuous",
"bounds": [
20.0,
40.0
],
"units": "degrees",
"initial_value": 30.0
},
{
"name": "support_blend_radius",
"type": "continuous",
"bounds": [
5.0,
15.0
],
"units": "mm",
"initial_value": 10.0
}
],
"objectives": [
{
"name": "minimize_mass",
"description": "Minimize total mass (weight reduction)",
"extractor": "mass_extractor",
"metric": "total_mass",
"direction": "minimize",
"weight": 5.0
},
{
"name": "minimize_max_stress",
"description": "Minimize maximum von Mises stress",
"extractor": "stress_extractor",
"metric": "max_von_mises",
"direction": "minimize",
"weight": 10.0
}
],
"constraints": [
{
"name": "max_displacement_limit",
"description": "Maximum allowable displacement",
"extractor": "displacement_extractor",
"metric": "max_displacement",
"type": "upper_bound",
"limit": 1.0,
"units": "mm"
},
{
"name": "max_stress_limit",
"description": "Maximum allowable von Mises stress",
"extractor": "stress_extractor",
"metric": "max_von_mises",
"type": "upper_bound",
"limit": 200.0,
"units": "MPa"
}
],
"optimization_settings": {
"n_trials": 150,
"sampler": "TPE",
"n_startup_trials": 20
},
"model_info": {
"sim_file": "/home/user/Atomizer/tests/Bracket_sim1.sim",
"solutions": [
{
"name": "DisableInThermalSolution",
"type": "DisableInThermalSolution",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Disable in Thermal Solution 3D",
"type": "Disable in Thermal Solution 3D",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "-Flow-Structural Coupled Solution Parameters",
"type": "-Flow-Structural Coupled Solution Parameters",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Direct Frequency Response",
"type": "Direct Frequency Response",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "*Thermal-Flow Coupled Solution Parameters",
"type": "*Thermal-Flow Coupled Solution Parameters",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "0Thermal-Structural Coupled Solution Parameters",
"type": "0Thermal-Structural Coupled Solution Parameters",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Linear Statics",
"type": "Linear Statics",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Disable in Thermal Solution 2D",
"type": "Disable in Thermal Solution 2D",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Thermal Solution Parameters",
"type": "Thermal Solution Parameters",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Nonlinear Statics",
"type": "Nonlinear Statics",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Modal Frequency Response",
"type": "Modal Frequency Response",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "1Pass Structural Contact Solution to Flow Solver",
"type": "1Pass Structural Contact Solution to Flow Solver",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "\"ObjectDisableInThermalSolution3D",
"type": "\"ObjectDisableInThermalSolution3D",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Design Optimization",
"type": "Design Optimization",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "\"ObjectDisableInThermalSolution2D",
"type": "\"ObjectDisableInThermalSolution2D",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Normal Modes",
"type": "Normal Modes",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Direct Transient Response",
"type": "Direct Transient Response",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
},
{
"name": "Modal Transient Response",
"type": "Modal Transient Response",
"solver": "NX Nastran",
"description": "Extracted from binary .sim file"
}
]
}
}

View File

@@ -0,0 +1,403 @@
"""
Optimization Configuration Builder
Helps users build multi-objective optimization configurations by:
1. Discovering available design variables from FEA model
2. Listing available objectives and constraints
3. Creating structured optimization_config.json
Supports:
- Multi-objective optimization (minimize weight + stress simultaneously)
- Constraints (max displacement, stress limits, mass limits)
- User selection of which objectives/constraints to apply
"""
from pathlib import Path
from typing import Dict, Any, List
import json
class OptimizationConfigBuilder:
"""
Interactive builder for optimization configurations.
Workflow:
1. Discover model capabilities (design variables, analysis type)
2. Present available objectives/constraints to user
3. Build configuration based on user selections
"""
# Available objectives that can be extracted from OP2 files
AVAILABLE_OBJECTIVES = {
'minimize_mass': {
'description': 'Minimize total mass (weight reduction)',
'extractor': 'mass_extractor',
'metric': 'total_mass',
'units': 'kg',
'direction': 'minimize',
'typical_weight': 5.0 # Higher priority in multi-objective
},
'minimize_max_stress': {
'description': 'Minimize maximum von Mises stress',
'extractor': 'stress_extractor',
'metric': 'max_von_mises',
'units': 'MPa',
'direction': 'minimize',
'typical_weight': 10.0 # Very important - failure prevention
},
'minimize_max_displacement': {
'description': 'Minimize maximum displacement (increase stiffness)',
'extractor': 'displacement_extractor',
'metric': 'max_displacement',
'units': 'mm',
'direction': 'minimize',
'typical_weight': 3.0
},
'minimize_volume': {
'description': 'Minimize total volume (material usage)',
'extractor': 'volume_extractor',
'metric': 'total_volume',
'units': 'mm^3',
'direction': 'minimize',
'typical_weight': 4.0
}
}
# Available constraints
AVAILABLE_CONSTRAINTS = {
'max_stress_limit': {
'description': 'Maximum allowable von Mises stress',
'extractor': 'stress_extractor',
'metric': 'max_von_mises',
'units': 'MPa',
'typical_value': 200.0, # Below yield strength with safety factor
'constraint_type': 'upper_bound'
},
'max_displacement_limit': {
'description': 'Maximum allowable displacement',
'extractor': 'displacement_extractor',
'metric': 'max_displacement',
'units': 'mm',
'typical_value': 1.0, # Stiffness requirement
'constraint_type': 'upper_bound'
},
'min_mass_limit': {
'description': 'Minimum required mass (structural integrity)',
'extractor': 'mass_extractor',
'metric': 'total_mass',
'units': 'kg',
'typical_value': 0.3,
'constraint_type': 'lower_bound'
},
'max_mass_limit': {
'description': 'Maximum allowable mass (weight budget)',
'extractor': 'mass_extractor',
'metric': 'total_mass',
'units': 'kg',
'typical_value': 0.5,
'constraint_type': 'upper_bound'
}
}
def __init__(self, model_discovery_result: Dict[str, Any]):
"""
Initialize with model discovery results.
Args:
model_discovery_result: Output from discover_fea_model()
"""
self.model_info = model_discovery_result
self.config = {
'design_variables': [],
'objectives': [],
'constraints': [],
'optimization_settings': {
'n_trials': 100,
'sampler': 'TPE',
'n_startup_trials': 20
}
}
def list_available_design_variables(self) -> List[Dict[str, Any]]:
"""
List all available design variables from model.
Returns:
List of design variable options
"""
if 'expressions' not in self.model_info:
return []
design_vars = []
for expr in self.model_info['expressions']:
if expr['value'] is not None: # Only variables with known values
design_vars.append({
'name': expr['name'],
'current_value': expr['value'],
'units': expr['units'],
'type': expr.get('type', 'Unknown'),
'suggested_bounds': self._suggest_bounds(expr)
})
return design_vars
def _suggest_bounds(self, expr: Dict[str, Any]) -> tuple:
"""
Suggest reasonable optimization bounds for a design variable.
Args:
expr: Expression dictionary
Returns:
(lower_bound, upper_bound)
"""
value = expr['value']
expr_type = expr.get('type', '').lower()
if 'angle' in expr_type or 'degrees' in expr.get('units', '').lower():
# Angles: ±15 degrees
return (max(0, value - 15), min(180, value + 15))
elif 'thickness' in expr['name'].lower() or 'dimension' in expr_type:
# Dimensions: ±30%
return (value * 0.7, value * 1.3)
elif 'radius' in expr['name'].lower() or 'diameter' in expr['name'].lower():
# Radii/diameters: ±25%
return (value * 0.75, value * 1.25)
else:
# Default: ±20%
return (value * 0.8, value * 1.2)
def list_available_objectives(self) -> Dict[str, Dict[str, Any]]:
"""
List all available optimization objectives.
Returns:
Dictionary of objective options
"""
return self.AVAILABLE_OBJECTIVES.copy()
def list_available_constraints(self) -> Dict[str, Dict[str, Any]]:
"""
List all available constraints.
Returns:
Dictionary of constraint options
"""
return self.AVAILABLE_CONSTRAINTS.copy()
def add_design_variable(self, name: str, lower_bound: float, upper_bound: float):
"""
Add a design variable to the configuration.
Args:
name: Expression name from model
lower_bound: Minimum value
upper_bound: Maximum value
"""
# Verify variable exists in model
expr = next((e for e in self.model_info['expressions'] if e['name'] == name), None)
if not expr:
raise ValueError(f"Design variable '{name}' not found in model")
self.config['design_variables'].append({
'name': name,
'type': 'continuous',
'bounds': [lower_bound, upper_bound],
'units': expr.get('units', ''),
'initial_value': expr['value']
})
def add_objective(self, objective_key: str, weight: float = None, target: float = None):
"""
Add an objective to the configuration.
Args:
objective_key: Key from AVAILABLE_OBJECTIVES
weight: Importance weight (for multi-objective)
target: Target value (optional, for goal programming)
"""
if objective_key not in self.AVAILABLE_OBJECTIVES:
raise ValueError(f"Unknown objective: {objective_key}")
obj_info = self.AVAILABLE_OBJECTIVES[objective_key]
objective = {
'name': objective_key,
'description': obj_info['description'],
'extractor': obj_info['extractor'],
'metric': obj_info['metric'],
'direction': obj_info['direction'],
'weight': weight or obj_info['typical_weight']
}
if target is not None:
objective['target'] = target
self.config['objectives'].append(objective)
def add_constraint(self, constraint_key: str, limit_value: float):
"""
Add a constraint to the configuration.
Args:
constraint_key: Key from AVAILABLE_CONSTRAINTS
limit_value: Constraint limit value
"""
if constraint_key not in self.AVAILABLE_CONSTRAINTS:
raise ValueError(f"Unknown constraint: {constraint_key}")
const_info = self.AVAILABLE_CONSTRAINTS[constraint_key]
constraint = {
'name': constraint_key,
'description': const_info['description'],
'extractor': const_info['extractor'],
'metric': const_info['metric'],
'type': const_info['constraint_type'],
'limit': limit_value,
'units': const_info['units']
}
self.config['constraints'].append(constraint)
def set_optimization_settings(self, n_trials: int = None, sampler: str = None):
"""
Configure optimization algorithm settings.
Args:
n_trials: Number of optimization iterations
sampler: 'TPE', 'CMAES', 'GP', etc.
"""
if n_trials:
self.config['optimization_settings']['n_trials'] = n_trials
if sampler:
self.config['optimization_settings']['sampler'] = sampler
def build(self) -> Dict[str, Any]:
"""
Build and validate the configuration.
Returns:
Complete optimization configuration
"""
# Validation
if not self.config['design_variables']:
raise ValueError("At least one design variable is required")
if not self.config['objectives']:
raise ValueError("At least one objective is required")
# Add metadata
self.config['model_info'] = {
'sim_file': self.model_info.get('sim_file', ''),
'solutions': self.model_info.get('solutions', [])
}
return self.config
def save(self, output_path: Path):
"""
Save configuration to JSON file.
Args:
output_path: Path to save configuration
"""
config = self.build()
with open(output_path, 'w') as f:
json.dump(config, f, indent=2)
print(f"Configuration saved to: {output_path}")
def print_summary(self):
"""Print a human-readable summary of the configuration."""
print("\n" + "="*60)
print("OPTIMIZATION CONFIGURATION SUMMARY")
print("="*60)
print(f"\nModel: {self.model_info.get('sim_file', 'Unknown')}")
print(f"\nDesign Variables ({len(self.config['design_variables'])}):")
for dv in self.config['design_variables']:
print(f"{dv['name']}: [{dv['bounds'][0]:.2f}, {dv['bounds'][1]:.2f}] {dv['units']}")
print(f"\nObjectives ({len(self.config['objectives'])}):")
for obj in self.config['objectives']:
print(f"{obj['description']} (weight: {obj['weight']:.1f})")
print(f"\nConstraints ({len(self.config['constraints'])}):")
for const in self.config['constraints']:
operator = '<=' if const['type'] == 'upper_bound' else '>='
print(f"{const['description']}: {const['metric']} {operator} {const['limit']} {const['units']}")
print(f"\nOptimization Settings:")
print(f" • Trials: {self.config['optimization_settings']['n_trials']}")
print(f" • Sampler: {self.config['optimization_settings']['sampler']}")
print("="*60 + "\n")
# Example usage
if __name__ == "__main__":
from mcp_server.tools.model_discovery import discover_fea_model
# Step 1: Discover model
print("Step 1: Discovering FEA model...")
model_result = discover_fea_model("tests/Bracket_sim1.sim")
# Step 2: Create builder
builder = OptimizationConfigBuilder(model_result)
# Step 3: Show available options
print("\n" + "="*60)
print("AVAILABLE DESIGN VARIABLES:")
print("="*60)
for dv in builder.list_available_design_variables():
print(f"\n{dv['name']}")
print(f" Current value: {dv['current_value']} {dv['units']}")
print(f" Suggested bounds: {dv['suggested_bounds']}")
print("\n" + "="*60)
print("AVAILABLE OBJECTIVES:")
print("="*60)
for key, obj in builder.list_available_objectives().items():
print(f"\n{key}")
print(f" Description: {obj['description']}")
print(f" Default weight: {obj['typical_weight']}")
print("\n" + "="*60)
print("AVAILABLE CONSTRAINTS:")
print("="*60)
for key, const in builder.list_available_constraints().items():
print(f"\n{key}")
print(f" Description: {const['description']}")
print(f" Typical value: {const['typical_value']} {const['units']}")
# Step 4: Build a multi-objective configuration
print("\n" + "="*60)
print("BUILDING CONFIGURATION:")
print("="*60)
# Add design variables
builder.add_design_variable('tip_thickness', 15.0, 25.0)
builder.add_design_variable('support_angle', 20.0, 40.0)
builder.add_design_variable('support_blend_radius', 5.0, 15.0)
# Add objectives: minimize weight AND minimize stress
builder.add_objective('minimize_mass', weight=5.0)
builder.add_objective('minimize_max_stress', weight=10.0)
# Add constraints: max displacement < 1.0 mm, max stress < 200 MPa
builder.add_constraint('max_displacement_limit', limit_value=1.0)
builder.add_constraint('max_stress_limit', limit_value=200.0)
# Set optimization settings
builder.set_optimization_settings(n_trials=150, sampler='TPE')
# Print summary
builder.print_summary()
# Save configuration
builder.save(Path('optimization_config.json'))
print("\nConfiguration ready for optimization!")

View File

@@ -0,0 +1,236 @@
"""
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()
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',
'cbar_stress',
'cbeam_stress'
]
max_stress_overall = 0.0
max_element_id = None
max_element_type = None
for table_name in stress_tables:
if 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':
# von Mises is usually last column
stresses = stress_data.data[0, :, -1] # timestep 0, all elements, last column
else:
# Max principal stress (second-to-last column typically)
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', '')
return {
'max_stress': float(max_stress_overall),
'stress_type': stress_type,
'element_id': int(max_element_id) if max_element_id else None,
'element_type': max_element_type,
'units': 'MPa', # NX typically uses 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']}")

BIN
tests/Bracket.prt Normal file

Binary file not shown.

BIN
tests/Bracket_fem1.fem Normal file

Binary file not shown.

BIN
tests/Bracket_sim1.sim Normal file

Binary file not shown.

0
tests/__init__.py Normal file
View File

File diff suppressed because it is too large Load Diff

View File

View File

View File

@@ -0,0 +1,211 @@
"""
Unit tests for MCP Model Discovery Tool
Tests the .sim file parser and FEA model discovery functionality.
"""
import pytest
from pathlib import Path
import sys
# Add project root to path
project_root = Path(__file__).parent.parent.parent.parent
sys.path.insert(0, str(project_root))
from mcp_server.tools.model_discovery import (
discover_fea_model,
format_discovery_result_for_llm,
SimFileParser
)
class TestSimFileParser:
"""Test the SimFileParser class"""
@pytest.fixture
def example_sim_path(self):
"""Path to example .sim file"""
return project_root / "examples" / "test_bracket.sim"
def test_parser_initialization(self, example_sim_path):
"""Test that parser initializes correctly"""
parser = SimFileParser(example_sim_path)
assert parser.sim_path.exists()
assert parser.tree is not None
assert parser.root is not None
def test_parser_file_not_found(self):
"""Test error handling for missing file"""
with pytest.raises(FileNotFoundError):
SimFileParser("/nonexistent/path/file.sim")
def test_parser_invalid_extension(self):
"""Test error handling for non-.sim file"""
with pytest.raises(ValueError):
SimFileParser(project_root / "README.md")
def test_extract_solutions(self, example_sim_path):
"""Test solution extraction"""
parser = SimFileParser(example_sim_path)
solutions = parser.extract_solutions()
assert len(solutions) > 0
assert solutions[0]['name'] == 'Structural Analysis 1'
assert solutions[0]['type'] == 'Static Structural'
assert solutions[0]['solver'] == 'NX Nastran'
def test_extract_expressions(self, example_sim_path):
"""Test expression extraction"""
parser = SimFileParser(example_sim_path)
expressions = parser.extract_expressions()
assert len(expressions) > 0
# Check for expected expressions
expr_names = [e['name'] for e in expressions]
assert 'wall_thickness' in expr_names
assert 'hole_diameter' in expr_names
assert 'rib_spacing' in expr_names
# Check expression values
wall_thickness = next(e for e in expressions if e['name'] == 'wall_thickness')
assert wall_thickness['value'] == '5.0'
assert wall_thickness['units'] == 'mm'
def test_extract_fem_info(self, example_sim_path):
"""Test FEM information extraction"""
parser = SimFileParser(example_sim_path)
fem_info = parser.extract_fem_info()
# Check mesh info
assert 'mesh' in fem_info
assert fem_info['mesh']['name'] == 'Bracket Mesh'
assert fem_info['mesh']['node_count'] == '8234'
assert fem_info['mesh']['element_count'] == '4521'
# Check materials
assert len(fem_info['materials']) > 0
assert fem_info['materials'][0]['name'] == 'Aluminum 6061-T6'
# Check loads
assert len(fem_info['loads']) > 0
assert fem_info['loads'][0]['name'] == 'Applied Force'
# Check constraints
assert len(fem_info['constraints']) > 0
assert fem_info['constraints'][0]['name'] == 'Fixed Support'
class TestDiscoverFEAModel:
"""Test the main discover_fea_model function"""
@pytest.fixture
def example_sim_path(self):
"""Path to example .sim file"""
return str(project_root / "examples" / "test_bracket.sim")
def test_successful_discovery(self, example_sim_path):
"""Test successful model discovery"""
result = discover_fea_model(example_sim_path)
assert result['status'] == 'success'
assert result['file_exists'] is True
assert 'solutions' in result
assert 'expressions' in result
assert 'fem_info' in result
assert 'summary' in result
# Check summary statistics
assert result['summary']['solution_count'] >= 1
assert result['summary']['expression_count'] >= 3
def test_file_not_found_error(self):
"""Test error handling for missing file"""
result = discover_fea_model("/nonexistent/file.sim")
assert result['status'] == 'error'
assert result['error_type'] == 'file_not_found'
assert 'message' in result
assert 'suggestion' in result
def test_result_structure(self, example_sim_path):
"""Test that result has expected structure"""
result = discover_fea_model(example_sim_path)
# Check top-level keys
expected_keys = ['status', 'sim_file', 'file_exists', 'solutions',
'expressions', 'fem_info', 'linked_files', 'metadata', 'summary']
for key in expected_keys:
assert key in result, f"Missing key: {key}"
# Check summary keys
expected_summary_keys = ['solution_count', 'expression_count',
'material_count', 'load_count', 'constraint_count']
for key in expected_summary_keys:
assert key in result['summary'], f"Missing summary key: {key}"
class TestFormatDiscoveryResult:
"""Test the Markdown formatting function"""
@pytest.fixture
def example_sim_path(self):
"""Path to example .sim file"""
return str(project_root / "examples" / "test_bracket.sim")
def test_format_success_result(self, example_sim_path):
"""Test formatting of successful discovery"""
result = discover_fea_model(example_sim_path)
formatted = format_discovery_result_for_llm(result)
assert isinstance(formatted, str)
assert '# FEA Model Analysis' in formatted
assert 'Solutions' in formatted
assert 'Expressions' in formatted
assert 'wall_thickness' in formatted
def test_format_error_result(self):
"""Test formatting of error result"""
result = discover_fea_model("/nonexistent/file.sim")
formatted = format_discovery_result_for_llm(result)
assert isinstance(formatted, str)
assert '' in formatted or 'Error' in formatted
assert result['message'] in formatted
# Integration test
def test_end_to_end_workflow():
"""
Test the complete workflow:
1. Discover model
2. Format for LLM
3. Verify output is useful
"""
example_sim = str(project_root / "examples" / "test_bracket.sim")
# Step 1: Discover
result = discover_fea_model(example_sim)
assert result['status'] == 'success'
# Step 2: Format
formatted = format_discovery_result_for_llm(result)
assert len(formatted) > 100 # Should be substantial output
# Step 3: Verify key information is present
assert 'wall_thickness' in formatted
assert 'Aluminum' in formatted
assert 'Static Structural' in formatted
print("\n" + "="*60)
print("INTEGRATION TEST OUTPUT:")
print("="*60)
print(formatted)
print("="*60)
if __name__ == "__main__":
# Run tests with pytest
pytest.main([__file__, "-v", "-s"])