owl-widgets 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- owl/__init__.py +59 -0
- owl/aperture_study.py +250 -0
- owl/bench.py +248 -0
- owl/core.py +530 -0
- owl/cross_section.py +44 -0
- owl/frontend.py +9 -0
- owl/static/PLOTLY-LICENSE.txt +9 -0
- owl/static/bench.css +313 -0
- owl/static/bench.js +637 -0
- owl/static/plotly.min.js +3620 -0
- owl/static/plotly_views.js +299 -0
- owl/static/studies.css +382 -0
- owl/static/studies.js +1183 -0
- owl/stop_study.py +303 -0
- owl/studies.py +178 -0
- owl/wave.py +250 -0
- owl_widgets-0.1.0.dist-info/METADATA +196 -0
- owl_widgets-0.1.0.dist-info/RECORD +20 -0
- owl_widgets-0.1.0.dist-info/WHEEL +4 -0
- owl_widgets-0.1.0.dist-info/licenses/LICENSE +21 -0
owl/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Paraxial optics core; lengths are in millimetres unless otherwise named."""
|
|
2
|
+
|
|
3
|
+
from .aperture_study import ApertureStudySpec, calculate_aperture_study
|
|
4
|
+
from .bench import Bench
|
|
5
|
+
from .core import (
|
|
6
|
+
Aperture,
|
|
7
|
+
FreeSpace,
|
|
8
|
+
GaussianBeam,
|
|
9
|
+
GaussianTrace,
|
|
10
|
+
Interface,
|
|
11
|
+
Mirror,
|
|
12
|
+
OpticalSystem,
|
|
13
|
+
RayTrace,
|
|
14
|
+
ThickLens,
|
|
15
|
+
ThinLens,
|
|
16
|
+
)
|
|
17
|
+
from .cross_section import CrossSection
|
|
18
|
+
from .stop_study import StopStudySpec, calculate_stop_study
|
|
19
|
+
from .studies import (
|
|
20
|
+
ApertureFocusStudy,
|
|
21
|
+
ApertureStudy,
|
|
22
|
+
PupilMapStudy,
|
|
23
|
+
RayWaveStudy,
|
|
24
|
+
StopRayStudy,
|
|
25
|
+
StopStudy,
|
|
26
|
+
)
|
|
27
|
+
from .wave import cross_sections, longitudinal_section
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Aperture",
|
|
31
|
+
"ApertureFocusStudy",
|
|
32
|
+
"ApertureStudy",
|
|
33
|
+
"ApertureStudySpec",
|
|
34
|
+
"calculate_aperture_study",
|
|
35
|
+
"Bench",
|
|
36
|
+
"CrossSection",
|
|
37
|
+
"cross_sections",
|
|
38
|
+
"longitudinal_section",
|
|
39
|
+
"FreeSpace",
|
|
40
|
+
"GaussianBeam",
|
|
41
|
+
"GaussianTrace",
|
|
42
|
+
"Interface",
|
|
43
|
+
|
|
44
|
+
"Mirror",
|
|
45
|
+
|
|
46
|
+
"ThickLens",
|
|
47
|
+
"OpticalSystem",
|
|
48
|
+
"PupilMapStudy",
|
|
49
|
+
"RayTrace",
|
|
50
|
+
"RayWaveStudy",
|
|
51
|
+
|
|
52
|
+
"StopRayStudy",
|
|
53
|
+
"StopStudy",
|
|
54
|
+
"StopStudySpec",
|
|
55
|
+
"calculate_stop_study",
|
|
56
|
+
"ThinLens",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
__version__ = "0.1.0"
|
owl/aperture_study.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Reusable numerical study of a Gaussian beam clipped at a thin lens.
|
|
2
|
+
|
|
3
|
+
Coordinates are millimetres and radians. ``z_mm=0`` is the input waist, the
|
|
4
|
+
lens/aperture is at ``distance_mm``, and positive z follows propagation. 2-D
|
|
5
|
+
arrays are indexed ``[y, x]``; longitudinal arrays are indexed ``[x, z]``.
|
|
6
|
+
The sole public calculation returns only finite, strict-JSON-compatible data.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from numbers import Integral, Real
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
__all__ = ["ApertureStudySpec", "calculate_aperture_study"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ApertureStudySpec:
|
|
20
|
+
waist_mm: float = 0.25
|
|
21
|
+
wavelength_nm: float = 1000.0
|
|
22
|
+
distance_mm: float = 100.0
|
|
23
|
+
focal_length_mm: float = 50.0
|
|
24
|
+
aperture_diameter_ratios: tuple[float | None, ...] = (None, 2.0, 1.0, 0.5)
|
|
25
|
+
grid_size: int = 256
|
|
26
|
+
window_mm: float = 8.0
|
|
27
|
+
longitudinal_samples: int = 180
|
|
28
|
+
ray_count: int = 100_000
|
|
29
|
+
seed: int = 0
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
for name in ("waist_mm", "wavelength_nm", "distance_mm", "focal_length_mm", "window_mm"):
|
|
33
|
+
value = getattr(self, name)
|
|
34
|
+
if isinstance(value, (bool, np.bool_)) or not isinstance(value, Real) or not np.isfinite(value):
|
|
35
|
+
raise ValueError(f"{name} must be a finite real number")
|
|
36
|
+
value = float(value)
|
|
37
|
+
if value <= 0:
|
|
38
|
+
raise ValueError(f"{name} must be positive")
|
|
39
|
+
object.__setattr__(self, name, value)
|
|
40
|
+
if (isinstance(self.grid_size, (bool, np.bool_)) or not isinstance(self.grid_size, Integral)
|
|
41
|
+
or self.grid_size < 64 or self.grid_size > 1024
|
|
42
|
+
or self.grid_size & (self.grid_size - 1)):
|
|
43
|
+
raise ValueError("grid_size must be a power of two from 64 through 1024")
|
|
44
|
+
object.__setattr__(self, "grid_size", int(self.grid_size))
|
|
45
|
+
for name, lower in (("longitudinal_samples", 16), ("ray_count", 1)):
|
|
46
|
+
value = getattr(self, name)
|
|
47
|
+
if isinstance(value, (bool, np.bool_)) or not isinstance(value, Integral) or value < lower:
|
|
48
|
+
raise ValueError(f"{name} must be an integer >= {lower}")
|
|
49
|
+
object.__setattr__(self, name, int(value))
|
|
50
|
+
if isinstance(self.seed, (bool, np.bool_)) or not isinstance(self.seed, Integral) or self.seed < 0:
|
|
51
|
+
raise ValueError("seed must be a nonnegative integer")
|
|
52
|
+
object.__setattr__(self, "seed", int(self.seed))
|
|
53
|
+
if not isinstance(self.aperture_diameter_ratios, tuple) or not self.aperture_diameter_ratios:
|
|
54
|
+
raise ValueError("aperture_diameter_ratios must be a nonempty tuple")
|
|
55
|
+
ratios: list[float | None] = []
|
|
56
|
+
for ratio in self.aperture_diameter_ratios:
|
|
57
|
+
if ratio is None:
|
|
58
|
+
ratios.append(None)
|
|
59
|
+
elif (isinstance(ratio, (bool, np.bool_)) or not isinstance(ratio, Real)
|
|
60
|
+
or not np.isfinite(ratio) or ratio <= 0):
|
|
61
|
+
raise ValueError("aperture diameter ratios must be positive finite numbers or None")
|
|
62
|
+
else:
|
|
63
|
+
ratios.append(float(ratio))
|
|
64
|
+
if len(set(ratios)) != len(ratios):
|
|
65
|
+
raise ValueError("aperture diameter ratios must be unique")
|
|
66
|
+
object.__setattr__(self, "aperture_diameter_ratios", tuple(ratios))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def calculate_aperture_study(spec: ApertureStudySpec) -> dict[str, Any]:
|
|
70
|
+
"""Calculate the complete aperture comparison as a finite JSON dictionary.
|
|
71
|
+
|
|
72
|
+
``None`` denotes the unclipped control. Intensities in ``scenarios`` share
|
|
73
|
+
the unclipped true-waist peak; ray and wave image-plane arrays each share
|
|
74
|
+
their own unclipped image-plane peak. Encircled fractions use incident
|
|
75
|
+
power/ray count as denominator, so they include aperture loss.
|
|
76
|
+
"""
|
|
77
|
+
if not isinstance(spec, ApertureStudySpec):
|
|
78
|
+
raise ValueError("spec must be an ApertureStudySpec")
|
|
79
|
+
|
|
80
|
+
n, width = spec.grid_size, spec.window_mm
|
|
81
|
+
w0, lam, d, focal = spec.waist_mm, spec.wavelength_nm * 1e-6, spec.distance_mm, spec.focal_length_mm
|
|
82
|
+
if d <= focal:
|
|
83
|
+
raise ValueError("distance_mm must exceed focal_length_mm for a finite real geometric image")
|
|
84
|
+
dx = width / n
|
|
85
|
+
x = (np.arange(n) - n // 2) * dx
|
|
86
|
+
xx, yy = np.meshgrid(x, x)
|
|
87
|
+
r2, radius = xx * xx + yy * yy, np.hypot(xx, yy)
|
|
88
|
+
freq = np.fft.fftfreq(n, d=dx)
|
|
89
|
+
f2 = freq[:, None] ** 2 + freq[None, :] ** 2
|
|
90
|
+
|
|
91
|
+
def propagate(field: np.ndarray, distance: float) -> np.ndarray:
|
|
92
|
+
return np.fft.ifft2(np.fft.fft2(field) * np.exp(-1j * np.pi * lam * distance * f2))
|
|
93
|
+
|
|
94
|
+
def slices(field: np.ndarray, distances: np.ndarray) -> np.ndarray:
|
|
95
|
+
spectrum = np.fft.fft2(field)
|
|
96
|
+
return np.asarray([
|
|
97
|
+
np.abs(np.fft.ifft2(spectrum * np.exp(-1j * np.pi * lam * z * f2))[n // 2]) ** 2
|
|
98
|
+
for z in distances
|
|
99
|
+
]).T
|
|
100
|
+
|
|
101
|
+
z_rayleigh = np.pi * w0**2 / lam
|
|
102
|
+
lens_radius = w0 * np.sqrt(1 + (d / z_rayleigh) ** 2)
|
|
103
|
+
q_lens = complex(d, z_rayleigh)
|
|
104
|
+
q_out = q_lens / (1 - q_lens / focal)
|
|
105
|
+
waist_after_lens = -q_out.real
|
|
106
|
+
true_waist_radius = np.sqrt(-lam / (np.pi * np.imag(1 / (q_out + waist_after_lens))))
|
|
107
|
+
geometric_image_after_lens = 1 / (1 / focal - 1 / d)
|
|
108
|
+
geometric_image_z = d + geometric_image_after_lens
|
|
109
|
+
image_radius = np.sqrt(-lam / (np.pi * np.imag(1 / (q_out + geometric_image_after_lens))))
|
|
110
|
+
z_end = geometric_image_z + 0.1 * geometric_image_after_lens
|
|
111
|
+
|
|
112
|
+
field0 = np.exp(-r2 / w0**2).astype(complex)
|
|
113
|
+
field_lens = propagate(field0, d) * np.exp(-1j * np.pi * r2 / (lam * focal))
|
|
114
|
+
incident_power = float(np.sum(np.abs(field_lens) ** 2))
|
|
115
|
+
reference_peak = float(np.max(np.abs(propagate(field_lens, waist_after_lens)) ** 2))
|
|
116
|
+
z_grid = np.linspace(0, z_end, spec.longitudinal_samples)
|
|
117
|
+
before = z_grid <= d
|
|
118
|
+
pre_slice = slices(field0, z_grid[before])
|
|
119
|
+
post_distances = z_grid[~before] - d
|
|
120
|
+
|
|
121
|
+
def mask_for(ratio: float | None) -> tuple[np.ndarray, float | None]:
|
|
122
|
+
if ratio is None:
|
|
123
|
+
return np.ones_like(radius), None
|
|
124
|
+
aperture_radius = ratio * lens_radius / 2
|
|
125
|
+
# Interpret the one-pixel ramp as covered pixel area. Its square root is
|
|
126
|
+
# the field multiplier, preserving that fractional area in optical power.
|
|
127
|
+
coverage = np.clip((aperture_radius - radius) / dx + 0.5, 0, 1)
|
|
128
|
+
return np.sqrt(coverage), aperture_radius
|
|
129
|
+
|
|
130
|
+
scenario_work: list[dict[str, Any]] = []
|
|
131
|
+
fields: list[np.ndarray] = []
|
|
132
|
+
for ratio in spec.aperture_diameter_ratios:
|
|
133
|
+
mask, aperture_radius = mask_for(ratio)
|
|
134
|
+
clipped = field_lens * mask
|
|
135
|
+
post_slice = slices(clipped, post_distances)
|
|
136
|
+
center = post_slice[n // 2]
|
|
137
|
+
peak_index = int(np.argmax(center))
|
|
138
|
+
peak_distance = float(post_distances[peak_index])
|
|
139
|
+
spot = np.abs(propagate(clipped, peak_distance)) ** 2
|
|
140
|
+
sampled_transmission = float(np.sum(np.abs(clipped) ** 2) / incident_power)
|
|
141
|
+
analytic_transmission = 1.0 if ratio is None else float(1 - np.exp(-(ratio**2) / 2))
|
|
142
|
+
brightest_z = d + peak_distance
|
|
143
|
+
scenario_work.append({
|
|
144
|
+
"diameter_ratio": ratio,
|
|
145
|
+
"aperture_radius_mm": aperture_radius,
|
|
146
|
+
"analytic_transmission": analytic_transmission,
|
|
147
|
+
"sampled_transmission": sampled_transmission,
|
|
148
|
+
"fresnel_number": None if aperture_radius is None else aperture_radius**2 / (lam * focal),
|
|
149
|
+
"brightest_z_mm": brightest_z,
|
|
150
|
+
"focal_shift_mm": brightest_z - (d + waist_after_lens),
|
|
151
|
+
"peak_ratio": float(np.max(spot) / reference_peak),
|
|
152
|
+
"longitudinal_intensity": (np.hstack((pre_slice, post_slice)) / reference_peak).tolist(),
|
|
153
|
+
"brightest_spot_intensity": (spot / reference_peak).tolist(),
|
|
154
|
+
})
|
|
155
|
+
fields.append(clipped)
|
|
156
|
+
|
|
157
|
+
# Statistical rays sample the same Gaussian phase space used by the notebook.
|
|
158
|
+
rng = np.random.default_rng(spec.seed)
|
|
159
|
+
positions = rng.normal(0, w0 / 2, (2, spec.ray_count))
|
|
160
|
+
angles = rng.normal(0, lam / (2 * np.pi * w0), (2, spec.ray_count))
|
|
161
|
+
lens_positions = positions + d * angles
|
|
162
|
+
lens_radius_rays = np.hypot(lens_positions[0], lens_positions[1])
|
|
163
|
+
output_angles = angles - lens_positions / focal
|
|
164
|
+
image_positions = lens_positions + geometric_image_after_lens * output_angles
|
|
165
|
+
edge_limit = 0.6
|
|
166
|
+
histogram_edges = np.arange(-edge_limit, edge_limit + 2 * dx, 2 * dx)
|
|
167
|
+
if histogram_edges[-1] < edge_limit:
|
|
168
|
+
histogram_edges = np.append(histogram_edges, edge_limit)
|
|
169
|
+
|
|
170
|
+
image_wave_intensities = [np.abs(propagate(field, geometric_image_after_lens)) ** 2 for field in fields]
|
|
171
|
+
wave_normalization = float(np.max(np.abs(propagate(field_lens, geometric_image_after_lens)) ** 2))
|
|
172
|
+
all_ray_histograms: list[np.ndarray] = []
|
|
173
|
+
comparison_work: list[dict[str, Any]] = []
|
|
174
|
+
circle = radius < image_radius
|
|
175
|
+
for ratio, aperture_radius, field, wave_image in zip(
|
|
176
|
+
spec.aperture_diameter_ratios,
|
|
177
|
+
[item["aperture_radius_mm"] for item in scenario_work], fields, image_wave_intensities):
|
|
178
|
+
keep = np.ones(spec.ray_count, dtype=bool) if ratio is None else lens_radius_rays <= aperture_radius
|
|
179
|
+
hist, _, _ = np.histogram2d(image_positions[0, keep], image_positions[1, keep], bins=(histogram_edges, histogram_edges))
|
|
180
|
+
hist = hist.T
|
|
181
|
+
all_ray_histograms.append(hist)
|
|
182
|
+
comparison_work.append({
|
|
183
|
+
"diameter_ratio": ratio,
|
|
184
|
+
"passing_ray_count": int(np.count_nonzero(keep)),
|
|
185
|
+
"ray_encircled_incident_fraction": float(np.count_nonzero(keep & (np.hypot(image_positions[0], image_positions[1]) < image_radius)) / spec.ray_count),
|
|
186
|
+
"wave_encircled_incident_fraction": float(np.sum(wave_image[circle]) / incident_power),
|
|
187
|
+
"ray_histogram": None,
|
|
188
|
+
"wave_intensity": (wave_image / wave_normalization).tolist(),
|
|
189
|
+
})
|
|
190
|
+
control_histogram, _, _ = np.histogram2d(
|
|
191
|
+
image_positions[0], image_positions[1], bins=(histogram_edges, histogram_edges)
|
|
192
|
+
)
|
|
193
|
+
ray_normalization = float(np.max(control_histogram))
|
|
194
|
+
for item, histogram in zip(comparison_work, all_ray_histograms):
|
|
195
|
+
item["ray_histogram"] = (histogram / ray_normalization).tolist()
|
|
196
|
+
|
|
197
|
+
fan_heights = np.linspace(-w0, w0, 5)
|
|
198
|
+
fan_angles = np.linspace(-lam / (np.pi * w0), lam / (np.pi * w0), 11)
|
|
199
|
+
fan_rays: list[dict[str, Any]] = []
|
|
200
|
+
for color_scalar, y0 in enumerate(fan_heights):
|
|
201
|
+
for theta0 in fan_angles:
|
|
202
|
+
y_lens = float(y0 + d * theta0)
|
|
203
|
+
theta_after = float(theta0 - y_lens / focal)
|
|
204
|
+
y_image = float(y_lens + geometric_image_after_lens * theta_after)
|
|
205
|
+
y_end = float(y_lens + (z_end - d) * theta_after)
|
|
206
|
+
fan_rays.append({
|
|
207
|
+
"launch_height_mm": float(y0),
|
|
208
|
+
"launch_angle_rad": float(theta0),
|
|
209
|
+
"color_scalar": color_scalar / 4,
|
|
210
|
+
"unclipped_path": {"z_mm": [0.0, d, geometric_image_z, z_end], "y_mm": [float(y0), y_lens, y_image, y_end]},
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
fan_scenarios = []
|
|
214
|
+
for ratio, scenario in zip(spec.aperture_diameter_ratios, scenario_work):
|
|
215
|
+
aperture_radius = scenario["aperture_radius_mm"]
|
|
216
|
+
paths = []
|
|
217
|
+
passing = 0
|
|
218
|
+
for ray in fan_rays:
|
|
219
|
+
blocked = aperture_radius is not None and abs(ray["unclipped_path"]["y_mm"][1]) > aperture_radius
|
|
220
|
+
passing += not blocked
|
|
221
|
+
path = ray["unclipped_path"]
|
|
222
|
+
paths.append({
|
|
223
|
+
"launch_height_mm": ray["launch_height_mm"], "launch_angle_rad": ray["launch_angle_rad"],
|
|
224
|
+
"color_scalar": ray["color_scalar"], "blocked": bool(blocked),
|
|
225
|
+
"z_mm": path["z_mm"][:2] if blocked else path["z_mm"],
|
|
226
|
+
"y_mm": path["y_mm"][:2] if blocked else path["y_mm"],
|
|
227
|
+
})
|
|
228
|
+
fan_scenarios.append({"diameter_ratio": ratio, "passing_count": passing, "paths": paths})
|
|
229
|
+
|
|
230
|
+
w_reference = np.empty_like(z_grid)
|
|
231
|
+
w_reference[before] = w0 * np.sqrt(1 + (z_grid[before] / z_rayleigh) ** 2)
|
|
232
|
+
for index in np.flatnonzero(~before):
|
|
233
|
+
q = q_out + (z_grid[index] - d)
|
|
234
|
+
w_reference[index] = np.sqrt(-lam / (np.pi * np.imag(1 / q)))
|
|
235
|
+
|
|
236
|
+
result = {
|
|
237
|
+
"schema_version": 1,
|
|
238
|
+
"coordinates": {"units": {"length": "mm", "angle": "rad"}, "array_order": {"longitudinal_intensity": "[x,z]", "spots_and_histograms": "[y,x]"}, "origin": "input_waist"},
|
|
239
|
+
"spec": {"waist_mm": w0, "wavelength_nm": spec.wavelength_nm, "distance_mm": d, "focal_length_mm": focal, "aperture_diameter_ratios": list(spec.aperture_diameter_ratios), "grid_size": n, "window_mm": width, "longitudinal_samples": spec.longitudinal_samples, "ray_count": spec.ray_count, "seed": spec.seed},
|
|
240
|
+
"grids": {"x_mm": x.tolist(), "z_mm": z_grid.tolist(), "image_histogram_edges_mm": histogram_edges.tolist()},
|
|
241
|
+
"normalization": {"longitudinal_and_brightest_spots": "unclipped_true_waist_peak", "unclipped_true_waist_peak": reference_peak, "image_plane_wave_peak": wave_normalization, "image_plane_ray_histogram_peak": ray_normalization},
|
|
242
|
+
"gaussian_reference": {"rayleigh_range_mm": z_rayleigh, "lens_radius_mm": lens_radius, "radius_by_z_mm": w_reference.tolist(), "geometric_image_z_mm": geometric_image_z, "geometric_image_radius_mm": image_radius, "true_waist_z_mm": d + waist_after_lens, "true_waist_radius_mm": true_waist_radius, "magnification": -geometric_image_after_lens / d},
|
|
243
|
+
"scenarios": scenario_work,
|
|
244
|
+
"ray_fan": {"launch_heights_mm": fan_heights.tolist(), "launch_angles_rad": fan_angles.tolist(), "scenarios": fan_scenarios},
|
|
245
|
+
"image_plane_comparison": {"z_mm": geometric_image_z, "reference_circle_radius_mm": image_radius, "scenarios": comparison_work},
|
|
246
|
+
}
|
|
247
|
+
# Catch accidental NumPy scalars, NaN, or infinity before crossing the seam.
|
|
248
|
+
import json
|
|
249
|
+
json.dumps(result, allow_nan=False)
|
|
250
|
+
return result
|
owl/bench.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Notebook optical bench; Python owns validation and all propagation."""
|
|
2
|
+
|
|
3
|
+
import pickle
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from numbers import Real
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import anywidget
|
|
11
|
+
import numpy as np
|
|
12
|
+
import traitlets
|
|
13
|
+
|
|
14
|
+
from .core import Aperture, FreeSpace, GaussianBeam, Interface, Mirror, OpticalSystem, ThickLens, ThinLens
|
|
15
|
+
from .frontend import _WIDGET_ESM
|
|
16
|
+
from .wave import cross_sections, longitudinal_section
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_GAUSSIAN_SAMPLES_PER_GAP = 80
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@lru_cache(maxsize=64)
|
|
23
|
+
def _cached_calculate(payload: bytes) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
24
|
+
return Bench._calculate(pickle.loads(payload))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Bench(anywidget.AnyWidget):
|
|
28
|
+
"""Edit a sequential optical system in a notebook.
|
|
29
|
+
|
|
30
|
+
``ray_span_mm`` is the launch half-span of parallel rays. A single ray is
|
|
31
|
+
launched on axis. The default Gaussian input waist is 0.1 mm at 550 nm.
|
|
32
|
+
Assign a complete new ``settings`` dictionary to update the bench (nested
|
|
33
|
+
in-place dict edits do not emit trait notifications). Invalid updates restore
|
|
34
|
+
the last valid settings and leave ``result`` unchanged, with details in
|
|
35
|
+
``error``. Invalid constructor/from_dict arguments raise ValueError.
|
|
36
|
+
|
|
37
|
+
Results contain interface-sampled rays (y_mm indexed by sample, then ray),
|
|
38
|
+
a sampled Gaussian radius, element positions, and output-plane metrics.
|
|
39
|
+
Both traces use the core's physical millimetre coordinates. Apertures mask
|
|
40
|
+
rays; the Gaussian envelope remains an unclipped reference. Cross sections
|
|
41
|
+
use scalar Fresnel FFT propagation with explicit grid/window controls and
|
|
42
|
+
sampling warnings. Saved settings include the wave sampling configuration.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
_esm = _WIDGET_ESM
|
|
46
|
+
_css = Path(__file__).parent / "static" / "bench.css"
|
|
47
|
+
|
|
48
|
+
view_kind = traitlets.Unicode("bench").tag(sync=True)
|
|
49
|
+
settings = traitlets.Dict().tag(sync=True)
|
|
50
|
+
result = traitlets.Dict().tag(sync=True)
|
|
51
|
+
error = traitlets.Unicode("").tag(sync=True)
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
system: OpticalSystem,
|
|
56
|
+
beam: GaussianBeam = GaussianBeam(waist_mm=0.1, wavelength_nm=550),
|
|
57
|
+
ray_span_mm: float = 0.25,
|
|
58
|
+
ray_count: int = 11,
|
|
59
|
+
*,
|
|
60
|
+
grid_size: int = 128,
|
|
61
|
+
window_mm: float | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
if not isinstance(system, OpticalSystem) or not isinstance(beam, GaussianBeam):
|
|
64
|
+
raise ValueError("Expected an OpticalSystem and a GaussianBeam")
|
|
65
|
+
settings, result = self._derive({
|
|
66
|
+
"system": system.to_dict(),
|
|
67
|
+
"beam": beam.to_dict(),
|
|
68
|
+
"ray_span_mm": ray_span_mm,
|
|
69
|
+
"ray_count": ray_count,
|
|
70
|
+
"wave": {"grid_size": grid_size, "window_mm": window_mm},
|
|
71
|
+
})
|
|
72
|
+
self._updating = True
|
|
73
|
+
self._valid_settings = deepcopy(settings)
|
|
74
|
+
super().__init__(settings=settings, result=result)
|
|
75
|
+
self._updating = False
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def _from_derived(
|
|
79
|
+
cls, settings: dict[str, Any], result: dict[str, Any]
|
|
80
|
+
) -> "Bench":
|
|
81
|
+
"""Create a fresh widget model from trusted, precomputed data."""
|
|
82
|
+
widget = cls.__new__(cls)
|
|
83
|
+
widget._updating = True
|
|
84
|
+
widget._valid_settings = deepcopy(settings)
|
|
85
|
+
anywidget.AnyWidget.__init__(
|
|
86
|
+
widget, settings=deepcopy(settings), result=deepcopy(result)
|
|
87
|
+
)
|
|
88
|
+
widget._updating = False
|
|
89
|
+
return widget
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _derive(data: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
93
|
+
try:
|
|
94
|
+
with np.errstate(over="raise", invalid="raise", divide="raise"):
|
|
95
|
+
return deepcopy(_cached_calculate(pickle.dumps(data)))
|
|
96
|
+
except (TypeError, ArithmeticError) as exc:
|
|
97
|
+
raise ValueError(f"Invalid optical parameters: {exc}") from exc
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _calculate(data: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
101
|
+
keys = {"system", "beam", "ray_span_mm", "ray_count"}
|
|
102
|
+
if not isinstance(data, dict) or set(data) not in (keys, keys | {"wave"}):
|
|
103
|
+
raise ValueError(f"Expected settings keys {sorted(keys)} and optional wave")
|
|
104
|
+
wave = data.get("wave", {"grid_size": 128, "window_mm": None})
|
|
105
|
+
if not isinstance(wave, dict) or set(wave) != {"grid_size", "window_mm"}:
|
|
106
|
+
raise ValueError("wave requires grid_size and window_mm")
|
|
107
|
+
system = OpticalSystem.from_dict(data["system"])
|
|
108
|
+
beam = GaussianBeam.from_dict(data["beam"])
|
|
109
|
+
# Match the widget's nm/µm controls before propagation, not only on save.
|
|
110
|
+
beam = GaussianBeam(
|
|
111
|
+
waist_mm=round(float(beam.waist_mm), 3),
|
|
112
|
+
wavelength_nm=round(float(beam.wavelength_nm)),
|
|
113
|
+
)
|
|
114
|
+
span = data["ray_span_mm"]
|
|
115
|
+
if isinstance(span, (bool, np.bool_)) or not isinstance(span, Real):
|
|
116
|
+
raise ValueError("ray_span_mm must be a finite nonnegative number")
|
|
117
|
+
try:
|
|
118
|
+
span = float(span)
|
|
119
|
+
except OverflowError as exc:
|
|
120
|
+
raise ValueError("ray_span_mm must be a finite nonnegative number") from exc
|
|
121
|
+
if not np.isfinite(span) or span < 0:
|
|
122
|
+
raise ValueError("ray_span_mm must be a finite nonnegative number")
|
|
123
|
+
count = data["ray_count"]
|
|
124
|
+
if isinstance(count, (bool, np.bool_)) or not isinstance(count, (int, np.integer)) or count < 1:
|
|
125
|
+
raise ValueError("ray_count must be a positive integer")
|
|
126
|
+
count = int(count)
|
|
127
|
+
try:
|
|
128
|
+
with np.errstate(over="raise", invalid="raise", divide="raise"):
|
|
129
|
+
# Scale a unit fan instead of subtracting potentially huge endpoints.
|
|
130
|
+
heights = np.linspace(-1.0, 1.0, count) * span if count > 1 else np.zeros(1)
|
|
131
|
+
rays = system.trace(np.vstack((heights, np.zeros(count))))
|
|
132
|
+
gaussian = system.gaussian(beam, samples_per_gap=_GAUSSIAN_SAMPLES_PER_GAP)
|
|
133
|
+
except ArithmeticError as exc:
|
|
134
|
+
raise ValueError("Propagation failed; check the optical parameters") from exc
|
|
135
|
+
elements = []
|
|
136
|
+
sample = 0
|
|
137
|
+
gaussian_sample = 0
|
|
138
|
+
aperture_stop_index = None
|
|
139
|
+
for index, element in enumerate(system.elements, start=1):
|
|
140
|
+
start_z = float(rays.z_mm[sample])
|
|
141
|
+
# A thick lens contributes entry, internal gap, and exit samples.
|
|
142
|
+
sample += 3 if isinstance(element, ThickLens) else 1
|
|
143
|
+
if isinstance(element, FreeSpace):
|
|
144
|
+
label = f"Gap {index}: {element.distance_mm:g} mm"
|
|
145
|
+
elif isinstance(element, ThinLens):
|
|
146
|
+
label = f"Thin lens {index}: f = {element.focal_length_mm:g} mm"
|
|
147
|
+
elif isinstance(element, Mirror):
|
|
148
|
+
label = f"Mirror {index}: R = {element.radius_mm:g} mm"
|
|
149
|
+
elif isinstance(element, Aperture):
|
|
150
|
+
label = f"Aperture {index}: radius = {element.radius_mm:g} mm"
|
|
151
|
+
elif isinstance(element, Interface):
|
|
152
|
+
label = f"Interface {index}: R = {element.radius_mm:g} mm, n = {element.n1:g} → {element.n2:g}"
|
|
153
|
+
else:
|
|
154
|
+
label = f"Thick lens {index}: R₁ = {element.radius1_mm:g}, R₂ = {element.radius2_mm:g}, t = {element.thickness_mm:g} mm, n = {element.n:g}"
|
|
155
|
+
elements.append({
|
|
156
|
+
**element.to_dict(), "start_z_mm": start_z,
|
|
157
|
+
"z_mm": float(rays.z_mm[sample]), "label": label,
|
|
158
|
+
})
|
|
159
|
+
if isinstance(element, Aperture) and aperture_stop_index is None:
|
|
160
|
+
aperture_stop_index = gaussian_sample + 1
|
|
161
|
+
if isinstance(element, FreeSpace):
|
|
162
|
+
gaussian_sample += _GAUSSIAN_SAMPLES_PER_GAP if element.distance_mm else 1
|
|
163
|
+
elif isinstance(element, ThickLens):
|
|
164
|
+
gaussian_sample += (_GAUSSIAN_SAMPLES_PER_GAP + 2) if element.thickness_mm else 3
|
|
165
|
+
else:
|
|
166
|
+
gaussian_sample += 1
|
|
167
|
+
settings = {
|
|
168
|
+
"system": system.to_dict(),
|
|
169
|
+
"beam": beam.to_dict(),
|
|
170
|
+
"ray_span_mm": span,
|
|
171
|
+
"ray_count": count,
|
|
172
|
+
"wave": deepcopy(wave),
|
|
173
|
+
}
|
|
174
|
+
sections = cross_sections(system, beam, **wave)
|
|
175
|
+
longitudinal = longitudinal_section(system, beam, **wave)
|
|
176
|
+
settings["wave"] = {
|
|
177
|
+
"grid_size": len(sections["x_mm"]),
|
|
178
|
+
"window_mm": None if wave["window_mm"] is None else sections["window_mm"],
|
|
179
|
+
}
|
|
180
|
+
for plane, element in zip(sections["planes"][1:], elements):
|
|
181
|
+
plane["label"] = element["label"]
|
|
182
|
+
alive = rays.alive
|
|
183
|
+
assert alive is not None # RayTrace materializes the default mask in __post_init__.
|
|
184
|
+
live_heights = rays.rays[-1, 0, alive[-1]]
|
|
185
|
+
result = {
|
|
186
|
+
"cross_sections": sections,
|
|
187
|
+
"longitudinal": longitudinal,
|
|
188
|
+
"rays": {"z_mm": rays.z_mm.tolist(), "y_mm": rays.rays[:, 0, :].tolist(), "alive": alive.tolist()},
|
|
189
|
+
"gaussian": {
|
|
190
|
+
"z_mm": gaussian.z_mm.tolist(),
|
|
191
|
+
"radius_mm": gaussian.radius_mm.tolist(),
|
|
192
|
+
"aperture_stop_index": aperture_stop_index,
|
|
193
|
+
},
|
|
194
|
+
"elements": elements,
|
|
195
|
+
"metrics": {
|
|
196
|
+
"length_mm": float(system.length_mm),
|
|
197
|
+
"output_z_mm": system.output_z_mm,
|
|
198
|
+
"output_n": system.output_n,
|
|
199
|
+
"output_direction": system.output_direction,
|
|
200
|
+
"output_radius_mm": float(gaussian.radius_mm[-1]),
|
|
201
|
+
"output_ray_half_span_mm": float(np.max(np.abs(live_heights))) if live_heights.size else 0.0,
|
|
202
|
+
"output_ray_count": int(live_heights.size),
|
|
203
|
+
"transmission": sections["planes"][-1]["transmission"],
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
return settings, result
|
|
207
|
+
|
|
208
|
+
@traitlets.validate("settings")
|
|
209
|
+
def _validate_settings(self, proposal: dict[str, Any]) -> dict[str, Any]:
|
|
210
|
+
# Validation precedes traitlets' equality check (True == 1), and also
|
|
211
|
+
# lets reassigning the last valid settings clear an earlier error.
|
|
212
|
+
if self._updating:
|
|
213
|
+
return proposal["value"]
|
|
214
|
+
try:
|
|
215
|
+
settings, result = self._derive(proposal["value"])
|
|
216
|
+
except ValueError as exc:
|
|
217
|
+
self.error = str(exc) or "Propagation failed; check the optical parameters"
|
|
218
|
+
return deepcopy(self._valid_settings)
|
|
219
|
+
self._valid_settings = deepcopy(settings)
|
|
220
|
+
with self.hold_sync():
|
|
221
|
+
self.result = result
|
|
222
|
+
self.error = ""
|
|
223
|
+
return settings
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def system(self) -> OpticalSystem:
|
|
227
|
+
"""The last successfully validated numerical system."""
|
|
228
|
+
return OpticalSystem.from_dict(self._valid_settings["system"])
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def beam(self) -> GaussianBeam:
|
|
232
|
+
"""The last successfully validated numerical input beam."""
|
|
233
|
+
return GaussianBeam.from_dict(self._valid_settings["beam"])
|
|
234
|
+
|
|
235
|
+
def to_dict(self) -> dict[str, Any]:
|
|
236
|
+
"""Return detached, JSON-compatible full settings (not UI/trace state)."""
|
|
237
|
+
return deepcopy(self._valid_settings)
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
def from_dict(cls, data: dict[str, Any]) -> "Bench":
|
|
241
|
+
"""Restore full settings, rejecting invalid or unknown fields."""
|
|
242
|
+
settings, _ = cls._derive(data)
|
|
243
|
+
return cls(
|
|
244
|
+
OpticalSystem.from_dict(settings["system"]),
|
|
245
|
+
GaussianBeam.from_dict(settings["beam"]),
|
|
246
|
+
settings["ray_span_mm"], settings["ray_count"],
|
|
247
|
+
**settings["wave"],
|
|
248
|
+
)
|