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:
@@ -1,12 +1,19 @@
|
||||
"""
|
||||
Study Insights API endpoints
|
||||
Provides physics-focused visualizations for completed optimization trials
|
||||
|
||||
Key Features:
|
||||
- Config-driven insights from optimization_config.json
|
||||
- AI recommendations based on objectives
|
||||
- Report generation with PDF export
|
||||
- Objective-linked and standalone insights
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse, HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from typing import List, Dict, Optional, Any
|
||||
import json
|
||||
import sys
|
||||
|
||||
@@ -19,6 +26,22 @@ router = APIRouter()
|
||||
STUDIES_DIR = Path(__file__).parent.parent.parent.parent.parent / "studies"
|
||||
|
||||
|
||||
class InsightSpecRequest(BaseModel):
|
||||
"""Request model for insight specification."""
|
||||
type: str
|
||||
name: str
|
||||
enabled: bool = True
|
||||
linked_objective: Optional[str] = None
|
||||
config: Dict[str, Any] = {}
|
||||
include_in_report: bool = True
|
||||
|
||||
|
||||
class GenerateReportRequest(BaseModel):
|
||||
"""Request model for report generation."""
|
||||
specs: Optional[List[InsightSpecRequest]] = None
|
||||
include_appendix: bool = True
|
||||
|
||||
|
||||
def resolve_study_path(study_id: str) -> Path:
|
||||
"""Find study folder by scanning all topic directories."""
|
||||
# First check direct path (backwards compatibility)
|
||||
@@ -38,7 +61,7 @@ def resolve_study_path(study_id: str) -> Path:
|
||||
raise HTTPException(status_code=404, detail=f"Study not found: {study_id}")
|
||||
|
||||
|
||||
@router.get("/studies/{study_id}/insights/available")
|
||||
@router.get("/studies/{study_id}/available")
|
||||
async def list_available_insights(study_id: str):
|
||||
"""List all insight types that can be generated for this study."""
|
||||
try:
|
||||
@@ -65,7 +88,7 @@ async def list_available_insights(study_id: str):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/studies/{study_id}/insights/all")
|
||||
@router.get("/studies/{study_id}/all")
|
||||
async def list_all_insights():
|
||||
"""List all registered insight types (regardless of availability for any study)."""
|
||||
try:
|
||||
@@ -83,14 +106,104 @@ async def list_all_insights():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/studies/{study_id}/insights/generate/{insight_type}")
|
||||
async def generate_insight(study_id: str, insight_type: str, trial_id: Optional[int] = None):
|
||||
class GenerateInsightRequest(BaseModel):
|
||||
"""Request model for insight generation."""
|
||||
iteration: Optional[str] = None # e.g., "iter5", "best_design_archive"
|
||||
trial_id: Optional[int] = None
|
||||
config: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _is_valid_op2(op2_path: Path) -> bool:
|
||||
"""Quick check if OP2 file is valid (not from a failed solve)."""
|
||||
try:
|
||||
# Check file size - failed OP2s are often very small
|
||||
if op2_path.stat().st_size < 10000: # Less than 10KB is suspicious
|
||||
return False
|
||||
|
||||
# Try to read header to verify it's a valid OP2
|
||||
with open(op2_path, 'rb') as f:
|
||||
header = f.read(100)
|
||||
# Valid OP2 files have specific markers
|
||||
if b'NASTRAN' not in header and b'XXXXXXXX' not in header:
|
||||
# Check for common valid patterns
|
||||
pass # Size check is usually enough
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/studies/{study_id}/iterations")
|
||||
async def list_iterations(study_id: str):
|
||||
"""List available iterations/trials with OP2 files for insight generation.
|
||||
|
||||
Returns iterations sorted by modification time (newest first).
|
||||
Only includes iterations with valid (non-corrupted) OP2 files.
|
||||
"""
|
||||
try:
|
||||
study_path = resolve_study_path(study_id)
|
||||
iterations = []
|
||||
|
||||
# Check 2_iterations folder
|
||||
iter_dir = study_path / "2_iterations"
|
||||
if iter_dir.exists():
|
||||
for subdir in sorted(iter_dir.iterdir(), reverse=True):
|
||||
if subdir.is_dir():
|
||||
op2_files = [f for f in subdir.glob("*.op2") if _is_valid_op2(f)]
|
||||
if op2_files:
|
||||
newest_op2 = max(op2_files, key=lambda p: p.stat().st_mtime)
|
||||
iterations.append({
|
||||
"id": subdir.name,
|
||||
"path": str(subdir.relative_to(study_path)),
|
||||
"op2_file": newest_op2.name,
|
||||
"modified": newest_op2.stat().st_mtime,
|
||||
"type": "iteration"
|
||||
})
|
||||
|
||||
# Check 3_results/best_design_archive folder
|
||||
best_dir = study_path / "3_results" / "best_design_archive"
|
||||
if best_dir.exists():
|
||||
op2_files = [f for f in best_dir.glob("**/*.op2") if _is_valid_op2(f)]
|
||||
if op2_files:
|
||||
newest_op2 = max(op2_files, key=lambda p: p.stat().st_mtime)
|
||||
iterations.insert(0, { # Insert at start as "best"
|
||||
"id": "best_design_archive",
|
||||
"path": "3_results/best_design_archive",
|
||||
"op2_file": newest_op2.name,
|
||||
"modified": newest_op2.stat().st_mtime,
|
||||
"type": "best",
|
||||
"label": "Best Design (Recommended)"
|
||||
})
|
||||
|
||||
# Sort by modification time (newest first), keeping best at top
|
||||
best = [i for i in iterations if i.get("type") == "best"]
|
||||
others = sorted([i for i in iterations if i.get("type") != "best"],
|
||||
key=lambda x: x["modified"], reverse=True)
|
||||
|
||||
return {
|
||||
"study_id": study_id,
|
||||
"iterations": best + others,
|
||||
"count": len(iterations)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/studies/{study_id}/generate/{insight_type}")
|
||||
async def generate_insight(
|
||||
study_id: str,
|
||||
insight_type: str,
|
||||
request: Optional[GenerateInsightRequest] = None
|
||||
):
|
||||
"""Generate a specific insight visualization.
|
||||
|
||||
Args:
|
||||
study_id: Study identifier
|
||||
insight_type: Type of insight (e.g., 'zernike_wfe', 'stress_field', 'design_space')
|
||||
trial_id: Optional specific trial to analyze (defaults to best trial)
|
||||
request: Optional generation config with iteration selection
|
||||
|
||||
Returns:
|
||||
JSON with plotly_figure data and summary statistics
|
||||
@@ -104,6 +217,25 @@ async def generate_insight(study_id: str, insight_type: str, trial_id: Optional[
|
||||
if insight is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown insight type: {insight_type}")
|
||||
|
||||
# If iteration specified, override the OP2 path
|
||||
if request and request.iteration:
|
||||
iteration_id = request.iteration
|
||||
if iteration_id == "best_design_archive":
|
||||
iter_path = study_path / "3_results" / "best_design_archive"
|
||||
else:
|
||||
iter_path = study_path / "2_iterations" / iteration_id
|
||||
|
||||
if iter_path.exists():
|
||||
op2_files = list(iter_path.glob("**/*.op2"))
|
||||
if op2_files:
|
||||
# Override the insight's OP2 path
|
||||
insight.op2_path = max(op2_files, key=lambda p: p.stat().st_mtime)
|
||||
# Re-find geometry
|
||||
try:
|
||||
insight.geo_path = insight._find_geometry_file(insight.op2_path)
|
||||
except (FileNotFoundError, AttributeError):
|
||||
pass # Use default
|
||||
|
||||
if not insight.can_generate():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -111,7 +243,9 @@ async def generate_insight(study_id: str, insight_type: str, trial_id: Optional[
|
||||
)
|
||||
|
||||
# Configure insight
|
||||
config = InsightConfig(trial_id=trial_id)
|
||||
trial_id = request.trial_id if request else None
|
||||
extra_config = request.config if request else {}
|
||||
config = InsightConfig(trial_id=trial_id, extra=extra_config)
|
||||
|
||||
# Generate
|
||||
result = insight.generate(config)
|
||||
@@ -123,6 +257,7 @@ async def generate_insight(study_id: str, insight_type: str, trial_id: Optional[
|
||||
"success": True,
|
||||
"insight_type": insight_type,
|
||||
"study_id": study_id,
|
||||
"iteration": request.iteration if request else None,
|
||||
"html_path": str(result.html_path) if result.html_path else None,
|
||||
"plotly_figure": result.plotly_figure,
|
||||
"summary": result.summary
|
||||
@@ -134,7 +269,7 @@ async def generate_insight(study_id: str, insight_type: str, trial_id: Optional[
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/studies/{study_id}/insights/view/{insight_type}")
|
||||
@router.get("/studies/{study_id}/view/{insight_type}")
|
||||
async def view_insight_html(study_id: str, insight_type: str):
|
||||
"""Get the HTML content for an insight (for iframe embedding).
|
||||
|
||||
@@ -180,7 +315,7 @@ async def view_insight_html(study_id: str, insight_type: str):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/studies/{study_id}/insights/generated")
|
||||
@router.get("/studies/{study_id}/generated")
|
||||
async def list_generated_insights(study_id: str):
|
||||
"""List all previously generated insight HTML files for a study."""
|
||||
try:
|
||||
@@ -219,3 +354,199 @@ async def list_generated_insights(study_id: str):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/studies/{study_id}/configured")
|
||||
async def get_configured_insights_endpoint(study_id: str):
|
||||
"""Get insights configured in the study's optimization_config.json."""
|
||||
try:
|
||||
study_path = resolve_study_path(study_id)
|
||||
|
||||
from optimization_engine.insights import get_configured_insights
|
||||
|
||||
specs = get_configured_insights(study_path)
|
||||
|
||||
return {
|
||||
"study_id": study_id,
|
||||
"configured": [
|
||||
{
|
||||
"type": spec.type,
|
||||
"name": spec.name,
|
||||
"enabled": spec.enabled,
|
||||
"linked_objective": spec.linked_objective,
|
||||
"config": spec.config,
|
||||
"include_in_report": spec.include_in_report
|
||||
}
|
||||
for spec in specs
|
||||
]
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ImportError as e:
|
||||
return {
|
||||
"study_id": study_id,
|
||||
"configured": [],
|
||||
"error": f"Insights module not available: {str(e)}"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/studies/{study_id}/recommend")
|
||||
async def recommend_insights_endpoint(study_id: str):
|
||||
"""Get AI recommendations for insights based on study objectives."""
|
||||
try:
|
||||
study_path = resolve_study_path(study_id)
|
||||
|
||||
from optimization_engine.insights import recommend_insights_for_study
|
||||
|
||||
recommendations = recommend_insights_for_study(study_path)
|
||||
|
||||
return {
|
||||
"study_id": study_id,
|
||||
"recommendations": recommendations
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ImportError as e:
|
||||
return {
|
||||
"study_id": study_id,
|
||||
"recommendations": [],
|
||||
"error": f"Insights module not available: {str(e)}"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/studies/{study_id}/report")
|
||||
async def generate_report(study_id: str, request: GenerateReportRequest):
|
||||
"""Generate comprehensive HTML report with all insights.
|
||||
|
||||
Args:
|
||||
study_id: Study identifier
|
||||
request: Report configuration with optional insight specs
|
||||
|
||||
Returns:
|
||||
JSON with report path and generation results
|
||||
"""
|
||||
try:
|
||||
study_path = resolve_study_path(study_id)
|
||||
|
||||
from optimization_engine.insights import (
|
||||
InsightReport, InsightSpec, get_configured_insights,
|
||||
recommend_insights_for_study
|
||||
)
|
||||
|
||||
# Build specs from request or config
|
||||
if request.specs:
|
||||
specs = [
|
||||
InsightSpec(
|
||||
type=s.type,
|
||||
name=s.name,
|
||||
enabled=s.enabled,
|
||||
linked_objective=s.linked_objective,
|
||||
config=s.config,
|
||||
include_in_report=s.include_in_report
|
||||
)
|
||||
for s in request.specs
|
||||
]
|
||||
else:
|
||||
# Try config first, then recommendations
|
||||
specs = get_configured_insights(study_path)
|
||||
if not specs:
|
||||
recommendations = recommend_insights_for_study(study_path)
|
||||
specs = [
|
||||
InsightSpec(
|
||||
type=rec['type'],
|
||||
name=rec['name'],
|
||||
linked_objective=rec.get('linked_objective'),
|
||||
config=rec.get('config', {})
|
||||
)
|
||||
for rec in recommendations
|
||||
]
|
||||
|
||||
if not specs:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No insights configured or recommended for this study"
|
||||
)
|
||||
|
||||
# Generate report
|
||||
report = InsightReport(study_path)
|
||||
results = report.generate_all(specs)
|
||||
report_path = report.generate_report_html(include_appendix=request.include_appendix)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"study_id": study_id,
|
||||
"report_path": str(report_path),
|
||||
"results": [
|
||||
{
|
||||
"type": r.insight_type,
|
||||
"name": r.insight_name,
|
||||
"success": r.success,
|
||||
"linked_objective": r.linked_objective,
|
||||
"error": r.error
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
"summary": {
|
||||
"total": len(results),
|
||||
"successful": sum(1 for r in results if r.success),
|
||||
"failed": sum(1 for r in results if not r.success)
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/studies/{study_id}/report/view")
|
||||
async def view_report(study_id: str):
|
||||
"""Get the latest generated report HTML for embedding."""
|
||||
try:
|
||||
study_path = resolve_study_path(study_id)
|
||||
report_path = study_path / "3_insights" / "STUDY_INSIGHTS_REPORT.html"
|
||||
|
||||
if not report_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No report generated yet. Use POST /insights/report to generate."
|
||||
)
|
||||
|
||||
return HTMLResponse(content=report_path.read_text(encoding='utf-8'))
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/studies/{study_id}/summary")
|
||||
async def get_insights_summary(study_id: str):
|
||||
"""Get insights summary JSON for Results page integration."""
|
||||
try:
|
||||
study_path = resolve_study_path(study_id)
|
||||
summary_path = study_path / "3_insights" / "insights_summary.json"
|
||||
|
||||
if not summary_path.exists():
|
||||
return {
|
||||
"study_id": study_id,
|
||||
"generated_at": None,
|
||||
"insights": []
|
||||
}
|
||||
|
||||
with open(summary_path) as f:
|
||||
summary = json.load(f)
|
||||
|
||||
summary["study_id"] = study_id
|
||||
return summary
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
Reference in New Issue
Block a user