diff --git a/src/optiverse/raytracing/__init__.py b/src/optiverse/raytracing/__init__.py index c897d385..df04c284 100644 --- a/src/optiverse/raytracing/__init__.py +++ b/src/optiverse/raytracing/__init__.py @@ -16,6 +16,7 @@ from .elements import Beamsplitter, Dichroic, Lens, Mirror, RefractiveInterfaceElement, Waveplate from .elements.base import IOpticalElement, RayIntersection from .engine import trace_rays_polymorphic +from .psf import ImagePlane, PointSpreadFunction, compute_geometric_psf from .ray import Polarization, Ray, RayPath __all__ = [ @@ -23,6 +24,8 @@ "Ray", "RayPath", "Polarization", + "ImagePlane", + "PointSpreadFunction", # Element interface and implementations "IOpticalElement", "RayIntersection", @@ -34,4 +37,6 @@ "Dichroic", # Raytracing engine "trace_rays_polymorphic", + # Analysis + "compute_geometric_psf", ] diff --git a/src/optiverse/raytracing/psf.py b/src/optiverse/raytracing/psf.py new file mode 100644 index 00000000..2d0bef7f --- /dev/null +++ b/src/optiverse/raytracing/psf.py @@ -0,0 +1,254 @@ +""" +Point-spread-function analysis for traced ray paths. + +The Optiverse raytracer is 2D, so the geometric PSF is represented as a +one-dimensional intensity profile along an image-plane line. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import cast + +import numpy as np + +from .ray import RayPath + +_EPSILON = 1e-12 + + +@dataclass +class ImagePlane: + """A 2D image plane represented by a point and a normal vector.""" + + point_mm: np.ndarray + normal: np.ndarray + + @classmethod + def from_xy_angle( + cls, x_mm: float, y_mm: float, normal_angle_deg: float = 0.0 + ) -> ImagePlane: + """Create an image plane from a point and a normal angle in degrees.""" + angle_rad = np.deg2rad(normal_angle_deg) + normal = np.array([np.cos(angle_rad), np.sin(angle_rad)], dtype=float) + return cls(point_mm=np.array([x_mm, y_mm], dtype=float), normal=normal) + + @property + def normalized_normal(self) -> np.ndarray: + """Return the plane normal as a unit vector.""" + return _normalized_vector(self.normal, "image-plane normal") + + @property + def tangent(self) -> np.ndarray: + """Return the positive coordinate direction along the image plane.""" + normal = self.normalized_normal + return np.array([-normal[1], normal[0]], dtype=float) + + +@dataclass +class PointSpreadFunction: + """Computed geometric PSF data and summary metrics.""" + + plane: ImagePlane + sample_positions_mm: np.ndarray + sample_weights: np.ndarray + bin_edges_mm: np.ndarray + bin_centers_mm: np.ndarray + intensity: np.ndarray + centroid_mm: float | None + rms_radius_mm: float | None + fwhm_mm: float | None + total_weight: float + histogram_weight: float + sample_count: int + + +def compute_geometric_psf( + ray_paths: Sequence[RayPath], + image_plane: ImagePlane, + *, + bin_count: int = 101, + extent_mm: float | None = None, + source_index: int | None = None, + first_intersection_only: bool = True, +) -> PointSpreadFunction: + """ + Compute a geometric PSF from ray crossings at an image plane. + + Args: + ray_paths: Traced ray paths to sample. + image_plane: Plane where the PSF is measured. + bin_count: Number of histogram bins in the returned intensity profile. + extent_mm: Optional half-width of the histogram about the plane origin. + source_index: Optional source index filter. + first_intersection_only: Count at most one crossing from each RayPath. + + Returns: + PointSpreadFunction with sampled ray positions and normalized histogram. + """ + if bin_count <= 0: + raise ValueError("bin_count must be positive") + if extent_mm is not None and extent_mm <= 0: + raise ValueError("extent_mm must be positive") + + point = _as_point(image_plane.point_mm, "image-plane point") + normal = image_plane.normalized_normal + tangent = image_plane.tangent + + positions: list[float] = [] + weights: list[float] = [] + + for path in ray_paths: + if source_index is not None and path.source_index != source_index: + continue + + for segment_index in range(max(0, len(path.points) - 1)): + p0 = _as_point(path.points[segment_index], "ray path point") + p1 = _as_point(path.points[segment_index + 1], "ray path point") + crossing = _segment_plane_crossing(p0, p1, point, normal) + if crossing is None: + continue + + intersection, t = crossing + positions.append(float(np.dot(intersection - point, tangent))) + weights.append(_path_weight_at(path, segment_index, t)) + + if first_intersection_only: + break + + sample_positions = np.asarray(positions, dtype=float) + sample_weights = np.asarray(weights, dtype=float) + total_weight = float(np.sum(sample_weights)) if sample_weights.size else 0.0 + + if sample_positions.size == 0 or total_weight <= 0: + bin_edges = _empty_bin_edges(bin_count, extent_mm) + bin_centers = _bin_centers(bin_edges) + return PointSpreadFunction( + plane=image_plane, + sample_positions_mm=sample_positions, + sample_weights=sample_weights, + bin_edges_mm=bin_edges, + bin_centers_mm=bin_centers, + intensity=np.zeros(bin_count, dtype=float), + centroid_mm=None, + rms_radius_mm=None, + fwhm_mm=None, + total_weight=total_weight, + histogram_weight=0.0, + sample_count=int(sample_positions.size), + ) + + centroid = float(np.average(sample_positions, weights=sample_weights)) + variance = float(np.average((sample_positions - centroid) ** 2, weights=sample_weights)) + rms_radius = float(np.sqrt(max(variance, 0.0))) + + bin_edges = _histogram_edges(sample_positions, bin_count, extent_mm) + histogram, _ = np.histogram(sample_positions, bins=bin_edges, weights=sample_weights) + histogram_weight = float(np.sum(histogram)) + intensity = histogram / histogram_weight if histogram_weight > 0 else histogram + bin_centers = _bin_centers(bin_edges) + fwhm = _fwhm_from_histogram(bin_centers, bin_edges, intensity) + + return PointSpreadFunction( + plane=image_plane, + sample_positions_mm=sample_positions, + sample_weights=sample_weights, + bin_edges_mm=bin_edges, + bin_centers_mm=bin_centers, + intensity=intensity, + centroid_mm=centroid, + rms_radius_mm=rms_radius, + fwhm_mm=fwhm, + total_weight=total_weight, + histogram_weight=histogram_weight, + sample_count=int(sample_positions.size), + ) + + +def _as_point(value: np.ndarray | Sequence[float], label: str) -> np.ndarray: + point = np.asarray(value, dtype=float) + if point.ndim == 0 or point.shape[0] < 2: + raise ValueError(f"{label} must have at least two coordinates") + return point[:2] + + +def _normalized_vector(value: np.ndarray | Sequence[float], label: str) -> np.ndarray: + vector = _as_point(value, label) + norm = float(np.linalg.norm(vector)) + if norm <= _EPSILON: + raise ValueError(f"{label} must be non-zero") + return vector / norm + + +def _segment_plane_crossing( + p0: np.ndarray, p1: np.ndarray, plane_point: np.ndarray, plane_normal: np.ndarray +) -> tuple[np.ndarray, float] | None: + delta = p1 - p0 + denom = float(np.dot(delta, plane_normal)) + if abs(denom) <= _EPSILON: + return None + + t = -float(np.dot(p0 - plane_point, plane_normal)) / denom + if t < -_EPSILON or t > 1.0 + _EPSILON: + return None + + t = min(1.0, max(0.0, t)) + return p0 + t * delta, t + + +def _path_weight_at(path: RayPath, segment_index: int, t: float) -> float: + if len(path.intensities) >= len(path.points): + i0 = float(path.intensities[segment_index]) + i1 = float(path.intensities[segment_index + 1]) + weight = i0 + t * (i1 - i0) + elif len(path.intensities) > segment_index: + weight = float(path.intensities[segment_index]) + else: + weight = float(path.rgba[3]) / 255.0 + return max(weight, 0.0) + + +def _histogram_edges( + sample_positions: np.ndarray, bin_count: int, extent_mm: float | None +) -> np.ndarray: + if extent_mm is not None: + return np.linspace(-extent_mm, extent_mm, bin_count + 1, dtype=float) + + lo = float(np.min(sample_positions)) + hi = float(np.max(sample_positions)) + if np.isclose(lo, hi): + pad = max(0.5, abs(lo) * 0.05) + lo -= pad + hi += pad + return np.linspace(lo, hi, bin_count + 1, dtype=float) + + +def _empty_bin_edges(bin_count: int, extent_mm: float | None) -> np.ndarray: + extent = 1.0 if extent_mm is None else extent_mm + return np.linspace(-extent, extent, bin_count + 1, dtype=float) + + +def _bin_centers(bin_edges: np.ndarray) -> np.ndarray: + return cast(np.ndarray, (bin_edges[:-1] + bin_edges[1:]) / 2.0) + + +def _fwhm_from_histogram( + bin_centers: np.ndarray, bin_edges: np.ndarray, intensity: np.ndarray +) -> float | None: + if intensity.size == 0: + return None + + peak = float(np.max(intensity)) + if peak <= 0: + return None + + above = np.flatnonzero(intensity >= peak / 2.0) + if above.size == 0: + return None + + left = int(above[0]) + right = int(above[-1]) + if left == right: + return float(bin_edges[left + 1] - bin_edges[left]) + return float(bin_centers[right] - bin_centers[left]) diff --git a/src/optiverse/ui/builders/action_builder.py b/src/optiverse/ui/builders/action_builder.py index 542794f6..8bc44f74 100644 --- a/src/optiverse/ui/builders/action_builder.py +++ b/src/optiverse/ui/builders/action_builder.py @@ -282,6 +282,9 @@ def build_actions(self) -> None: w.act_clear = QtGui.QAction("Clear Rays", w) w.act_clear.triggered.connect(w.clear_rays) + w.act_point_spread = QtGui.QAction("Point Spread Function...", w) + w.act_point_spread.triggered.connect(w.show_point_spread_function_dialog) + w.act_editor = QtGui.QAction("Component Editor…", w) w.act_editor.setShortcut("Ctrl+E") w.act_editor.setShortcutContext(QtCore.Qt.ShortcutContext.WindowShortcut) @@ -502,6 +505,7 @@ def build_menubar(self) -> None: return mTools.addAction(w.act_retrace) mTools.addAction(w.act_clear) + mTools.addAction(w.act_point_spread) mTools.addSeparator() mTools.addAction(w.act_inspect) # Path Measure hidden: feature is buggy (toolbar button also hidden) diff --git a/src/optiverse/ui/controllers/raytracing_controller.py b/src/optiverse/ui/controllers/raytracing_controller.py index 20ed5f16..1e48b92c 100644 --- a/src/optiverse/ui/controllers/raytracing_controller.py +++ b/src/optiverse/ui/controllers/raytracing_controller.py @@ -81,6 +81,28 @@ def ray_data(self) -> list: """Get the current ray data (list of RayPath objects).""" return self._ray_data + def compute_geometric_psf( + self, + *, + plane_x_mm: float, + plane_y_mm: float = 0.0, + normal_angle_deg: float = 0.0, + bin_count: int = 101, + extent_mm: float | None = None, + source_index: int | None = None, + ): + """Compute a geometric point-spread function from the current ray paths.""" + from ...raytracing import ImagePlane, compute_geometric_psf + + image_plane = ImagePlane.from_xy_angle(plane_x_mm, plane_y_mm, normal_angle_deg) + return compute_geometric_psf( + self._ray_data, + image_plane, + bin_count=bin_count, + extent_mm=extent_mm, + source_index=source_index, + ) + @property def autotrace(self) -> bool: """Get autotrace enabled state.""" diff --git a/src/optiverse/ui/views/main_window.py b/src/optiverse/ui/views/main_window.py index ae98dc09..af3fd6d1 100644 --- a/src/optiverse/ui/views/main_window.py +++ b/src/optiverse/ui/views/main_window.py @@ -100,6 +100,7 @@ class MainWindow(QtWidgets.QMainWindow): _raywidth_group: QtGui.QActionGroup act_retrace: QtGui.QAction act_clear: QtGui.QAction + act_point_spread: QtGui.QAction act_editor: QtGui.QAction act_reload: QtGui.QAction act_open_library_folder: QtGui.QAction @@ -596,6 +597,23 @@ def retrace(self): """Trace all rays from sources through optical elements.""" self.raytracing_controller.retrace() + def show_point_spread_function_dialog(self): + """Open the point-spread-function analysis dialog.""" + if not self.ray_data: + self.retrace() + if not self.ray_data: + QtWidgets.QMessageBox.information( + self, + "Point Spread Function", + "Trace rays before computing a point spread function.", + ) + return + + from .point_spread_dialog import PointSpreadFunctionDialog + + dialog = PointSpreadFunctionDialog(self.raytracing_controller, self) + dialog.exec() + def _maybe_retrace(self): """Retrace if autotrace is enabled (with debouncing).""" self._schedule_retrace() diff --git a/src/optiverse/ui/views/point_spread_dialog.py b/src/optiverse/ui/views/point_spread_dialog.py new file mode 100644 index 00000000..6342a873 --- /dev/null +++ b/src/optiverse/ui/views/point_spread_dialog.py @@ -0,0 +1,150 @@ +""" +Point-spread-function dialog. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6 import QtCore, QtWidgets + +if TYPE_CHECKING: + from ...raytracing import PointSpreadFunction + from ..controllers.raytracing_controller import RaytracingController + + +class PointSpreadFunctionDialog(QtWidgets.QDialog): + """Dialog for computing a geometric PSF from current traced rays.""" + + def __init__( + self, + raytracing_controller: RaytracingController, + parent: QtWidgets.QWidget | None = None, + ): + super().__init__(parent) + self._raytracing_controller = raytracing_controller + self._last_result: PointSpreadFunction | None = None + + self.setWindowTitle("Point Spread Function") + self.setMinimumWidth(420) + + layout = QtWidgets.QVBoxLayout(self) + + form = QtWidgets.QFormLayout() + form.setFieldGrowthPolicy(QtWidgets.QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + + self._plane_x = QtWidgets.QDoubleSpinBox() + self._plane_x.setRange(-1_000_000.0, 1_000_000.0) + self._plane_x.setDecimals(3) + default_x, default_y = _default_plane_origin(raytracing_controller) + self._plane_x.setValue(default_x) + self._plane_x.setSuffix(" mm") + form.addRow("Plane X", self._plane_x) + + self._plane_y = QtWidgets.QDoubleSpinBox() + self._plane_y.setRange(-1_000_000.0, 1_000_000.0) + self._plane_y.setDecimals(3) + self._plane_y.setValue(default_y) + self._plane_y.setSuffix(" mm") + form.addRow("Plane Y", self._plane_y) + + self._normal_angle = QtWidgets.QDoubleSpinBox() + self._normal_angle.setRange(-360.0, 360.0) + self._normal_angle.setDecimals(3) + self._normal_angle.setValue(0.0) + self._normal_angle.setSuffix(" deg") + form.addRow("Normal angle", self._normal_angle) + + self._extent = QtWidgets.QDoubleSpinBox() + self._extent.setRange(0.0, 1_000_000.0) + self._extent.setDecimals(3) + self._extent.setSpecialValueText("Auto") + self._extent.setValue(0.0) + self._extent.setSuffix(" mm") + form.addRow("Half-width", self._extent) + + self._bin_count = QtWidgets.QSpinBox() + self._bin_count.setRange(1, 10_000) + self._bin_count.setValue(101) + form.addRow("Bins", self._bin_count) + + layout.addLayout(form) + + self._result = QtWidgets.QTextEdit() + self._result.setReadOnly(True) + self._result.setMinimumHeight(160) + layout.addWidget(self._result) + + buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Close) + compute_button = buttons.addButton( + "Compute", QtWidgets.QDialogButtonBox.ButtonRole.ActionRole + ) + if compute_button is None: + raise RuntimeError("Could not create point-spread-function compute button") + self._compute_button = compute_button + self._compute_button.clicked.connect(self.compute) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + self.compute() + + @property + def last_result(self) -> PointSpreadFunction | None: + """Return the most recent computed PSF.""" + return self._last_result + + def compute(self) -> None: + """Compute and display the PSF.""" + extent = self._extent.value() if self._extent.value() > 0 else None + self._last_result = self._raytracing_controller.compute_geometric_psf( + plane_x_mm=self._plane_x.value(), + plane_y_mm=self._plane_y.value(), + normal_angle_deg=self._normal_angle.value(), + bin_count=self._bin_count.value(), + extent_mm=extent, + ) + self._result.setPlainText(self._format_result(self._last_result)) + + @staticmethod + def _format_result(psf: PointSpreadFunction) -> str: + if psf.sample_count == 0: + return "No ray intersections found at the selected image plane." + + centroid = _format_optional_mm(psf.centroid_mm) + rms = _format_optional_mm(psf.rms_radius_mm) + fwhm = _format_optional_mm(psf.fwhm_mm) + histogram_note = "" + if psf.histogram_weight < psf.total_weight: + missing = psf.total_weight - psf.histogram_weight + histogram_note = f"\nOut-of-range weight: {missing:.4g}" + + return ( + f"Samples: {psf.sample_count}\n" + f"Total weight: {psf.total_weight:.4g}\n" + f"Centroid: {centroid}\n" + f"RMS radius: {rms}\n" + f"FWHM: {fwhm}" + f"{histogram_note}" + ) + + def keyPressEvent(self, event) -> None: # type: ignore[no-untyped-def] + if event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter): + self.compute() + return + super().keyPressEvent(event) + + +def _format_optional_mm(value: float | None) -> str: + if value is None: + return "n/a" + return f"{value:.6g} mm" + + +def _default_plane_origin(raytracing_controller: RaytracingController) -> tuple[float, float]: + endpoints = [path.points[-1] for path in raytracing_controller.ray_data if path.points] + if not endpoints: + return 100.0, 0.0 + + x_mm = max(float(point[0]) for point in endpoints) + y_mm = sum(float(point[1]) for point in endpoints) / len(endpoints) + return x_mm, y_mm diff --git a/tests/raytracing/test_psf.py b/tests/raytracing/test_psf.py new file mode 100644 index 00000000..08431688 --- /dev/null +++ b/tests/raytracing/test_psf.py @@ -0,0 +1,145 @@ +import numpy as np +import pytest + +from optiverse.core.models import Polarization +from optiverse.raytracing import ImagePlane, RayPath, compute_geometric_psf + + +def _path( + p0: tuple[float, float], + p1: tuple[float, float], + *, + source_index: int = 0, + intensities: list[float] | None = None, +) -> RayPath: + return RayPath( + points=[np.array(p0, dtype=float), np.array(p1, dtype=float)], + rgba=(255, 0, 0, 255), + polarization=Polarization.horizontal(), + wavelength_nm=633.0, + source_index=source_index, + intensities=intensities or [1.0, 1.0], + ) + + +def test_geometric_psf_samples_image_plane_crossings(): + paths = [ + _path((0.0, -1.0), (10.0, -1.0)), + _path((0.0, 0.0), (10.0, 0.0)), + _path((0.0, 1.0), (10.0, 1.0)), + ] + + psf = compute_geometric_psf( + paths, + ImagePlane.from_xy_angle(5.0, 0.0, 0.0), + bin_count=3, + extent_mm=1.5, + ) + + assert psf.sample_count == 3 + assert np.allclose(np.sort(psf.sample_positions_mm), [-1.0, 0.0, 1.0]) + assert psf.centroid_mm == pytest.approx(0.0) + assert psf.rms_radius_mm == pytest.approx(np.sqrt(2.0 / 3.0)) + assert psf.total_weight == pytest.approx(3.0) + assert np.sum(psf.intensity) == pytest.approx(1.0) + + +def test_geometric_psf_ignores_parallel_and_missing_segments(): + paths = [ + _path((0.0, 1.0), (10.0, 1.0)), + _path((6.0, -1.0), (10.0, 1.0)), + _path((0.0, -2.0), (4.0, -2.0)), + ] + + psf = compute_geometric_psf(paths, ImagePlane.from_xy_angle(5.0, 0.0), bin_count=5) + + assert psf.sample_count == 1 + assert psf.sample_positions_mm[0] == pytest.approx(1.0) + + +def test_geometric_psf_uses_interpolated_intensity_weights(): + paths = [ + _path((0.0, -1.0), (10.0, -1.0), intensities=[1.0, 0.0]), + _path((0.0, 1.0), (10.0, 1.0), intensities=[1.0, 1.0]), + ] + + psf = compute_geometric_psf( + paths, + ImagePlane.from_xy_angle(5.0, 0.0), + bin_count=2, + extent_mm=1.5, + ) + + assert np.sort(psf.sample_weights).tolist() == pytest.approx([0.5, 1.0]) + assert psf.centroid_mm == pytest.approx(1.0 / 3.0) + assert psf.total_weight == pytest.approx(1.5) + + +def test_geometric_psf_filters_by_source_index(): + paths = [ + _path((0.0, -3.0), (10.0, -3.0), source_index=0), + _path((0.0, 2.0), (10.0, 2.0), source_index=1), + ] + + psf = compute_geometric_psf( + paths, + ImagePlane.from_xy_angle(5.0, 0.0), + source_index=1, + ) + + assert psf.sample_count == 1 + assert psf.sample_positions_mm[0] == pytest.approx(2.0) + + +def test_geometric_psf_reports_histogram_fwhm(): + paths = [ + _path((0.0, -1.0), (10.0, -1.0), intensities=[0.2, 0.2]), + _path((0.0, 0.0), (10.0, 0.0), intensities=[1.0, 1.0]), + _path((0.0, 1.0), (10.0, 1.0), intensities=[0.2, 0.2]), + ] + + psf = compute_geometric_psf( + paths, + ImagePlane.from_xy_angle(5.0, 0.0), + bin_count=3, + extent_mm=1.5, + ) + + assert psf.fwhm_mm == pytest.approx(1.0) + + +def test_geometric_psf_returns_empty_result_without_crossings(): + psf = compute_geometric_psf( + [_path((0.0, 0.0), (1.0, 0.0))], + ImagePlane.from_xy_angle(5.0, 0.0), + bin_count=7, + ) + + assert psf.sample_count == 0 + assert psf.centroid_mm is None + assert psf.rms_radius_mm is None + assert psf.fwhm_mm is None + assert len(psf.intensity) == 7 + assert np.sum(psf.intensity) == 0.0 + + +def test_geometric_psf_counts_zero_weight_crossings_without_metrics(): + psf = compute_geometric_psf( + [_path((0.0, 0.0), (10.0, 0.0), intensities=[0.0, 0.0])], + ImagePlane.from_xy_angle(5.0, 0.0), + ) + + assert psf.sample_count == 1 + assert psf.total_weight == 0.0 + assert psf.centroid_mm is None + assert np.sum(psf.intensity) == 0.0 + + +def test_geometric_psf_validates_inputs(): + plane = ImagePlane.from_xy_angle(5.0, 0.0) + + with pytest.raises(ValueError, match="bin_count"): + compute_geometric_psf([], plane, bin_count=0) + + with pytest.raises(ValueError, match="normal"): + compute_geometric_psf([], ImagePlane(np.array([0.0, 0.0]), np.array([0.0, 0.0]))) diff --git a/tests/ui/test_main_window.py b/tests/ui/test_main_window.py index 42e29e52..57c7f6cb 100644 --- a/tests/ui/test_main_window.py +++ b/tests/ui/test_main_window.py @@ -3,5 +3,13 @@ def test_main_window_smoke(qtbot): w = MainWindow() qtbot.addWidget(w) - w.show() - assert w.windowTitle().startswith("Photonic Sandbox") + try: + w.raytracing_controller._retrace_timer.stop() + w.file_controller._autosave_timer.stop() + w.show() + assert w.windowTitle().startswith("Optiverse v") + assert "Untitled" in w.windowTitle() + finally: + w.raytracing_controller._retrace_timer.stop() + w.file_controller._autosave_timer.stop() + w.close() diff --git a/tests/ui/test_point_spread_dialog.py b/tests/ui/test_point_spread_dialog.py new file mode 100644 index 00000000..1b05ee32 --- /dev/null +++ b/tests/ui/test_point_spread_dialog.py @@ -0,0 +1,74 @@ +import numpy as np +import pytest + +from optiverse.core.models import Polarization +from optiverse.raytracing import RayPath + + +def _ray_path(y_mm: float) -> RayPath: + return RayPath( + points=[np.array([0.0, y_mm]), np.array([10.0, y_mm])], + rgba=(255, 0, 0, 255), + polarization=Polarization.horizontal(), + wavelength_nm=633.0, + intensities=[1.0, 1.0], + ) + + +def test_raytracing_controller_computes_geometric_psf(qapp, scene): + from unittest.mock import MagicMock + + from optiverse.ui.controllers.raytracing_controller import RaytracingController + + controller = RaytracingController( + scene=scene, + ray_renderer=MagicMock(), + log_service=MagicMock(), + ) + controller._ray_data = [_ray_path(-1.0), _ray_path(1.0)] + + psf = controller.compute_geometric_psf(plane_x_mm=5.0, bin_count=3, extent_mm=2.0) + + assert psf.sample_count == 2 + assert psf.centroid_mm == pytest.approx(0.0) + + +def test_point_spread_dialog_computes_and_displays_result(qtbot, qapp, scene): + from unittest.mock import MagicMock + + from optiverse.ui.controllers.raytracing_controller import RaytracingController + from optiverse.ui.views.point_spread_dialog import PointSpreadFunctionDialog + + controller = RaytracingController( + scene=scene, + ray_renderer=MagicMock(), + log_service=MagicMock(), + ) + controller._ray_data = [_ray_path(-1.0), _ray_path(1.0)] + + dialog = PointSpreadFunctionDialog(controller) + qtbot.addWidget(dialog) + + assert dialog.last_result is not None + assert dialog.last_result.sample_count == 2 + assert "Samples: 2" in dialog._result.toPlainText() + dialog.close() + + +def test_main_window_has_point_spread_action(qtbot, monkeypatch): + from PyQt6 import QtWidgets + + from optiverse.ui.views.main_window import MainWindow + + monkeypatch.setattr(QtWidgets.QMessageBox, "information", lambda *a, **kw: None) + + window = MainWindow() + qtbot.addWidget(window) + window.autotrace = False + window.raytracing_controller._retrace_timer.stop() + window.file_controller._autosave_timer.stop() + + assert window.act_point_spread.text() == "Point Spread Function..." + window.show_point_spread_function_dialog() + + window.close()