Phase 2 - Structural Analysis: - extract_principal_stress: σ1, σ2, σ3 principal stresses from OP2 - extract_strain_energy: Element and total strain energy - extract_spc_forces: Reaction forces at boundary conditions Phase 3 - Multi-Physics: - extract_temperature: Nodal temperatures from thermal OP2 (SOL 153/159) - extract_temperature_gradient: Thermal gradient approximation - extract_heat_flux: Element heat flux from thermal analysis - extract_modal_mass: Modal effective mass from F06 (SOL 103) - get_first_frequency: Convenience function for first natural frequency Documentation: - Updated SYS_12_EXTRACTOR_LIBRARY.md with E12-E18 specifications - Updated NX_OPEN_AUTOMATION_ROADMAP.md marking Phase 3 complete - Added test_phase3_extractors.py for validation All extractors follow consistent API pattern returning Dict with success, data, and error fields for robust error handling. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
468 lines
17 KiB
Python
468 lines
17 KiB
Python
"""
|
|
Temperature Extractor for Thermal Analysis Results
|
|
===================================================
|
|
|
|
Phase 3 Task 3.1 - NX Open Automation Roadmap
|
|
|
|
Extracts nodal temperature results from Nastran OP2 files for thermal optimization.
|
|
|
|
Usage:
|
|
from optimization_engine.extractors.extract_temperature import extract_temperature
|
|
|
|
result = extract_temperature("path/to/thermal.op2", subcase=1)
|
|
print(f"Max temperature: {result['max_temperature']} K")
|
|
|
|
Supports:
|
|
- SOL 153 (Steady-State Heat Transfer)
|
|
- SOL 159 (Transient Heat Transfer)
|
|
- Thermal subcases in coupled analyses
|
|
"""
|
|
|
|
import numpy as np
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional, List, Union
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def extract_temperature(
|
|
op2_file: Union[str, Path],
|
|
subcase: int = 1,
|
|
nodes: Optional[List[int]] = None,
|
|
return_field: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Extract nodal temperatures from thermal analysis OP2 file.
|
|
|
|
Args:
|
|
op2_file: Path to the OP2 results file
|
|
subcase: Subcase number to extract (default: 1)
|
|
nodes: Optional list of specific node IDs to extract.
|
|
If None, extracts all nodes.
|
|
return_field: If True, include full temperature field in result
|
|
|
|
Returns:
|
|
dict: {
|
|
'success': bool,
|
|
'max_temperature': float (K or °C depending on model units),
|
|
'min_temperature': float,
|
|
'avg_temperature': float,
|
|
'max_node_id': int (node with max temperature),
|
|
'min_node_id': int (node with min temperature),
|
|
'node_count': int,
|
|
'temperatures': dict (node_id: temp) - only if return_field=True,
|
|
'unit': str ('K' or 'C'),
|
|
'subcase': int,
|
|
'error': str or None
|
|
}
|
|
|
|
Example:
|
|
>>> result = extract_temperature("thermal_analysis.op2", subcase=1)
|
|
>>> if result['success']:
|
|
... print(f"Max temp: {result['max_temperature']:.1f} K at node {result['max_node_id']}")
|
|
... print(f"Temperature range: {result['min_temperature']:.1f} - {result['max_temperature']:.1f} K")
|
|
"""
|
|
op2_path = Path(op2_file)
|
|
|
|
if not op2_path.exists():
|
|
return {
|
|
'success': False,
|
|
'error': f"OP2 file not found: {op2_path}",
|
|
'max_temperature': None,
|
|
'min_temperature': None,
|
|
'avg_temperature': None,
|
|
'max_node_id': None,
|
|
'min_node_id': None,
|
|
'node_count': 0,
|
|
'subcase': subcase
|
|
}
|
|
|
|
try:
|
|
from pyNastran.op2.op2 import read_op2
|
|
|
|
# Read OP2 with minimal output
|
|
op2 = read_op2(str(op2_path), load_geometry=False, debug=False, log=None)
|
|
|
|
# Check for temperature results
|
|
# pyNastran stores temperatures in different attributes depending on analysis type
|
|
temperatures = None
|
|
|
|
# Method 1: Check temperatures attribute (SOL 153/159)
|
|
if hasattr(op2, 'temperatures') and op2.temperatures:
|
|
if subcase in op2.temperatures:
|
|
temp_data = op2.temperatures[subcase]
|
|
temperatures = _extract_from_table(temp_data, nodes)
|
|
logger.debug(f"Found temperatures in op2.temperatures[{subcase}]")
|
|
|
|
# Method 2: Check thermal load results (alternative storage)
|
|
if temperatures is None and hasattr(op2, 'thermal_load_vectors'):
|
|
if subcase in op2.thermal_load_vectors:
|
|
temp_data = op2.thermal_load_vectors[subcase]
|
|
temperatures = _extract_from_table(temp_data, nodes)
|
|
logger.debug(f"Found temperatures in op2.thermal_load_vectors[{subcase}]")
|
|
|
|
# Method 3: Check displacement as temperature (some solvers store temp in disp)
|
|
# In thermal analysis, "displacement" can actually be temperature
|
|
if temperatures is None and hasattr(op2, 'displacements') and op2.displacements:
|
|
if subcase in op2.displacements:
|
|
disp_data = op2.displacements[subcase]
|
|
# Check if this is actually temperature data
|
|
# Temperature data typically has only 1 DOF (scalar field)
|
|
if hasattr(disp_data, 'data'):
|
|
data = disp_data.data
|
|
if len(data.shape) >= 2:
|
|
# For thermal, we expect scalar temperature at each node
|
|
# Column 0 of translational data contains temperature
|
|
temps_array = data[0, :, 0] if len(data.shape) == 3 else data[:, 0]
|
|
node_ids = disp_data.node_gridtype[:, 0]
|
|
temperatures = {int(nid): float(temps_array[i])
|
|
for i, nid in enumerate(node_ids)
|
|
if nodes is None or nid in nodes}
|
|
logger.debug(f"Found temperatures in op2.displacements[{subcase}] (thermal mode)")
|
|
|
|
if temperatures is None or len(temperatures) == 0:
|
|
# List available subcases for debugging
|
|
available_subcases = []
|
|
if hasattr(op2, 'temperatures') and op2.temperatures:
|
|
available_subcases.extend(list(op2.temperatures.keys()))
|
|
|
|
return {
|
|
'success': False,
|
|
'error': f"No temperature results found for subcase {subcase}. "
|
|
f"Available subcases: {available_subcases}",
|
|
'max_temperature': None,
|
|
'min_temperature': None,
|
|
'avg_temperature': None,
|
|
'max_node_id': None,
|
|
'min_node_id': None,
|
|
'node_count': 0,
|
|
'subcase': subcase
|
|
}
|
|
|
|
# Compute statistics
|
|
temp_values = np.array(list(temperatures.values()))
|
|
temp_nodes = np.array(list(temperatures.keys()))
|
|
|
|
max_idx = np.argmax(temp_values)
|
|
min_idx = np.argmin(temp_values)
|
|
|
|
result = {
|
|
'success': True,
|
|
'error': None,
|
|
'max_temperature': float(temp_values[max_idx]),
|
|
'min_temperature': float(temp_values[min_idx]),
|
|
'avg_temperature': float(np.mean(temp_values)),
|
|
'std_temperature': float(np.std(temp_values)),
|
|
'max_node_id': int(temp_nodes[max_idx]),
|
|
'min_node_id': int(temp_nodes[min_idx]),
|
|
'node_count': len(temperatures),
|
|
'unit': 'K', # Nastran typically uses Kelvin
|
|
'subcase': subcase
|
|
}
|
|
|
|
if return_field:
|
|
result['temperatures'] = temperatures
|
|
|
|
return result
|
|
|
|
except ImportError:
|
|
return {
|
|
'success': False,
|
|
'error': "pyNastran not installed. Install with: pip install pyNastran",
|
|
'max_temperature': None,
|
|
'min_temperature': None,
|
|
'avg_temperature': None,
|
|
'max_node_id': None,
|
|
'min_node_id': None,
|
|
'node_count': 0,
|
|
'subcase': subcase
|
|
}
|
|
except Exception as e:
|
|
logger.exception(f"Error extracting temperature from {op2_path}")
|
|
return {
|
|
'success': False,
|
|
'error': str(e),
|
|
'max_temperature': None,
|
|
'min_temperature': None,
|
|
'avg_temperature': None,
|
|
'max_node_id': None,
|
|
'min_node_id': None,
|
|
'node_count': 0,
|
|
'subcase': subcase
|
|
}
|
|
|
|
|
|
def _extract_from_table(temp_data, nodes: Optional[List[int]] = None) -> Dict[int, float]:
|
|
"""Extract temperature values from a pyNastran result table."""
|
|
temperatures = {}
|
|
|
|
if hasattr(temp_data, 'data') and hasattr(temp_data, 'node_gridtype'):
|
|
data = temp_data.data
|
|
node_ids = temp_data.node_gridtype[:, 0]
|
|
|
|
# Data shape is typically (ntimes, nnodes, ncomponents)
|
|
# For temperature, we want the first component
|
|
if len(data.shape) == 3:
|
|
temp_values = data[0, :, 0] # First time step, all nodes, first component
|
|
elif len(data.shape) == 2:
|
|
temp_values = data[:, 0]
|
|
else:
|
|
temp_values = data
|
|
|
|
for i, nid in enumerate(node_ids):
|
|
if nodes is None or nid in nodes:
|
|
temperatures[int(nid)] = float(temp_values[i])
|
|
|
|
return temperatures
|
|
|
|
|
|
def extract_temperature_gradient(
|
|
op2_file: Union[str, Path],
|
|
subcase: int = 1,
|
|
method: str = 'nodal_difference'
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Extract temperature gradients from thermal analysis.
|
|
|
|
Computes temperature gradients based on nodal temperature differences.
|
|
This is useful for identifying thermal stress hot spots.
|
|
|
|
Args:
|
|
op2_file: Path to the OP2 results file
|
|
subcase: Subcase number
|
|
method: Gradient calculation method:
|
|
- 'nodal_difference': Max temperature difference between adjacent nodes
|
|
- 'element_based': Gradient within elements (requires mesh connectivity)
|
|
|
|
Returns:
|
|
dict: {
|
|
'success': bool,
|
|
'max_gradient': float (K/mm or temperature units/length),
|
|
'avg_gradient': float,
|
|
'temperature_range': float (max - min temperature),
|
|
'gradient_location': tuple (node_id_hot, node_id_cold),
|
|
'error': str or None
|
|
}
|
|
|
|
Note:
|
|
For accurate gradients, element-based calculation requires mesh connectivity
|
|
which may not be available in all OP2 files. The nodal_difference method
|
|
provides an approximation based on temperature range.
|
|
"""
|
|
# First extract temperatures
|
|
temp_result = extract_temperature(op2_file, subcase=subcase, return_field=True)
|
|
|
|
if not temp_result['success']:
|
|
return {
|
|
'success': False,
|
|
'error': temp_result['error'],
|
|
'max_gradient': None,
|
|
'avg_gradient': None,
|
|
'temperature_range': None,
|
|
'gradient_location': None
|
|
}
|
|
|
|
temperatures = temp_result.get('temperatures', {})
|
|
|
|
if len(temperatures) < 2:
|
|
return {
|
|
'success': False,
|
|
'error': "Need at least 2 nodes to compute gradient",
|
|
'max_gradient': None,
|
|
'avg_gradient': None,
|
|
'temperature_range': None,
|
|
'gradient_location': None
|
|
}
|
|
|
|
# Compute temperature range (proxy for max gradient without mesh)
|
|
temp_range = temp_result['max_temperature'] - temp_result['min_temperature']
|
|
|
|
# For nodal_difference method, we report the temperature range
|
|
# True gradient computation would require mesh connectivity
|
|
return {
|
|
'success': True,
|
|
'error': None,
|
|
'max_gradient': temp_range, # Simplified - actual gradient needs mesh
|
|
'avg_gradient': temp_range / 2, # Rough estimate
|
|
'temperature_range': temp_range,
|
|
'gradient_location': (temp_result['max_node_id'], temp_result['min_node_id']),
|
|
'max_temperature': temp_result['max_temperature'],
|
|
'min_temperature': temp_result['min_temperature'],
|
|
'note': "Gradient approximated from temperature range. "
|
|
"Accurate gradient requires mesh connectivity."
|
|
}
|
|
|
|
|
|
def extract_heat_flux(
|
|
op2_file: Union[str, Path],
|
|
subcase: int = 1,
|
|
element_type: str = 'all'
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Extract element heat flux from thermal analysis OP2 file.
|
|
|
|
Args:
|
|
op2_file: Path to the OP2 results file
|
|
subcase: Subcase number
|
|
element_type: Element type to extract ('all', 'ctetra', 'chexa', etc.)
|
|
|
|
Returns:
|
|
dict: {
|
|
'success': bool,
|
|
'max_heat_flux': float (W/mm² or model units),
|
|
'min_heat_flux': float,
|
|
'avg_heat_flux': float,
|
|
'max_element_id': int,
|
|
'element_count': int,
|
|
'unit': str,
|
|
'error': str or None
|
|
}
|
|
"""
|
|
op2_path = Path(op2_file)
|
|
|
|
if not op2_path.exists():
|
|
return {
|
|
'success': False,
|
|
'error': f"OP2 file not found: {op2_path}",
|
|
'max_heat_flux': None,
|
|
'min_heat_flux': None,
|
|
'avg_heat_flux': None,
|
|
'max_element_id': None,
|
|
'element_count': 0,
|
|
'subcase': subcase
|
|
}
|
|
|
|
try:
|
|
from pyNastran.op2.op2 import read_op2
|
|
|
|
op2 = read_op2(str(op2_path), load_geometry=False, debug=False, log=None)
|
|
|
|
# Check for heat flux results
|
|
# pyNastran stores thermal flux in chbdyg_thermal_load or similar
|
|
heat_flux_data = None
|
|
|
|
# Check various thermal flux attributes
|
|
flux_attrs = [
|
|
'chbdyg_thermal_load',
|
|
'chbdye_thermal_load',
|
|
'chbdyp_thermal_load',
|
|
'thermalLoad_CONV',
|
|
'thermalLoad_CHBDY'
|
|
]
|
|
|
|
for attr in flux_attrs:
|
|
if hasattr(op2, attr):
|
|
data = getattr(op2, attr)
|
|
if data and subcase in data:
|
|
heat_flux_data = data[subcase]
|
|
logger.debug(f"Found heat flux in op2.{attr}[{subcase}]")
|
|
break
|
|
|
|
if heat_flux_data is None:
|
|
return {
|
|
'success': False,
|
|
'error': f"No heat flux results found for subcase {subcase}. "
|
|
"Heat flux output may not be requested in the analysis.",
|
|
'max_heat_flux': None,
|
|
'min_heat_flux': None,
|
|
'avg_heat_flux': None,
|
|
'max_element_id': None,
|
|
'element_count': 0,
|
|
'subcase': subcase
|
|
}
|
|
|
|
# Extract flux values
|
|
if hasattr(heat_flux_data, 'data'):
|
|
data = heat_flux_data.data
|
|
element_ids = heat_flux_data.element if hasattr(heat_flux_data, 'element') else []
|
|
|
|
# Flux magnitude
|
|
if len(data.shape) == 3:
|
|
flux_values = np.linalg.norm(data[0, :, :], axis=1)
|
|
else:
|
|
flux_values = np.abs(data[:, 0]) if len(data.shape) == 2 else data
|
|
|
|
max_idx = np.argmax(flux_values)
|
|
|
|
return {
|
|
'success': True,
|
|
'error': None,
|
|
'max_heat_flux': float(np.max(flux_values)),
|
|
'min_heat_flux': float(np.min(flux_values)),
|
|
'avg_heat_flux': float(np.mean(flux_values)),
|
|
'max_element_id': int(element_ids[max_idx]) if len(element_ids) > max_idx else None,
|
|
'element_count': len(flux_values),
|
|
'unit': 'W/mm²',
|
|
'subcase': subcase
|
|
}
|
|
|
|
return {
|
|
'success': False,
|
|
'error': "Could not parse heat flux data format",
|
|
'max_heat_flux': None,
|
|
'min_heat_flux': None,
|
|
'avg_heat_flux': None,
|
|
'max_element_id': None,
|
|
'element_count': 0,
|
|
'subcase': subcase
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Error extracting heat flux from {op2_path}")
|
|
return {
|
|
'success': False,
|
|
'error': str(e),
|
|
'max_heat_flux': None,
|
|
'min_heat_flux': None,
|
|
'avg_heat_flux': None,
|
|
'max_element_id': None,
|
|
'element_count': 0,
|
|
'subcase': subcase
|
|
}
|
|
|
|
|
|
# Convenience function for optimization constraints
|
|
def get_max_temperature(op2_file: Union[str, Path], subcase: int = 1) -> float:
|
|
"""
|
|
Get maximum temperature from OP2 file.
|
|
|
|
Convenience function for use in optimization constraints.
|
|
Returns inf if extraction fails.
|
|
|
|
Args:
|
|
op2_file: Path to OP2 file
|
|
subcase: Subcase number
|
|
|
|
Returns:
|
|
float: Maximum temperature or inf on failure
|
|
"""
|
|
result = extract_temperature(op2_file, subcase=subcase)
|
|
if result['success']:
|
|
return result['max_temperature']
|
|
else:
|
|
logger.warning(f"Temperature extraction failed: {result['error']}")
|
|
return float('inf')
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
if len(sys.argv) > 1:
|
|
op2_file = sys.argv[1]
|
|
subcase = int(sys.argv[2]) if len(sys.argv) > 2 else 1
|
|
|
|
print(f"Extracting temperature from: {op2_file}")
|
|
result = extract_temperature(op2_file, subcase=subcase)
|
|
|
|
if result['success']:
|
|
print(f"\n=== Temperature Results (Subcase {subcase}) ===")
|
|
print(f"Max temperature: {result['max_temperature']:.2f} {result['unit']} (node {result['max_node_id']})")
|
|
print(f"Min temperature: {result['min_temperature']:.2f} {result['unit']} (node {result['min_node_id']})")
|
|
print(f"Avg temperature: {result['avg_temperature']:.2f} {result['unit']}")
|
|
print(f"Node count: {result['node_count']}")
|
|
else:
|
|
print(f"Error: {result['error']}")
|
|
else:
|
|
print("Usage: python extract_temperature.py <op2_file> [subcase]")
|