Skip to content

Report a converged but physically impossible orbit as flag 9 (#493) - #497

Merged
matthewholman merged 2 commits into
mainfrom
feat/493-implausible-orbit-flag
Sep 1, 2026
Merged

Report a converged but physically impossible orbit as flag 9 (#493)#497
matthewholman merged 2 commits into
mainfrom
feat/493-implausible-orbit-flag

Conversation

@matthewholman

Copy link
Copy Markdown
Collaborator

Adds flag 9: converged, but the orbit is not physically possible.

The criterion is hyperbolic excess speed, not boundedness. Layup is expected to
fit interstellar objects, and those are unbound and fast -- 3I/ATLAS arrives at
about 59 km/s -- so an unbound orbit is never on its own grounds for rejection.
Default threshold 200 km/s (orbitfit.MAX_EXCESS_SPEED_KM_S, 0 disables it).

Measured on 196 short arcs cut from MPC histories, with a long-arc fit of the
same object as truth: the gate rejected no correct fit, and every fit it
rejected was wrong. It is a floor, not a filter -- it catches the unambiguous
cases and cannot reach fits that are plausible and still wrong.

Applied to the result the caller receives, not inside the C++ fit: the driver
runs one fit per IOD candidate and reads a non-zero flag as "candidate failed",
so a per-candidate verdict is retried away and reported as flag 3.

Like flag 2, a gated fit reports state and covariance as NaN.

Two things found on the way, both filed separately rather than fixed here: 59%
of flag=0 short-arc fits have the wrong semimajor axis (#485 says 37%, measured
against a physicality proxy rather than truth), and orbitfit.py overwrites the
C++ flag on two paths.

Toward #493.

A short arc can converge with an excellent reduced chi-square onto a state
no real object could occupy, because the arc does not constrain the
velocity. The chi-square gate cannot catch this: it is anti-correlated with
the failure, since the less the object moves across the arc the better the
fit (#485). Measured against long-arc truth orbits, 59% of flag=0 short-arc
fits have the wrong semimajor axis.

The criterion is hyperbolic excess speed, not boundedness. Layup is expected
to fit genuine interstellar objects, and those are unbound and fast --
3I/ATLAS arrives at about 59 km/s -- so an unbound orbit is never on its own
grounds for rejection. The default threshold is 200 km/s, about 3.4x the
fastest interstellar object yet observed; on 196 measured short-arc fits it
rejected no correct fit, and every fit it rejected was wrong.

Applied to the result the caller receives rather than inside the C++ fit.
The driver runs one fit per IOD candidate and reads a non-zero flag as
'this candidate failed', so a verdict set per-candidate is retried away and
reported as flag 3 or 4.
Comment thread src/layup/orbitfit.py Outdated
else:
logger.debug("Non-grav refinement did not converge; reporting non-grav params as NaN.")

# Physical-plausibility gate (issue #493). Applied here, to the result the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not put issues into the doc strings This does not help maintainable.

A short arc can converge with an excellent reduced chi-square onto a state no
real object could occupy, because the arc does not constrain the velocity. The
chi-square gate cannot catch this -- it is anti-correlated with the failure,
since the less the object moves the better the fit (issue #485).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no issues in doc strings.

Comment thread src/layup/orbitfit.py Outdated
# speed is evidence of a bad fit rather than an unusual object. The default is
# about 3.4x the fastest interstellar object yet observed. Measured against
# long-arc truth orbits it rejected no correct fit, and every fit it rejected was
# wrong. Set to 0 to disable the check.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need all this text as a doc string/comment that duplicates what is in function. I think a shorter summary here would be useful.

import layup.orbitfit as orbitfit_module
from layup.orbitfit import FLAG_IMPLAUSIBLE_ORBIT

GM_SUN = 2.9591220828559115e-4 # au^3/day^2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we not using constants.py to store these values?

from layup.orbitfit import FLAG_IMPLAUSIBLE_ORBIT

GM_SUN = 2.9591220828559115e-4 # au^3/day^2
KM_S_IN_AU_DAY = 86400.0 / 149597870.7

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we not using constants.py to store these values?

# interesting side of the gate, and layup already ships its discovery-arc
# astrometry. Moving the threshold across its excess speed must move the flag,
# which tests the gate itself rather than the accessor.
# ---------------------------------------------------------------------------

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A natural fixture? It doesn't matter whether layup ships the observations in a demo or something else. This is not relevant to a code comment about what the code is doing. Tests should be appropriate whether or not the data is available already or not. I'm not sure which is the fastest. I'd either remove or revise this entire comment.

Comment thread src/layup/orbitfit.py Outdated
# wrong. Set to 0 to disable the check.
MAX_EXCESS_SPEED_KM_S = 200.0

_KM_S_IN_AU_PER_DAY = 86400.0 / 149597870.7

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we not using constants.py to store these constants in?



def set_max_v_inf(km_s):
orbitfit_module.MAX_EXCESS_SPEED_KM_S = km_s

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we changing the values of constants? I get wary when tests does this. They should be able to set with what's already in the constant set in the code. A unit test can simply be written without having to edit all of this?

Comment thread src/layup/orbitfit.py Outdated
than heliocentric, and the Sun's mass rather than the whole solar system's,
are both far below the threshold that matters here.
"""
if not MAX_EXCESS_SPEED_KM_S > 0:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is really bad to have 0 be turning off this check - just setting the implausible speed to very high would do the same thing keep the same logic or set some kind of flag to turn this check off

Comment thread src/layup/orbitfit.py
``state`` is the barycentric equatorial state (au, au/day). Barycentric rather
than heliocentric, and the Sun's mass rather than the whole solar system's,
are both far below the threshold that matters here.
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doc stings re not in the same format as elsewhere in layup

def test_default_threshold_clears_the_fastest_known_interstellar_object():
"""The default must not reject a real interstellar object.

Layup's stated advantage over OrbFit and OpenOrb is that it handles the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not comment about other people's packages in comments?

Comment thread src/layup/orbitfit.py Outdated
# occupy (issue #493). It sits with flag 2 (chi-square too large) and flag 6
# (degenerate covariance) as a fit that converged and was then rejected by a
# gate, rather than with the flags for fits that never converged.
FLAG_IMPLAUSIBLE_ORBIT = 9

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where are the other flags defined. I think all flags should be easily findable with their numbers. Maybe we need another file that stores those constants?

@mschwamb mschwamb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback for consideration

matthewholman added a commit that referenced this pull request Aug 31, 2026
Per review feedback on #497 that issue numbers in docstrings do not help
maintainability. The explanations stay; only the numbers go. Commit
messages remain the durable place for the issue trail.
Resolves against the outcome columns that shipped in #513.
matthewholman added a commit that referenced this pull request Aug 31, 2026
Follows #497, which applies the hyperbolic-excess-speed check and adds
FLAG_IMPLAUSIBLE_ORBIT. failed_physical is no longer reserved.

Says what the check does and does not do: it catches the extreme case, the
threshold is deliberately generous because layup is expected to fit real
interstellar objects, and it will therefore accept short-arc orbits that
are implausible without being impossible. Names the constant and says
which way to move it.

DEPENDS ON #497 - flag 9 does not exist until that merges.
@matthewholman

Copy link
Copy Markdown
Collaborator Author

I believe your concerns have been addressed, @mschwamb

@matthewholman
matthewholman merged commit 6e8d197 into main Sep 1, 2026
7 checks passed
@matthewholman
matthewholman deleted the feat/493-implausible-orbit-flag branch September 1, 2026 10:07
matthewholman added a commit that referenced this pull request Sep 10, 2026
Review asked for one place that defines the flags, so a docstring does
not have to be maintained alongside them. That place exists --
CONVERGED_FLAGS in constants.py -- but it was incomplete: #497 added
FLAG_IMPLAUSIBLE_ORBIT as a converged outcome and never added it here,
so the constant contradicted the flag's own comment. That is the
maintenance failure the review predicted, already happened once.

- CONVERGED_FLAGS gains FLAG_IMPLAUSIBLE_ORBIT, with the rule written
  down: every flag whose comment begins "converged" belongs in it.
- from_flag maps flag 9 to STAGE_COMPLETE and sets failed_physical, so
  it reconstructs like flags 2 and 6. Before this a flag-9 row came back
  byte-identical to "did not converge", losing that it had converged and
  why it was rejected. The contract test covered eight flags and not
  this one.
- the -sf test derives its flags from CONVERGED_FLAGS rather than
  writing them out, so it tracks the constant.

Also per review, the docstrings describe the code rather than the
change: the rationale sentence is removed, the flag values are no longer
listed in prose, the "had no coverage" framing is dropped, and the mask
uses FLAG_CONVERGED rather than a bare 0.

124 tests pass across the outcome, csq, accepted, incremental,
sequential, radar and nongrav suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWmtkZQeT8EFD4e2eovpm7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants