"""Individual diagnostic checks.
Each check is a small function that reads an `Optic` and returns zero or
more `Diagnostic` findings. Checks never mutate the `Optic` they inspect and
never call back into it beyond reading its public attributes — the
`optiland.diagnostics` subpackage depends on `optiland.optic`, never the
other way around.
To add a new check: write a function matching `SystemCheck` and append it to
`CHECKS`. Nothing else needs to change (open/closed).
Kramer Harrison, 2026
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
import optiland.backend as be
from optiland.diagnostics.report import Diagnostic, Severity
from optiland.surfaces.object_surface import ObjectSurface
if TYPE_CHECKING:
from optiland._types import ScalarOrArray
from optiland.optic.optic import Optic
from optiland.surfaces.standard_surface import Surface
SystemCheck = Callable[["Optic"], "list[Diagnostic]"]
_DOC_BASE = "https://www.optiland.org/docs/conventions.html"
def _doc_url(code: str) -> str:
return f"{_DOC_BASE}#{code.lower()}"
# --------------------------------------------------------------------------
# OPT001-OPT006: simple, single-condition presence checks.
#
# Each of these fires at most one diagnostic, based on one boolean fact
# about the system. Rather than repeat the same "if broken: build and
# return a Diagnostic" shape six times, each is declared as data and run
# through a single, shared executor.
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class _PresenceRule:
"""A diagnostic that fires when `is_broken(lens)` is true."""
code: str
severity: Severity
is_broken: Callable[[Optic], bool]
message: Callable[[Optic], str]
fix: str
def _run_presence_rule(lens: Optic, rule: _PresenceRule) -> list[Diagnostic]:
if not rule.is_broken(lens):
return []
return [
Diagnostic(
severity=rule.severity,
code=rule.code,
message=rule.message(lens),
fix=rule.fix,
doc_url=_doc_url(rule.code),
)
]
_OPT001_NO_WAVELENGTHS = _PresenceRule(
code="OPT001",
severity=Severity.ERROR,
is_broken=lambda lens: lens.wavelengths.num_wavelengths == 0,
message=lambda lens: "No wavelengths are defined on the optical system.",
fix="lens.wavelengths.add(value=0.55, is_primary=True)",
)
_OPT002_NO_PRIMARY_WAVELENGTH = _PresenceRule(
code="OPT002",
severity=Severity.ERROR,
is_broken=lambda lens: (
lens.wavelengths.num_wavelengths > 0
and not any(w.is_primary for w in lens.wavelengths)
),
message=lambda lens: "Wavelengths are defined, but none is marked primary.",
fix="lens.wavelengths.add(value=0.55, is_primary=True)",
)
_OPT003_NO_APERTURE = _PresenceRule(
code="OPT003",
severity=Severity.ERROR,
is_broken=lambda lens: lens.aperture is None,
message=lambda lens: "No aperture is defined on the optical system.",
fix='lens.set_aperture(aperture_type="EPD", value=25)',
)
_OPT004_NO_STOP_SURFACE = _PresenceRule(
code="OPT004",
severity=Severity.ERROR,
is_broken=lambda lens: (
len(lens.surfaces.surfaces) > 0
and not any(surf.is_stop for surf in lens.surfaces.surfaces)
),
message=lambda lens: "No stop surface is defined.",
fix="lens.surfaces.add(..., is_stop=True) on the aperture surface",
)
_OPT005_TOO_FEW_SURFACES = _PresenceRule(
code="OPT005",
severity=Severity.ERROR,
is_broken=lambda lens: lens.surfaces.num_surfaces < 2,
message=lambda lens: (
f"The system has only {lens.surfaces.num_surfaces} surface(s); "
"at least an object and an image surface are required."
),
fix=(
"lens.surfaces.add(index=0, thickness=be.inf) for the object "
"surface, then add an image surface last"
),
)
_OPT006_NO_FIELDS = _PresenceRule(
code="OPT006",
severity=Severity.ERROR,
is_broken=lambda lens: len(lens.fields.fields) == 0,
message=lambda lens: "No fields are defined on the optical system.",
fix="lens.fields.add(y=0)",
)
[docs]
def check_no_wavelengths(lens: Optic) -> list[Diagnostic]:
"""OPT001: no wavelengths are defined."""
return _run_presence_rule(lens, _OPT001_NO_WAVELENGTHS)
[docs]
def check_no_primary_wavelength(lens: Optic) -> list[Diagnostic]:
"""OPT002: wavelengths exist, but none is marked primary."""
return _run_presence_rule(lens, _OPT002_NO_PRIMARY_WAVELENGTH)
[docs]
def check_no_aperture(lens: Optic) -> list[Diagnostic]:
"""OPT003: no aperture is defined."""
return _run_presence_rule(lens, _OPT003_NO_APERTURE)
[docs]
def check_no_stop_surface(lens: Optic) -> list[Diagnostic]:
"""OPT004: no surface is marked as the aperture stop."""
return _run_presence_rule(lens, _OPT004_NO_STOP_SURFACE)
[docs]
def check_too_few_surfaces(lens: Optic) -> list[Diagnostic]:
"""OPT005: fewer than 2 surfaces, so there is no object/image pair."""
return _run_presence_rule(lens, _OPT005_TOO_FEW_SURFACES)
[docs]
def check_no_fields(lens: Optic) -> list[Diagnostic]:
"""OPT006: no fields are defined."""
return _run_presence_rule(lens, _OPT006_NO_FIELDS)
# --------------------------------------------------------------------------
# OPT007, OPT011: "find the one offending surface" checks.
# --------------------------------------------------------------------------
[docs]
def check_object_surface_not_first(lens: Optic) -> list[Diagnostic]:
"""OPT007: the object surface is not at index 0."""
surfaces = lens.surfaces.surfaces
misplaced = next(
(
index
for index, surf in enumerate(surfaces)
if isinstance(surf, ObjectSurface) and index != 0
),
None,
)
if misplaced is None:
return []
return [
Diagnostic(
severity=Severity.WARNING,
code="OPT007",
message=f"The object surface is at index {misplaced}, not 0.",
fix="Reorder surfaces; the object surface must be index 0.",
where=misplaced,
doc_url=_doc_url("OPT007"),
)
]
[docs]
def check_stop_at_object_or_image(lens: Optic) -> list[Diagnostic]:
"""OPT011: the stop surface is the object or image surface."""
surfaces = lens.surfaces.surfaces
last = len(surfaces) - 1
found = next(
(
index
for index, surf in enumerate(surfaces)
if surf.is_stop and index in (0, last)
),
None,
)
if found is None:
return []
role = "object" if found == 0 else "image"
return [
Diagnostic(
severity=Severity.WARNING,
code="OPT011",
message=(
f"The stop surface is the {role} surface (index {found}); "
"this is usually a mistake."
),
fix="Move is_stop=True to the intended aperture surface.",
where=found,
doc_url=_doc_url("OPT011"),
)
]
# --------------------------------------------------------------------------
# OPT008: non-finite interior thickness or NaN radius.
# --------------------------------------------------------------------------
def _interior_thickness_diagnostic(
index: int, surf: Surface, n: int
) -> Diagnostic | None:
# The object surface (index 0) may legitimately sit at infinity, and
# the image surface's thickness is not meaningful, so only interior
# surfaces are checked for non-finite thickness.
if not (0 < index < n - 1):
return None
if be.isfinite(be.array(surf.thickness)).all():
return None
return Diagnostic(
severity=Severity.WARNING,
code="OPT008",
message=f"Surface {index} has a non-finite thickness ({surf.thickness}).",
fix=f"Set a finite thickness on surface {index}.",
where=index,
doc_url=_doc_url("OPT008"),
)
def _nan_radius_diagnostic(index: int, surf: Surface) -> Diagnostic | None:
radius = getattr(surf.geometry, "radius", None)
# An infinite radius is a normal, flat surface; only NaN is a bug.
if radius is None or not be.isnan(be.array(radius)).any():
return None
return Diagnostic(
severity=Severity.WARNING,
code="OPT008",
message=f"Surface {index} has a NaN radius of curvature.",
fix=f"Set a valid radius on surface {index}.",
where=index,
doc_url=_doc_url("OPT008"),
)
[docs]
def check_non_finite_interior_thickness(lens: Optic) -> list[Diagnostic]:
"""OPT008: an interior surface has a non-finite thickness or NaN radius."""
surfaces = lens.surfaces.surfaces
n = len(surfaces)
candidates = [
_interior_thickness_diagnostic(index, surf, n)
for index, surf in enumerate(surfaces)
] + [_nan_radius_diagnostic(index, surf) for index, surf in enumerate(surfaces)]
return [d for d in candidates if d is not None]
# --------------------------------------------------------------------------
# OPT009: a wavelength falls outside a material's dispersion data range.
# --------------------------------------------------------------------------
def _material_dispersion_range(surf: Surface) -> tuple[float, float, str] | None:
material = getattr(surf, "material_post", None)
material_data = getattr(material, "material_data", None)
if not material_data:
return None
min_wl = material_data.get("min_wavelength")
max_wl = material_data.get("max_wavelength")
if min_wl is None or max_wl is None:
return None
name = getattr(material, "name", None) or repr(material)
return min_wl, max_wl, name
def _wavelength_range_diagnostic(
index: int, name: str, min_wl: float, max_wl: float, value: float
) -> Diagnostic:
return Diagnostic(
severity=Severity.WARNING,
code="OPT009",
message=(
f"Wavelength {value} um is outside the valid dispersion range "
f"[{min_wl}, {max_wl}] um of material '{name}' on surface {index}."
),
fix=(
"Use a wavelength within the material's data range, "
"or choose a different material."
),
where=index,
doc_url=_doc_url("OPT009"),
)
[docs]
def check_wavelength_outside_material_range(lens: Optic) -> list[Diagnostic]:
"""OPT009: a wavelength falls outside a material's dispersion data range."""
findings: list[Diagnostic] = []
for index, surf in enumerate(lens.surfaces.surfaces):
dispersion_range = _material_dispersion_range(surf)
if dispersion_range is None:
continue
min_wl, max_wl, name = dispersion_range
findings.extend(
_wavelength_range_diagnostic(index, name, min_wl, max_wl, w.value)
for w in lens.wavelengths
if w.value < min_wl or w.value > max_wl
)
return findings
# --------------------------------------------------------------------------
# OPT010: a thickness has an unexpected sign, or is exactly zero.
# --------------------------------------------------------------------------
def _thickness_sign_diagnostic(
index: int, thickness: ScalarOrArray, expect_negative: bool, reflections: int
) -> Diagnostic | None:
is_bad = thickness >= 0 if expect_negative else thickness <= 0
if not is_bad:
return None
expected = "negative" if expect_negative else "positive"
return Diagnostic(
severity=Severity.WARNING,
code="OPT010",
message=(
f"Surface {index} has thickness {thickness}, but "
f"{reflections} reflection(s) precede it (inclusive), "
f"so a {expected} value was expected."
),
fix=f"Set a {expected} thickness on surface {index}.",
where=index,
doc_url=_doc_url("OPT010"),
)
[docs]
def check_non_positive_thickness(lens: Optic) -> list[Diagnostic]:
"""OPT010: a thickness has an unexpected sign, or is exactly zero.
A reflective surface (a mirror) flips the sign of every thickness that
follows it, since propagation now runs the other way along z — see the
"Sign Conventions" section of the conventions doc. The expected sign is
therefore tracked by the parity of the number of reflective surfaces
seen so far (including the current one), not assumed to always be
positive; a two-mirror system like a Cassegrain returns to positive
thicknesses after its second mirror.
"""
surfaces = lens.surfaces.surfaces
n = len(surfaces)
findings: list[Diagnostic] = []
reflections = 0
for index in range(n - 1):
interaction_model = getattr(surfaces[index], "interaction_model", None)
if getattr(interaction_model, "is_reflective", False):
reflections += 1
thickness = surfaces[index].thickness
if not be.isfinite(be.array(thickness)).all():
continue
diagnostic = _thickness_sign_diagnostic(
index, thickness, reflections % 2 == 1, reflections
)
if diagnostic is not None:
findings.append(diagnostic)
return findings
# --------------------------------------------------------------------------
# OPT012: a probe trace shows no rays reaching the image surface.
# --------------------------------------------------------------------------
def _system_ready_for_probe_trace(lens: Optic) -> bool:
preconditions = (
lens.surfaces.num_surfaces >= 2,
lens.wavelengths.num_wavelengths > 0,
any(w.is_primary for w in lens.wavelengths),
lens.aperture is not None,
any(surf.is_stop for surf in lens.surfaces.surfaces),
len(lens.fields.fields) > 0,
)
return all(preconditions)
def _probe_trace_reaches_image(lens: Optic) -> bool:
"""True if a probe trace reaches the image, or can't be judged."""
hx, hy = lens.fields.get_field_coords()[0]
try:
lens.trace(hx, hy, lens.primary_wavelength, num_rays=8)
except Exception:
# Ray tracing failures surface through their own exceptions; a probe
# trace that raises is not this check's concern to report.
return True
intensity = lens.surfaces.intensity[-1, :]
return bool(be.any(intensity != 0))
[docs]
def check_no_rays_reach_image(lens: Optic) -> list[Diagnostic]:
"""OPT012: a probe trace shows no rays reaching the image surface.
Only runs once the system is complete enough to trace: it needs
wavelengths, a primary wavelength, an aperture, a stop, at least two
surfaces, and at least one field. Any check failure above already
reports the root cause, so this is skipped rather than duplicated.
"""
if not _system_ready_for_probe_trace(lens):
return []
if _probe_trace_reaches_image(lens):
return []
return [
Diagnostic(
severity=Severity.WARNING,
code="OPT012",
message="No rays from a probe trace reached the image surface.",
fix=(
"Check ray aiming and aperture sizing; the pupil or field may "
"be too large for this system."
),
doc_url=_doc_url("OPT012"),
)
]
CHECKS: list[SystemCheck] = [
check_no_wavelengths,
check_no_primary_wavelength,
check_no_aperture,
check_no_stop_surface,
check_too_few_surfaces,
check_no_fields,
check_object_surface_not_first,
check_non_finite_interior_thickness,
check_wavelength_outside_material_range,
check_non_positive_thickness,
check_stop_at_object_or_image,
check_no_rays_reach_image,
]