feat: Add OPD method support to Zernike visualization with Standard/OPD toggle

Major improvements to Zernike WFE visualization:

- Add ZernikeDashboardInsight: Unified dashboard with all orientations (40°, 60°, 90°)
  on one page with light theme and executive summary
- Add OPD method toggle: Switch between Standard (Z-only) and OPD (X,Y,Z) methods
  in ZernikeWFEInsight with interactive buttons
- Add lateral displacement maps: Visualize X,Y displacement for each orientation
- Add displacement component views: Toggle between WFE, ΔX, ΔY, ΔZ in relative views
- Add metrics comparison table showing both methods side-by-side

New extractors:
- extract_zernike_figure.py: ZernikeOPDExtractor using BDF geometry interpolation
- extract_zernike_opd.py: Parabola-based OPD with focal length

Key finding: OPD method gives 8-11% higher WFE values than Standard method
(more conservative/accurate for surfaces with lateral displacement under gravity)

Documentation updates:
- SYS_12: Added E22 ZernikeOPD as recommended method
- SYS_16: Added ZernikeDashboard, updated ZernikeWFE with OPD features
- Cheatsheet: Added Zernike method comparison table

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-22 21:03:19 -05:00
parent d089003ced
commit d19fc39a2a
19 changed files with 8117 additions and 396 deletions

View File

@@ -8,25 +8,86 @@ of specific designs.
Architecture:
- StudyInsight: Abstract base class for all insight types
- InsightRegistry: Central registry for available insight types
- InsightSpec: Config-defined insight specification from optimization_config.json
- InsightReport: Aggregates multiple insights into a study report with PDF export
- Each insight can generate standalone HTML or Plotly data for dashboard
Config Schema (in optimization_config.json):
"insights": [
{
"type": "zernike_wfe",
"name": "WFE at 40 vs 20 deg",
"enabled": true,
"linked_objective": "rel_filtered_rms_40_vs_20",
"config": { "target_subcase": "3", "reference_subcase": "2" },
"include_in_report": true
},
{
"type": "design_space",
"name": "Parameter Exploration",
"enabled": true,
"linked_objective": null,
"config": {},
"include_in_report": true
}
]
Usage:
from optimization_engine.insights import get_insight, list_insights
# Get specific insight
insight = get_insight('zernike_wfe')
if insight.can_generate(study_path):
html_path = insight.generate_html(study_path, trial_id=47)
plotly_data = insight.get_plotly_data(study_path, trial_id=47)
insight = get_insight('zernike_wfe', study_path)
if insight.can_generate():
html_path = insight.generate_html(trial_id=47)
plotly_data = insight.get_plotly_data(trial_id=47)
# Get insights from config
specs = get_configured_insights(study_path)
for spec in specs:
insight = get_insight(spec.type, study_path)
result = insight.generate(spec.to_config())
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Type
from typing import Any, Dict, List, Optional, Type, Union
from datetime import datetime
import json
@dataclass
class InsightSpec:
"""
Insight specification from optimization_config.json.
Defines what insights the user wants for their study, optionally
linking them to specific objectives.
"""
type: str # Insight type ID (e.g., 'zernike_wfe')
name: str # User-defined display name
enabled: bool = True # Whether to generate this insight
linked_objective: Optional[str] = None # Objective name this visualizes (or None)
config: Dict[str, Any] = field(default_factory=dict) # Type-specific config
include_in_report: bool = True # Include in study report
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'InsightSpec':
"""Create InsightSpec from config dictionary."""
return cls(
type=data['type'],
name=data.get('name', data['type']),
enabled=data.get('enabled', True),
linked_objective=data.get('linked_objective'),
config=data.get('config', {}),
include_in_report=data.get('include_in_report', True)
)
def to_config(self) -> 'InsightConfig':
"""Convert spec to InsightConfig for generation."""
return InsightConfig(extra=self.config)
@dataclass
class InsightConfig:
"""Configuration for an insight instance."""
@@ -46,10 +107,14 @@ class InsightConfig:
class InsightResult:
"""Result from generating an insight."""
success: bool
insight_type: str = "" # Which insight generated this
insight_name: str = "" # Display name
html_path: Optional[Path] = None
plotly_figure: Optional[Dict[str, Any]] = None # Plotly figure as dict
summary: Optional[Dict[str, Any]] = None # Key metrics
error: Optional[str] = None
linked_objective: Optional[str] = None # Objective this relates to (if any)
generated_at: Optional[str] = None # ISO timestamp
class StudyInsight(ABC):
@@ -66,15 +131,29 @@ class StudyInsight(ABC):
- insight_type: Unique identifier (e.g., 'zernike_wfe')
- name: Human-readable name
- description: What this insight shows
- category: Physics domain (see INSIGHT_CATEGORIES)
- applicable_to: List of study types this applies to
- can_generate(): Check if study has required data
- _generate(): Core generation logic
"""
# Physics categories for grouping
INSIGHT_CATEGORIES = {
'optical': 'Optical',
'structural_static': 'Structural (Static)',
'structural_dynamic': 'Structural (Dynamic)',
'structural_modal': 'Structural (Modal)',
'thermal': 'Thermal',
'kinematic': 'Kinematic',
'design_exploration': 'Design Exploration',
'other': 'Other'
}
# Class-level metadata (override in subclasses)
insight_type: str = "base"
name: str = "Base Insight"
description: str = "Abstract base insight"
category: str = "other" # Physics domain (key from INSIGHT_CATEGORIES)
applicable_to: List[str] = [] # e.g., ['mirror', 'structural', 'all']
# Required files/data patterns
@@ -273,21 +352,57 @@ class InsightRegistry:
'type': cls.insight_type,
'name': cls.name,
'description': cls.description,
'category': cls.category,
'category_label': StudyInsight.INSIGHT_CATEGORIES.get(cls.category, 'Other'),
'applicable_to': cls.applicable_to
}
for cls in self._insights.values()
]
def list_available(self, study_path: Path) -> List[Dict[str, Any]]:
def list_available(self, study_path: Path, fast_mode: bool = True) -> List[Dict[str, Any]]:
"""
List insights that can be generated for a specific study.
Args:
study_path: Path to study directory
fast_mode: If True, use quick file existence checks instead of full can_generate()
Returns:
List of available insight metadata
"""
study_path = Path(study_path)
# Fast mode: check file patterns once, then filter insights by what they need
if fast_mode:
# Quick scan for data files (non-recursive, just check known locations)
has_op2 = self._quick_has_op2(study_path)
has_db = (study_path / "2_results" / "study.db").exists()
available = []
for insight_type, cls in self._insights.items():
# Quick filter based on required_files attribute
required = getattr(cls, 'required_files', [])
can_gen = True
if '*.op2' in required and not has_op2:
can_gen = False
if 'study.db' in required and not has_db:
can_gen = False
if can_gen:
available.append({
'type': insight_type,
'name': cls.name,
'description': cls.description,
'category': getattr(cls, 'category', 'other'),
'category_label': StudyInsight.INSIGHT_CATEGORIES.get(
getattr(cls, 'category', 'other'), 'Other'
),
'applicable_to': getattr(cls, 'applicable_to', [])
})
return available
# Slow mode: full can_generate() check (for accuracy)
available = []
for insight_type, cls in self._insights.items():
try:
@@ -296,12 +411,43 @@ class InsightRegistry:
available.append({
'type': insight_type,
'name': cls.name,
'description': cls.description
'description': cls.description,
'category': getattr(cls, 'category', 'other'),
'category_label': StudyInsight.INSIGHT_CATEGORIES.get(
getattr(cls, 'category', 'other'), 'Other'
),
'applicable_to': getattr(cls, 'applicable_to', [])
})
except Exception:
pass # Skip insights that fail to initialize
return available
def _quick_has_op2(self, study_path: Path) -> bool:
"""Quick check if study has OP2 files without recursive search."""
# Check common locations only (non-recursive)
check_dirs = [
study_path / "3_results" / "best_design_archive",
study_path / "2_iterations",
study_path / "1_setup" / "model",
]
for check_dir in check_dirs:
if not check_dir.exists():
continue
# Non-recursive check of immediate children
try:
for item in check_dir.iterdir():
if item.suffix.lower() == '.op2':
return True
# Check one level down for iterations
if item.is_dir():
for subitem in item.iterdir():
if subitem.suffix.lower() == '.op2':
return True
except (PermissionError, OSError):
continue
return False
# Global registry instance
_registry = InsightRegistry()
@@ -334,3 +480,458 @@ def list_insights() -> List[Dict[str, Any]]:
def list_available_insights(study_path: Path) -> List[Dict[str, Any]]:
"""List insights available for a specific study."""
return _registry.list_available(study_path)
def get_configured_insights(study_path: Path) -> List[InsightSpec]:
"""
Get insight specifications from study's optimization_config.json.
Returns:
List of InsightSpec objects defined in the config, or empty list
"""
study_path = Path(study_path)
config_path = study_path / "1_setup" / "optimization_config.json"
if not config_path.exists():
return []
with open(config_path) as f:
config = json.load(f)
insights_config = config.get('insights', [])
return [InsightSpec.from_dict(spec) for spec in insights_config]
def recommend_insights_for_study(study_path: Path) -> List[Dict[str, Any]]:
"""
Recommend insights based on study type and objectives.
Analyzes the optimization_config.json to suggest appropriate insights.
Returns recommendations with reasoning.
Returns:
List of recommendation dicts with 'type', 'name', 'reason', 'linked_objective'
"""
study_path = Path(study_path)
config_path = study_path / "1_setup" / "optimization_config.json"
if not config_path.exists():
return []
with open(config_path) as f:
config = json.load(f)
recommendations = []
objectives = config.get('objectives', [])
# Check for Zernike-related objectives
for obj in objectives:
obj_name = obj.get('name', '')
extractor = obj.get('extractor_config', {})
# WFE objectives -> recommend zernike_wfe
if any(kw in obj_name.lower() for kw in ['rms', 'wfe', 'zernike', 'filtered']):
target_sc = extractor.get('target_subcase', '')
ref_sc = extractor.get('reference_subcase', '')
recommendations.append({
'type': 'zernike_wfe',
'name': f"WFE: {obj.get('description', obj_name)[:50]}",
'reason': f"Visualizes Zernike WFE for objective '{obj_name}'",
'linked_objective': obj_name,
'config': {
'target_subcase': target_sc,
'reference_subcase': ref_sc
}
})
# Mass objectives -> no direct insight, but note for design space
elif 'mass' in obj_name.lower():
pass # Covered by design_space
# Stress-related
elif any(kw in obj_name.lower() for kw in ['stress', 'von_mises', 'strain']):
recommendations.append({
'type': 'stress_field',
'name': f"Stress: {obj.get('description', obj_name)[:50]}",
'reason': f"Visualizes stress distribution for objective '{obj_name}'",
'linked_objective': obj_name,
'config': {}
})
# Frequency/modal
elif any(kw in obj_name.lower() for kw in ['freq', 'modal', 'eigen', 'vibration']):
recommendations.append({
'type': 'modal',
'name': f"Modal: {obj.get('description', obj_name)[:50]}",
'reason': f"Shows mode shapes for objective '{obj_name}'",
'linked_objective': obj_name,
'config': {}
})
# Temperature/thermal
elif any(kw in obj_name.lower() for kw in ['temp', 'thermal', 'heat']):
recommendations.append({
'type': 'thermal',
'name': f"Thermal: {obj.get('description', obj_name)[:50]}",
'reason': f"Shows temperature distribution for objective '{obj_name}'",
'linked_objective': obj_name,
'config': {}
})
# Always recommend design_space for any optimization
if objectives:
recommendations.append({
'type': 'design_space',
'name': 'Design Space Exploration',
'reason': 'Shows parameter-objective relationships across all trials',
'linked_objective': None,
'config': {}
})
return recommendations
class InsightReport:
"""
Aggregates multiple insights into a comprehensive study report.
Generates:
- Full HTML report with all insights embedded
- Individual insight HTMLs in 3_insights/
- PDF export capability (via browser print)
- Summary JSON for Results page
Usage:
report = InsightReport(study_path)
report.add_insight(result1)
report.add_insight(result2)
report.generate_html() # Creates 3_insights/STUDY_INSIGHTS_REPORT.html
"""
def __init__(self, study_path: Path):
self.study_path = Path(study_path)
self.insights_path = self.study_path / "3_insights"
self.results_path = self.study_path / "3_results"
self.results: List[InsightResult] = []
# Load study config for metadata
config_path = self.study_path / "1_setup" / "optimization_config.json"
if config_path.exists():
with open(config_path) as f:
self.config = json.load(f)
else:
self.config = {}
def add_insight(self, result: InsightResult) -> None:
"""Add an insight result to the report."""
if result.success:
self.results.append(result)
def generate_all(self, specs: Optional[List[InsightSpec]] = None) -> List[InsightResult]:
"""
Generate all configured or specified insights.
Args:
specs: List of InsightSpecs to generate (or None to use config)
Returns:
List of InsightResult objects
"""
if specs is None:
specs = get_configured_insights(self.study_path)
results = []
for spec in specs:
if not spec.enabled:
continue
insight = get_insight(spec.type, self.study_path)
if insight is None:
results.append(InsightResult(
success=False,
insight_type=spec.type,
insight_name=spec.name,
error=f"Unknown insight type: {spec.type}"
))
continue
if not insight.can_generate():
results.append(InsightResult(
success=False,
insight_type=spec.type,
insight_name=spec.name,
error=f"Cannot generate {spec.name}: required data not found"
))
continue
config = spec.to_config()
result = insight.generate(config)
result.insight_type = spec.type
result.insight_name = spec.name
result.linked_objective = spec.linked_objective
result.generated_at = datetime.now().isoformat()
results.append(result)
self.add_insight(result)
return results
def generate_report_html(self, include_appendix: bool = True) -> Path:
"""
Generate comprehensive HTML report with all insights.
Args:
include_appendix: Whether to include full-size insight appendix
Returns:
Path to generated HTML report
"""
self.insights_path.mkdir(parents=True, exist_ok=True)
study_name = self.config.get('study_name', self.study_path.name)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
# Build HTML
html_parts = [self._report_header(study_name, timestamp)]
# Executive summary
html_parts.append(self._executive_summary())
# Objective-linked insights
linked = [r for r in self.results if r.linked_objective]
if linked:
html_parts.append('<h2>Objective Insights</h2>')
for result in linked:
html_parts.append(self._insight_section(result, compact=True))
# Standalone insights
standalone = [r for r in self.results if not r.linked_objective]
if standalone:
html_parts.append('<h2>General Insights</h2>')
for result in standalone:
html_parts.append(self._insight_section(result, compact=True))
# Appendix with full-size figures
if include_appendix and self.results:
html_parts.append(self._appendix_section())
html_parts.append(self._report_footer())
# Write report
report_path = self.insights_path / "STUDY_INSIGHTS_REPORT.html"
report_path.write_text('\n'.join(html_parts), encoding='utf-8')
# Also write summary JSON for Results page
self._write_summary_json()
return report_path
def _report_header(self, study_name: str, timestamp: str) -> str:
"""Generate HTML header."""
return f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Study Insights Report - {study_name}</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<style>
:root {{
--bg-dark: #111827;
--bg-card: #1f2937;
--border: #374151;
--text: #f9fafb;
--text-muted: #9ca3af;
--primary: #3b82f6;
--success: #22c55e;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-dark);
color: var(--text);
line-height: 1.6;
padding: 2rem;
}}
.container {{ max-width: 1200px; margin: 0 auto; }}
h1 {{ font-size: 2rem; margin-bottom: 0.5rem; color: var(--primary); }}
h2 {{ font-size: 1.5rem; margin: 2rem 0 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }}
h3 {{ font-size: 1.1rem; margin-bottom: 0.5rem; }}
.meta {{ color: var(--text-muted); font-size: 0.9rem; margin-bottom: 2rem; }}
.card {{
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}}
.summary-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}}
.stat {{
background: var(--bg-card);
padding: 1rem;
border-radius: 8px;
text-align: center;
}}
.stat-value {{ font-size: 1.5rem; font-weight: bold; color: var(--success); }}
.stat-label {{ font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; }}
.objective-tag {{
display: inline-block;
background: var(--primary);
color: white;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
margin-left: 0.5rem;
}}
.plot-container {{ width: 100%; height: 400px; margin: 1rem 0; }}
.appendix-plot {{ height: 600px; page-break-inside: avoid; }}
table {{ width: 100%; border-collapse: collapse; margin: 1rem 0; }}
th, td {{ padding: 0.5rem; text-align: left; border-bottom: 1px solid var(--border); }}
th {{ color: var(--text-muted); font-weight: normal; font-size: 0.85rem; }}
.print-btn {{
position: fixed;
top: 1rem;
right: 1rem;
background: var(--primary);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}}
.print-btn:hover {{ opacity: 0.9; }}
@media print {{
.print-btn {{ display: none; }}
body {{ background: white; color: black; }}
.card {{ border: 1px solid #ccc; }}
}}
</style>
</head>
<body>
<button class="print-btn" onclick="window.print()">Save as PDF</button>
<div class="container">
<h1>Study Insights Report</h1>
<p class="meta">{study_name} | Generated: {timestamp}</p>
'''
def _executive_summary(self) -> str:
"""Generate executive summary section."""
n_objectives = len(self.config.get('objectives', []))
n_insights = len(self.results)
linked = len([r for r in self.results if r.linked_objective])
return f'''
<div class="summary-grid">
<div class="stat">
<div class="stat-value">{n_insights}</div>
<div class="stat-label">Total Insights</div>
</div>
<div class="stat">
<div class="stat-value">{linked}</div>
<div class="stat-label">Objective-Linked</div>
</div>
<div class="stat">
<div class="stat-value">{n_objectives}</div>
<div class="stat-label">Study Objectives</div>
</div>
</div>
'''
def _insight_section(self, result: InsightResult, compact: bool = False) -> str:
"""Generate HTML section for a single insight."""
objective_tag = ""
if result.linked_objective:
objective_tag = f'<span class="objective-tag">{result.linked_objective}</span>'
# Summary table
summary_html = ""
if result.summary:
rows = ""
for key, value in list(result.summary.items())[:6]:
if isinstance(value, float):
value = f"{value:.4g}"
rows += f"<tr><td>{key}</td><td>{value}</td></tr>"
summary_html = f"<table><tbody>{rows}</tbody></table>"
# Compact plot or placeholder
plot_html = ""
if result.plotly_figure and compact:
plot_id = f"plot_{result.insight_type}_{id(result)}"
plot_json = json.dumps(result.plotly_figure)
plot_html = f'''
<div id="{plot_id}" class="plot-container"></div>
<script>
var fig = {plot_json};
fig.layout.height = 350;
fig.layout.margin = {{l:50,r:50,t:30,b:50}};
Plotly.newPlot("{plot_id}", fig.data, fig.layout, {{responsive:true}});
</script>
'''
return f'''
<div class="card">
<h3>{result.insight_name}{objective_tag}</h3>
{summary_html}
{plot_html}
</div>
'''
def _appendix_section(self) -> str:
"""Generate appendix with full-size figures."""
html = '<h2>Appendix: Full-Size Visualizations</h2>'
for i, result in enumerate(self.results):
if not result.plotly_figure:
continue
plot_id = f"appendix_plot_{i}"
plot_json = json.dumps(result.plotly_figure)
html += f'''
<div class="card" style="page-break-before: always;">
<h3>Appendix {i+1}: {result.insight_name}</h3>
<div id="{plot_id}" class="appendix-plot"></div>
<script>
var fig = {plot_json};
fig.layout.height = 550;
Plotly.newPlot("{plot_id}", fig.data, fig.layout, {{responsive:true}});
</script>
</div>
'''
return html
def _report_footer(self) -> str:
"""Generate HTML footer."""
return '''
</div>
</body>
</html>'''
def _write_summary_json(self) -> None:
"""Write summary JSON for Results page integration."""
summary = {
'generated_at': datetime.now().isoformat(),
'study_name': self.config.get('study_name', self.study_path.name),
'insights': []
}
for result in self.results:
summary['insights'].append({
'type': result.insight_type,
'name': result.insight_name,
'success': result.success,
'linked_objective': result.linked_objective,
'html_path': str(result.html_path) if result.html_path else None,
'summary': result.summary
})
summary_path = self.insights_path / "insights_summary.json"
with open(summary_path, 'w') as f:
json.dump(summary, f, indent=2)