Skip to content

Add further orbit dissimilarity criteria, orbit classification and threshold methods - #97

Merged
dvida merged 28 commits into
wmpg:masterfrom
AstroEloy:feat/additional-d-criteria
Sep 21, 2026
Merged

dvida merged 28 commits into
wmpg:masterfrom
AstroEloy:feat/additional-d-criteria

Conversation

@AstroEloy

@AstroEloy AstroEloy commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Adds the orbit dissimilarity criteria the literature offers besides D_SH, D_D,
D_H and D_N, plus two new modules: one for criteria that classify a single
orbit, and one for choosing a criterion's threshold.

No existing D criterion is modified. calcDSH, calcDH, calcDD,
calcVgComponents, calcDN, calcDV and calcDVuncert are byte-identical to
master, and the __main__ block is untouched.

The branch is no longer purely additive, and an earlier revision of this
description wrongly said it was. The review pass added wmpl/Utils/OrbitConstants.py
and touched Orbit.py, MeanOrbit.py and REBOUND.py so that Dcriteria no
longer imports OrbitClassification (and with it scipy), and so the Gaussian
constant is defined once. Current contribution against upstream/master:
13 files, +4467 / −3. Whether that layering work should be split into its own
PR is still open — see the review thread.

Branches from master, not from the companion bug-fix PR, so the two are
independent.

New in Dcriteria.py

function reference
calcRho1, calcRho2, calcRho5 Kholshevnikov, Kokhirova, Babadzhanov & Khamroev (2016), MNRAS 462, 2275
calcC Neslusan (2002)
calcDR Valsecchi, Jopek & Froeschle (1999), MNRAS 304, 743
calcDB, calcDT Jenniskens (2008), Icarus 194, 13
calcDX Rudawska, Matlovic, Toth & Kornos (2015), P&SS 118, 38
calcDVJopek Jopek, Rudawska & Bartczak (2008), EM&P 102, 73
calcDACS Asher, Clube & Steel (1993), MNRAS 264, 93, eq. 2
calcDSAC Steel, Asher & Clube (1991), MNRAS 251, 632, as eq. 1 of the above

The rho metrics are the only criteria here that satisfy the triangle inequality
and the only ones defined for circular orbits. Both properties are tested with
D_D as the control: the triple

(0.955, 0.348, 168.20 deg, 184.34 deg,  29.66 deg)
(0.959, 0.441, 168.33 deg, 186.31 deg, 153.67 deg)
(0.965, 0.900, 168.27 deg, 184.55 deg, 209.86 deg)

breaks the D_D triangle inequality by 0.0679, while rho_2 has 0.2298 of slack
on the same triple. D_D also raises ZeroDivisionError for two circular orbits,
which the metrics handle.

calcDV, the unpublished Vida criterion already in the module, keeps its name so
the public API does not break; the new Jopek function is calcDVJopek.

All new functions are written against numpy rather than math and accept
scalars or arrays, which also removes the branch construct that produced the
D_H node sign bug in the companion PR.

New module OrbitClassification.py

These take one orbit and return a dynamical class, so they do not belong among the
pairwise criteria: calcTisserand (Whipple 1954), calcKresakK, calcKresakP,
calcAphelionDistance, calcOrbitalEnergy, and the two-parameter criteria
isCometaryQi, isCometaryEi, isCometaryKi, isCometaryPi from Jopek &
Williams (2013), MNRAS 430, 2377, eqs 8 to 12. Q-i and E-i are the two the paper
found most reliable.

New module Dthresholds.py

thresholdDr (Southworth & Hawkins 1963; Lindblad 1971), thresholdBreakPoint
(Neslusan, Svoren & Porubcan 1995), thresholdReliability (Jopek, Valsecchi &
Froeschle 1999) and thresholdRandomPairing (Jopek & Bronikowska 2017, P&SS 143,
43), whose threshold depends on the D function and the population as well as on
the sample size. Its twelve published coefficient pairs are exposed as
RANDOM_PAIRING_COEFFS; an unpublished combination raises ValueError rather
than substituting a default.

Worth flagging for users of thresholdDr: Jopek & Bronikowska measured the
probability of a coincidental pair at those thresholds as 1.0 for bolide samples
of 200 to 800 orbits, i.e. a "similar" pair is certain whether or not any orbits
are related. It runs 7 to 9 times looser than the threshold that delivers 0.01.
That is recorded in its docstring.

Thresholds are stated per criterion, never invented

Each docstring carries the criterion's published threshold or states that none was
published. Seven of the papers publish none, so no default is supplied. They would
not transfer anyway: rho carries units of sqrt(AU) with L = 1 AU, while
D_SH and D_D are dimensionless, and the random-pairing thresholds differ by a
factor of about 2.4 between D_SH and D_D at fixed sample size.

Verification

81 tests across test_DcriteriaAdditional.py (67) and test_Dthresholds.py (14).
Each criterion is checked against a value tabulated in its own paper, or, where
none is tabulated, against an analytic identity it must satisfy by construction.

Paper values. rho_1, rho_2 and rho_5 reproduce table 2 of Kholshevnikov
et al. (2016) for 96P/Machholz 1 and 2003 EH1 at 7415 BC:

computed paper
rho_1 0.0346 0.036
rho_2 0.0357 0.035
rho_5 0.0154 0.016

The residual is set by the precision of the published elements: perturbing them
within the half-width of their quoted rounding moves rho_2 over
[0.0354, 0.0360]. The test tolerance is 0.002, and every plausible transcription
error of the formulae was measured to miss by at least 0.0099, five times that —
flipping the sign of Delta in cos P gives 0.0450, dropping the e1*e2 term
gives 1.1875, using cos P in place of cos I gives 0.0475.

The paper's closed form for cos P was also checked independently against the dot
product of the two perihelion unit vectors, agreeing to 5.4e-16 over 20000 random
pairs.

thresholdRandomPairing reproduces eq. 14 of Jopek & Bronikowska as tabulated in
their column D_(14) to 7.7e-05, and the nine large-sample formulae track their
table 7 to 6.4e-04 absolute and 5.0% relative over 200 to 16000 orbits (they are
least-squares fits through those points, so exact agreement is not expected). The
criterion ratios of their table 8 follow from the formulae alone and match all six
published averages to two decimals. thresholdDr reproduces their column D_(1)
to 4.2e-05, inside the 5e-05 rounding floor of a four-decimal column.

calcTisserand gives 3.026 for 2P/Encke against the literature 3.03, and exactly
3 for an orbit identical to Jupiter's.

The calcDACS scale is published, and independently cross-checked here. Asher,
Clube & Steel (1993) print it in eq. 2 itself, which reads
D² = ((a₁−a₂)/3)² + (e₁−e₂)² + (2 sin((i₁−i₂)/2))². An earlier revision of this
description presented the argument below as a determination of the scale, which
overstated it — the value is published, not inferred.

It is worth keeping as a cross-check, because it is independent of the printed
equation and happens to be the only way the paper's own table can be brought to
bear. Table 1 cannot be recomputed from its own columns: the inclination is first
adjusted by Brouwer (1947) secular perturbation theory to the smallest value the
orbit ever reaches, and the eccentricity likewise, while the table reports observed
values. The semi-major axis is not adjusted and the other terms are non-negative,
so its term is a rigorous lower bound on every tabulated D, and it is attained
exactly by P/Encke and 1991 TB2. Those two facts bracket the scale at 3 AU, in
agreement with the printed value:

scale outcome
1.0 / 2.0 / 2.5 lower bound violated by 0.3100 / 0.0500 / 0.0200
3.0 holds on all 26 rows, attained by 2 of them
3.5 / 4.0 bound slack everywhere, largest ratio 0.857 / 0.750

Both tests were run against substituted scales to confirm they reject every value
but 3.0. The docstring states that no secular adjustment is applied by the
function, so passing observed elements will not reproduce the paper's table.

Analytic identities, where no paper value exists: the triangle inequality for
the metrics with D_D as control; rho_5 <= rho_2 with rho_5 attaining the
minimum of rho_2 under a scan over both nodes and both arguments of perihelion;
D_R <= D_N against the existing calcDN; C against the angular momentum term
of rho_1; the D_V energy term against -1/(2a); D_B's C3 term taking the
smallest angular difference across the 0/360 deg cut; and the two Taurid forms
sharing their eccentricity and inclination terms, with their separately quoted
reference orbits agreeing (a1(1 - e1) = 0.3780 AU against the published
q1 = 0.375 AU, inside the range implied by the quoted precision).

A self-comparison cannot return exactly zero, for the same reason as in the
D_SH family: the square of the metric cancels to the 1e-16 rounding level and
the square root lifts that to about 1e-08. Measured floor over 5000 random orbits
is 3.7e-08 for rho_1, 6.0e-08 for rho_2 and exactly 0 for rho_5, so the
tests allow 1e-07.

D_B, D_T and D_X verified against their primary sources

An earlier revision of this PR carried caveats saying these three were transcribed
from a secondary source. The primary papers have since been checked and all three
formulae were correct, but the surrounding documentation was not. Corrected in
8389eab:

  • D_X's weights are published, and are now the defaults. Rudawska et al.
    state them in section 2.2: w_lambda = 0.17, w_alpha = 1.20,
    w_delta = 1.20, w_v = 0.20. They were previously required arguments on the
    belief that the paper left them undefined. The velocity unit is not ambiguous
    either — the paper tabulates V_g in km/s — and a threshold is published:
    groups merge at D_X <= 0.15.
  • D_T and D_B have published thresholds. Jenniskens section 3.2:
    D_T < 0.3 with D_B < 1.0 identifies parent bodies and siblings;
    D_T < 0.6 with D_B < 1.5 also captures bodies related through an earlier
    fragmentation. Both docstrings previously said no threshold was published.
  • calcDT now takes q rather than a, following the paper's eq. 13, which
    writes the Tisserand parameter in q and e "because the observational errors
    in these orbital elements are smaller than those in a". The two forms agree to
    2.1e-14 over 200000 orbits, and eq. 13 stays finite at e = 1 where the
    (a, e, i) form evaluates to nan. This also brings D_T into the variables
    the rest of the module works in.

New paper values behind all three: T_J computes 3.0264 for 2P/Encke against
table 1's 3.03 and 4.5108 for 3200 Phaethon against 4.51; mean within-shower D_X
computes 0.061 / 0.101 / 0.070 for the Geminids, Perseids and Orionids against the
quoted 0.06 / 0.09 / 0.07; and the tabulated Taurid branch means give
D_X = 0.244, above the 0.15 merge threshold, consistent with the paper reporting
that its method keeps them separate.

A caution on section 2.1 of Jenniskens (2008), which restates the older
criteria — it should not be used as a source for them. Its eq. 9 writes the last
D_H term with (e1 - e2)/2 where (e1 + e2)/2 belongs; its eq. 5 writes the
D_D eccentricity term with (e1 - e2) in the denominator; and its eq. 3 writes
cos((i1 - i2)/2) in Pi_21 where cos((i1 + i2)/2) belongs. That last one was
checked against the mutual-node vector definition over 200000 pairs: the form in
this codebase agrees 100.00% of the time and the form as printed agrees 0.00%
,
so no change is warranted. The author's own criteria, D_B and D_T, are correct.

D_V corrected against its primary source

1a2c8fc closes the last unverified formula, and it was a real error, not just a
documentation one
. The paper defines the vectorial elements "in the units AU,
AU/day and the mass of the Sun", with mu = k^2. This module computed them with
mu = 1 — self-consistent, but on a different scale from the one the published
weights were measured on, and the weights are dimensional. For a Geminid-like orbit
the tabulated energy dispersion is 0.91% of the energy in the paper's units and
0.00027% of it in the units used before; only the first is a credible spread.
The angular momentum now carries a factor of k and the energy k^2.

The weights are published and are now the defaults. Eq. 5 gives
w = (2*sigma)^-2, so a pair differing by twice the dispersion in one element
contributes exactly 1 — that is what fixes the criterion's scale. Table 1's
dispersions for five stream ages plus the sporadic background are in
DV_DISPERSIONS, with calcDVWeights turning any of them into weights; the
default is the 4000-year set the paper used for its own search.

The 1.5 and 2 are deliberate, not transcription noise: the energy is
invariant under the principal secular perturbation and the third angular momentum
component semi-invariant, so the paper weights those two up beyond their
dispersions.

Thresholds are published (table 2, 99% reliability), now in DV_THRESHOLDS.
The column is headed D_V x 10^-1, which has to be read as the printed figures
being D_V scaled down by ten. Simulating pairs from one stream using table 1's
dispersions settles it: 97.1% of same-stream pairs fall below 3.128, as an
association threshold should, while 0.0% fall below 0.3128. Both readings are
asserted in the tests so the choice is visible to a reviewer.

Remaining provenance caveat

  • calcC — Neslusan's volume is still not accessible. Its formula is
    corroborated by the description in Jopek, Rudawska & Bartczak (2008), which is a
    second source rather than the original, and the docstring says so. Whether that
    volume publishes a threshold is unchecked; none is supplied.
  • thresholdBreakPoint is implemented as the method is described in the
    accessible literature; the 1995 paper's ADS scan carries no text layer, so its
    exact formalisation of where the turn is located could not be read. The break is
    taken as the bin of strongest negative curvature of the cumulative
    distribution, which on a synthetic stream uniform over [0, 0.05] plus a
    background over [0.30, 1.0] returns 0.04997.
  • thresholdReliability considers pairs only. Larger minimum group sizes need
    a clustering algorithm, which is a separate concern from the threshold.
  • Kresak's K and P are cited by author and year only. Their definitions and
    cometary limits are taken from Jopek & Williams (2013) eqs 11 and 12, which was
    verified directly; volume and page numbers for the 1967 and 1969 papers could
    not be checked and so are not quoted.

Tancredi (2014)

Added in 7a4735b: classifyTancrediComet, classifyTancrediAsteroid,
isTancrediResonanceProtected, calcHillRadius, calcResonanceSemiMajorAxis.

Applying a bare Tisserand cut to the asteroid population returns 7332 candidates,
over 97% of them on stable orbits. The criterion's value is the two conditions that
follow: the body must not be held away from Jupiter by a mean-motion resonance,
and its orbit must actually reach a giant planet. The paper reports a false
positive rate of 0.19 and false negative rate of 0.05 against direct integration.

Verified against the paper's own numbers: eq. 3 reproduces all eight resonance
semi-major axes of table 1 to 7e-04; eq. 2 reproduces all four quoted giant-planet
Hill radii to 2e-03; 233P/La Sagra, the one new "Comet" in an Asteroidal Orbit the
paper reports, gives T_Jup = 3.0860 against the quoted 3.086 and classifies as
'asteroidal orbit'; the quasi-Hilda orbit used to justify the 3.05 limit gives
3.0492 against the quoted 3.05; Chiron classifies as a Centaur and Encke as
Jupiter family; and the Hildas are correctly excluded as resonance protected.

The supplementary appendices are now implemented too (f2ca39a), so the criterion
runs from orbital elements alone and the two approximations noted earlier are gone.

Appendix AcalcMOID minimises the distance between a point on each orbit over
the two eccentric anomalies, restarting from the three further points the appendix
prescribes because the surface has more than one local minimum.
calcGiantPlanetMOIDs wraps it over the four giant planets, returning each distance
in that planet's own Hill radii. Verified on the orbits of figure A.1, chosen there
precisely because a single search falls into the second minimum: the four-start
search returns 0.240830 AU against 0.240833 from a 600×600 grid. Coplanar
circles of radius 1 and 1.2 give exactly 0.2, and the result is symmetric.
calcMinMOIDForTisserand traces the forbidden region, closing to zero at T = 3
and widening above it. Eq. A.1 is solved directly rather than through the cubic of
eq. A.3 it squares to, which carries two spurious roots.

Appendix BcalcResonanceWidth computes the libration half width from the
eccentricity, with calcLaplaceCoefficient evaluated from its integral definition
rather than the truncated series so it stays exact as alpha approaches 1. It
reproduces all five widths tabulated in table 1:

resonance order computed table 1 ratio
4:1 3 0.00754 0.0075 1.005
3:1 2 0.02873 0.0287 1.001
5:2 3 0.02598 0.0260 0.999
7:3 4 0.02146 0.0215 0.998
2:1 1 0.11274 0.1127 1.000

One reading of eq. B.6 had to be settled for the last of those. Its final term
divides by j2, which is negative since j2 = -p. Taking the magnitude gives
0.10927, 3% low, and would need e = 0.3183 to match — outside the stated range.
Carrying the sign through gives 0.11274 at e = 0.3, the same eccentricity that
reproduces the other four, so the signed reading is the intended one. Noted at the
point of use.

The three outer resonances keep the fixed widths the paper itself adopts for them,
where the expansion no longer holds — the paper's choice, not an approximation
added here.

A note on that paper: the second form of eq. 1, rewriting the Tisserand parameter
in q and Q, is missing the planet's semi-major axis inside the radical as
printed, evaluating to 4.689 where the first form gives 3.030. The first form is
the one implemented.

Deliberately not included

Nothing. Every criterion named in the scope is implemented.

Review pass

149d51f records a review pass over the three modules. No formula changed. It
corrected one wrong statement about units in calcRho1; removed two
"no threshold is published" claims made about papers that were never accessible
(calcC, calcDVJopek) — the same mistake that turned out to be wrong for D_B,
D_T and D_X; reordered calcDX's arguments to (ra, dec, sol, vg) to match
calcDN, calcDR and calcDV, since all eight arguments are floats and the
previous order invited a silent wrong answer; replaced a test that recovered a
Tisserand parameter as 3.0 + D_T, valid only for T > 3; added isCometaryEi,
which the Q-i docstring recommended but which was not implemented; and named
D_B's dispersions and D_V's unverified coefficients at their point of use.

Out of scope

No pairwise matrices at survey scale. 10^6 orbits is 10^12 pairs and needs
chunking or a KD-tree pre-filter, which is a separate concern from this module.

🤖 Generated with Claude Code

AstroEloy and others added 9 commits September 18, 2026 09:17
Adds the criteria that the literature offers besides D_SH, D_D, D_H and D_N, and
a new module for the criteria that classify a single orbit rather than comparing
two. The existing functions are untouched: this commit is 443 insertions and 0
deletions to Dcriteria.py.

New in Dcriteria.py:

  calcRho1, calcRho2, calcRho5   Kholshevnikov, Kokhirova, Babadzhanov & Khamroev
                                 (2016), MNRAS 462, 2275
  calcC                          Neslusan (2002)
  calcDR                         Valsecchi, Jopek & Froeschle (1999), MNRAS 304, 743
  calcDB, calcDT                 Jenniskens (2008), Icarus 194, 13
  calcDX                         Rudawska, Matlovic, Toth & Kornos (2015), P&SS 118, 38
  calcDVJopek                    Jopek, Rudawska & Bartczak (2008), EM&P 102, 73

New in OrbitClassification.py: calcTisserand (Whipple 1954), calcKresakK,
calcKresakP, calcAphelionDistance, and the two-parameter criteria isCometaryQi,
isCometaryKi, isCometaryPi (Jopek & Williams 2013, MNRAS 430, 2377, eqs 8-12).

The rho metrics are the only ones here that satisfy the triangle inequality, and
the only ones defined for circular orbits. Both properties are tested, with D_D
as the control: the triple

  (0.955, 0.348, 168.20 deg, 184.34 deg,  29.66 deg)
  (0.959, 0.441, 168.33 deg, 186.31 deg, 153.67 deg)
  (0.965, 0.900, 168.27 deg, 184.55 deg, 209.86 deg)

breaks the D_D triangle inequality by 0.0679, while rho_2 has 0.2298 of slack on
the same triple. D_D also raises ZeroDivisionError for two circular orbits, which
rho_1, rho_2 and rho_5 handle.

All new functions are written against np rather than math and accept scalars or
arrays, which also removes the branch class that produced the D_H node sign bug.

Thresholds are stated per criterion in the docstrings. None of these seven papers
publishes one, so no default threshold is supplied. They would not be transferable
anyway: rho carries units of sqrt(AU) with L = 1 AU, while D_SH and D_D are
dimensionless.

Verification. rho_1, rho_2 and rho_5 reproduce table 2 of Kholshevnikov et al.
(2016) for 96P/Machholz 1 and 2003 EH1 at 7415 BC:

                computed   paper
    rho_1        0.0346    0.036
    rho_2        0.0357    0.035
    rho_5        0.0154    0.016

The residual is set by the precision of the published elements: perturbing them
within the half-width of their quoted rounding moves rho_2 over [0.0354, 0.0360].
The test tolerance is 0.002, and every plausible transcription error of the
formulae was measured to miss by at least 0.0099, five times that: flipping the
sign of Delta in cos P gives 0.0450, dropping the e1*e2 term gives 1.1875, using
cos P in place of cos I gives 0.0475.

The paper's closed form for cos P was checked independently against the dot
product of the two perihelion unit vectors, agreeing to 5.4e-16 over 20000 random
pairs. calcTisserand gives 3.026 for 2P/Encke against the literature 3.03, and
exactly 3 for an orbit identical to Jupiter's. Where no paper value exists the
tests use analytic identities: rho_5 <= rho_2 with rho_5 attaining the minimum of
rho_2 under a scan over both nodes and both arguments of perihelion, D_R <= D_N
against the existing calcDN, C against the angular momentum term of rho_1, and
the D_V energy term against -1/(2a).

A self-comparison cannot return exactly zero for the same reason as in the D_SH
family: the square of the metric cancels to the 1e-16 rounding level and the
square root lifts that to about 1e-8. Measured over 5000 random orbits the floor
is 3.7e-08 for rho_1, 6.0e-08 for rho_2 and exactly 0 for rho_5, so the tests
allow 1e-7.

Two criteria are shipped with required rather than default weights. Rudawska et
al. (2015) leave the four D_X weights undefined and give no method for choosing
them, and do not state the units of V_g, which matter because the velocity
difference enters the radiant terms as |V_g1 - V_g2| + 1. Jopek et al. (2008)
define the D_V weights as dispersions measured over a reference set of showers,
so no universally valid default exists; the paper itself is paywalled and its
tabulated weight values could not be read, which is why they are not supplied
here. The 1.5 and 2 coefficients in D_V follow the only accessible transcription
of its equation and have not been checked against the original.

calcDV, the unpublished Vida criterion already in this module, is deliberately
left under its existing name so the public API does not break; the new function
is calcDVJopek.

Asher, Clube & Steel (1993) is not included. The paper is paywalled and the ADS
scan's OCR loses the normalising constant of the semi-major axis term. The
structure, the reference orbit (a = 2.1 AU, e = 0.82, i = 4 deg) and the threshold
(D = 0.15) are legible, but fitting the recoverable form to the paper's own table
1 leaves an rms of 0.031 against D values quoted to two decimals, five times the
rounding floor, so the equation has not been reconstructed correctly and is not
guessed at here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A D criterion is only half of a stream search; the threshold below which two
orbits count as associated has to be chosen too, and a value taken from the
literature is tied to the criterion, the database size and the sporadic
background of the study it came from.

New module Dthresholds.py:

  thresholdDr            D_r = 0.20 (360/N)^(1/4), Southworth & Hawkins (1963);
                         Lindblad (1971), Smithson. Contrib. Astrophys. 12, 1
  thresholdBreakPoint    Neslusan, Svoren & Porubcan (1995), EM&P 68, 427;
                         Neslusan, Hajdukova & Jakubik (2013), A&A 560, A47
  thresholdReliability   Jopek, Valsecchi & Froeschle (1999), MNRAS 304, 751

thresholdDr returns 0.20 at its anchor of 360 orbits and scales as the fourth
root, so sixteen times the orbits halves it; both are tested exactly.

thresholdReliability builds synthetic samples by shuffling each parameter
independently, which preserves every marginal distribution and destroys the
correlations a real stream would carry, then returns the largest threshold at
which the requested fraction of those samples contains no associated pair. The
test re-draws shuffled samples with an independent seed and confirms the returned
threshold really does leave them free of pairs at the promised rate. Only pairs
are considered, since anything larger needs a clustering algorithm, which is a
separate concern from the threshold.

thresholdBreakPoint locates the break as the bin of strongest negative curvature
of the cumulative distribution. On a synthetic sample of a stream uniform over
[0, 0.05] plus a background uniform over [0.30, 1.0] it returns 0.04997, i.e. the
stream edge to within one bin, which is the tightest threshold that still takes
the whole stream. Note the caveat the method carries: the break is only meaningful
when the stream is a large enough fraction of the sample to bend the curve, and
the function will still return its best candidate when it is not.

Jopek & Bronikowska (2017), P&SS 143, 43 is not included. Its threshold depends on
the D function as well as on the sample size, but the paper is paywalled and no
accessible source states the dependence, so it is not guessed at here. The
break-point method is implemented as described in the accessible literature; the
1995 paper's ADS scan carries no text layer, so its exact formalisation of where
the turn is located could not be read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
calcRho1 cited Kholshevnikov et al. as (1993); the paper is (2016), as the other
two rho docstrings already had it.

The Kresak references carried volume and page numbers that could not be checked
against the original 1967 and 1969 papers, so they are reduced to author and year.
The definitions themselves are unaffected: they are taken from Jopek & Williams
(2013), eqs 11 and 12, which is cited on the following line in each docstring and
was verified directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keeps every new line inside the 110 column width the module already uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fills the omission noted when Dthresholds.py was added: Jopek & Bronikowska
(2017), P&SS 143, 43, doi:10.1016/j.pss.2016.12.004, whose threshold depends on
the D function and on the population as well as on the sample size.

New: thresholdRandomPairing(n_orbits, d_criterion, population), returning the
threshold at which the probability of a coincidental pair is 0.01. The twelve
published coefficient pairs are exposed as RANDOM_PAIRING_COEFFS:

    D_c = A*N**(-b)

    criterion  population   A        b
    D_SH       bolides      0.3186   0.431    (eq. 14)
    D_H        bolides      0.3143   0.438    (eq. 15)
    D_D        bolides      0.1240   0.423    (eq. 16)
    D_SH       NEAs         0.2558   0.408    (eq. 17)
    D_H        NEAs         0.2193   0.405    (eq. 20)
    D_D        NEAs         0.1049   0.408    (eq. 23)
    D_SH       radar        0.4007   0.450    (eq. 18)
    D_H        radar        0.3768   0.447    (eq. 21)
    D_D        radar        0.1543   0.436    (eq. 24)
    D_SH       video        0.5837   0.487    (eq. 19)
    D_H        video        0.4808   0.464    (eq. 22)
    D_D        video        0.1724   0.446    (eq. 25)

An unpublished combination raises ValueError rather than falling back on a
default, since no threshold exists to fall back to.

Verification against the paper's own tables:

  - eq. 14 reproduces its tabulation in column D_(14) of table 3 to 7.7e-05.
  - the nine large-sample formulae track the measured thresholds of table 7 to
    6.4e-04 absolute and 5.0% relative over 200 to 16000 orbits. They are
    least-squares fits through those points, so they are not expected to pass
    through each one exactly; the tests allow 1e-03 and 6%.
  - the criterion ratios of table 8 follow from the formulae alone: D_SH/D_H and
    D_SH/D_D come out as 1.14/2.44 for NEAs, 1.03/2.30 for radar and 0.99/2.38
    for video, matching all six published averages to two decimals.

This also supplies a real paper value for thresholdDr, which until now was tested
only against its own anchor. Formula 1 reproduces column D_(1) of table 3 to
4.2e-05, inside the 5e-05 rounding floor of a four-decimal column.

The paper's practical conclusion is recorded in thresholdDr's docstring: for
bolide samples of 200 to 800 orbits the scaled Southworth & Hawkins threshold
corresponds to a coincidental pairing probability of 1.0, so a "similar" pair is
certain whether or not any orbits are related. It runs 7 to 9 times looser than
the threshold that actually delivers 0.01, which is asserted in the tests.

Also noted: the thresholds depend strongly on how the synthetic orbits are drawn.
Drawing the elements uniformly rather than reproducing the observed distributions
including the Earth-crossing condition inflates the threshold by about a factor
of two, which is why thresholdReliability shuffles the observed values instead of
sampling a box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fills the omission noted when the criteria were added. calcDACS implements eq. 2
of Asher, Clube & Steel (1993), MNRAS 264, 93, doi:10.1093/mnras/264.1.93:

    D_ACS^2 = ((a1 - a2)/3)^2 + (e1 - e2)^2 + (2 sin((i1 - i2)/2))^2

The Taurid Complex core orbit it is normally evaluated against is exposed as
TC_REFERENCE_A = 2.1 AU, TC_REFERENCE_E = 0.82 and TC_REFERENCE_INCL = 4 deg, and
the scale of the semi-major axis term as DACS_A_SCALE = 3 AU.

The criterion deliberately carries no node or longitude term. The Taurid Complex
has been dispersed in longitude of perihelion by Jovian perturbations, so a
longitude term appropriate to a narrow stream would dominate the sum; the paper
tests longitude alignment separately, after selecting on (a, e, i). That is
recorded in the docstring, since it is the reason the signature is as narrow as
it is.

Published thresholds, both in the docstring: D = 0.15 restricts the selection to
the core of the complex, as used with the perihelion-distance form in Steel, Asher
& Clube (1991); the 1993 paper suggests D of about 0.2 as the value that best
defines Taurid Complex asteroids.

Determination of the scale. Table 1 of the paper cannot be recomputed from its own
columns, because the inclination is first adjusted by Brouwer (1947) secular
perturbation theory to the smallest value the orbit ever reaches, and the
eccentricity likewise, while the table reports observed values. The semi-major
axis is not adjusted, and the other two terms are non-negative, so the semi-major
axis term is a rigorous lower bound on every tabulated D. That bounds the scale
from below. It is also attained exactly, by P/Encke and by 1991 TB2, whose
adjusted eccentricity and inclination therefore coincide with the reference orbit,
which bounds it from above. The two together fix the scale at 3 AU from the table
alone:

    scale   outcome
    1.0     lower bound violated by 0.3100
    2.0     lower bound violated by 0.0500
    2.5     lower bound violated by 0.0200
    3.0     bound holds on all 26 rows and is attained by 2 of them
    3.5     bound slack everywhere, largest ratio 0.857
    4.0     bound slack everywhere, largest ratio 0.750

Both tests were run against substituted scales to confirm they reject every value
but 3.0, so they pin the published constant rather than merely accepting it.

This also explains why the constant was previously left unimplemented. Fitting the
recoverable form to table 1 gave a best scale of 3.87 with an rms of 0.031, five
times the rounding floor, and residuals correlating with inclination. The
correlation was the secular adjustment: the fit was reconciling a D computed from
the minimum inclination against the observed inclination in the table. The
equation was recoverable all along; the table was not the right thing to fit to.

Because of that, the docstring states plainly that no adjustment is applied here
and that passing observed elements will not reproduce the paper's table. It also
notes the perihelion-distance form of Steel, Asher & Clube (1991), MNRAS 251, 632,
which drops the scale factor and suits meteoroids, whose q is better determined
than their a, where the form implemented here suits asteroids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
calcDACS was added in the semi-major axis form alone, which made it the only
orbital-element criterion in this module that cannot be evaluated from the
elements the rest of the module works in. Nothing justified leaving the earlier
form out: it needs strictly less than the others.

calcDSAC implements eq. 1 of Asher, Clube & Steel (1993), attributed there to
Steel, Asher & Clube (1991), MNRAS 251, 632:

    D^2 = (q1 - q2)^2 + (e1 - e2)^2 + (2 sin((i1 - i2)/2))^2

The reference perihelion distance of the Taurid Complex core is added as
TC_REFERENCE_Q = 0.375 AU, alongside the existing TC_REFERENCE_E and
TC_REFERENCE_INCL, which both forms share.

The two forms are not interchangeable and the docstrings now point at each other.
The perihelion form is for meteoroids, whose q is better determined than their a,
since a carries the full weight of the velocity uncertainty. The semi-major axis
form is for asteroids, whose a is well determined. The perihelion term carries no
scale factor, where the semi-major axis term is divided by 3 AU, so a 1 AU
difference contributes 1 to the first and 1/3 to the second.

A second difference worth recording: the semi-major axis form has to have its
inclination secularly adjusted before use, while this form does not when applied
to meteoroids, because an orbit must cross the Earth's to produce a meteor, which
already constrains the inclination to be low. That is in the docstring.

On the data each criterion needs. After this commit every orbital-element
criterion in the module is evaluable from (q, e, i, node, peri), which is what the
command line accepts:

    q, e, i, node, peri   calcDSH, calcDD, calcDH, calcRho1, calcRho2, calcRho5,
                          calcC, calcDVJopek, calcDB (no q), calcDSAC (no node,
                          peri)
    a, e, i               calcDACS, calcDT, via a = q/(1 - e)
    radiant and speed     calcDN, calcDR, calcDV, calcDVuncert, calcDX

Only the last group needs anything beyond the orbital elements, which is inherent
to criteria defined on geocentric quantities and predates this work.

Verification. No paper value exists for this form: the 1993 paper's table 1 is
computed with eq. 2, and the 1991 paper was not accessible, so the tests are
identities. The sharpest is that the two forms must describe the same reference
orbit, and they do: a1(1 - e1) = 2.1 x 0.18 = 0.3780 AU against the published
q1 = 0.375 AU, which sits inside [0.3588, 0.3978] AU, the range implied by the two
significant figures of a1 and the two decimals of e1. The eccentricity and
inclination terms are shared between the two forms, so stripping the leading term
from each must leave the same remainder, checked to 1e-9 over 300 orbit pairs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The primary papers for these three became available, and they contradict the
provenance caveats recorded when they were added. All three formulae were correct
as implemented; the documentation around them was not.

D_X: the weights are published, contrary to the caveat

Rudawska et al. (2015) state the weights outright, in section 2.2: "To normalise
contributions of each term in D_X, we used values: w_lambda = 0.17, w_alpha =
1.20, w_delta = 1.20, and w_v = 0.20." They are now the defaults, exposed as
DX_W_SOL, DX_W_RA, DX_W_DEC and DX_W_VG and still overridable, rather than
required arguments.

The other two caveats also fall. The velocity unit is not ambiguous: the paper
tabulates Vg in km/s throughout, and that is now stated. A threshold is published:
groups merge when D_X <= 0.15, from their step 2.

Verified end to end against values the paper quotes for the mean D_X within three
showers. Drawing members from each shower's mean and its tabulated dispersions in
table 2:

              computed   paper
    GEM        0.061      0.06
    PER        0.101      0.09
    ORI        0.070      0.07

The test allows 0.02. It rejects unit weights, which give 0.073, 0.116 and 0.083,
but it does not pin the four weights individually: weight sets of similar
magnitude also pass. That limitation is stated in the test docstring, and the
weight values are additionally asserted against the published numbers directly.

A second check uses real table 2 rows: the paper reports that its method keeps the
Southern and Northern Taurids separate, and their tabulated means give D_X = 0.244
and 0.241 depending on order, above the 0.15 merge threshold.

The asymmetry is now documented rather than left to be discovered. The right
ascension term is scaled by cos of the first declination and the velocity term by
the first velocity, so exchanging the orbits changes the result, by 0.003 in the
Taurid case above. The paper applies the criterion to a group mean against a group
mean, where this does not matter.

One measured finding that does not support the paper's stated rationale: the
weights are described as normalising each term's contribution, but over pairs
drawn within a shower from the dispersions of table 2 the root-mean-square term
contributions span a factor of 44 with the published weights against a factor of
7.4 with unit weights, the right ascension term dominating. This is not asserted
anywhere in the code; the docstring records the weights and their source without
repeating the claim.

D_T: thresholds exist, and the paper writes it in q and e

Jenniskens (2008) publishes thresholds for both of his criteria, in section 3.2:
D_T < 0.3 with D_B < 1.0 identifies parent bodies and siblings, and D_T < 0.6 with
D_B < 1.5 also captures bodies related through an earlier fragmentation. The
docstrings previously said no threshold was published for either.

His eq. 13 writes the Tisserand parameter in terms of q and e rather than a,
"because the observational errors in these orbital elements are smaller than those
in a". calcDT now takes q rather than a, which follows the paper and also brings it
into the variables the rest of the module works in. The two forms agree to 2.1e-14
over 200000 random orbits, and the q form has a concrete advantage: at e = 1 the
semi-major axis diverges and the (a, e, i) form evaluates to nan, while eq. 13
stays finite. Both properties are tested.

T_J now has paper values behind it. Against table 1: 2P/Encke computes 3.0264
against 3.03, and 3200 Phaethon 4.5108 against 4.51.

D_B: better provenance and the published thresholds

The formula is confirmed exactly, including the 0.13, 0.06 and 14.2 deg
dispersions. The docstring now records where the invariants come from rather than
describing them vaguely: C1 from the z-component of the angular momentum and the
energy, C2 as the Lidov (1961, 1962) integral of the twice-averaged three-body
problem, C3 as the longitude of perihelion, all three via Babadzhanov (1989). It
also records the caveat the author states and does not correct for, that C1, C2
and C3 are not orthogonal.

Note on section 2.1 of Jenniskens (2008), which restates the older criteria and
should not be used as a source for them. Its eq. 9 writes the last D_H term with
(e1 - e2)/2 where (e1 + e2)/2 belongs, the same error as in the recent review; its
eq. 5 writes the D_D eccentricity term with (e1 - e2) in the denominator; and its
eq. 3 writes cos((i1 - i2)/2) in Pi_21 where cos((i1 + i2)/2) belongs. That last
one was checked against the mutual-node vector definition over 200000 pairs: the
form in this codebase agrees 100.00% of the time and the form as printed agrees
0.00% of the time, so no change is warranted. The author's own criteria, D_B and
D_T, are correct.

Its eq. 3 does independently confirm the node sign convention of the companion
bug-fix PR, stating that "the sign of arcsin should be opposite when
|Omega_1 - Omega_2| > 180 deg".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A fresh read of the three new modules turned up one wrong statement, two claims
made about papers that were never read, an argument order that invited silent
misuse, a test that would misreport half its possible inputs, and a handful of
inconsistencies. No formula changed; one criterion was added.

Wrong statement

calcRho1 claimed the angular momentum term "carries units of length while the
eccentricity term is dimensionless". Both terms are dimensionless: the first is a
length divided by L. The real point, now stated, is that the choice of L fixes how
the two are weighted against each other and so fixes the numerical scale.

Absence claims about papers that were not read

calcC and calcDVJopek both asserted "No threshold is published". Neither source
was accessible, so that was an unverifiable claim about an absence, which is the
same mistake that the previous commit corrected in D_B, D_T and D_X, where the
thresholds turned out to exist. Both now say the question is unchecked and that
none is supplied here. The rho docstrings are softened to "no published threshold
was found" for the same reason.

calcDVJopek also stated that the paper defines the weights as "the reciprocal
dispersions", which is more specific than anything that could be checked, and
contradicted the commit message that introduced it. It now says the weights are
defined in terms of the dispersions and that the definition could not be read.

calcDR gains the opposite correction: Valsecchi et al. recommend no threshold, but
Jenniskens (2008) reports D_N < 0.20, and since D_R <= D_N that is a necessary
condition on D_R. Worth a reader knowing.

Argument order

calcDX took (sol, ra, dec, vg), following the order of the paper's equation, while
calcDN, calcDR and calcDV all take (ra, dec, sol, vg). Every argument is a float,
so calling one with the other's order returns a plausible wrong number in silence.
calcDX now matches the rest of the module, with a docstring line saying why it
departs from the paper's ordering.

A test that could pass while being wrong

test_DT_reproduces_the_jenniskens_tisserand_values recovered a Tisserand parameter
as 3.0 + D_T against a reference orbit of T = 3. D_T is an absolute difference, so
that only holds for T > 3. Both objects in the test are above 3 so it passed, but
for 21P/Giacobini-Zinner, whose tabulated T_J is 2.47, the construction returns
3.53. It now checks the parameter against the table directly and checks separately
that D_T is the difference of two tabulated values.

Missing criterion

isCometaryQi's docstring recommended E-i as one of the two most reliable
two-parameter criteria, and E-i was not implemented. Added as isCometaryEi, with
calcOrbitalEnergy, GAUSS_K_SQUARED and JW_ENERGY_LIMIT. The published limit of
-5.28e-05 corresponds to a cut on the semi-major axis alone at 2.8022 AU, which
the test asserts. The eccentricity is unused and kept only so the five criteria
share a signature.

Consistency

  - D_B's dispersions were inline literals while D_X's weights and D_ACS's scale
    were named constants. They are now DB_SIGMA_C1, DB_SIGMA_C2 and DB_SIGMA_C3.
  - The 1.5 and 2 in D_V, the two coefficients that could not be verified, now
    carry a comment at the point of use rather than only a note in the PR.
  - calcDSAC returned "[float] D value" where every sibling names the criterion.
  - JW_APHELION_LIMIT and JW_KRESAK_P_LIMIT had no units.
  - Two inline "import inspect" in the tests moved to the top of the file.
  - Four line continuations inside already-open parentheses removed.

thresholdReliability now documents its cost, which grows as the number of trials
times the square of the number of orbits; a few thousand orbits is not practical
and wants a chunked search instead.

One comment removed rather than reworded: the note above the D_X weights repeated
the paper's description of them as normalising each term's contribution. The
commit that added them recorded a measurement that this does not hold, so the
comment now says the weights are reproduced as published values rather than as a
normalisation to rely on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AstroEloy
AstroEloy force-pushed the feat/additional-d-criteria branch from fd328c7 to 149d51f Compare September 18, 2026 07:19
AstroEloy and others added 8 commits September 18, 2026 09:26
Closes the second of the two omissions recorded when this branch was opened.
Tancredi (2014), Icarus 234, 66, doi:10.1016/j.icarus.2014.02.013, classifies a
single orbit as cometary or asteroidal, so it belongs in OrbitClassification.py
alongside the Tisserand and Kresak criteria.

New: classifyTancrediComet, classifyTancrediAsteroid, isTancrediResonanceProtected,
calcHillRadius, calcResonanceSemiMajorAxis, plus the constants the scheme needs.

What the criterion adds over a Tisserand cut

Applying the cometary Tisserand range to the asteroid population returns 7332
candidates, of which over 97% are on stable orbits that never approach a giant
planet. Two further conditions do the real work: the body must not be held away
from Jupiter by a mean-motion resonance, and its orbit must actually reach the
planet, expressed through the minimum orbital intersection distance. With those
the paper reports a false positive rate of 0.19 and a false negative rate of 0.05.

The upper Tisserand limit is 3.05 rather than 3, because Jupiter's orbit is not
circular and because encounters out to a few Hill radii still perturb an orbit.

Verification against the paper's own numbers

  - eq. 3 reproduces the semi-major axis at the centre of all eight resonances of
    table 1 to 7e-04, against a table quoted to three or four figures.
  - eq. 2 reproduces the Hill radii quoted for all four giant planets to 2e-03:
    Jupiter 0.3551 against 0.355, Saturn 0.4377 against 0.436, Uranus 0.4688
    against 0.469, Neptune 0.7752 against 0.776.
  - 233P/La Sagra, the one new "Comet" in an Asteroidal Orbit the paper reports,
    computes T_Jup = 3.0860 against the quoted 3.086 and classifies as
    'asteroidal orbit'.
  - The quasi-Hilda orbit the paper gives to justify the 3.05 limit, a = 4.05 AU
    with e = 0 and i = 0, computes T_Jup = 3.0492 against the quoted 3.05 and
    falls just inside the Jupiter family class, as intended.
  - 2060 Chiron classifies as a Centaur and 2P/Encke as Jupiter family, both as
    the paper has them.
  - The Hildas, in the 3:2 resonance, are recognised as resonance protected and
    so are not returned as asteroids in cometary orbits, which is the case the
    filter exists for.

Two limits, both stated in the docstrings

The two minimum orbital intersection distances are arguments rather than computed
here. Computing a MOID is a separate problem and the paper's method for it is in a
supplementary appendix that was not available. No MOID routine exists elsewhere in
wmpl either. They are taken in units of the relevant planet's Hill radius, which is
how the paper's own figures express them, and calcHillRadius is provided for the
conversion.

The libration widths used for the resonance test are the maxima tabulated in table
1. The paper computes a width that varies with eccentricity, by a method given in
the same unavailable appendix, so an orbit near the edge of a resonance may be
called protected here when the paper would not. Named in the docstring so the
difference is visible at the point of use.

One further note on the paper: the second form of eq. 1, which rewrites the
Tisserand parameter in terms of q and Q, is missing the planet's semi-major axis
inside the radical as printed and evaluates to 4.689 where the first form gives
3.030. The first form is the one implemented, in calcTisserand, and is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The supplementary appendices became available, so the two approximations noted
when the Tancredi classification was added are gone. The criterion now runs from
orbital elements alone.

Appendix A: the minimum orbital intersection distance

calcMOID minimises the distance between a point on each orbit over the two
eccentric anomalies. That surface carries more than one local minimum, so the
search is restarted from the three further points the appendix prescribes and the
smallest of the four results is taken. calcGiantPlanetMOIDs wraps it over the four
giant planets and returns each distance in units of that planet's own Hill radius,
which is the unit the criterion compares against, closing the loop that previously
required the caller to supply both MOIDs.

Verified on the orbits of figure A.1, which the paper chose because the surface has
a second minimum that a single search falls into: the four-start search returns
0.240830 AU against 0.240833 from a 600 by 600 grid over both anomalies. Two
coplanar circles of radius 1 and 1.2 give exactly 0.2, an orbit is at zero distance
from itself, and the result does not depend on which orbit is given first.

Also added calcMaxPerihelionForTisserand and calcMinMOIDForTisserand, which trace
the edge of the forbidden region in the Tisserand against MOID plane. Eq. A.1 is
solved directly rather than through the cubic of eq. A.3 that it squares to, since
that cubic carries two spurious roots: at T = 3.3 its roots are 1.937, 0.551 and
0.234, of which only the middle one satisfies the original equation. The bound
closes to zero at T = 3, where the orbit just touches the planet's, and widens
above it, both asserted.

Appendix B: the libration widths

calcResonanceWidth computes the half width of a resonance from the eccentricity
through the pendulum model, replacing the tabulated maxima used as a fixed width
before. Supporting it are calcLaplaceCoefficient, evaluated from its integral
definition rather than the truncated series of eq. B.8 so that it stays exact as
alpha approaches 1, calcLaplaceDerivative using the Murray & Dermott recursions,
and calcDisturbingFunctionTerm for the four orders of table B.1. The coefficients
depend only on the resonance, so they are cached.

Verified against the widths tabulated in table 1 of the main text, evaluated at
the top of the eccentricity range the paper considers reliable:

    resonance   order   computed   table 1   ratio
    4:1           3      0.00754    0.0075   1.005
    3:1           2      0.02873    0.0287   1.001
    5:2           3      0.02598    0.0260   0.999
    7:3           4      0.02146    0.0215   0.998
    2:1           1      0.11274    0.1127   1.000

One reading of eq. B.6 had to be settled to get the last of those. Its final term
divides by j2, the second integer of the critical angle, which is negative since
j2 = -p. Taking its magnitude gives 0.10927, 3% below the tabulated value, and
would require e = 0.3183 to match, outside the stated range. Carrying the sign
through gives 0.11274 at e = 0.3, the same eccentricity that reproduces the other
four. The signed reading is therefore the intended one, and the code says so where
it is used.

The eccentricity is held to [0.01, 0.3] as the appendix requires, since the
expansion diverges below and is unreliable above; outside that range the width is
held at the value on the nearer edge, which is asserted.

The three outer resonances keep the fixed widths the paper adopts for them rather
than computed ones, which is the paper's own choice where the expansion no longer
holds, not an approximation introduced here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the last unverified formula in this branch. Jopek, Rudawska & Bartczak
(2008), EM&P 102, 73, doi:10.1007/s11038-007-9197-8, became available, and it
changes the implementation as well as the documentation.

A real error: the units were wrong

The paper defines the vectorial elements "in the units AU, AU/day and the mass of
the Sun", with mu = k^2. This module computed them with mu = 1, which is
self-consistent but puts the angular momentum and the energy on a different scale
from the one the published weights were measured on. Since the weights are
dimensional, the two cannot be mixed. The angular momentum now carries a factor of
k and the energy a factor of k^2.

How far off that was: for a Geminid-like orbit the paper's tabulated energy
dispersion is 0.91% of the energy in the paper's units, and 0.00027% of it in the
units used here before. Only the first is a credible spread.

The weights are published, and are now the defaults

Eq. 5 defines them as w = (2*sigma)^-2, the reciprocal squared dispersion of each
element within a stream, so a pair differing by twice the dispersion in one element
contributes exactly 1 to the sum. That is what fixes the scale of the criterion and
makes its thresholds meaningful.

Table 1 gives the dispersions for a stream at five ages and for the sporadic
background, all now in DV_DISPERSIONS, with calcDVWeights turning any of them into
weights. The default is the 4000 year set, which is what the paper used for its own
search. w_h, w_e and w_E become keyword arguments rather than required ones.

The earlier docstring said the paper defined the weights as reciprocal dispersions,
then a later commit softened that to "in terms of the dispersions" because it could
not be checked. The original wording was the closer of the two.

The 1.5 and the 2 are deliberate, not transcription noise

Both were flagged here as unverified. The paper states the reason: the energy is
invariant under the principal secular perturbation and the third angular momentum
component is semi-invariant, so it weights those two up beyond what their
dispersions alone would give.

Thresholds are published

Table 2 gives them at the 99% reliability level against the smallest accepted group
size, now in DV_THRESHOLDS. The docstring previously said whether any threshold was
published was unchecked.

The column is headed "D_V x 10^-1", which has to be read as the printed figures
being D_V scaled down by ten, so the thresholds run from 2.414 to 3.128 rather than
0.2414 to 0.3128. Simulating pairs drawn from one stream using the dispersions of
table 1 settles it: 97.1% of same-stream pairs fall below 3.128, which is what an
association threshold at 99% reliability should do, while 0.0% fall below 0.3128.
Both readings are asserted in the tests so the choice is visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reference list of Jopek, Rudawska & Bartczak (2008) identifies the volume as
the proceedings of the US/European Celestial Mechanics Workshop held in Poznan in
July 2000, pages 365 to 366, and dates it 2001 where other citations give 2002.
Both are now recorded, since the volume itself is still not accessible here and the
discrepancy is worth a reader knowing.

That paper also describes the criterion in its own words, as the difference of the
orbital momentum vectors per unit mass of two orbits, which is what is implemented
here. That is corroboration from a second source rather than verification against
the original, and the docstring keeps saying so.

Worth noting for anyone chasing the Drummond reference: the same reference list
gives Icarus 47, 500 to 517 for Drummond, which is "Earth-orbit-approaching comets
and their theoretical meteor radiants", not the paper that defines D_D. That is
Icarus 45, 545 to 553. A third source getting the Drummond citation wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The right-ascension term of D_X put cos(dec1) inside the sine argument rather
than multiplying the sine. Rudawska et al. (2015) eq. 2 writes the term as
[2 sin((a1 - a2)/2) cos(d1)]^2, where cos(d1) scales the chord. The two forms
agree to first order in the radiant separation, which is why no test in the
suite could tell them apart: on the PR's own Taurid-branch case the difference
is 0.00%. They diverge for widely separated radiants, reaching +41% at 180 deg
of right ascension and 60 deg of declination, so the old form systematically
overstated D_X for dissimilar pairs. A new test compares the isolated term
against the published closed form at a separation large enough to distinguish
them, and fails on the previous form.

Two guards in the Tancredi classifier:

- calcTisserand returns nan for an unbound orbit, where the parameter is not
  defined. Every comparison against nan is False, so classifyTancrediComet
  reached 'unclassified' by falling through every branch rather than by
  recognising the case. It now tests for it, and calcTisserand documents the
  nan, pointing at tisserandParameterJupiter as the scalar equivalent that
  returns None instead.
- The class intervals were open at both ends, so an orbit landing exactly on
  T = 2 or T = 3.05 matched no branch and came back 'unclassified'. They are
  now half open.

Documentation corrections found while checking the formulas against their
sources:

- The 3 AU scale of D_ACS is printed in eq. 2 of Asher, Clube & Steel (1993),
  which reads D^2 = ((a1 - a2)/3)^2 + (e1 - e2)^2 + (2 sin((i1 - i2)/2))^2. It
  is a published constant, not one inferred from their table 1.
- calcC builds the angular momentum as sqrt(p) while calcDVJopek builds it as
  k*sqrt(p), so the two are on scales differing by the Gaussian constant. The
  convention Neslusan uses could not be established, which matters for anyone
  comparing a C value against a published one.
- calcMOID takes the semi-major axis where every criterion in this module takes
  the perihelion distance, with otherwise identical signatures.
- calcGiantPlanetMOIDs returns a dictionary, not the two scalars
  classifyTancrediAsteroid expects.
- calcDR takes scalars only, since calcVgComponents is written with the math
  module; and its w1 keyword is a term weight, not an argument of perihelion.
- The HILL_RADIUS_* constants are the paper's values rather than output of
  calcHillRadius, which is why that function has no call site.

Comments added to the functions carrying bare transcribed coefficients: the
table B.1 polynomials, the Laplace coefficient recursion and its cache, the
four Powell restart points of the MOID search, and the 3-1-3 rotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ction

Dcriteria.py imported A_JUPITER, GAUSS_K and GAUSS_K_SQUARED from
OrbitClassification.py, whose own docstring says the two modules are kept
apart, and dragged scipy.integrate and scipy.optimize into the import of a
module that needs neither. The three constants now live in
wmpl/Utils/OrbitConstants.py, which imports nothing, and OrbitClassification
re-exports them so existing imports keep working. Dcriteria imported on its own
no longer loads OrbitClassification or scipy.

The Gaussian gravitational constant was written out in four places. Orbit.py
had it inline as a bare literal and MeanOrbit.py defined its own copy; both now
take it from the shared module, MeanOrbit keeping GAUSS_GRAVITY_CONST as an
alias for the callers that import that name.

Jupiter's semi-major axis is deliberately NOT unified, which is worth recording
because it looks like an oversight. OrbitClassification uses 5.20336 AU and
REBOUND.py and Orbit.py use 5.204267 AU. Substituting the latter moves the 1:1
resonance to 5.2043 AU and misses the 5.203 that Tancredi (2014) tabulates, so
the test reproducing that table fails. The two values belong to different
sources; the difference is 1.7e-4 relative and shifts a Tisserand parameter by
about 1e-4, far below any class boundary. Both constants now say so.

tisserandParameterJupiter and calcTisserand now cross-reference each other,
along with the two classifiers built on them, which split at 3 and at 3.05 and
return different vocabularies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in the REBOUND work merged as wmpg#98, wmpg#99 and wmpg#100, including the wmpl/Docs
folder this branch adds pages to.
…holds

Three pages in wmpl/Docs, following the format of the REBOUND manual, plus the
index entry for each.

OrbitSimilarity.md covers the sixteen pairwise criteria: a table giving each
one's inputs, whether it is a metric, its published threshold or the absence of
one, and its source; then sections on the classical family, the Kholshevnikov
metrics, the geocentric criteria and the two Taurid Complex forms. The pitfalls
section carries the things that actually cost people results: that thresholds
transfer neither between criteria nor between datasets, that only the rho
family satisfies the triangle inequality, that D_D divides by zero for circular
orbits, and that a self-comparison lands near 1e-8 rather than at zero.

OrbitClassification.md covers the single-orbit criteria and the Tancredi
scheme, and points out where the repository already has a Tisserand
implementation with a different Jupiter constant and a different class
vocabulary. It also says plainly which parts are expensive, since calcMOID runs
Powell four times and calcGiantPlanetMOIDs calls it four more.

DThresholds.md leads with the warning that the traditional Southworth & Hawkins
threshold has a measured coincidental-pair probability of 1.0 for bolide
samples, so it cannot support a claim of association, and points at
thresholdRandomPairing instead.

Every page ends with a verification table recording which formulas were checked
against a primary source, which were confirmed only indirectly, and which could
not be reached at all. All eight code examples were executed against the working
tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dvida

dvida commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Review pass: 4 commits pushed to this branch

Reviewed with every formula checked against its primary source where one could be reached. 78/78 tests passed as submitted; 214 pass now across Utils, Rebound and MetSim.

The headline: calcRho1/2/5, the centrepiece of this PR, are exactly right. I pulled Kholshevnikov et al. (2016) MNRAS 462, 2275 and compared term by term — eqs 11, 15 and 22 match the implementation character for character, and ρ₅'s quotient space matches its docstring. The triangle-inequality and circular-orbit arguments in the description hold.

One real bug, in calcDX.

Rudawska et al. (2015) eq. 2 writes the right-ascension term as [2 sin((α₁−α₂)/2) · cos δ₁]²cos δ₁ scales the chord. The code had it inside the sine:

term_ra = w_ra*(d_vg + 1.0)*(2*np.sin((ra1 - ra2)/2.0*np.cos(dec1)))**2
#                                                 ^ cos(dec1) inside sin()

The two agree to first order in the radiant separation, which is exactly why the suite could not catch it:

ΔRA δ₁ old correct error
20° 60° 0.03038 0.03015 +0.8%
90° 50° 1.0597 0.9958 +6.4%
180° 60° 1.5492 1.0954 +41.4%

All 78 tests still passed with it fixed, and on your own Taurid-branch case the difference is 0.00%. So the claim that every criterion was checked against a paper value does not quite hold for this one — the within-shower regime cannot distinguish the forms. 71764f7 fixes it and adds a test at a separation large enough to tell them apart, which fails on the old form.

In fairness to the practical impact: the error only grows for widely separated radiants, which sit far above the 0.15 merge threshold anyway, so shower-search outcomes are unlikely to change.

One claim in the description is more favourable than you thought

The D_ACS scale of 3 AU is published. The description devotes a section to inferring it from table 1 via a bounding argument. I read eq. 2 from the ADS scan of Asher, Clube & Steel (1993):

D² = ((a₁ − a₂)/3)² + (e₁ − e₂)² + (2 sin((i₁ − i₂)/2))²

It is printed right there. The elaborate inference was unnecessary — and it happens to have landed on the correct value. 71764f7 records the provenance at the constant. Their eq. 1 (Steel, Asher & Clube 1991) is also confirmed with no scale factor, q₁ = 0.375, e₁ = 0.82, i₁ = 4°.

71764f7 — other correctness items

  • calcTisserand returns nan for an unbound orbit, and every comparison against nan is False, so classifyTancrediComet reached 'unclassified' by falling through every branch rather than by recognising the case. Now explicit, and documented.
  • The Tancredi class intervals were open at both ends, so an orbit landing exactly on T = 2 or T = 3.05 matched no branch. Now half open, with a test that fails on the old form.
  • Documented: calcMOID takes a where every criterion here takes q, with otherwise identical signatures; calcGiantPlanetMOIDs returns a dict, not the two scalars classifyTancrediAsteroid wants; calcDR is scalars-only and its w1 is a weight, not an argument of perihelion; the HILL_RADIUS_* constants are the paper's values, which is why calcHillRadius has no call site.
  • calcC builds the angular momentum as √p while calcDVJopek builds it as k√p — the same physical quantity on scales differing by 58×. Neslušan (2002) is a 2000 workshop proceedings chapter I could not get behind; the code now says the convention is unresolved, which matters for anyone comparing a C value against a published one.

97e31b4 — layering, and a constant that should not be unified

Dcriteria.py imported three constants from OrbitClassification.py, whose own docstring says the two are kept apart, and dragged scipy.integrate/scipy.optimize into a module needing neither. They now live in a new wmpl/Utils/OrbitConstants.py that imports nothing; OrbitClassification re-exports them so nothing breaks. Verified: Dcriteria imported standalone no longer loads OrbitClassification or scipy.

The Gaussian constant appeared in four places — Orbit.py had it as a bare literal, MeanOrbit.py kept its own copy. Both now take it from the shared module.

I tried to unify Jupiter's semi-major axis and backed out. OrbitClassification uses 5.20336, REBOUND.py and Orbit.py use 5.204267. Substituting the latter moves the 1:1 resonance to 5.2043 and misses the 5.203 in Tancredi's table 1 — test_tancredi_resonance_semimajor_axes_match_table1 fails. The two belong to different sources and are not interchangeable. Both constants now say so, so the next person does not "fix" it.

1a95d17 — three manuals in wmpl/Docs/

OrbitSimilarity.md, OrbitClassification.md and DThresholds.md, following the REBOUND manual's format, indexed from the docs README. Each ends with a verification table recording what was checked against a primary source, what was confirmed only indirectly, and what could not be reached — so a later reader can tell the difference. All eight code examples were executed against the working tree.

The branch also merges current master, which brings in the wmpl/Docs folder these pages live in.


What I could not verify

Stated plainly rather than left to look checked:

Source Outcome
Kholshevnikov et al. (2016) Read — ρ₁, ρ₂, ρ₅ verified
Asher, Clube & Steel (1993) Read (ADS scan) — eqs 1 and 2 verified, including the 3 AU scale
Jopek & Bronikowska (2017) Read (arXiv:1609.03968) — all 12 threshold coefficient pairs and the validity ranges verified exactly
Courtot, Shober & Vaubaillon (2026) review Read — D_SH, D_D, D_H, D_R, D_N, D_B, D_T, D_V and D_X verified, including D_V's asymmetric 1.5× on h_Z and 2× on the energy term
Tancredi (2014) Unreachable. Paywalled; the author's own site no longer resolves and the Wayback Machine has no capture. The 3.05 limit and the class structure are corroborated by a 2025 paper Tancredi co-authored, but table 1, the Hill radii, table B.1 and eqs A.1 and B.5–B.7 are unverified. The implementation reproduces the tabulated resonance centres and widths to the printed precision, which is strong indirect evidence, not a check.
Jenniskens (2008), Icarus 194, 13 Unreachable. So I cannot confirm or refute the description's claim that its eqs 3, 5 and 9 contain three typos. That claim is currently unsupported in the PR as well, and worth softening or sourcing. Also: Icarus 194, 13 is titled "Meteoroid streams that trace to candidate dormant comets"; "Mostly dormant comets…" is a different 2008 Jenniskens paper in EM&P 102, 505. Worth checking which one the formulas came from.
Neslušan (2002) Unreachable. Springer proceedings chapter, paywalled. The k-factor question above stays open.

Two smaller notes

  • Test naming. This PR and Fix the D_H node sign convention and guard the acos domain in Dcriteria #96 use test_snake_case; every other test in wmpl uses testCamelCase. Renaming 78 + 7 tests is your call, so I left them and matched the local style in the ones I added.
  • None of the twelve new criteria are wired into the __main__ CLI, which still exposes only D_SH, D_D and D_H. Probably deliberate, but worth a decision.

For the record, I also checked #96 against this: after both merge there is no inconsistency. Π₂₁ and the ρ family's angle P are two different, well-defined angles related by cos P = cos u₁ cos u₂ + sin u₁ sin u₂ cos I₂₁, each correct for its own criterion, and #96's fix makes calcDSH reproduce Jopek's mutual-node definition to 3e-11 rad over 200,000 random pairs.

🤖 Generated with Claude Code

@dvida

dvida commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

@AstroEloy — could you review the four commits I pushed to this branch (e6e3afb..1a95d17) before I merge? Details are in the review comment above; this is the short list of what actually needs your judgement rather than just a look.

Please check these three, where I changed behaviour or contradicted the description:

  1. The calcDX fix (71764f7). I read the right-ascension term as [2 sin((α₁−α₂)/2)·cos δ₁]² and moved cos δ₁ out of the sine argument. You have the primary source — does that match your reading of eq. 2? The old and new forms agree to first order, so the existing tests pass either way, which is why it slipped through.

  2. The D_ACS scale. It is printed in eq. 2 of Asher, Clube & Steel (1993), which reads D² = ((a₁−a₂)/3)² + (e₁−e₂)² + (2sin((i₁−i₂)/2))². Your bounding argument from table 1 landed on the right value, but it was not needed — worth trimming that section from the description so it does not read as an inference.

  3. Jupiter's semi-major axis is deliberately not unified. I tried folding A_JUPITER = 5.20336 into the 5.204267 that REBOUND.py and Orbit.py use, and test_tancredi_resonance_semimajor_axes_match_table1 fails — 5.20336 is what reproduces Tancredi's table. I documented the split at both constants instead. If you know which value Tancredi actually states, that would settle it properly.

Two open questions I could not close:

  1. The Jenniskens (2008) typo claim. The description asserts its eqs 3, 5 and 9 are misprinted. Icarus 194, 13 is paywalled and I could not reach it, so that claim is unsupported in the PR. Could you cite the page, or soften it? Related: Icarus 194, 13 is "Meteoroid streams that trace to candidate dormant comets""Mostly dormant comets…" is a different 2008 Jenniskens paper (EM&P 102, 505). Worth confirming which one the D_B and D_T formulas came from.

  2. The Neslušan C scale. calcC builds the angular momentum as √p, calcDVJopek as k√p — a factor of 58 between two functions in the same module. The 2000 proceedings chapter is behind a paywall. If you have it, does Neslušan carry the k?

Things I changed that you may simply disagree with:

  1. The PR is no longer purely additive. It now adds wmpl/Utils/OrbitConstants.py and touches Orbit.py, MeanOrbit.py and REBOUND.py, to stop Dcriteria importing OrbitClassification (and with it scipy) and to define the Gaussian constant once. Happy to split that into its own PR if you would rather keep this one clean — the description still says +N/−0.

  2. Test naming. This PR and Fix the D_H node sign convention and guard the acos domain in Dcriteria #96 use test_snake_case; the rest of wmpl uses testCamelCase. I left yours alone and matched local style in the tests I added. Your call whether to rename.

  3. None of the twelve new criteria are exposed in the __main__ CLI, which still offers only D_SH, D_D and D_H. Deliberate?

The branch also merges current master, so it carries the wmpl/Docs folder that the three new manuals live in. 214 tests pass, and it still merges cleanly with master and with #96.

🤖 Generated with Claude Code

D_SH and D_H choose the branch of an arcsine from the raw difference of the two
ascending nodes, so the convention holds only when both are given in [0, 360).
A node written as 710 rather than 350 selects the other branch and returns a
different value.

This is the limitation documented by the companion fix in wmpg#96, and it belongs
here because this is the page a user reads before choosing a criterion. The
criteria built on vectors instead of an arcsine, the rho family and
calcDVJopek, have no branch to choose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
71764f7 added test_DX_right_ascension_term_scales_the_chord_not_the_angle,
test_tancredi_class_limits_are_half_open and
test_tancredi_unbound_orbit_is_not_silently_unclassified, but did not add them to
the list the module's standalone runner iterates over. pytest discovers tests by
name and ran all three; the standalone path the module docstring documents ran
neither, so it reported 64 tests where 67 exist.

The one that matters most is the first: it is the regression test for the D_X
right-ascension fix in that same commit, and the form it guards against is the one
that was in the tree. All three pass; the runner now reports 67.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AstroEloy

Copy link
Copy Markdown
Contributor Author

Checked all four commits. The review is right on the substance, including on the
one item where it contradicts me. Answers to the numbered points below, and one
defect found in the review commits themselves.

First: three of the tests added in 71764f7 were never running

71764f7 added test_DX_right_ascension_term_scales_the_chord_not_the_angle,
test_tancredi_class_limits_are_half_open and
test_tancredi_unbound_orbit_is_not_silently_unclassified, but did not add them
to the list the module's standalone runner iterates over. pytest discovers by name
and ran all three; python -m wmpl.Utils.Tests.test_DcriteriaAdditional, the path
the module docstring documents, reported 64 tests where 67 exist.

The one silently skipped was the regression test for the calcDX fix in that same
commit — guarding against the form that was in the tree until that commit landed.
All three pass. Fixed in 0e84f10; the runner now reports 67, so 81 across both
files.

1. calcDX — you are right, I was wrong

Accepted. The decisive argument is not textual but geometric: every other term in
eq. 2 is a chord, 2 sin(Δ/2). cos δ is what turns a difference in right
ascension into a true angular distance, and it does that by scaling the chord.
sin(Δα·cos δ / 2) is not the chord of anything.

Your figures reproduce exactly — at ΔRA = 180°, δ = 60° the two forms differ by
41.4%. And your diagnosis of why it slipped through is the right one: the
within-shower regime cannot separate the forms, so my claim that every criterion
was checked against a paper value did not in fact cover this one. That was an
overstatement on my part, not just a gap in coverage.

2. D_ACS — agreed, and the description is corrected

You are right that the /3 is printed in eq. 2 and that the description read as
though it had to be inferred. Description updated: the scale is now stated as
published, with the bounding argument kept and relabelled as an independent
cross-check rather than a determination.

Worth keeping rather than deleting, for one reason: it is the only way the paper's
own table 1 can be brought to bear at all, since the table cannot be recomputed
from its own columns. But it is a check, not the source, and it should not have
been framed as one.

3. Jupiter's semi-major axis — settled, and Tancredi does state it

He states it implicitly but unambiguously. Table 1's 1:1 row is the value: eq. 3
gives a_res = (p/(p+q))^(2/3) · a_P, which for 1:1 is just a_P, and table 1
lists the 1:1 resonance at 5.203 AU.

So 5.203 is Tancredi's value, A_JUPITER = 5.20336 is correct for this module, and
the split from the 5.204267 in REBOUND.py and Orbit.py is a real difference of
sources rather than an inconsistency. Your decision to document the split instead
of unifying it was correct, and it now has a citation behind it rather than only a
failing test.

4. Jenniskens — the paper is the right one, and the typo claim is sourceable

Which paper: Icarus 194, 13, "Meteoroid streams that trace to candidate dormant
comets"
. That is what every docstring cites, with the matching DOI
10.1016/j.icarus.2007.09.016. The string "Mostly dormant" appears zero times
anywhere in this PR — code, tests or description — so there is nothing to correct.
D_B and D_T come from its eqs 15 to 18, and the σ values 0.13, 0.06 and 14.2°
from the paragraph below eq. 17.

The typo claim is in section 2.1 of that same paper, which restates the older
criteria before introducing the author's own. Three places:

  • eq. 3 writes cos((i₁−i₂)/2) in Π₁₂ where cos((i₁+i₂)/2) belongs
  • eq. 5 writes the D_D eccentricity term with (e₁−e₂) in the denominator
  • eq. 9 writes the last D_H term with (e₁−e₂)/2 where (e₁+e₂)/2 belongs

Eq. 3 is settled numerically rather than by reading. Against the mutual-node
definition of Π, over 100,000 random orbit pairs:

form agreement
cos((i₁+i₂)/2), as in this codebase 100.00%
cos((i₁−i₂)/2), as printed in eq. 3 0.00%

So no change is warranted, and the claim is not unsupported — it just was not
sourced in the description. Happy to add the section and equation numbers there if
you want it on the record in the PR rather than only in this thread.

The author's own criteria in that paper, D_B and D_T, are correct. It is only
its restatement of prior work that is unreliable, which is an argument for the
approach this PR took of verifying each formula against the paper that introduced
it.

5. The Neslušan k-factor — agreed, stays open

Your documentation of it is the right outcome. The one thing I can add is negative:
Jopek, Rudawska & Bartczak (2008) describe the criterion in their introduction as
"the difference between the orbital momentum vectors (per unit mass) of two orbits"
without fixing the units, so that paper does not settle it either. The 2000
proceedings chapter remains the only source that would.

6, 7 and 8 — still with @AstroEloy

These are calls I should not make on his behalf, so flagging rather than answering:
whether to split the OrbitConstants layering into its own PR, whether to rename
the 81 tests here and the 7 in #96 to testCamelCase, and whether to expose the
twelve criteria in the __main__ CLI.

On 6 specifically, the description has been corrected either way: it no longer
claims a purely additive diff and now states the real contribution against
upstream/master as 13 files, +4467 / −3.


81 tests pass here, and the branch still merges cleanly.

🤖 Generated with Claude Code

The CLI computed D_SH, D_D and D_H while the module now defines sixteen criteria.
All of them are now reported, grouped by what they need, along with the published
threshold for each or a note that none exists.

Criteria on the orbital elements, always computed: D_SH, D_D, D_H, rho_1, rho_2,
rho_5, C, D_V, D_B, D_T, D_ACS and D_SAC. The semi-major axis that D_ACS and the
classification take is derived as q/(1 - e).

Criteria on the geocentric radiant and speed, when those are available: D_N, D_R
and D_X, plus the Vida D_V when a corrected heliocentric direction is present.
They come from a second trajectory file, or from the new --ra, --dec, --sol and
--vg arguments. The four have to be given together; giving some of them warns and
skips the group rather than computing a wrong number from defaults.

The dynamical classification of each orbit is reported too, side by side: the
Tisserand parameter, Kresak K and P, the aphelion distance, the four Jopek &
Williams two-parameter tests and the Tancredi class.

Two details that would otherwise bite:

The orbit stores the geocentric and heliocentric speeds in m/s while every
criterion here takes km/s, so both are divided by 1000 on the way in. Running the
same pair through the two paths, once from a pickle and once through --vg in km/s,
gives identical D_N, D_R and D_X, which is what checks the conversion.

The classification functions are imported inside __main__ rather than at module
level, so that importing Dcriteria still does not pull in OrbitClassification, the
decoupling introduced in 97e31b4. Verified: OrbitClassification is still absent
from sys.modules after importing Dcriteria.

Also fixed: traj_path2 was a required positional, so the manual entry its own help
text describes was unreachable, and the code that guards it with "is not None"
could never take that branch. It now takes nargs='?', which is what the rest of
the block was already written for.

The dead code after sys.exit() at the end of the block is left alone.

Exercised in four modes: two trajectory files, one file with manual elements, one
file with manual elements plus the four radiant arguments, and a partial set of
radiant arguments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AstroEloy

Copy link
Copy Markdown
Contributor Author

Decisions on 6, 7 and 8, from @AstroEloy.

6 — the constants work stays here

No separate PR. Keep OrbitConstants.py and the three touched files on this
branch.

One correction to the justification, though, because it does not hold as stated.
The review says "Dcriteria imported standalone no longer loads
OrbitClassification or scipy"
. The first half is right and I re-verified it. The
second is not:

import wmpl                          -> scipy loaded: True  (512 submodules)
import wmpl.Utils.OrbitConstants     -> scipy loaded: True
import wmpl.Utils.Dcriteria          -> scipy loaded: True

wmpl/__init__.py runs pkgutil.walk_packages and imports the whole library, so
any absolute import of a wmpl submodule drags scipy in regardless of what
Dcriteria itself imports. The decoupling from OrbitClassification is real and
holds — that module is genuinely absent from sys.modules — but the scipy benefit
is currently nullified by the package's own __init__. Worth knowing before it is
cited as a reason elsewhere.

The description has already been corrected: it no longer claims a purely additive
diff, and states the real contribution against upstream/master as
13 files, +4467 / −3.

7 — test names stay as they are

Your note says you "matched the local style in the ones I added", but the three
tests in 71764f7 are test_DX_right_ascension_term_scales_the_chord_not_the_angle,
test_tancredi_class_limits_are_half_open and
test_tancredi_unbound_orbit_is_not_silently_unclassified — all with underscores.
You matched the file, not wmpl. So nothing is mixed: 81 tests here and 7 in #96,
all in one style, 0 in camelCase.

Given that, renaming 88 names buys nothing functional and the files are internally
consistent, so they stay. If the project would rather converge later, it is a
mechanical rename and better done across the suite at once than in this PR.

8 — every criterion is now in the CLI (f05d352)

It was not deliberate. The CLI computed three criteria while the module defines
sixteen; all sixteen are now reported, grouped by what they need, each with its
published threshold or a note that none exists.

  • Orbital elements, always: D_SH, D_D, D_H, rho_1, rho_2, rho_5,
    C, D_V, D_B, D_T, D_ACS, D_SAC
  • Radiant and speed, when available: D_N, D_R, D_X, and the Vida D_V
    when a corrected heliocentric direction is present. These come from a second
    trajectory file or from new --ra, --dec, --sol, --vg arguments
  • Classification of each orbit, side by side: Tisserand, Kresak K and P,
    aphelion, the four Jopek & Williams tests, and the Tancredi class

Two things worth flagging:

A unit trap. Orbit stores v_g and v_h in m/s; every criterion here takes
km/s. Both are divided by 1000 on the way in. Running the same pair through both
paths — once from a pickle, once through --vg in km/s — gives identical D_N,
D_R and D_X, which is what checks it.

The layering is preserved. The classification functions are imported inside
__main__, not at module level, so importing Dcriteria still does not pull in
OrbitClassification. Verified after the change.

One pre-existing bug fixed along the way: traj_path2 was a required
positional, so the manual entry its own help text describes was unreachable, and
the if cml_args.traj_path2 is not None branch below could never be taken. It now
takes nargs='?', which is what that code was already written for.

The dead code after sys.exit() is left alone, as before.

Also: the three tests from 71764f7 were not running (0e84f10)

Flagged in my previous comment — they were absent from the list the standalone
runner iterates, so python -m wmpl.Utils.Tests.test_DcriteriaAdditional reported
64 of 67. Among them was the regression test for your own calcDX fix. Fixed; the
runner now reports 67.


81 tests pass, the CLI was exercised in four modes, and the branch still merges
cleanly.

🤖 Generated with Claude Code

@AstroEloy

AstroEloy commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

WIP. I will:

  • add cli for OrbitClassification
  • improve Dcriteria's cli to i) handle two manually introduced orbits, ii) allow user to indicate the criterion (or criteria) that want to use, iii) remove threshold information from the Dcriteria cli output, and iv) allow user to disable OrbitClassification when using Dcriteria.
  • explore if it is possible and deserible to include orbital classification from NEOMOD and METEOMOD

AstroEloy and others added 3 commits September 20, 2026 14:17
Take both orbits from wherever the user has them. The first orbit is now
either a trajectory pickle or --q1 --e1 --i1 --peri1 --node1, exactly as the
second one already was, so the tool works on published elements with no
pickle in sight. The unnumbered -q -e -i -p -n keep working as aliases of
the second orbit's arguments, so existing command lines are unaffected.

Drive the printing from a registry of (key, label, callable), which gives
-c/--criterion its list of keys for free. Selecting a subset matters once
sixteen criteria are printed and only one is wanted.

Print no thresholds. The community has not settled on one way to derive
them: the value depends on the number of orbits involved, and for a single
pair it is not well defined at all. Printing a number next to each criterion
implies a decision this tool is not entitled to make. The candidate methods
survive as a comment above the registry, and wmpl.Utils.Dthresholds
implements them for a caller who has a sample to derive one from; nothing
here imports it.

Add --no-classification to skip the dynamical classification, and --quiet to
print one 'KEY VALUE' line per criterion and nothing else, so the tool can be
called in a loop. OrbitClassification is imported inside __main__, so that
importing Dcriteria as a module still does not pull in scipy through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reading the paper itself rather than a summary of it settles three things.

The K criterion is Whipple's (1954), eq. 3 of the paper, not Kresak's; Kresak
contributed P and Q. Rename calcKresakK to calcWhippleK so the attribution
in the name is right before anything depends on it. The form with the
semi-major axis is confirmed: the paper offers Pribram and Neuschwanstein,
two meteorites of asteroidal origin, as the case where K fails, quoting
K ~ 0.08, and only this form reproduces that. Record the paper's own caveat,
that K is inconclusive for short-period orbits of low eccentricity, in the
docstring: it produced 16.4 per cent exceptions among near-Earth asteroids
against 3.9 for Q, so an orbit that lands near zero has not been classified.

The T-i limit of 0.58 is reconciled. The paper's eq. 10 defines T as
1/a + 2*a_J^-1.5*sqrt(a*(1 - e^2))*cos(i), which is the Tisserand parameter
divided by a_J and so carries units of 1/AU. Scaling by a_J puts the limit at
3.018, the familiar Jupiter family cut, and comparing a value of
calcTisserand against 0.58 as printed would have called every orbit cometary.

With the limit settled, add isCometaryTi, which completes the five criteria
of the paper, and print T-i alongside the other four.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AstroEloy and others added 3 commits September 20, 2026 14:43
Classifying one orbit meant running Dcriteria against a second orbit that had
nothing to do with the question, and reading the classification block out of
its output. Give the module its own command line instead.

It takes the orbit from a trajectory pickle or from elements, and accepts the
size as either a perihelion distance or a semi-major axis, since the criteria
are written in terms of a and asteroid catalogues quote it while meteoroid
work quotes q. The node and the argument of perihelion are optional: they are
needed only for the distances to the giant planets, and so for the Tancredi
classification of an asteroid, which is skipped without them.

Stop on an unbound orbit rather than compute a semi-major axis from it. Every
criterion here is a function of a, and dividing by 1 - e with e above 1 would
print numbers that read as classifications.

--quiet prints one 'KEY VALUE' line per quantity, matching the Dcriteria
command line, so the two can be called the same way in a loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dcriteria and ParentBodySearch each named the criteria their own way.
Dcriteria offered sixteen keys through -c and printed labels like D_SH;
ParentBodySearch had an if/elif over three lowercase strings and printed
whatever the user typed next to the column, so -d dsh produced a column
headed 'D crit dsh' and a typo silently produced D_SH results under the
typo's name.

Move the registry of criteria on the orbital elements out of the Dcriteria
command line to module level and add getElementCriterion, so that both
modules read the same list. A key now means the same criterion and prints the
same label in both, and ParentBodySearch reaches all twelve element criteria
rather than three. The command line stops on a key it does not know instead
of relabelling a D_SH run; findParentBodies keeps its warn and fall back, so
a caller sees no change.

The registry passes the two orbits as (q, e, i, node, peri) tuples, since the
criteria do not agree on an argument order: some read all five elements, D_B
drops the perihelion distance and D_ACS reads the semi-major axis instead. A
test pins each packing against the direct call, because a mistake there would
be silent.

D_ACS made the semi-major axis from q/(1 - e), which raises on the roughly
1800 orbits of exactly e = 1 in the comet catalogue that ParentBodySearch
sweeps. Give it infinity instead, which is the right value for a difference
of semi-major axes against an unbounded orbit, and skip bodies whose criterion
is not finite so they never rank as a match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The keys and labels live in one place and are read from two. Nothing at
either end said so, so an edit to a label in Dcriteria would quietly change
what ParentBodySearch accepts on -d and prints above its results column.

Put a short warning where someone about to make that edit will be reading:
the module docstring of ParentBodySearch, its findParentBodies argument, and
getElementCriterion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AstroEloy

AstroEloy commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

This branch is done from my side.

While working on this I also explored whether source-region models (NEOMOD, METEOMOD) could be plugged into the dynamical classification here or in a follow-up. Notes for whoever picks this up next:

  • NEOMOD: not advisable to include as-is. It's calibrated for 15 < H < 28 (roughly sub-km to ~10 m objects), and using it for meteoroid-sized bodies means extrapolating well outside that range. At those smaller sizes, radiation pressure and other non-gravitational effects that NEOMOD's underlying integrations don't account for start to matter, so the extrapolated source probabilities wouldn't be trustworthy.
  • METEOMOD: potentially a much better fit (its simulations use 1 m particles), but it needs more investigation, and including it means redistributing someone else's model/data. I've reached out to the main author (Miroslav Brož) to ask for permission and check for any caveats before doing anything further. Putting this on hold until at least that's resolved. Even with the author's approval, it might not be advisable to include it; this will be considered later.

Brings in the D_H node sign fix and the acos domain guards merged as wmpg#96.

The branch was five commits behind, so its own command line reported the
pre-fix D_H: PR wmpg#96's worked example (q = 0.5, e = 0.7, i = 15 deg,
O1 = 10 deg, w1 = 150 deg, w2 = 190 deg, O2 = 350 deg) came out as 0.698722
where master gives 0.266746. That is now consistent.

Merges without conflict; 223 tests pass on the result.
@dvida

dvida commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

@AstroEloy — checked all of it. You are right on both counts against me, and the branch is now up to date with master.

You found a real defect in my commits

Confirmed exactly: 64 registered, 67 defined, and all three missing were mine — including the regression test for my own calcDX fix, which is the one that most needed to run. I registered the tests I added to test_Dcriteria.py in #96 and then failed to do the same here. 0e84f10 is the right fix; I audited all three test files and registration is now complete (10 + 72 + 14).

And the scipy claim was misleading

Reproduced your result:

import wmpl.Utils.Dcriteria   ->  scipy loaded: True,  OrbitClassification loaded: True

wmpl/__init__.py runs pkgutil.walk_packages, so any real import of a wmpl submodule pulls the whole library in. The decoupling from OrbitClassification is genuine at the source level, but the scipy benefit is nullified in practice and I should not have stated it without that caveat.

What I verified of your new work

Check Result
ParentBodySearch, the three original keys max difference 0.000e+00 over 20,000 pairs; case-insensitivity works; unknown key still warns and falls back to D_SH
The T-i unit correction Confirmed. The paper's 0.58 /AU × a_J = 3.0179, and T_paper * a_J matches calcTisserand to 8.9e-16. Comparing against 0.58 directly would have called 1510 of 2000 random orbits cometary
calcKresakKcalcWhippleK Clean rename, no dangling references
CLIs Exercised in five modes: both orbits manual, pickle plus manual, -c selection, --no_classification, and the OrbitClassification CLI

The np.isfinite guard against parabolic comets and the traj_path2 positional fix are both good catches that were not in scope and are worth having.

On the Jenniskens point: you are right that "Mostly dormant" appears nowhere in this PR. That title came from my own agent prompt, not from your code, so I flagged a non-issue. The eq. 3 numbers settle the typo claim — please do add the section and equation numbers to the description, since right now that evidence lives only in this thread.


9e96ec4 — merged master in, which was blocking

The branch was five commits behind and therefore did not have #96. Its own command line reported the pre-fix D_H: running PR #96's worked example through it gave

  D_SH        =     0.266746
  D_H         =     0.698722      <- pre-#96

where master gives 0.266746 for both. After the merge:

  D_SH        =     0.266746
  D_H         =     0.266746

Merged without conflict. 229 tests pass, and all three standalone runners report their full counts.

Nothing else outstanding from my side. Your WIP list — criterion selection, disabling classification, the source-region models — reads as follow-up work, and putting METEOMOD on hold pending Brož's permission is the right call on redistributing someone else's model.

🤖 Generated with Claude Code

…ySearch docstrings

Master now carries wmpg#101 (repo-wide spelling fixes) and wmpg#102 (Sun passages as close encounters).
The only conflicts were two docstrings in wmpl/Utils/ParentBodySearch.py, where wmpg#101 corrected
"comapring" and "heighest" in text that this branch had rewritten. Resolved by keeping this
branch's text with both spellings corrected. Everything else merged automatically.

Verified on the merged tree: 235 tests pass under pytest (wmpl/Utils/Tests and wmpl/Rebound/Tests),
the standalone runners report 10, 72 and 14, and codespell finds nothing new in the changed files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dvida

dvida commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Master (now with #101 and #102) merged into this branch again. The only conflicts were two docstrings in wmpl/Utils/ParentBodySearch.py, where #101 corrected "comapring" and "heighest" in text this branch had rewritten; kept this branch's text with both spellings fixed. Everything else merged automatically.

On the merged tree: 235 tests pass under pytest, the standalone runners report 10 / 72 / 14, and codespell finds nothing new in the changed files (its only hits are the astronomical "precess", which is pre-existing on master and not a typo).

🤖 Generated with Claude Code

@dvida
dvida merged commit 062e98e into wmpg:master Sep 21, 2026
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