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>
938 lines
32 KiB
Python
938 lines
32 KiB
Python
"""
|
|
Study Insights - Base Classes and Infrastructure
|
|
|
|
Study Insights provide physics-focused visualizations for optimization results.
|
|
Unlike Analysis (optimizer-centric), Insights show the engineering reality
|
|
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', 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, 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."""
|
|
trial_id: Optional[int] = None # Specific trial to visualize (None = best)
|
|
colorscale: str = 'Turbo'
|
|
output_dir: Optional[Path] = None # Where to save HTML (None = study/3_insights/)
|
|
|
|
# Visual settings
|
|
amplification: float = 1.0 # Deformation scale factor
|
|
lighting: bool = True # 3D lighting effects
|
|
|
|
# Type-specific config (passed through)
|
|
extra: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
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):
|
|
"""
|
|
Abstract base class for study-specific physics visualizations.
|
|
|
|
Each insight type provides:
|
|
- Detection: Can this insight be generated for this study?
|
|
- HTML generation: Standalone interactive report
|
|
- Plotly data: For embedding in dashboard
|
|
- Summary: Key metrics extracted
|
|
|
|
Subclasses must implement:
|
|
- 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
|
|
required_files: List[str] = [] # e.g., ['*.op2', '*.bdf']
|
|
|
|
def __init__(self, study_path: Path):
|
|
"""
|
|
Initialize insight for a specific study.
|
|
|
|
Args:
|
|
study_path: Path to study directory (studies/{name}/)
|
|
"""
|
|
self.study_path = Path(study_path)
|
|
self.setup_path = self.study_path / "1_setup"
|
|
self.results_path = self.study_path / "2_results"
|
|
self.insights_path = self.study_path / "3_insights"
|
|
|
|
# Load study config if available
|
|
self.config = self._load_study_config()
|
|
|
|
def _load_study_config(self) -> Dict[str, Any]:
|
|
"""Load optimization_config.json if it exists."""
|
|
config_path = self.setup_path / "optimization_config.json"
|
|
if config_path.exists():
|
|
with open(config_path) as f:
|
|
return json.load(f)
|
|
return {}
|
|
|
|
@abstractmethod
|
|
def can_generate(self) -> bool:
|
|
"""
|
|
Check if this insight can be generated for the study.
|
|
|
|
Returns:
|
|
True if all required data is available
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def _generate(self, config: InsightConfig) -> InsightResult:
|
|
"""
|
|
Core generation logic. Implemented by subclasses.
|
|
|
|
Args:
|
|
config: Insight configuration
|
|
|
|
Returns:
|
|
InsightResult with HTML path and/or Plotly data
|
|
"""
|
|
pass
|
|
|
|
def generate(self, config: Optional[InsightConfig] = None) -> InsightResult:
|
|
"""
|
|
Generate the insight visualization.
|
|
|
|
Args:
|
|
config: Optional configuration (uses defaults if None)
|
|
|
|
Returns:
|
|
InsightResult with generated content
|
|
"""
|
|
if config is None:
|
|
config = InsightConfig()
|
|
|
|
# Ensure output directory exists
|
|
if config.output_dir is None:
|
|
config.output_dir = self.insights_path
|
|
config.output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Check prerequisites
|
|
if not self.can_generate():
|
|
return InsightResult(
|
|
success=False,
|
|
error=f"Cannot generate {self.name}: required data not found"
|
|
)
|
|
|
|
try:
|
|
return self._generate(config)
|
|
except Exception as e:
|
|
return InsightResult(
|
|
success=False,
|
|
error=f"Error generating {self.name}: {str(e)}"
|
|
)
|
|
|
|
def generate_html(
|
|
self,
|
|
trial_id: Optional[int] = None,
|
|
**kwargs
|
|
) -> Optional[Path]:
|
|
"""
|
|
Convenience method to generate standalone HTML.
|
|
|
|
Args:
|
|
trial_id: Specific trial to visualize (None = best)
|
|
**kwargs: Additional config options
|
|
|
|
Returns:
|
|
Path to generated HTML file, or None on failure
|
|
"""
|
|
config = InsightConfig(trial_id=trial_id, extra=kwargs)
|
|
result = self.generate(config)
|
|
return result.html_path if result.success else None
|
|
|
|
def get_plotly_data(
|
|
self,
|
|
trial_id: Optional[int] = None,
|
|
**kwargs
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get Plotly figure data for dashboard embedding.
|
|
|
|
Args:
|
|
trial_id: Specific trial to visualize (None = best)
|
|
**kwargs: Additional config options
|
|
|
|
Returns:
|
|
Plotly figure as dictionary, or None on failure
|
|
"""
|
|
config = InsightConfig(trial_id=trial_id, extra=kwargs)
|
|
result = self.generate(config)
|
|
return result.plotly_figure if result.success else None
|
|
|
|
def get_summary(self, trial_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get key metrics summary without full visualization.
|
|
|
|
Args:
|
|
trial_id: Specific trial (None = best)
|
|
|
|
Returns:
|
|
Dictionary of key metrics
|
|
"""
|
|
config = InsightConfig(trial_id=trial_id)
|
|
result = self.generate(config)
|
|
return result.summary if result.success else None
|
|
|
|
|
|
class InsightRegistry:
|
|
"""
|
|
Central registry for available insight types.
|
|
|
|
Usage:
|
|
registry = InsightRegistry()
|
|
registry.register(ZernikeWFEInsight)
|
|
|
|
# Get insight for a study
|
|
insight = registry.get('zernike_wfe', study_path)
|
|
|
|
# List available insights for a study
|
|
available = registry.list_available(study_path)
|
|
"""
|
|
|
|
_instance = None
|
|
_insights: Dict[str, Type[StudyInsight]] = {}
|
|
|
|
def __new__(cls):
|
|
"""Singleton pattern."""
|
|
if cls._instance is None:
|
|
cls._instance = super().__new__(cls)
|
|
cls._insights = {}
|
|
return cls._instance
|
|
|
|
def register(self, insight_class: Type[StudyInsight]) -> None:
|
|
"""
|
|
Register an insight type.
|
|
|
|
Args:
|
|
insight_class: StudyInsight subclass to register
|
|
"""
|
|
self._insights[insight_class.insight_type] = insight_class
|
|
|
|
def get(self, insight_type: str, study_path: Path) -> Optional[StudyInsight]:
|
|
"""
|
|
Get an insight instance for a study.
|
|
|
|
Args:
|
|
insight_type: Registered insight type ID
|
|
study_path: Path to study directory
|
|
|
|
Returns:
|
|
Configured insight instance, or None if not found
|
|
"""
|
|
if insight_type not in self._insights:
|
|
return None
|
|
return self._insights[insight_type](study_path)
|
|
|
|
def list_all(self) -> List[Dict[str, Any]]:
|
|
"""
|
|
List all registered insight types.
|
|
|
|
Returns:
|
|
List of insight metadata dictionaries
|
|
"""
|
|
return [
|
|
{
|
|
'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, 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:
|
|
insight = cls(study_path)
|
|
if insight.can_generate():
|
|
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', [])
|
|
})
|
|
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()
|
|
|
|
|
|
def register_insight(insight_class: Type[StudyInsight]) -> Type[StudyInsight]:
|
|
"""
|
|
Decorator to register an insight class.
|
|
|
|
Usage:
|
|
@register_insight
|
|
class MyInsight(StudyInsight):
|
|
insight_type = 'my_insight'
|
|
...
|
|
"""
|
|
_registry.register(insight_class)
|
|
return insight_class
|
|
|
|
|
|
def get_insight(insight_type: str, study_path: Path) -> Optional[StudyInsight]:
|
|
"""Get an insight instance by type."""
|
|
return _registry.get(insight_type, study_path)
|
|
|
|
|
|
def list_insights() -> List[Dict[str, Any]]:
|
|
"""List all registered insight types."""
|
|
return _registry.list_all()
|
|
|
|
|
|
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)
|