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 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
+ )