""" Thermal Field Insight Provides visualization of temperature distributions from thermal FEA results. Shows temperature contours, gradients, and thermal statistics. Applicable to: Thermal analysis and thermo-structural optimization studies. """ from pathlib import Path from datetime import datetime from typing import Dict, Any, List, Optional, Tuple import numpy as np from .base import StudyInsight, InsightConfig, InsightResult, register_insight # Lazy imports _plotly_loaded = False _go = None _make_subplots = None _Triangulation = None _OP2 = None _BDF = None def _load_dependencies(): """Lazy load heavy dependencies.""" global _plotly_loaded, _go, _make_subplots, _Triangulation, _OP2, _BDF if not _plotly_loaded: import plotly.graph_objects as go from plotly.subplots import make_subplots from matplotlib.tri import Triangulation from pyNastran.op2.op2 import OP2 from pyNastran.bdf.bdf import BDF _go = go _make_subplots = make_subplots _Triangulation = Triangulation _OP2 = OP2 _BDF = BDF _plotly_loaded = True @register_insight class ThermalInsight(StudyInsight): """ Thermal field visualization. Shows: - 3D mesh colored by temperature - Temperature distribution histogram - Hot/cold spot identification - Temperature gradient visualization """ insight_type = "thermal" name = "Thermal Analysis" description = "Temperature distribution and thermal gradients" category = "thermal" applicable_to = ["thermal", "thermo-structural", "all"] required_files = ["*.op2"] def __init__(self, study_path: Path): super().__init__(study_path) self.op2_path: Optional[Path] = None self.geo_path: Optional[Path] = None self._node_geo: Optional[Dict] = None self._temperatures: Optional[Dict] = None def can_generate(self) -> bool: """Check if OP2 file with temperature data exists.""" search_paths = [ self.results_path, self.study_path / "2_iterations", self.setup_path / "model", ] for search_path in search_paths: if not search_path.exists(): continue op2_files = list(search_path.glob("**/*solution*.op2")) if not op2_files: op2_files = list(search_path.glob("**/*.op2")) if op2_files: self.op2_path = max(op2_files, key=lambda p: p.stat().st_mtime) break if self.op2_path is None: return False # Try to find geometry try: self.geo_path = self._find_geometry_file(self.op2_path) except FileNotFoundError: pass # Verify temperature data exists try: _load_dependencies() op2 = _OP2() op2.read_op2(str(self.op2_path)) # Check for temperature results (various possible attributes) return bool(hasattr(op2, 'temperatures') and op2.temperatures) except Exception: return False def _find_geometry_file(self, op2_path: Path) -> Path: """Find BDF/DAT geometry file.""" folder = op2_path.parent base = op2_path.stem for ext in ['.dat', '.bdf']: cand = folder / (base + ext) if cand.exists(): return cand for f in folder.iterdir(): if f.suffix.lower() in ['.dat', '.bdf']: return f raise FileNotFoundError(f"No geometry file found for {op2_path}") def _load_data(self): """Load geometry and temperature data from OP2.""" if self._temperatures is not None: return _load_dependencies() # Load geometry if available if self.geo_path and self.geo_path.exists(): bdf = _BDF() bdf.read_bdf(str(self.geo_path)) self._node_geo = {int(nid): node.get_position() for nid, node in bdf.nodes.items()} else: self._node_geo = {} # Load temperature data op2 = _OP2() op2.read_op2(str(self.op2_path)) self._temperatures = {} if hasattr(op2, 'temperatures'): for key, temp_obj in op2.temperatures.items(): if hasattr(temp_obj, 'data'): data = temp_obj.data if data.ndim == 3: data = data[0] # First time step ngt = temp_obj.node_gridtype.astype(int) if hasattr(temp_obj, 'node_gridtype') else None node_ids = ngt if ngt is not None and ngt.ndim == 1 else (ngt[:, 0] if ngt is not None else None) self._temperatures[str(key)] = { 'node_ids': node_ids, 'temperatures': data.flatten() if data.ndim > 1 else data, } def _generate(self, config: InsightConfig) -> InsightResult: """Generate thermal field visualization.""" self._load_data() if not self._temperatures: return InsightResult(success=False, error="No temperature data found in OP2") _load_dependencies() # Configuration colorscale = config.extra.get('colorscale', 'Thermal') temp_unit = config.extra.get('temp_unit', 'K') # Aggregate temperature data all_temps = [] all_node_ids = [] for key, data in self._temperatures.items(): temps = data['temperatures'] if isinstance(temps, np.ndarray): all_temps.extend(temps.flatten().tolist()) if data['node_ids'] is not None: all_node_ids.extend(data['node_ids'].flatten().tolist()) all_temps = np.array(all_temps) if len(all_temps) == 0: return InsightResult(success=False, error="No valid temperature values found") max_temp = float(np.max(all_temps)) min_temp = float(np.min(all_temps)) mean_temp = float(np.mean(all_temps)) temp_range = max_temp - min_temp # Build visualization fig = _make_subplots( rows=2, cols=2, specs=[ [{"type": "scene", "colspan": 2}, None], [{"type": "xy"}, {"type": "table"}] ], row_heights=[0.65, 0.35], subplot_titles=[ "Temperature Distribution", "Temperature Histogram", "Summary Statistics" ] ) # 3D temperature field if self._node_geo and all_node_ids: # Build node-to-temp mapping temp_map = {} for key, data in self._temperatures.items(): if data['node_ids'] is not None: for nid, temp in zip(data['node_ids'].flatten(), data['temperatures'].flatten()): temp_map[int(nid)] = temp node_ids = list(self._node_geo.keys()) X = np.array([self._node_geo[nid][0] for nid in node_ids]) Y = np.array([self._node_geo[nid][1] for nid in node_ids]) Z = np.array([self._node_geo[nid][2] for nid in node_ids]) colors = np.array([temp_map.get(nid, mean_temp) for nid in node_ids]) try: tri = _Triangulation(X, Y) if tri.triangles is not None and len(tri.triangles) > 0: i, j, k = tri.triangles.T fig.add_trace(_go.Mesh3d( x=X, y=Y, z=Z, i=i, j=j, k=k, intensity=colors, colorscale=colorscale, opacity=0.95, flatshading=False, lighting=dict(ambient=0.5, diffuse=0.7, specular=0.2), showscale=True, colorbar=dict(title=f"Temp ({temp_unit})", thickness=15, len=0.5) ), row=1, col=1) except Exception: fig.add_trace(_go.Scatter3d( x=X, y=Y, z=Z, mode='markers', marker=dict(size=4, color=colors, colorscale=colorscale, showscale=True), ), row=1, col=1) else: fig.add_annotation( text="3D mesh not available", xref="paper", yref="paper", x=0.5, y=0.7, showarrow=False, font=dict(size=14, color='white') ) fig.update_scenes( camera=dict(eye=dict(x=1.5, y=1.5, z=1.0)), xaxis=dict(title="X", showbackground=True), yaxis=dict(title="Y", showbackground=True), zaxis=dict(title="Z", showbackground=True), ) # Temperature histogram fig.add_trace(_go.Histogram( x=all_temps, nbinsx=50, marker_color='#f97316', opacity=0.8, name='Temperature' ), row=2, col=1) fig.update_xaxes(title_text=f"Temperature ({temp_unit})", row=2, col=1) fig.update_yaxes(title_text="Count", row=2, col=1) # Summary table stats_labels = [ "Maximum Temperature", "Minimum Temperature", "Mean Temperature", "Temperature Range", "Number of Nodes", ] stats_values = [ f"{max_temp:.2f} {temp_unit}", f"{min_temp:.2f} {temp_unit}", f"{mean_temp:.2f} {temp_unit}", f"{temp_range:.2f} {temp_unit}", str(len(all_temps)), ] fig.add_trace(_go.Table( header=dict(values=["Metric", "Value"], fill_color='#1f2937', font=dict(color='white')), cells=dict(values=[stats_labels, stats_values], fill_color='#374151', font=dict(color='white')) ), row=2, col=2) # Layout fig.update_layout( width=1400, height=900, paper_bgcolor='#111827', plot_bgcolor='#1f2937', font=dict(color='white'), title=dict(text="Atomizer Thermal Analysis", x=0.5, font=dict(size=18)), showlegend=False ) # Save HTML timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_dir = config.output_dir or self.insights_path output_dir.mkdir(parents=True, exist_ok=True) html_path = output_dir / f"thermal_{timestamp}.html" html_path.write_text( fig.to_html(include_plotlyjs='cdn', full_html=True), encoding='utf-8' ) return InsightResult( success=True, html_path=html_path, plotly_figure=fig.to_dict(), summary={ 'max_temp': max_temp, 'min_temp': min_temp, 'mean_temp': mean_temp, 'temp_range': temp_range, 'temp_unit': temp_unit, } )