"""
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
)