""" Design Space Insight Provides interactive visualization of the design space explored during optimization. Shows parameter relationships, objective landscapes, and design evolution. This insight bridges optimization metrics (from Analysis) with physics understanding, showing how design parameters affect the physical objectives. Applicable to: All optimization studies with completed trials. """ from pathlib import Path from datetime import datetime from typing import Dict, Any, List, Optional, Tuple import sqlite3 import json import numpy as np from .base import StudyInsight, InsightConfig, InsightResult, register_insight # Lazy imports _plotly_loaded = False _go = None _make_subplots = None def _load_dependencies(): """Lazy load heavy dependencies.""" global _plotly_loaded, _go, _make_subplots if not _plotly_loaded: import plotly.graph_objects as go from plotly.subplots import make_subplots _go = go _make_subplots = make_subplots _plotly_loaded = True @register_insight class DesignSpaceInsight(StudyInsight): """ Design space exploration visualization. Shows: - Parallel coordinates plot of parameters vs objectives - Scatter matrix of parameter relationships - 3D parameter-objective landscape - Best design summary with physics interpretation """ insight_type = "design_space" name = "Design Space Explorer" description = "Interactive parameter-objective relationship visualization" category = "design_exploration" applicable_to = ["all"] # Works with any optimization study required_files = [] # Requires study.db, not OP2 def __init__(self, study_path: Path): super().__init__(study_path) self.db_path = self.results_path / "study.db" self._trials: Optional[List[Dict]] = None self._params: Optional[List[str]] = None self._objectives: Optional[List[str]] = None def can_generate(self) -> bool: """Check if study.db exists with trial data.""" if not self.db_path.exists(): return False try: conn = sqlite3.connect(str(self.db_path)) cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'") count = cursor.fetchone()[0] conn.close() return count >= 5 # Need at least 5 trials except Exception: return False def _load_data(self): """Load trial data from study.db.""" if self._trials is not None: return conn = sqlite3.connect(str(self.db_path)) cursor = conn.cursor() # Get completed trials cursor.execute(""" SELECT trial_id, params, values, state FROM trials WHERE state = 'COMPLETE' ORDER BY trial_id """) self._trials = [] self._params = None self._objectives = None for row in cursor.fetchall(): trial_id, params_json, values_json, state = row try: params = json.loads(params_json) if params_json else {} values = json.loads(values_json) if values_json else {} except json.JSONDecodeError: continue # Flatten nested values flat_values = {} for k, v in values.items(): if isinstance(v, dict): flat_values.update(v) else: flat_values[k] = v self._trials.append({ 'trial_id': trial_id, 'params': params, 'values': flat_values, }) # Extract param and objective names from first trial if self._params is None: self._params = list(params.keys()) if self._objectives is None: self._objectives = list(flat_values.keys()) conn.close() def _generate(self, config: InsightConfig) -> InsightResult: """Generate design space visualization.""" self._load_data() if not self._trials or len(self._trials) < 5: return InsightResult(success=False, error=f"Need at least 5 trials, found: {len(self._trials or [])}") _load_dependencies() # Configuration colorscale = config.extra.get('colorscale', 'Viridis') primary_objective = config.extra.get('primary_objective', None) # Use first objective if not specified if primary_objective is None and self._objectives: primary_objective = self._objectives[0] # Build data arrays n_trials = len(self._trials) param_data = {p: [] for p in self._params} obj_data = {o: [] for o in self._objectives} trial_ids = [] for trial in self._trials: trial_ids.append(trial['trial_id']) for p in self._params: param_data[p].append(trial['params'].get(p, np.nan)) for o in self._objectives: obj_data[o].append(trial['values'].get(o, np.nan)) # Convert to arrays for p in self._params: param_data[p] = np.array(param_data[p]) for o in self._objectives: obj_data[o] = np.array(obj_data[o]) # Find best trial if primary_objective and primary_objective in obj_data: obj_values = obj_data[primary_objective] valid_mask = ~np.isnan(obj_values) if np.any(valid_mask): best_idx = np.nanargmin(obj_values) best_trial = self._trials[best_idx] best_value = obj_values[best_idx] else: best_trial = None best_value = None else: best_trial = None best_value = None # Build visualization n_params = len(self._params) n_objs = len(self._objectives) # Layout: 2x2 grid fig = _make_subplots( rows=2, cols=2, specs=[ [{"type": "parcoords", "colspan": 2}, None], [{"type": "scatter3d" if n_params >= 2 else "xy"}, {"type": "table"}] ], row_heights=[0.55, 0.45], subplot_titles=[ "Parallel Coordinates - Design Space", "Parameter Landscape", "Best Design" ] ) # 1. Parallel coordinates dimensions = [] # Add parameters for p in self._params: values = param_data[p] if not np.all(np.isnan(values)): dimensions.append(dict( label=p, values=values, range=[float(np.nanmin(values)), float(np.nanmax(values))] )) # Add objectives for o in self._objectives: values = obj_data[o] if not np.all(np.isnan(values)): dimensions.append(dict( label=o, values=values, range=[float(np.nanmin(values)), float(np.nanmax(values))] )) if dimensions: # Color by primary objective color_values = obj_data.get(primary_objective, trial_ids) if isinstance(color_values, list): color_values = np.array(color_values) fig.add_trace(_go.Parcoords( line=dict( color=color_values, colorscale=colorscale, showscale=True, colorbar=dict(title=primary_objective or "Trial", thickness=15) ), dimensions=dimensions, ), row=1, col=1) # 2. 3D Parameter landscape (first 2 params vs primary objective) if n_params >= 2 and primary_objective: x_param = self._params[0] y_param = self._params[1] z_values = obj_data.get(primary_objective, []) fig.add_trace(_go.Scatter3d( x=param_data[x_param], y=param_data[y_param], z=z_values, mode='markers', marker=dict( size=6, color=z_values, colorscale=colorscale, opacity=0.8, showscale=False, ), text=[f"Trial {tid}" for tid in trial_ids], hovertemplate=( f"{x_param}: %{{x:.3f}}
" f"{y_param}: %{{y:.3f}}
" f"{primary_objective}: %{{z:.4f}}
" "%{text}" ), ), row=2, col=1) # Highlight best point if best_trial: fig.add_trace(_go.Scatter3d( x=[best_trial['params'].get(x_param)], y=[best_trial['params'].get(y_param)], z=[best_value], mode='markers', marker=dict(size=12, color='red', symbol='diamond'), name='Best', showlegend=True, ), row=2, col=1) fig.update_scenes( xaxis_title=x_param, yaxis_title=y_param, zaxis_title=primary_objective, ) elif n_params >= 1 and primary_objective: # 2D scatter x_param = self._params[0] z_values = obj_data.get(primary_objective, []) fig.add_trace(_go.Scatter( x=param_data[x_param], y=z_values, mode='markers', marker=dict(size=8, color=z_values, colorscale=colorscale), ), row=2, col=1) fig.update_xaxes(title_text=x_param, row=2, col=1) fig.update_yaxes(title_text=primary_objective, row=2, col=1) # 3. Best design table if best_trial: labels = ["Metric", "Value"] # Combine params and objectives table_labels = ["Trial ID"] + self._params + self._objectives table_values = [str(best_trial['trial_id'])] for p in self._params: val = best_trial['params'].get(p, 'N/A') table_values.append(f"{val:.4f}" if isinstance(val, (int, float)) else str(val)) for o in self._objectives: val = best_trial['values'].get(o, 'N/A') table_values.append(f"{val:.4f}" if isinstance(val, (int, float)) else str(val)) fig.add_trace(_go.Table( header=dict(values=labels, fill_color='#1f2937', font=dict(color='white')), cells=dict(values=[table_labels, table_values], fill_color='#374151', font=dict(color='white')) ), row=2, col=2) else: fig.add_trace(_go.Table( header=dict(values=["Info"], fill_color='#1f2937', font=dict(color='white')), cells=dict(values=[["No valid trials found"]], fill_color='#374151', font=dict(color='white')) ), row=2, col=2) # Layout fig.update_layout( width=1500, height=1000, paper_bgcolor='#111827', plot_bgcolor='#1f2937', font=dict(color='white'), title=dict( text=f"Atomizer Design Space Explorer
" f"{n_trials} trials, {n_params} parameters, {n_objs} objectives", x=0.5, font=dict(size=18) ), ) # 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"design_space_{timestamp}.html" html_path.write_text( fig.to_html(include_plotlyjs='cdn', full_html=True), encoding='utf-8' ) # Summary summary = { 'n_trials': n_trials, 'n_params': n_params, 'n_objectives': n_objs, 'parameters': self._params, 'objectives': self._objectives, } if best_trial: summary['best_trial_id'] = best_trial['trial_id'] summary['best_params'] = best_trial['params'] summary['best_values'] = best_trial['values'] return InsightResult( success=True, html_path=html_path, plotly_figure=fig.to_dict(), summary=summary )