Hexagonal distribution traces significantly more rays than described in optic.trace() · Issue #757 · optiland/optiland · GitHub
Skip to content

Hexagonal distribution traces significantly more rays than described in optic.trace() #757

Closed

Description

@kumar-vishesh

Describe the bug
'hexapolar' distribution is a function of the number of rings rather than the number of points/rays. optic.trace() is a function of num_rays, so when you pass num_rays = 100 the hexapolar distribution actually generates a distribution of 20201 rays which are then traced through the optics. All other distributions are a function of num_points so this is not an issue for them, but it is important since 'hexapolar' is the default distribution for the ray tracing engine.

To Reproduce
Run optic.trace(....,num_rays=100, distribution="hexapolar")
output will have a dimension equal to 20201 instead of 100.

Expected behavior
Should be modified to support num_rays rather than rings(

Screenshots
Copy of the latest code in Optiland:

class HexagonalDistribution(BaseDistribution):
"""A class representing a hexagonal distribution.

Generates points in a hexagonal pattern. The total number of points is
`1 + 3 * num_rings * (num_rings + 1)`, including the center point.

Attributes:
    x: Array of x-coordinates of the generated points.
    y: Array of y-coordinates of the generated points.

"""

def generate_points(self, num_rings: int = 6):
    """Generate points in a hexagonal distribution.

    Args:
        num_rings: Number of rings in the hexagonal distribution.
            Defaults to 6.

    """
    x = be.zeros([1])
    y = be.zeros([1])
    r = be.linspace(0, 1, num_rings + 1)

    for i in range(num_rings):
        num_theta = 6 * (i + 1)
        theta = be.linspace(0, 2 * be.pi, num_theta + 1)[:-1]
        x = be.concatenate([x, r[i + 1] * be.cos(theta)])
        y = be.concatenate([y, r[i + 1] * be.sin(theta)])

    self.x = x
    self.y = y

Environment
All environments

Additional context
N/A

Activity

  1. RayPragma commented on Sep 9, 2026

    @RayPragma
    Contributor

    We came at this from the other side: we've been driving hexapolar traces through
    num_rays for a while, and after getting bitten once we ended up writing the ring
    semantics down as a documented invariant rather than treating it as a bug. Two things from
    that experience that might be useful here.

    The counts. Confirming on CookeTriplet, current master:

    num_rays= 8  ->  217 rays
    num_rays=16  ->  817 rays
    num_rays=32  -> 3169 rays
    

    i.e. 1 + 3·N·(N+1). (For num_rays=100 that gives 30301, not the 20201 in the report —
    worth double-checking which surface/array the 20201 was read off, since it may be a
    different count.)

    On the proposed fix. I'd gently argue against making num_rays mean a point count for
    hexapolar, for a reason that isn't obvious until you optimize with it: the ring count is
    the parameter you actually want to control. Hexapolar's value is that it is a structured,
    rotationally symmetric sampling — an arbitrary point count can't be honoured without either
    rounding to the nearest ring count anyway, or breaking the structure that makes the
    distribution worth using. And the ring count is what maps onto cost in a predictable way.
    We settled on three different ring counts for three purposes:

    • optimization Jacobians — low. You need the gradient direction to be right, not the
      absolute RMS. Going from 24 rings to 11 was a ~5× speedup in our optimization loop with
      no change in where it converged, and one LM Jacobian is (n_vars+1) × n_operands ×
      n_fields traces, so this is the number that dominates runtime.
    • one-shot evaluation (spot RMS, MTF reported to a user) — lower still is fine, since
      it's evaluated once.
    • display — higher, but past ~16 rings (817 rays) you're drawing beyond visual density.

    If num_rays silently became a point count, all three of those would change meaning, and
    every existing script would quietly trace a different number of rays and get slightly
    different RMS values with no error and no diff. That seems worse than the current
    surprise.

    What does seem worth fixing is that the same parameter name means two different things
    depending on the value of a string argument — points for every other distribution, rings
    for hexapolar — and nothing in the signature says so. Options that keep results stable:

    1. Accept num_rings= explicitly for hexapolar and keep num_rays= working as an alias,
      documenting the two as equivalent-but-differently-named.
    2. Keep the parameter, but document 1 + 3·N·(N+1) at every entry point (optic.trace,
      the operand input_data, SpotDiagram), which today only says "num_rays".
    3. Emit the resolved ray count somewhere reachable, so a user who passes 100 and gets
      30301 can find that out without reading the distribution class.

    Happy to open a docs PR for (2) if that's the direction you'd want — it's the smallest
    change that removes the trap without altering anyone's numbers.

  2. HarrisonKramer commented on Sep 16, 2026

    @HarrisonKramer
    Collaborator

    Hi @kumar-vishesh and @RayPragma,

    Thanks for the discussion on this. I agree with @RayPragma's assessment: preserving the ring-based semantics for hexapolar is critical so we don't alter sampling structure, runtime costs, or convergence in existing optimization workflows and scripts. We'd also prefer not to introduce an extra num_rings parameter at this time.

    That said, clarifying the documentation at the entry points (e.g. in the optic.trace() parameter description and the rms_spot_size operand docs) to explicitly note the $1 + 3N(N+1)$ ring formula would be very helpful to prevent this surprise.

    @RayPragma, we'd gladly welcome a docs PR for option (2) if you're still open to opening one! I'll assign you here.

  3. RayPragma commented on Oct 22, 2026

    @RayPragma
    Contributor

    Hi @HarrisonKramer, apologies for the long silence on this. You assigned it to me over a month
    ago and I let it slip; sorry for leaving the issue hanging after offering the PR. It's up now: #988

    It only touches docs and docstrings, so no results change:

    • Optic.trace() and RealRayTracer.trace(): for hexapolar, num_rays is the ring count N, giving
      1 + 3·N·(N+1) rays per field point (the default of 100 traces 30301). The Notes now say what
      num_rays means for each built-in distribution. They previously called the uniform value
      "total number of rays per axis", which was also misleading.
    • The rms_spot_size operand, SequencedOptic.trace(), RmsWavefrontErrorVsField and
      optimal_focus_distance() get the same one-line note.
    • The cheat sheet and quickstart examples now state the resulting ray count, e.g. the
      rms_spot_size example with num_rays=100 traces 30301 rays per evaluation.

    @kumar-vishesh, thanks again for the report, and sorry it took this long to close out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions