feat(V&V): Zernike pipeline validation - synthetic WFE generator + round-trip validator

- generate_synthetic_wfe.py: Creates synthetic OPD surfaces from known Zernike coefficients
- validate_zernike_roundtrip.py: Round-trip validation (generate → fit → compare)
- validation_suite/: 18 test cases (single mode, multi-mode, noisy, edge cases)
- All 18 test cases pass with 0.000 nm error (clean) and <0.3 nm (10nm noise)
- M1 params: 1200mm dia, 135.75mm inner radius, 50 Noll modes

Project: P-Zernike-Validation (GigaBIT M1)
Requested by: Adyn Miles (StarSpec) for risk reduction before M2/M3 ordering
This commit is contained in:
2026-03-09 15:49:06 +00:00
parent 9b0769f3f4
commit f9373bee99
39 changed files with 2143215 additions and 0 deletions

View File

@@ -0,0 +1,526 @@
#!/usr/bin/env python3
"""
Synthetic WFE Surface Generator for Zernike Pipeline Validation
================================================================
Generates synthetic Optical Path Difference (OPD) maps from user-defined
Zernike coefficients. Used to validate the Atomizer Zernike fitting pipeline
by creating "known truth" surfaces that can be round-tripped through the
WFE_from_CSV_OPD tool.
Features:
- Noll-indexed Zernike polynomials (standard optical convention)
- Full-disk or annular aperture support
- Configurable grid density, mirror diameter, noise level
- Multiple output formats: CSV (for WFE_from_CSV_OPD), NumPy, plot
- Preset test cases for common validation scenarios
Usage:
# Single mode test (pure astigmatism)
python generate_synthetic_wfe.py --mode single --zernike "5:100" --output test_astig.csv
# Multi-mode realistic mirror
python generate_synthetic_wfe.py --mode multi --output test_multi.csv
# Custom coefficients
python generate_synthetic_wfe.py --zernike "5:80,7:45,9:25,11:15" --output test_custom.csv
# With noise
python generate_synthetic_wfe.py --mode multi --noise 2.0 --output test_noisy.csv
# Full test suite (generates all validation cases)
python generate_synthetic_wfe.py --suite --output-dir validation_suite/
Output CSV format (matching WFE_from_CSV_OPD):
x(mm), y(mm), dz(mm)
where dz is surface displacement (OPD = 2 * dz for reflective surfaces)
Author: Mario (Atomizer V&V)
Created: 2026-03-09
Project: P-Zernike-Validation (GigaBIT M1)
"""
import sys
import os
import argparse
import json
from pathlib import Path
from math import factorial
from typing import Dict, List, Tuple, Optional
import numpy as np
# ============================================================================
# Zernike Polynomial Mathematics (matching extract_zernike.py conventions)
# ============================================================================
def noll_indices(j: int) -> Tuple[int, int]:
"""Convert Noll index j to radial order n and azimuthal frequency m."""
if j < 1:
raise ValueError("Noll index j must be >= 1")
count = 0
n = 0
while True:
if n == 0:
ms = [0]
elif n % 2 == 0:
ms = [0] + [m for k in range(1, n // 2 + 1) for m in (-2 * k, 2 * k)]
else:
ms = [m for k in range(0, (n + 1) // 2) for m in (-(2 * k + 1), (2 * k + 1))]
for m in ms:
count += 1
if count == j:
return n, m
n += 1
def zernike_radial(n: int, m: int, r: np.ndarray) -> np.ndarray:
"""Compute radial component R_n^m(r)."""
R = np.zeros_like(r)
m_abs = abs(m)
for s in range((n - m_abs) // 2 + 1):
coef = ((-1) ** s * factorial(n - s) /
(factorial(s) *
factorial((n + m_abs) // 2 - s) *
factorial((n - m_abs) // 2 - s)))
R += coef * r ** (n - 2 * s)
return R
def zernike_noll(j: int, r: np.ndarray, theta: np.ndarray) -> np.ndarray:
"""Evaluate Noll-indexed Zernike polynomial Z_j(r, theta)."""
n, m = noll_indices(j)
R = zernike_radial(n, m, r)
if m == 0:
return R
elif m > 0:
return R * np.cos(m * theta)
else:
return R * np.sin(-m * theta)
def zernike_name(j: int) -> str:
"""Get common optical name for Zernike mode."""
n, m = noll_indices(j)
names = {
(0, 0): "Piston",
(1, -1): "Tilt X", (1, 1): "Tilt Y",
(2, 0): "Defocus",
(2, -2): "Astigmatism 45°", (2, 2): "Astigmatism 0°",
(3, -1): "Coma X", (3, 1): "Coma Y",
(3, -3): "Trefoil X", (3, 3): "Trefoil Y",
(4, 0): "Primary Spherical",
(4, -2): "2nd Astig X", (4, 2): "2nd Astig Y",
(4, -4): "Quadrafoil X", (4, 4): "Quadrafoil Y",
(5, -1): "2nd Coma X", (5, 1): "2nd Coma Y",
(5, -3): "2nd Trefoil X", (5, 3): "2nd Trefoil Y",
(5, -5): "Pentafoil X", (5, 5): "Pentafoil Y",
(6, 0): "2nd Spherical",
}
if (n, m) in names:
return names[(n, m)]
return f"Z({n},{m:+d})"
# ============================================================================
# Surface Generation
# ============================================================================
def generate_grid(
diameter_mm: float = 1200.0,
inner_radius_mm: float = 135.75,
n_points_radial: int = 200,
grid_type: str = "cartesian",
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Generate a 2D grid of points within the mirror aperture.
Returns:
x_mm, y_mm: Physical coordinates in mm
mask: Boolean array (True = inside aperture)
"""
outer_radius = diameter_mm / 2.0
if grid_type == "cartesian":
# Regular Cartesian grid
n = n_points_radial * 2
x_1d = np.linspace(-outer_radius, outer_radius, n)
y_1d = np.linspace(-outer_radius, outer_radius, n)
x_mm, y_mm = np.meshgrid(x_1d, y_1d)
x_mm = x_mm.ravel()
y_mm = y_mm.ravel()
elif grid_type == "scattered":
# Random scattered points (simulates real mesh)
n_total = (n_points_radial * 2) ** 2
rng = np.random.default_rng(42)
x_mm = rng.uniform(-outer_radius, outer_radius, n_total)
y_mm = rng.uniform(-outer_radius, outer_radius, n_total)
else:
raise ValueError(f"Unknown grid_type: {grid_type}")
# Apply aperture mask
r = np.sqrt(x_mm**2 + y_mm**2)
if inner_radius_mm > 0:
mask = (r <= outer_radius) & (r >= inner_radius_mm)
else:
mask = r <= outer_radius
return x_mm[mask], y_mm[mask], mask
def synthesize_surface(
x_mm: np.ndarray,
y_mm: np.ndarray,
coefficients: Dict[int, float],
diameter_mm: float = 1200.0,
noise_rms_nm: float = 0.0,
seed: int = 42,
) -> Tuple[np.ndarray, Dict]:
"""
Generate a synthetic OPD surface from Zernike coefficients.
Args:
x_mm, y_mm: Physical coordinates in mm
coefficients: Dict of {Noll_index: amplitude_nm}
diameter_mm: Mirror diameter in mm
noise_rms_nm: RMS of Gaussian noise to add (nm)
seed: Random seed for reproducibility
Returns:
opd_nm: OPD values in nanometers at each point
metadata: Dict with ground truth info
"""
outer_radius = diameter_mm / 2.0
# Normalize to unit disk
r_norm = np.sqrt(x_mm**2 + y_mm**2) / outer_radius
theta = np.arctan2(y_mm, x_mm)
# Build surface from Zernike modes
opd_nm = np.zeros_like(x_mm)
for j, amp_nm in coefficients.items():
Z_j = zernike_noll(j, r_norm, theta)
opd_nm += amp_nm * Z_j
# Compute ground truth RMS (before noise)
rms_total = np.sqrt(np.mean(opd_nm**2))
# Add noise if requested
if noise_rms_nm > 0:
rng = np.random.default_rng(seed)
noise = rng.normal(0, noise_rms_nm, size=opd_nm.shape)
opd_nm += noise
rms_with_noise = np.sqrt(np.mean(opd_nm**2))
metadata = {
"input_coefficients": {str(j): amp for j, amp in coefficients.items()},
"coefficient_names": {str(j): zernike_name(j) for j in coefficients},
"n_points": len(x_mm),
"diameter_mm": diameter_mm,
"rms_nm_clean": float(rms_total),
"rms_nm_with_noise": float(rms_with_noise),
"noise_rms_nm": noise_rms_nm,
"seed": seed,
}
return opd_nm, metadata
# ============================================================================
# Output Formats
# ============================================================================
def write_csv_opd(
filepath: str,
x_mm: np.ndarray,
y_mm: np.ndarray,
opd_nm: np.ndarray,
opd_unit: str = "nm",
):
"""
Write OPD surface to CSV format compatible with WFE_from_CSV_OPD.
Default format: x(mm), y(mm), opd(nm)
Can also output in mm for displacement: x(mm), y(mm), dz(mm)
NOTE: Update this function once the exact WFE_from_CSV_OPD format is confirmed.
"""
filepath = Path(filepath)
filepath.parent.mkdir(parents=True, exist_ok=True)
if opd_unit == "nm":
header = "x(mm),y(mm),opd(nm)"
data = np.column_stack([x_mm, y_mm, opd_nm])
elif opd_unit == "mm":
# Convert nm to mm for displacement
dz_mm = opd_nm / 1e6
header = "x(mm),y(mm),dz(mm)"
data = np.column_stack([x_mm, y_mm, dz_mm])
else:
raise ValueError(f"Unknown opd_unit: {opd_unit}")
np.savetxt(filepath, data, delimiter=",", header=header, comments="",
fmt="%.10e")
print(f" Written: {filepath} ({len(x_mm)} points)")
def write_metadata(filepath: str, metadata: Dict):
"""Write ground truth metadata as JSON."""
filepath = Path(filepath)
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, 'w') as f:
json.dump(metadata, f, indent=2)
print(f" Metadata: {filepath}")
# ============================================================================
# Preset Test Cases
# ============================================================================
# Realistic M1 mirror: typical gravity-induced deformation pattern
PRESET_REALISTIC = {
5: 80.0, # Astigmatism 0° — dominant gravity mode
6: 45.0, # Astigmatism 45°
7: 30.0, # Coma X
8: 20.0, # Coma Y
9: 15.0, # Trefoil X
11: 10.0, # Primary Spherical
13: 5.0, # Secondary Astigmatism
16: 3.0, # Secondary Coma
22: 2.0, # Secondary Spherical
}
# Single-mode test cases
SINGLE_MODE_TESTS = {
"Z05_astig_0deg": {5: 100.0},
"Z06_astig_45deg": {6: 100.0},
"Z07_coma_x": {7: 100.0},
"Z08_coma_y": {8: 100.0},
"Z09_trefoil_x": {9: 100.0},
"Z10_trefoil_y": {10: 100.0},
"Z11_spherical": {11: 100.0},
"Z22_2nd_spherical": {22: 50.0},
"Z37_high_order": {37: 30.0},
}
# Edge case tests
EDGE_CASE_TESTS = {
"near_zero": {5: 0.1, 7: 0.05, 11: 0.01}, # Sub-nm coefficients
"large_amplitude": {5: 500.0, 7: 300.0}, # Large deformation
"many_modes": {j: 100.0 / j for j in range(5, 51)}, # All modes, decreasing
}
def generate_test_suite(
output_dir: str,
diameter_mm: float = 1200.0,
inner_radius_mm: float = 135.75,
n_points_radial: int = 200,
):
"""Generate the full validation test suite."""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
all_cases = {}
# 1. Single-mode tests
print("\n=== Single-Mode Tests ===")
for name, coeffs in SINGLE_MODE_TESTS.items():
print(f"\nGenerating: {name}")
x, y, _ = generate_grid(diameter_mm, inner_radius_mm, n_points_radial)
opd, meta = synthesize_surface(x, y, coeffs, diameter_mm)
write_csv_opd(output_dir / f"{name}.csv", x, y, opd)
write_metadata(output_dir / f"{name}_truth.json", meta)
all_cases[name] = meta
# 2. Realistic multi-mode
print("\n=== Realistic Multi-Mode ===")
name = "realistic_gravity"
print(f"\nGenerating: {name}")
x, y, _ = generate_grid(diameter_mm, inner_radius_mm, n_points_radial)
opd, meta = synthesize_surface(x, y, PRESET_REALISTIC, diameter_mm)
write_csv_opd(output_dir / f"{name}.csv", x, y, opd)
write_metadata(output_dir / f"{name}_truth.json", meta)
all_cases[name] = meta
# 3. Noisy versions
print("\n=== Noisy Tests ===")
for noise_level in [1.0, 5.0, 10.0]:
name = f"realistic_noise_{noise_level:.0f}nm"
print(f"\nGenerating: {name}")
x, y, _ = generate_grid(diameter_mm, inner_radius_mm, n_points_radial)
opd, meta = synthesize_surface(x, y, PRESET_REALISTIC, diameter_mm, noise_rms_nm=noise_level)
write_csv_opd(output_dir / f"{name}.csv", x, y, opd)
write_metadata(output_dir / f"{name}_truth.json", meta)
all_cases[name] = meta
# 4. Edge cases
print("\n=== Edge Case Tests ===")
for name, coeffs in EDGE_CASE_TESTS.items():
print(f"\nGenerating: {name}")
x, y, _ = generate_grid(diameter_mm, inner_radius_mm, n_points_radial)
opd, meta = synthesize_surface(x, y, coeffs, diameter_mm)
write_csv_opd(output_dir / f"{name}.csv", x, y, opd)
write_metadata(output_dir / f"{name}_truth.json", meta)
all_cases[name] = meta
# 5. Full-disk (no central hole) for comparison
print("\n=== Full-Disk (No Hole) ===")
name = "realistic_full_disk"
print(f"\nGenerating: {name}")
x, y, _ = generate_grid(diameter_mm, 0.0, n_points_radial)
opd, meta = synthesize_surface(x, y, PRESET_REALISTIC, diameter_mm)
meta["inner_radius_mm"] = 0.0
write_csv_opd(output_dir / f"{name}.csv", x, y, opd)
write_metadata(output_dir / f"{name}_truth.json", meta)
all_cases[name] = meta
# 6. Scattered points (simulates real FEA mesh)
print("\n=== Scattered Grid (FEA-like) ===")
name = "realistic_scattered"
print(f"\nGenerating: {name}")
x, y, _ = generate_grid(diameter_mm, inner_radius_mm, n_points_radial, grid_type="scattered")
opd, meta = synthesize_surface(x, y, PRESET_REALISTIC, diameter_mm)
meta["grid_type"] = "scattered"
write_csv_opd(output_dir / f"{name}.csv", x, y, opd)
write_metadata(output_dir / f"{name}_truth.json", meta)
all_cases[name] = meta
# Summary
print(f"\n{'='*60}")
print(f"Generated {len(all_cases)} test cases in: {output_dir}")
print(f"{'='*60}")
# Write suite manifest
manifest = {
"suite": "Zernike Pipeline Validation",
"generated": "2026-03-09",
"mirror_diameter_mm": diameter_mm,
"inner_radius_mm": inner_radius_mm,
"n_zernike_modes": 50,
"n_points_radial": n_points_radial,
"cases": all_cases,
}
manifest_path = output_dir / "suite_manifest.json"
with open(manifest_path, 'w') as f:
json.dump(manifest, f, indent=2)
print(f"Manifest: {manifest_path}")
return all_cases
# ============================================================================
# CLI
# ============================================================================
def parse_zernike_string(s: str) -> Dict[int, float]:
"""Parse 'j1:amp1,j2:amp2,...' format."""
coeffs = {}
for pair in s.split(","):
parts = pair.strip().split(":")
if len(parts) != 2:
raise ValueError(f"Invalid format: '{pair}'. Use 'j:amplitude'")
j = int(parts[0])
amp = float(parts[1])
coeffs[j] = amp
return coeffs
def main():
parser = argparse.ArgumentParser(
description="Generate synthetic WFE surfaces for Zernike validation",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Pure astigmatism (100nm)
python generate_synthetic_wfe.py --zernike "5:100" -o test_astig.csv
# Realistic multi-mode mirror
python generate_synthetic_wfe.py --preset realistic -o test_realistic.csv
# Full validation suite
python generate_synthetic_wfe.py --suite -d validation_suite/
# Custom with noise
python generate_synthetic_wfe.py --zernike "5:80,7:45,11:15" --noise 2.0 -o test.csv
"""
)
parser.add_argument("--zernike", "-z", type=str,
help="Zernike coefficients as 'j:amp_nm,...' (e.g. '5:100,7:50')")
parser.add_argument("--preset", choices=["realistic"],
help="Use preset coefficient set")
parser.add_argument("--suite", action="store_true",
help="Generate full validation test suite")
parser.add_argument("--output", "-o", type=str, default="synthetic_wfe.csv",
help="Output CSV file path")
parser.add_argument("--output-dir", "-d", type=str, default="validation_suite",
help="Output directory for --suite mode")
parser.add_argument("--diameter", type=float, default=1200.0,
help="Mirror diameter in mm (default: 1200)")
parser.add_argument("--inner-radius", type=float, default=135.75,
help="Inner radius in mm for annular aperture (default: 135.75, 0=full disk)")
parser.add_argument("--grid-points", type=int, default=200,
help="Number of radial grid points (default: 200, total ~ 4*N^2)")
parser.add_argument("--noise", type=float, default=0.0,
help="Gaussian noise RMS in nm (default: 0)")
parser.add_argument("--grid-type", choices=["cartesian", "scattered"],
default="cartesian",
help="Grid type (default: cartesian)")
parser.add_argument("--unit", choices=["nm", "mm"], default="nm",
help="OPD output unit (default: nm)")
args = parser.parse_args()
print("=" * 60)
print("Synthetic WFE Surface Generator")
print("Zernike Pipeline Validation — GigaBIT M1")
print("=" * 60)
print(f" Mirror diameter: {args.diameter} mm")
print(f" Inner radius: {args.inner_radius} mm")
print(f" Grid points: ~{(args.grid_points*2)**2}")
if args.suite:
generate_test_suite(
args.output_dir,
args.diameter,
args.inner_radius,
args.grid_points,
)
else:
# Determine coefficients
if args.preset == "realistic":
coeffs = PRESET_REALISTIC
elif args.zernike:
coeffs = parse_zernike_string(args.zernike)
else:
print("\nERROR: Specify --zernike, --preset, or --suite")
sys.exit(1)
print(f"\n Coefficients:")
for j, amp in sorted(coeffs.items()):
print(f" Z{j:2d} ({zernike_name(j):20s}): {amp:8.2f} nm")
# Generate
x, y, _ = generate_grid(args.diameter, args.inner_radius,
args.grid_points, args.grid_type)
opd, meta = synthesize_surface(x, y, coeffs, args.diameter,
noise_rms_nm=args.noise)
print(f"\n Points in aperture: {len(x)}")
print(f" RMS (clean): {meta['rms_nm_clean']:.3f} nm")
if args.noise > 0:
print(f" RMS (noisy): {meta['rms_nm_with_noise']:.3f} nm")
# Write outputs
print(f"\n Output unit: {args.unit}")
write_csv_opd(args.output, x, y, opd, opd_unit=args.unit)
meta_path = Path(args.output).with_suffix('.json')
write_metadata(str(meta_path), meta)
print("\nDone!")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
Zernike Round-Trip Validator
=============================
Reads a synthetic WFE CSV + its truth JSON, fits Zernike coefficients,
and compares recovered vs input coefficients.
This validates that the Zernike fitting math itself is correct by
doing a generate → fit → compare round-trip.
Usage:
# Single file
python validate_zernike_roundtrip.py validation_suite/Z05_astig_0deg.csv
# Full suite
python validate_zernike_roundtrip.py --suite validation_suite/
Author: Mario (Atomizer V&V)
Created: 2026-03-09
"""
import sys
import json
import argparse
from pathlib import Path
from math import factorial
from typing import Dict, Tuple
import numpy as np
from numpy.linalg import lstsq
# ============================================================================
# Zernike Math (same as generate_synthetic_wfe.py)
# ============================================================================
def noll_indices(j: int) -> Tuple[int, int]:
if j < 1:
raise ValueError("Noll index j must be >= 1")
count = 0
n = 0
while True:
if n == 0:
ms = [0]
elif n % 2 == 0:
ms = [0] + [m for k in range(1, n // 2 + 1) for m in (-2 * k, 2 * k)]
else:
ms = [m for k in range(0, (n + 1) // 2) for m in (-(2 * k + 1), (2 * k + 1))]
for m in ms:
count += 1
if count == j:
return n, m
n += 1
def zernike_radial(n, m, r):
R = np.zeros_like(r)
m_abs = abs(m)
for s in range((n - m_abs) // 2 + 1):
coef = ((-1) ** s * factorial(n - s) /
(factorial(s) * factorial((n + m_abs) // 2 - s) * factorial((n - m_abs) // 2 - s)))
R += coef * r ** (n - 2 * s)
return R
def zernike_noll(j, r, theta):
n, m = noll_indices(j)
R = zernike_radial(n, m, r)
if m == 0:
return R
elif m > 0:
return R * np.cos(m * theta)
else:
return R * np.sin(-m * theta)
def zernike_name(j):
n, m = noll_indices(j)
names = {
(0, 0): "Piston", (1, -1): "Tilt X", (1, 1): "Tilt Y",
(2, 0): "Defocus", (2, -2): "Astig 45°", (2, 2): "Astig 0°",
(3, -1): "Coma X", (3, 1): "Coma Y",
(3, -3): "Trefoil X", (3, 3): "Trefoil Y",
(4, 0): "Spherical", (4, -2): "2ndAstig X", (4, 2): "2ndAstig Y",
(6, 0): "2nd Spherical",
}
return names.get((n, m), f"Z({n},{m:+d})")
# ============================================================================
# Zernike Fitting (Least Squares)
# ============================================================================
def fit_zernike(x_mm, y_mm, opd_nm, diameter_mm=1200.0, n_modes=50):
"""
Fit Zernike coefficients to OPD data via least-squares.
Returns:
coefficients: array of shape (n_modes,), Noll-indexed from j=1
"""
outer_radius = diameter_mm / 2.0
r_norm = np.sqrt(x_mm**2 + y_mm**2) / outer_radius
theta = np.arctan2(y_mm, x_mm)
# Build Zernike basis matrix
Z = np.zeros((len(x_mm), n_modes))
for j in range(1, n_modes + 1):
Z[:, j - 1] = zernike_noll(j, r_norm, theta)
# Least-squares fit
coeffs, residuals, rank, sv = lstsq(Z, opd_nm, rcond=None)
return coeffs
# ============================================================================
# Validation
# ============================================================================
def validate_file(csv_path: str, n_modes: int = 50, diameter_mm: float = 1200.0,
tolerance_nm: float = 0.5, verbose: bool = True):
"""
Validate a single synthetic WFE file.
Returns:
passed: bool
results: dict with comparison details
"""
csv_path = Path(csv_path)
truth_path = csv_path.with_name(csv_path.stem + "_truth.json")
if not truth_path.exists():
# Try removing any suffix before _truth
base = csv_path.stem
truth_path = csv_path.with_name(base + "_truth.json")
if not truth_path.exists():
print(f" WARNING: No truth file found for {csv_path.name}")
return None, None
# Load CSV
data = np.loadtxt(csv_path, delimiter=",", skiprows=1)
x_mm = data[:, 0]
y_mm = data[:, 1]
opd_nm = data[:, 2]
# Load truth
with open(truth_path) as f:
truth = json.load(f)
input_coeffs = {int(k): v for k, v in truth["input_coefficients"].items()}
# Fit Zernike
recovered = fit_zernike(x_mm, y_mm, opd_nm, diameter_mm, n_modes)
# Compare
max_error = 0.0
results = {"modes": {}}
all_passed = True
if verbose:
print(f"\n {'Mode':>6} {'Name':>20} {'Input(nm)':>10} {'Recovered(nm)':>14} {'Error(nm)':>10} {'Status':>8}")
print(f" {'-'*6} {'-'*20} {'-'*10} {'-'*14} {'-'*10} {'-'*8}")
for j in range(1, n_modes + 1):
input_val = input_coeffs.get(j, 0.0)
recovered_val = recovered[j - 1]
error = abs(recovered_val - input_val)
max_error = max(max_error, error)
mode_passed = error < tolerance_nm
if not mode_passed:
all_passed = False
results["modes"][j] = {
"input": input_val,
"recovered": float(recovered_val),
"error": float(error),
"passed": mode_passed,
}
# Only print modes with non-zero input or significant recovery
if verbose and (abs(input_val) > 0.01 or abs(recovered_val) > tolerance_nm):
status = "" if mode_passed else ""
print(f" Z{j:>4d} {zernike_name(j):>20} {input_val:>10.3f} {recovered_val:>14.3f} {error:>10.6f} {status:>8}")
results["max_error_nm"] = float(max_error)
results["all_passed"] = all_passed
results["tolerance_nm"] = tolerance_nm
results["n_points"] = len(x_mm)
if verbose:
print(f"\n Max error: {max_error:.6f} nm")
print(f" Tolerance: {tolerance_nm:.3f} nm")
print(f" Result: {'✅ PASS' if all_passed else '❌ FAIL'}")
return all_passed, results
def validate_suite(suite_dir: str, n_modes: int = 50, tolerance_nm: float = 0.5):
"""Validate all test cases in a suite directory."""
suite_dir = Path(suite_dir)
csv_files = sorted(suite_dir.glob("*.csv"))
print(f"\nValidating {len(csv_files)} test cases in: {suite_dir}")
print("=" * 70)
summary = {}
n_pass = 0
n_fail = 0
n_skip = 0
for csv_file in csv_files:
print(f"\n{''*70}")
print(f"Test: {csv_file.name}")
passed, results = validate_file(csv_file, n_modes, tolerance_nm=tolerance_nm)
if passed is None:
n_skip += 1
summary[csv_file.stem] = "SKIP"
elif passed:
n_pass += 1
summary[csv_file.stem] = "PASS"
else:
n_fail += 1
summary[csv_file.stem] = "FAIL"
print(f"\n{'='*70}")
print(f"SUITE SUMMARY")
print(f"{'='*70}")
print(f" PASS: {n_pass}")
print(f" FAIL: {n_fail}")
print(f" SKIP: {n_skip}")
print(f" Total: {len(csv_files)}")
print(f"\n Overall: {'✅ ALL PASSED' if n_fail == 0 else '❌ FAILURES DETECTED'}")
return n_fail == 0, summary
def main():
parser = argparse.ArgumentParser(description="Validate Zernike round-trip accuracy")
parser.add_argument("input", nargs="?", help="CSV file or --suite directory")
parser.add_argument("--suite", type=str, help="Validate all CSVs in directory")
parser.add_argument("--n-modes", type=int, default=50)
parser.add_argument("--tolerance", type=float, default=0.5,
help="Max acceptable coefficient error in nm (default: 0.5)")
parser.add_argument("--diameter", type=float, default=1200.0)
args = parser.parse_args()
if args.suite:
passed, summary = validate_suite(args.suite, args.n_modes, args.tolerance)
sys.exit(0 if passed else 1)
elif args.input:
passed, results = validate_file(args.input, args.n_modes, args.diameter, args.tolerance)
sys.exit(0 if passed else 1)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"input_coefficients": {
"5": 100.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.90787728635817,
"rms_nm_with_noise": 41.90787728635817,
"noise_rms_nm": 0.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"input_coefficients": {
"6": 100.0
},
"coefficient_names": {
"6": "Astigmatism 0\u00b0"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.87578387312423,
"rms_nm_with_noise": 41.87578387312423,
"noise_rms_nm": 0.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"input_coefficients": {
"7": 100.0
},
"coefficient_names": {
"7": "Coma X"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 35.928378983095506,
"rms_nm_with_noise": 35.928378983095506,
"noise_rms_nm": 0.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"input_coefficients": {
"8": 100.0
},
"coefficient_names": {
"8": "Coma Y"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 35.928378983095506,
"rms_nm_with_noise": 35.928378983095506,
"noise_rms_nm": 0.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"input_coefficients": {
"9": 100.0
},
"coefficient_names": {
"9": "Trefoil X"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 36.27358028530051,
"rms_nm_with_noise": 36.27358028530051,
"noise_rms_nm": 0.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"input_coefficients": {
"10": 100.0
},
"coefficient_names": {
"10": "Trefoil Y"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 36.27358028530051,
"rms_nm_with_noise": 36.27358028530051,
"noise_rms_nm": 0.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"input_coefficients": {
"11": 100.0
},
"coefficient_names": {
"11": "Primary Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.33581266115739,
"rms_nm_with_noise": 41.33581266115739,
"noise_rms_nm": 0.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"input_coefficients": {
"22": 50.0
},
"coefficient_names": {
"22": "2nd Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 17.392678653363138,
"rms_nm_with_noise": 17.392678653363138,
"noise_rms_nm": 0.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"input_coefficients": {
"37": 30.0
},
"coefficient_names": {
"37": "Z(8,+0)"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 9.330123545845794,
"rms_nm_with_noise": 9.330123545845794,
"noise_rms_nm": 0.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
{
"input_coefficients": {
"5": 500.0,
"7": 300.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"7": "Coma X"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 235.6361394467916,
"rms_nm_with_noise": 235.6361394467916,
"noise_rms_nm": 0.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
{
"input_coefficients": {
"5": 20.0,
"6": 16.666666666666668,
"7": 14.285714285714286,
"8": 12.5,
"9": 11.11111111111111,
"10": 10.0,
"11": 9.090909090909092,
"12": 8.333333333333334,
"13": 7.6923076923076925,
"14": 7.142857142857143,
"15": 6.666666666666667,
"16": 6.25,
"17": 5.882352941176471,
"18": 5.555555555555555,
"19": 5.2631578947368425,
"20": 5.0,
"21": 4.761904761904762,
"22": 4.545454545454546,
"23": 4.3478260869565215,
"24": 4.166666666666667,
"25": 4.0,
"26": 3.8461538461538463,
"27": 3.7037037037037037,
"28": 3.5714285714285716,
"29": 3.4482758620689653,
"30": 3.3333333333333335,
"31": 3.225806451612903,
"32": 3.125,
"33": 3.0303030303030303,
"34": 2.9411764705882355,
"35": 2.857142857142857,
"36": 2.7777777777777777,
"37": 2.7027027027027026,
"38": 2.6315789473684212,
"39": 2.5641025641025643,
"40": 2.5,
"41": 2.4390243902439024,
"42": 2.380952380952381,
"43": 2.3255813953488373,
"44": 2.272727272727273,
"45": 2.2222222222222223,
"46": 2.1739130434782608,
"47": 2.127659574468085,
"48": 2.0833333333333335,
"49": 2.0408163265306123,
"50": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"10": "Trefoil Y",
"11": "Primary Spherical",
"12": "2nd Astig X",
"13": "2nd Astig Y",
"14": "Quadrafoil X",
"15": "Quadrafoil Y",
"16": "2nd Coma X",
"17": "2nd Coma Y",
"18": "2nd Trefoil X",
"19": "2nd Trefoil Y",
"20": "Pentafoil X",
"21": "Pentafoil Y",
"22": "2nd Spherical",
"23": "Z(6,-2)",
"24": "Z(6,+2)",
"25": "Z(6,-4)",
"26": "Z(6,+4)",
"27": "Z(6,-6)",
"28": "Z(6,+6)",
"29": "Z(7,-1)",
"30": "Z(7,+1)",
"31": "Z(7,-3)",
"32": "Z(7,+3)",
"33": "Z(7,-5)",
"34": "Z(7,+5)",
"35": "Z(7,-7)",
"36": "Z(7,+7)",
"37": "Z(8,+0)",
"38": "Z(8,-2)",
"39": "Z(8,+2)",
"40": "Z(8,-4)",
"41": "Z(8,+4)",
"42": "Z(8,-6)",
"43": "Z(8,+6)",
"44": "Z(8,-8)",
"45": "Z(8,+8)",
"46": "Z(9,-1)",
"47": "Z(9,+1)",
"48": "Z(9,-3)",
"49": "Z(9,+3)",
"50": "Z(9,-5)"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 16.40426082558852,
"rms_nm_with_noise": 16.40426082558852,
"noise_rms_nm": 0.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"input_coefficients": {
"5": 0.1,
"7": 0.05,
"11": 0.01
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"7": "Coma X",
"11": "Primary Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 0.04578284369521264,
"rms_nm_with_noise": 0.04578284369521264,
"noise_rms_nm": 0.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"input_coefficients": {
"5": 80.0,
"6": 45.0,
"7": 30.0,
"8": 20.0,
"9": 15.0,
"11": 10.0,
"13": 5.0,
"16": 3.0,
"22": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"11": "Primary Spherical",
"13": "2nd Astig Y",
"16": "2nd Coma X",
"22": "2nd Spherical"
},
"n_points": 124980,
"diameter_mm": 1200.0,
"rms_nm_clean": 40.21839241800354,
"rms_nm_with_noise": 40.21839241800354,
"noise_rms_nm": 0.0,
"seed": 42,
"inner_radius_mm": 0.0
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
{
"input_coefficients": {
"5": 80.0,
"6": 45.0,
"7": 30.0,
"8": 20.0,
"9": 15.0,
"11": 10.0,
"13": 5.0,
"16": 3.0,
"22": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"11": "Primary Spherical",
"13": "2nd Astig Y",
"16": "2nd Coma X",
"22": "2nd Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.22340829508179,
"rms_nm_with_noise": 41.22340829508179,
"noise_rms_nm": 0.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
{
"input_coefficients": {
"5": 80.0,
"6": 45.0,
"7": 30.0,
"8": 20.0,
"9": 15.0,
"11": 10.0,
"13": 5.0,
"16": 3.0,
"22": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"11": "Primary Spherical",
"13": "2nd Astig Y",
"16": "2nd Coma X",
"22": "2nd Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.22340829508179,
"rms_nm_with_noise": 42.39618932696218,
"noise_rms_nm": 10.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
{
"input_coefficients": {
"5": 80.0,
"6": 45.0,
"7": 30.0,
"8": 20.0,
"9": 15.0,
"11": 10.0,
"13": 5.0,
"16": 3.0,
"22": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"11": "Primary Spherical",
"13": "2nd Astig Y",
"16": "2nd Coma X",
"22": "2nd Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.22340829508179,
"rms_nm_with_noise": 41.23235114137111,
"noise_rms_nm": 1.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
{
"input_coefficients": {
"5": 80.0,
"6": 45.0,
"7": 30.0,
"8": 20.0,
"9": 15.0,
"11": 10.0,
"13": 5.0,
"16": 3.0,
"22": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"11": "Primary Spherical",
"13": "2nd Astig Y",
"16": "2nd Coma X",
"22": "2nd Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.22340829508179,
"rms_nm_with_noise": 41.511570286278975,
"noise_rms_nm": 5.0,
"seed": 42
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"input_coefficients": {
"5": 80.0,
"6": 45.0,
"7": 30.0,
"8": 20.0,
"9": 15.0,
"11": 10.0,
"13": 5.0,
"16": 3.0,
"22": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"11": "Primary Spherical",
"13": "2nd Astig Y",
"16": "2nd Coma X",
"22": "2nd Spherical"
},
"n_points": 119376,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.32177716032332,
"rms_nm_with_noise": 41.32177716032332,
"noise_rms_nm": 0.0,
"seed": 42,
"grid_type": "scattered"
}

View File

@@ -0,0 +1,456 @@
{
"suite": "Zernike Pipeline Validation",
"generated": "2026-03-09",
"mirror_diameter_mm": 1200.0,
"inner_radius_mm": 135.75,
"n_zernike_modes": 50,
"n_points_radial": 200,
"cases": {
"Z05_astig_0deg": {
"input_coefficients": {
"5": 100.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.90787728635817,
"rms_nm_with_noise": 41.90787728635817,
"noise_rms_nm": 0.0,
"seed": 42
},
"Z06_astig_45deg": {
"input_coefficients": {
"6": 100.0
},
"coefficient_names": {
"6": "Astigmatism 0\u00b0"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.87578387312423,
"rms_nm_with_noise": 41.87578387312423,
"noise_rms_nm": 0.0,
"seed": 42
},
"Z07_coma_x": {
"input_coefficients": {
"7": 100.0
},
"coefficient_names": {
"7": "Coma X"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 35.928378983095506,
"rms_nm_with_noise": 35.928378983095506,
"noise_rms_nm": 0.0,
"seed": 42
},
"Z08_coma_y": {
"input_coefficients": {
"8": 100.0
},
"coefficient_names": {
"8": "Coma Y"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 35.928378983095506,
"rms_nm_with_noise": 35.928378983095506,
"noise_rms_nm": 0.0,
"seed": 42
},
"Z09_trefoil_x": {
"input_coefficients": {
"9": 100.0
},
"coefficient_names": {
"9": "Trefoil X"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 36.27358028530051,
"rms_nm_with_noise": 36.27358028530051,
"noise_rms_nm": 0.0,
"seed": 42
},
"Z10_trefoil_y": {
"input_coefficients": {
"10": 100.0
},
"coefficient_names": {
"10": "Trefoil Y"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 36.27358028530051,
"rms_nm_with_noise": 36.27358028530051,
"noise_rms_nm": 0.0,
"seed": 42
},
"Z11_spherical": {
"input_coefficients": {
"11": 100.0
},
"coefficient_names": {
"11": "Primary Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.33581266115739,
"rms_nm_with_noise": 41.33581266115739,
"noise_rms_nm": 0.0,
"seed": 42
},
"Z22_2nd_spherical": {
"input_coefficients": {
"22": 50.0
},
"coefficient_names": {
"22": "2nd Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 17.392678653363138,
"rms_nm_with_noise": 17.392678653363138,
"noise_rms_nm": 0.0,
"seed": 42
},
"Z37_high_order": {
"input_coefficients": {
"37": 30.0
},
"coefficient_names": {
"37": "Z(8,+0)"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 9.330123545845794,
"rms_nm_with_noise": 9.330123545845794,
"noise_rms_nm": 0.0,
"seed": 42
},
"realistic_gravity": {
"input_coefficients": {
"5": 80.0,
"6": 45.0,
"7": 30.0,
"8": 20.0,
"9": 15.0,
"11": 10.0,
"13": 5.0,
"16": 3.0,
"22": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"11": "Primary Spherical",
"13": "2nd Astig Y",
"16": "2nd Coma X",
"22": "2nd Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.22340829508179,
"rms_nm_with_noise": 41.22340829508179,
"noise_rms_nm": 0.0,
"seed": 42
},
"realistic_noise_1nm": {
"input_coefficients": {
"5": 80.0,
"6": 45.0,
"7": 30.0,
"8": 20.0,
"9": 15.0,
"11": 10.0,
"13": 5.0,
"16": 3.0,
"22": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"11": "Primary Spherical",
"13": "2nd Astig Y",
"16": "2nd Coma X",
"22": "2nd Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.22340829508179,
"rms_nm_with_noise": 41.23235114137111,
"noise_rms_nm": 1.0,
"seed": 42
},
"realistic_noise_5nm": {
"input_coefficients": {
"5": 80.0,
"6": 45.0,
"7": 30.0,
"8": 20.0,
"9": 15.0,
"11": 10.0,
"13": 5.0,
"16": 3.0,
"22": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"11": "Primary Spherical",
"13": "2nd Astig Y",
"16": "2nd Coma X",
"22": "2nd Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.22340829508179,
"rms_nm_with_noise": 41.511570286278975,
"noise_rms_nm": 5.0,
"seed": 42
},
"realistic_noise_10nm": {
"input_coefficients": {
"5": 80.0,
"6": 45.0,
"7": 30.0,
"8": 20.0,
"9": 15.0,
"11": 10.0,
"13": 5.0,
"16": 3.0,
"22": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"11": "Primary Spherical",
"13": "2nd Astig Y",
"16": "2nd Coma X",
"22": "2nd Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.22340829508179,
"rms_nm_with_noise": 42.39618932696218,
"noise_rms_nm": 10.0,
"seed": 42
},
"near_zero": {
"input_coefficients": {
"5": 0.1,
"7": 0.05,
"11": 0.01
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"7": "Coma X",
"11": "Primary Spherical"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 0.04578284369521264,
"rms_nm_with_noise": 0.04578284369521264,
"noise_rms_nm": 0.0,
"seed": 42
},
"large_amplitude": {
"input_coefficients": {
"5": 500.0,
"7": 300.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"7": "Coma X"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 235.6361394467916,
"rms_nm_with_noise": 235.6361394467916,
"noise_rms_nm": 0.0,
"seed": 42
},
"many_modes": {
"input_coefficients": {
"5": 20.0,
"6": 16.666666666666668,
"7": 14.285714285714286,
"8": 12.5,
"9": 11.11111111111111,
"10": 10.0,
"11": 9.090909090909092,
"12": 8.333333333333334,
"13": 7.6923076923076925,
"14": 7.142857142857143,
"15": 6.666666666666667,
"16": 6.25,
"17": 5.882352941176471,
"18": 5.555555555555555,
"19": 5.2631578947368425,
"20": 5.0,
"21": 4.761904761904762,
"22": 4.545454545454546,
"23": 4.3478260869565215,
"24": 4.166666666666667,
"25": 4.0,
"26": 3.8461538461538463,
"27": 3.7037037037037037,
"28": 3.5714285714285716,
"29": 3.4482758620689653,
"30": 3.3333333333333335,
"31": 3.225806451612903,
"32": 3.125,
"33": 3.0303030303030303,
"34": 2.9411764705882355,
"35": 2.857142857142857,
"36": 2.7777777777777777,
"37": 2.7027027027027026,
"38": 2.6315789473684212,
"39": 2.5641025641025643,
"40": 2.5,
"41": 2.4390243902439024,
"42": 2.380952380952381,
"43": 2.3255813953488373,
"44": 2.272727272727273,
"45": 2.2222222222222223,
"46": 2.1739130434782608,
"47": 2.127659574468085,
"48": 2.0833333333333335,
"49": 2.0408163265306123,
"50": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"10": "Trefoil Y",
"11": "Primary Spherical",
"12": "2nd Astig X",
"13": "2nd Astig Y",
"14": "Quadrafoil X",
"15": "Quadrafoil Y",
"16": "2nd Coma X",
"17": "2nd Coma Y",
"18": "2nd Trefoil X",
"19": "2nd Trefoil Y",
"20": "Pentafoil X",
"21": "Pentafoil Y",
"22": "2nd Spherical",
"23": "Z(6,-2)",
"24": "Z(6,+2)",
"25": "Z(6,-4)",
"26": "Z(6,+4)",
"27": "Z(6,-6)",
"28": "Z(6,+6)",
"29": "Z(7,-1)",
"30": "Z(7,+1)",
"31": "Z(7,-3)",
"32": "Z(7,+3)",
"33": "Z(7,-5)",
"34": "Z(7,+5)",
"35": "Z(7,-7)",
"36": "Z(7,+7)",
"37": "Z(8,+0)",
"38": "Z(8,-2)",
"39": "Z(8,+2)",
"40": "Z(8,-4)",
"41": "Z(8,+4)",
"42": "Z(8,-6)",
"43": "Z(8,+6)",
"44": "Z(8,-8)",
"45": "Z(8,+8)",
"46": "Z(9,-1)",
"47": "Z(9,+1)",
"48": "Z(9,-3)",
"49": "Z(9,+3)",
"50": "Z(9,-5)"
},
"n_points": 118572,
"diameter_mm": 1200.0,
"rms_nm_clean": 16.40426082558852,
"rms_nm_with_noise": 16.40426082558852,
"noise_rms_nm": 0.0,
"seed": 42
},
"realistic_full_disk": {
"input_coefficients": {
"5": 80.0,
"6": 45.0,
"7": 30.0,
"8": 20.0,
"9": 15.0,
"11": 10.0,
"13": 5.0,
"16": 3.0,
"22": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"11": "Primary Spherical",
"13": "2nd Astig Y",
"16": "2nd Coma X",
"22": "2nd Spherical"
},
"n_points": 124980,
"diameter_mm": 1200.0,
"rms_nm_clean": 40.21839241800354,
"rms_nm_with_noise": 40.21839241800354,
"noise_rms_nm": 0.0,
"seed": 42,
"inner_radius_mm": 0.0
},
"realistic_scattered": {
"input_coefficients": {
"5": 80.0,
"6": 45.0,
"7": 30.0,
"8": 20.0,
"9": 15.0,
"11": 10.0,
"13": 5.0,
"16": 3.0,
"22": 2.0
},
"coefficient_names": {
"5": "Astigmatism 45\u00b0",
"6": "Astigmatism 0\u00b0",
"7": "Coma X",
"8": "Coma Y",
"9": "Trefoil X",
"11": "Primary Spherical",
"13": "2nd Astig Y",
"16": "2nd Coma X",
"22": "2nd Spherical"
},
"n_points": 119376,
"diameter_mm": 1200.0,
"rms_nm_clean": 41.32177716032332,
"rms_nm_with_noise": 41.32177716032332,
"noise_rms_nm": 0.0,
"seed": 42,
"grid_type": "scattered"
}
}
}