Multi-Sequence Tracing Framework#
The sequences framework (optiland.sequences) lets a single Optic
be traced through user-defined sub-sequences: alternative traversal orders over the
same surface objects. This is how Optiland models ghost paths, reverse traces, and
sub-component views without duplicating the optical system.
Multi-sequence tracing is the middle ground between a plain Optic (a fixed surface
order) and full non-sequential ray tracing (no order at all): same surfaces, same
parameters, just a different order. Branching arms (beamsplitters) and true
non-sequential (ray-driven) tracing are separate, unrelated features.
Why not just reorder the surfaces?#
A naive implementation would build a second list of the same Surface objects and
trace them in a new order. This breaks in two ways:
Recorded ray data lives on the surface.
Surface._record_realwritessurface.x/y/z/L/M/N/opd/intensityin place. A sequence that revisits a surface (e.g. a ghost path bouncing off it twice) would have the second hit silently overwrite the first, corrupting anything reading that surface’s data.Incident material is derived from the physical chain.
Surface.material_preresolves viaprevious_surface.material_post. Reordering or reversing the traversal without touching that chain yields physically wrong refraction.
SurfaceView#
The unit of a sequence is not Surface but SurfaceView,
a lightweight proxy that shares state with a base Surface and owns the rest:
Shared with the base |
Owned by the view |
|---|---|
|
Record buffers ( |
|
|
|
|
Coating / BSDF objects (non-polarized) |
|
|
Polarized coatings ( |
Because geometry and non-polarized materials/coatings are shared by reference,
editing a base surface, or optimizing a variable on it, is immediately visible in every
sequence built over it. Polarized coatings are the one exception: a
FresnelCoating/ThinFilmCoating is constructed against a fixed (material_pre,
material_post) pair, which a reversed or reflected view resolves differently than its
base surface does. SurfaceView therefore rebuilds its own copy of the coating,
bound to the view’s own resolved media (and, for a multilayer ThinFilmCoating, with
the layer stack reversed for a reverse view) — everything else about the coating
(materials, layer thicknesses) still traces back to the base surface’s definition.
Riding the existing pipeline#
_TracingCoordinator.trace() is stateless and reaches everything through the
surface argument it is given. The ray-side dispatch (RealRays.trace_on_surface,
record_on_surface) is likewise pure duck typing, calling surface._trace_real(self)
and surface._record_real(self). A SurfaceView implementing that same interface
therefore traces through the existing, unforked pipeline. Concretely, every kernel on
SurfaceView (trace, reset, _trace_real, _trace_paraxial,
_record_real, _record_paraxial) dispatches to type(base_surface).<kernel>
bound to the view, rather than reimplementing Surface’s physics inline. This matters
in practice: ObjectSurface and
ImageSurface override several of these kernels
(the object surface skips the coordinate-system localize/globalize step entirely, since
the object is often at z = -inf, where that transform is undefined). Reimplementing
generic Surface physics inline would silently diverge for those two surface types.
This is a deliberate constraint, not an implementation detail: zero changes to
Surface, _TracingCoordinator, or the rays classes are needed for this feature.
A design that forks the trace loop instead will rot on the next physics change to that
loop.
Material and direction resolution#
For each step, given the base surface and whether that step is traversed in reverse:
Forward:
material_pre= the base surface’s ownmaterial_pre(via its normal chain);material_post= the base surface’s ownmaterial_post.Reverse: the two are swapped.
Reflective override:
material_postcollapses tomaterial_preregardless of direction, since a reflection never crosses into the far medium.
A sequence is physically consistent iff, for every adjacent pair of steps, the previous
step’s effective exit medium equals the next step’s incident medium. This is checked at
construction time by validate_sequence(), which raises
SequenceValidationError naming the offending step
index. Inconsistent sequences fail loudly at construction, not silently at trace time.
Step syntax and direction inference#
A raw step is either a bare surface index (forward, nominal interaction) or an
(index, "reflect") / (index, "refract") pair to force an interaction type at
that surface — a plain list of the same two elements (e.g. [3, "reflect"]) is
accepted identically, since that is the shape a pair round-trips to through JSON.
Direction is not specified explicitly; it starts forward and flips after
every reflective step, since a reflection is what physically reverses the direction of
propagation. A surface that is itself a nominal mirror (is_reflective already true on
its base interaction model, e.g. the primary of a Cassegrain) also flips the direction
even when the step gives no explicit override.
parse_steps() implements the raw-step inference;
resolve_sequence() refines it against each base
surface’s nominal reflectivity.
For example, a two-bounce ghost between surfaces 2 and 3:
steps = [0, 1, 2, (3, "reflect"), (2, "reflect"), 3, 4]
reads as: forward through 0, 1, 2; partially reflect off 3 (direction now reverse); partially reflect off 2 (direction now forward again); continue forward through 3, 4.
SequencedSurfaceGroup and SequencedOptic#
SequencedSurfaceGroup presents the
read/trace subset of the SurfaceGroup
interface (stacked x/y/z/… records, positions, stop_index,
n(), trace(), reset()) over a resolved list of views. Unlike
SurfaceGroup, a sequence is static once resolved: there is no add/remove; to
change the traversal, resolve a new sequence.
SequencedOptic composes over a base
Optic rather than subclassing it. aperture, fields, wavelengths,
polarization, apodization, and paraxial are delegated to the base optic by
reference; surfaces is exposed as a SequencedSurfaceGroup.
Ray definition (conjugates, aperture stop, ray aiming) always comes from the base
optic’s own nominal sequence: a sub-sequence defines traversal only.
SequencedOptic.trace()/trace_generic() reuse the base optic’s own
RayGenerator to build the incoming rays, then trace them through the sequence’s own
views instead of the base surfaces. This is deliberate: a ghost sub-sequence frequently
has no meaningful conjugates of its own, so deriving rays from the base sequence sidesteps
that problem entirely rather than solving it.
The only change to Optic itself is the additive
add_sequence() method, which resolves a
SequencedOptic and stores it in optic.sequences[name]:
seq = optic.add_sequence(
"ghost_2_3",
steps=[0, 1, 2, (3, "reflect"), (2, "reflect"), 3, 4],
)
rays = seq.trace(Hx=0, Hy=0, wavelength=0.55)
Because SequencedOptic exposes trace, trace_generic, surfaces, n(),
object_surface, and image_surface with the same shapes as Optic, most
analyses and optimization operands work against a sequence unmodified. image_surface
means the sequence’s own terminal step here, not necessarily the system’s physical image
plane, since a ghost path does not always end there.
Serialization#
Optic.to_dict()/Optic.from_dict() round-trip every sequence registered via
add_sequence: each entry in optic.sequences is serialized as its raw_steps
list under a top-level "sequences" key, and re-resolved against the (already
deserialized) base surfaces on load. Because a sequence is just a raw step list resolved
against the base surfaces, this is enough to reconstruct it exactly — there is no
separate view state to persist.
Known limitations (v1)#
No per-sequence first-order analysis.
paraxialis always the base optic’s; a sequence cannot yet define its own conjugates, entrance pupil, or first-order properties independent of the base system.No visualization overlay. There is no built-in way yet to draw the base system and a sequence together, color-coded by traversal.
No automated ghost enumeration. Sequences are hand-specified; there is no helper that proposes candidate 2-bounce ghost paths from a surface list.
Analyses are not guaranteed to be meaningful for every sequence. Some rays in a sequence may fail to survive real geometry (total internal reflection, missing the clear aperture after a bounce); this shows up as
NaNin ray data, consistent with how the rest of Optiland already represents non-surviving rays.
None of these block ordinary use (ghost analysis, reverse traces, sub-component checks); they are tracked as follow-up work.