Files
Atomizer/tools/extract_mirror_optical_specs.py
Anto01 f13563d7ab feat: Major update - Physics docs, Zernike OPD, insights, NX journals, tools
Documentation:
- Add docs/06_PHYSICS/ with Zernike fundamentals and OPD method docs
- Add docs/guides/CMA-ES_EXPLAINED.md optimization guide
- Update CLAUDE.md and ATOMIZER_CONTEXT.md with current architecture
- Update OP_01_CREATE_STUDY protocol

Planning:
- Add DYNAMIC_RESPONSE plans for random vibration/PSD support
- Add OPTIMIZATION_ENGINE_MIGRATION_PLAN for code reorganization

Insights System:
- Update design_space, modal_analysis, stress_field, thermal_field insights
- Improve error handling and data validation

NX Journals:
- Add analyze_wfe_zernike.py for Zernike WFE analysis
- Add capture_study_images.py for automated screenshots
- Add extract_expressions.py and introspect_part.py utilities
- Add user_generated_journals/journal_top_view_image_taking.py

Tests & Tools:
- Add comprehensive Zernike OPD test suite
- Add audit_v10 tests for WFE validation
- Add tools for Pareto graphs and mirror data extraction
- Add migrate_studies_to_topics.py utility

Knowledge Base:
- Initialize LAC (Learning Atomizer Core) with failure/success patterns

Dashboard:
- Update Setup.tsx and launch_dashboard.py
- Add restart-dev.bat helper script

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 19:47:37 -05:00

295 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""
Extract Mirror Optical Specifications from FEA Mesh Geometry
This tool analyzes mirror mesh geometry to estimate optical specifications
including focal length, aperture diameter, f-number, and radius of curvature.
Usage:
# From study directory containing OP2 files
python -m optimization_engine.tools.extract_mirror_optical_specs .
# From specific OP2 file
python -m optimization_engine.tools.extract_mirror_optical_specs path/to/results.op2
# Save to study README
python -m optimization_engine.tools.extract_mirror_optical_specs . --update-readme
Output:
- Console: Optical specifications summary
- Optional: Updates parent README.md with validated specs
Author: Atomizer Framework
"""
from pathlib import Path
import argparse
import sys
# Add project root to path (tools/ is at project root, so parent is Atomizer/)
sys.path.insert(0, str(Path(__file__).parent.parent))
import numpy as np
def find_op2_file(path: Path) -> Path:
"""Find an OP2 file from path (file or directory)."""
path = Path(path)
if path.is_file() and path.suffix.lower() == '.op2':
return path
if path.is_dir():
# Look in common locations
search_patterns = [
'**/2_iterations/**/*.op2',
'**/*.op2',
'2_iterations/**/*.op2',
'1_setup/model/*.op2',
]
for pattern in search_patterns:
op2_files = list(path.glob(pattern))
if op2_files:
# Return most recent
return max(op2_files, key=lambda p: p.stat().st_mtime)
raise FileNotFoundError(f"No OP2 file found in {path}")
def extract_optical_specs(op2_path: Path, verbose: bool = True) -> dict:
"""
Extract optical specifications from mirror mesh geometry.
Args:
op2_path: Path to OP2 file
verbose: Print detailed output
Returns:
dict with optical specifications
"""
from optimization_engine.extractors.extract_zernike_opd import (
ZernikeOPDExtractor,
estimate_focal_length_from_geometry
)
if verbose:
print(f"Analyzing: {op2_path}")
print("=" * 60)
extractor = ZernikeOPDExtractor(op2_path)
# Get geometry
geo = extractor.node_geometry
all_pos = np.array(list(geo.values()))
x, y, z = all_pos[:, 0], all_pos[:, 1], all_pos[:, 2]
# Compute radius/diameter
r = np.sqrt(x**2 + y**2)
# Estimate focal length
focal = estimate_focal_length_from_geometry(x, y, z, concave=True)
# Derived quantities
diameter = 2 * r.max()
f_number = focal / diameter
RoC = 2 * focal # Radius of curvature
sag = r.max()**2 / (4 * focal) # Surface sag at edge
central_obs = r.min() if r.min() > 1.0 else 0.0 # Central obscuration
# Parabola fit quality check
r_sq = x**2 + y**2
A = np.column_stack([r_sq, np.ones_like(r_sq)])
coeffs, _, _, _ = np.linalg.lstsq(A, z, rcond=None)
a, b = coeffs
z_fit = a * r_sq + b
rms_error = np.sqrt(np.mean((z - z_fit)**2))
# Determine fit quality
if rms_error < 0.1:
fit_quality = "Excellent"
fit_note = "Focal length estimate is reliable"
elif rms_error < 1.0:
fit_quality = "Good"
fit_note = "Focal length estimate is reasonably accurate"
else:
fit_quality = "Poor"
fit_note = "Consider using explicit focal length from optical design"
specs = {
'aperture_diameter_mm': diameter,
'aperture_radius_mm': r.max(),
'focal_length_mm': focal,
'f_number': f_number,
'radius_of_curvature_mm': RoC,
'surface_sag_mm': sag,
'central_obscuration_mm': central_obs,
'node_count': len(geo),
'x_range_mm': (x.min(), x.max()),
'y_range_mm': (y.min(), y.max()),
'z_range_mm': (z.min(), z.max()),
'parabola_fit_rms_mm': rms_error,
'fit_quality': fit_quality,
'fit_note': fit_note,
'source_file': str(op2_path),
}
if verbose:
print()
print("MIRROR OPTICAL SPECIFICATIONS (from mesh geometry)")
print("=" * 60)
print()
print(f"Aperture Diameter: {diameter:.1f} mm ({diameter/1000:.3f} m)")
print(f"Aperture Radius: {r.max():.1f} mm")
if central_obs > 0:
print(f"Central Obscuration: {central_obs:.1f} mm")
print()
print(f"Estimated Focal Length: {focal:.1f} mm ({focal/1000:.3f} m)")
print(f"Radius of Curvature: {RoC:.1f} mm ({RoC/1000:.3f} m)")
print(f"f-number (f/D): f/{f_number:.2f}")
print()
print(f"Surface Sag at Edge: {sag:.2f} mm")
print()
print("--- Mesh Statistics ---")
print(f"Node count: {len(geo)}")
print(f"X range: {x.min():.1f} to {x.max():.1f} mm")
print(f"Y range: {y.min():.1f} to {y.max():.1f} mm")
print(f"Z range: {z.min():.2f} to {z.max():.2f} mm")
print()
print("--- Parabola Fit Quality ---")
print(f"RMS fit residual: {rms_error:.4f} mm ({rms_error*1000:.2f} µm)")
print(f"Quality: {fit_quality} - {fit_note}")
print()
print("=" * 60)
return specs
def generate_readme_section(specs: dict) -> str:
"""Generate markdown section for README."""
return f"""## 2. Optical Prescription
> **Source**: Estimated from mesh geometry. Validate against optical design.
| Parameter | Value | Units | Status |
|-----------|-------|-------|--------|
| Aperture Diameter | {specs['aperture_diameter_mm']:.1f} | mm | Estimated |
| Focal Length | {specs['focal_length_mm']:.1f} | mm | Estimated |
| f-number | f/{specs['f_number']:.2f} | - | Computed |
| Radius of Curvature | {specs['radius_of_curvature_mm']:.1f} | mm | Computed (2×f) |
| Central Obscuration | {specs['central_obscuration_mm']:.1f} | mm | From mesh |
| Surface Type | Parabola | - | Assumed |
**Fit Quality**: {specs['fit_quality']} ({specs['fit_note']})
### 2.1 Usage in OPD Extractor
For rigorous WFE analysis, use explicit focal length:
```python
from optimization_engine.extractors import ZernikeOPDExtractor
extractor = ZernikeOPDExtractor(
op2_file,
focal_length={specs['focal_length_mm']:.1f}, # mm - validate against design
concave=True
)
```
"""
def update_readme(study_dir: Path, specs: dict):
"""Update parent README with optical specs."""
readme_path = study_dir / 'README.md'
if not readme_path.exists():
print(f"README.md not found at {readme_path}")
return False
content = readme_path.read_text(encoding='utf-8')
# Find and replace optical prescription section
new_section = generate_readme_section(specs)
# Look for existing section
import re
pattern = r'## 2\. Optical Prescription.*?(?=## 3\.|$)'
if re.search(pattern, content, re.DOTALL):
content = re.sub(pattern, new_section + '\n---\n\n', content, flags=re.DOTALL)
print(f"Updated optical prescription in {readme_path}")
else:
print(f"Could not find '## 2. Optical Prescription' section in {readme_path}")
print("Please add manually or check section numbering.")
return False
readme_path.write_text(content, encoding='utf-8')
return True
def main():
parser = argparse.ArgumentParser(
description='Extract mirror optical specifications from FEA mesh',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Analyze current study directory
python -m optimization_engine.tools.extract_mirror_optical_specs .
# Analyze specific OP2 file
python -m optimization_engine.tools.extract_mirror_optical_specs results.op2
# Update parent README with specs
python -m optimization_engine.tools.extract_mirror_optical_specs . --update-readme
"""
)
parser.add_argument('path', type=str,
help='Path to OP2 file or study directory')
parser.add_argument('--update-readme', action='store_true',
help='Update parent README.md with optical specs')
parser.add_argument('--quiet', '-q', action='store_true',
help='Suppress detailed output')
parser.add_argument('--json', action='store_true',
help='Output specs as JSON')
args = parser.parse_args()
try:
path = Path(args.path).resolve()
op2_path = find_op2_file(path)
specs = extract_optical_specs(op2_path, verbose=not args.quiet and not args.json)
if args.json:
import json
print(json.dumps(specs, indent=2, default=str))
if args.update_readme:
# Find study root (parent of geometry type folder)
study_dir = path if path.is_dir() else path.parent
# Go up to geometry type level
while study_dir.name not in ['studies', ''] and not (study_dir / 'README.md').exists():
if (study_dir.parent / 'README.md').exists():
study_dir = study_dir.parent
break
study_dir = study_dir.parent
if (study_dir / 'README.md').exists():
update_readme(study_dir, specs)
else:
print(f"Could not find parent README.md to update")
except FileNotFoundError as e:
print(f"Error: {e}")
sys.exit(1)
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()