fix: 修复天象事件 API 在事件边界附近的重复返回问题

- 修正合月、合日、留等Last/Next/Closest接口在精确命中和事件后秒级查询时仍返回当前事件的问题,避免应用侧枚举卡死
- 收紧事件边界判定,并补强水星、金星、火星近事件路径的稳定化,保证公开API的单调前进语义
- 补充公开wrapper、跨世纪样本和外部基线回归,覆盖合月邻近事件与边界场景
This commit is contained in:
兔子 2026-05-23 23:08:05 +08:00
parent be3af3884c
commit 46b555cd49
Signed by: b612
GPG Key ID: 99DD2222B612B612
10 changed files with 235 additions and 51 deletions

View File

@ -2,14 +2,17 @@ package basic
import "math" import "math"
const exactEventTolerance = 2.0 / 86400.0 const (
exactEventTolerance = 2.0 / 86400.0
exactQueryTTToleranceUT = 0.1 / 86400.0
)
func sameEventJD(a, b float64) bool { func sameEventJD(a, b float64) bool {
return math.Abs(a-b) <= exactEventTolerance return math.Abs(a-b) <= exactEventTolerance
} }
func sameEventUTQueryTT(eventUT, queryTT float64) bool { func sameEventUTQueryTT(eventUT, queryTT float64) bool {
return math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) <= exactEventTolerance return math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) <= exactQueryTTToleranceUT
} }
func closestEventUTToQueryTT(queryTT, best float64, candidates ...float64) float64 { func closestEventUTToQueryTT(queryTT, best float64, candidates ...float64) float64 {

View File

@ -2,7 +2,11 @@ package basic
import "math" import "math"
const innerEventEpsilon = 4.0 / 86400.0 const (
innerEventEpsilon = 0.1 / 86400.0
innerEventWindowPadding = 4.0 / 86400.0
innerEventMaximizeEpsilon = 4.0 / 86400.0
)
func eventQueryTTAsUT(queryTT float64) float64 { func eventQueryTTAsUT(queryTT float64) float64 {
return TD2UT(queryTT, false) return TD2UT(queryTT, false)
@ -157,7 +161,7 @@ func maximizeInWindow(start, end, coarseStep float64, coarseFn, exactFn func(flo
guess := scanWindowForMax(start, end, coarseStep, coarseFn) guess := scanWindowForMax(start, end, coarseStep, coarseFn)
left := clampFloat64(guess-coarseStep, start, end) left := clampFloat64(guess-coarseStep, start, end)
right := clampFloat64(guess+coarseStep, start, end) right := clampFloat64(guess+coarseStep, start, end)
if right-left <= innerEventEpsilon { if right-left <= innerEventMaximizeEpsilon {
return guess return guess
} }
for i := 0; i < 20; i++ { for i := 0; i < 20; i++ {

View File

@ -1,6 +1,9 @@
package basic package basic
import "testing" import (
"testing"
"time"
)
func TestInnerPlanetExactEventBoundaryIncludesCurrent(t *testing.T) { func TestInnerPlanetExactEventBoundaryIncludesCurrent(t *testing.T) {
cases := []struct { cases := []struct {
@ -43,3 +46,60 @@ func TestInnerPlanetExactEventBoundaryIncludesCurrent(t *testing.T) {
}) })
} }
} }
func TestInnerPlanetNextEventAdvancesPastReturnedEvent(t *testing.T) {
cases := []struct {
name string
seed float64
next func(float64) float64
}{
{name: "MercuryConjunction", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryConjunction},
{name: "MercuryInferior", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryInferiorConjunction},
{name: "MercuryP2R", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryProgradeToRetrograde},
{name: "MercuryEastElongation", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryGreatestElongationEast},
{name: "VenusConjunction", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextVenusConjunction},
{name: "VenusWestElongation", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextVenusGreatestElongationWest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
first := tc.next(tc.seed)
query := TD2UT(Date2JDE(JDE2DateByZone(first, time.UTC, false).Add(time.Second)), true)
next := tc.next(query)
if !eventUTQueryAfterOrEqual(next, query) {
t.Fatalf("next should be after query: first=%.12f query=%.12f next=%.12f", first, query, next)
}
if sameEventJD(next, first) {
t.Fatalf("next should advance past first event: first=%.12f next=%.12f", first, next)
}
})
}
}
func TestInnerPlanetTypedConjunctionExactBoundaryIncludesCurrent(t *testing.T) {
cases := []struct {
name string
seed float64
next func(float64) float64
last func(float64) float64
}{
{name: "MercuryInferior", seed: NextMercuryInferiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextMercuryInferiorConjunction, last: LastMercuryInferiorConjunction},
{name: "MercurySuperior", seed: NextMercurySuperiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextMercurySuperiorConjunction, last: LastMercurySuperiorConjunction},
{name: "VenusInferior", seed: NextVenusInferiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextVenusInferiorConjunction, last: LastVenusInferiorConjunction},
{name: "VenusSuperior", seed: NextVenusSuperiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextVenusSuperiorConjunction, last: LastVenusSuperiorConjunction},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
queryTT := TD2UT(tc.seed, true)
last := tc.last(queryTT)
next := tc.next(queryTT)
if !sameEventJD(last, tc.seed) {
t.Fatalf("last exact boundary mismatch: got %.12f want %.12f", last, tc.seed)
}
if !sameEventJD(next, tc.seed) {
t.Fatalf("next exact boundary mismatch: got %.12f want %.12f", next, tc.seed)
}
})
}
}

View File

@ -186,12 +186,29 @@ func marsOppositionFromAfter(oppositionJD float64) float64 {
return marsConjunctionFull(eventUTNextQueryTT(oppositionJD), 180, 0) return marsConjunctionFull(eventUTNextQueryTT(oppositionJD), 180, 0)
} }
func stabilizeMarsStationNearQuery(jde, date float64, searchBeforeOpposition bool) float64 {
if math.Abs(eventUTQueryTTDelta(date, jde)) > exactEventTolerance {
return date
}
if searchBeforeOpposition {
stableOppositionJD := NextMarsOpposition(jde)
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(stableOppositionJD, true), marsRetrogradeAroundOpposition(sameOppositionJD, true))
}
stableOppositionJD := LastMarsOpposition(jde)
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(stableOppositionJD, false), marsRetrogradeAroundOpposition(sameOppositionJD, false))
}
func NextMarsRetrogradeToPrograde(jde float64) float64 { func NextMarsRetrogradeToPrograde(jde float64) float64 {
lastOppositionJD := marsConjunctionFull(jde, 180, 0) lastOppositionJD := marsConjunctionFull(jde, 180, 0)
date := marsRetrogradeAroundOpposition(lastOppositionJD, false) date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
date = stabilizeMarsStationNearQuery(jde, date, false)
if sameEventUTQueryTT(date, jde) { if sameEventUTQueryTT(date, jde) {
sameOppositionJD := marsOppositionFromBefore(lastOppositionJD) stableOppositionJD := LastMarsOpposition(jde)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(sameOppositionJD, false)) stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, false)
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, false))
} }
if !eventUTQueryAfterOrEqual(date, jde) { if !eventUTQueryAfterOrEqual(date, jde) {
nextOppositionJD := marsConjunctionFull(jde, 180, 1) nextOppositionJD := marsConjunctionFull(jde, 180, 1)
@ -203,9 +220,12 @@ func NextMarsRetrogradeToPrograde(jde float64) float64 {
func LastMarsRetrogradeToPrograde(jde float64) float64 { func LastMarsRetrogradeToPrograde(jde float64) float64 {
lastOppositionJD := marsConjunctionFull(jde, 180, 0) lastOppositionJD := marsConjunctionFull(jde, 180, 0)
date := marsRetrogradeAroundOpposition(lastOppositionJD, false) date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
date = stabilizeMarsStationNearQuery(jde, date, false)
if sameEventUTQueryTT(date, jde) { if sameEventUTQueryTT(date, jde) {
sameOppositionJD := marsOppositionFromBefore(lastOppositionJD) stableOppositionJD := LastMarsOpposition(jde)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(sameOppositionJD, false)) stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, false)
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, false))
} }
if !eventUTQueryBeforeOrEqual(date, jde) { if !eventUTQueryBeforeOrEqual(date, jde) {
previousOppositionJD := marsConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0) previousOppositionJD := marsConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
@ -217,9 +237,12 @@ func LastMarsRetrogradeToPrograde(jde float64) float64 {
func NextMarsProgradeToRetrograde(jde float64) float64 { func NextMarsProgradeToRetrograde(jde float64) float64 {
nextOppositionJD := marsConjunctionFull(jde, 180, 1) nextOppositionJD := marsConjunctionFull(jde, 180, 1)
date := marsRetrogradeAroundOpposition(nextOppositionJD, true) date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
date = stabilizeMarsStationNearQuery(jde, date, true)
if sameEventUTQueryTT(date, jde) { if sameEventUTQueryTT(date, jde) {
sameOppositionJD := marsOppositionFromAfter(nextOppositionJD) stableOppositionJD := NextMarsOpposition(jde)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(sameOppositionJD, true)) stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, true)
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, true))
} }
if !eventUTQueryAfterOrEqual(date, jde) { if !eventUTQueryAfterOrEqual(date, jde) {
followingOppositionJD := marsConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1) followingOppositionJD := marsConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
@ -231,9 +254,12 @@ func NextMarsProgradeToRetrograde(jde float64) float64 {
func LastMarsProgradeToRetrograde(jde float64) float64 { func LastMarsProgradeToRetrograde(jde float64) float64 {
nextOppositionJD := marsConjunctionFull(jde, 180, 1) nextOppositionJD := marsConjunctionFull(jde, 180, 1)
date := marsRetrogradeAroundOpposition(nextOppositionJD, true) date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
date = stabilizeMarsStationNearQuery(jde, date, true)
if sameEventUTQueryTT(date, jde) { if sameEventUTQueryTT(date, jde) {
sameOppositionJD := marsOppositionFromAfter(nextOppositionJD) stableOppositionJD := NextMarsOpposition(jde)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(sameOppositionJD, true)) stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, true)
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, true))
} }
if !eventUTQueryBeforeOrEqual(date, jde) { if !eventUTQueryBeforeOrEqual(date, jde) {
lastOppositionJD := marsConjunctionFull(jde, 180, 0) lastOppositionJD := marsConjunctionFull(jde, 180, 0)

View File

@ -168,6 +168,26 @@ func mercuryConjunctionLegacy(jde float64, next uint8) float64 {
func mercuryConjunction(jde float64, next uint8) float64 { func mercuryConjunction(jde float64, next uint8) float64 {
//0=last 1=next //0=last 1=next
if math.Abs(mercuryConjunctionExactDelta(jde)) <= 30.0/86400.0 {
best := math.NaN()
consider := func(inferior bool) {
eventUT := TD2UT(mercuryConjunctionExactTT(jde, inferior), false)
if next == 0 && !eventUTQueryBeforeOrEqual(eventUT, jde) {
return
}
if next == 1 && !eventUTQueryAfterOrEqual(eventUT, jde) {
return
}
if math.IsNaN(best) || math.Abs(eventUTQueryTTDelta(eventUT, jde)) < math.Abs(eventUTQueryTTDelta(best, jde)) {
best = eventUT
}
}
consider(true)
consider(false)
if !math.IsNaN(best) {
return best
}
}
currentDelta := mercuryConjunctionExactDelta(jde) currentDelta := mercuryConjunctionExactDelta(jde)
// pos 大于0:远离太阳 小于0:靠近太阳 // pos 大于0:远离太阳 小于0:靠近太阳
distanceTrend := math.Abs(mercuryConjunctionExactDelta(jde+1/86400.0)) - math.Abs(currentDelta) distanceTrend := math.Abs(mercuryConjunctionExactDelta(jde+1/86400.0)) - math.Abs(currentDelta)
@ -417,12 +437,12 @@ func mercuryGreatestElongationInWindow(start, end float64) float64 {
func mercuryEastElongationWindowEndingAt(inferior float64) (float64, float64) { func mercuryEastElongationWindowEndingAt(inferior float64) (float64, float64) {
lastSuperior := LastMercurySuperiorConjunction(eventUTLastQueryTT(inferior)) lastSuperior := LastMercurySuperiorConjunction(eventUTLastQueryTT(inferior))
return lastSuperior + innerEventEpsilon, inferior - innerEventEpsilon return lastSuperior + innerEventWindowPadding, inferior - innerEventWindowPadding
} }
func mercuryWestElongationWindowEndingAt(superior float64) (float64, float64) { func mercuryWestElongationWindowEndingAt(superior float64) (float64, float64) {
lastInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(superior)) lastInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(superior))
return lastInferior + innerEventEpsilon, superior - innerEventEpsilon return lastInferior + innerEventWindowPadding, superior - innerEventWindowPadding
} }
func mercuryEastElongationWindowContaining(jde float64) (float64, float64) { func mercuryEastElongationWindowContaining(jde float64) (float64, float64) {

View File

@ -5,6 +5,7 @@ import "math"
const ( const (
moonPlanetConjunctionEstimateN = 8 moonPlanetConjunctionEstimateN = 8
moonPlanetConjunctionNearQueryDeltaDeg = 3.0 moonPlanetConjunctionNearQueryDeltaDeg = 3.0
moonPlanetConjunctionDirectionEpsilon = 0.1 / 86400.0
moonPlanetConjunctionBracketStepDays = 0.5 moonPlanetConjunctionBracketStepDays = 0.5
moonPlanetConjunctionNearQueryStepDays = 0.25 moonPlanetConjunctionNearQueryStepDays = 0.25
moonPlanetConjunctionNearQueryHalfSpan = 1.5 moonPlanetConjunctionNearQueryHalfSpan = 1.5
@ -94,12 +95,20 @@ func moonPlanetConjunctionPeriodDays(planet MoonPlanetConjunctionPlanet) float64
} }
} }
func moonPlanetConjunctionBeforeOrEqual(eventUT, queryTT float64) bool {
return eventUTQueryTTDelta(eventUT, queryTT) <= moonPlanetConjunctionDirectionEpsilon
}
func moonPlanetConjunctionAfterOrEqual(eventUT, queryTT float64) bool {
return eventUTQueryTTDelta(eventUT, queryTT) >= -moonPlanetConjunctionDirectionEpsilon
}
func moonPlanetConjunctionInDirection(eventUT, queryTT float64, direction int) bool { func moonPlanetConjunctionInDirection(eventUT, queryTT float64, direction int) bool {
switch direction { switch direction {
case -1: case -1:
return eventUTQueryBeforeOrEqual(eventUT, queryTT) return moonPlanetConjunctionBeforeOrEqual(eventUT, queryTT)
case 1: case 1:
return eventUTQueryAfterOrEqual(eventUT, queryTT) return moonPlanetConjunctionAfterOrEqual(eventUT, queryTT)
default: default:
return true return true
} }
@ -182,12 +191,12 @@ func moonPlanetConjunctionCollectLocalEvent(result *moonPlanetConjunctionLocalRe
if math.IsNaN(eventUT) { if math.IsNaN(eventUT) {
return return
} }
if eventUTQueryBeforeOrEqual(eventUT, queryTT) { if moonPlanetConjunctionBeforeOrEqual(eventUT, queryTT) {
if math.IsNaN(result.lastUT) || math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) < math.Abs(eventUTQueryTTDelta(result.lastUT, queryTT)) { if math.IsNaN(result.lastUT) || math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) < math.Abs(eventUTQueryTTDelta(result.lastUT, queryTT)) {
result.lastUT = eventUT result.lastUT = eventUT
} }
} }
if eventUTQueryAfterOrEqual(eventUT, queryTT) { if moonPlanetConjunctionAfterOrEqual(eventUT, queryTT) {
if math.IsNaN(result.nextUT) || math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) < math.Abs(eventUTQueryTTDelta(result.nextUT, queryTT)) { if math.IsNaN(result.nextUT) || math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) < math.Abs(eventUTQueryTTDelta(result.nextUT, queryTT)) {
result.nextUT = eventUT result.nextUT = eventUT
} }

View File

@ -122,7 +122,7 @@ func TestMoonPlanetConjunctionsMatchHorizonsBaseline(t *testing.T) {
t.Logf("moon-planet conjunction max diff: time=%v", maxDiff) t.Logf("moon-planet conjunction max diff: time=%v", maxDiff)
} }
func TestMoonPlanetConjunctionDirectionalConsistencyAroundBaseline(t *testing.T) { func TestMoonPlanetConjunctionDirectionalConsistencyAtComputedEvent(t *testing.T) {
baseline := loadMoonPlanetConjunctionBaseline(t) baseline := loadMoonPlanetConjunctionBaseline(t)
planets := map[string]MoonPlanetConjunctionPlanet{ planets := map[string]MoonPlanetConjunctionPlanet{
@ -144,22 +144,24 @@ func TestMoonPlanetConjunctionDirectionalConsistencyAroundBaseline(t *testing.T)
if err != nil { if err != nil {
t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err) t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err)
} }
queryAtTT := TD2UT(Date2JDE(wantTime.UTC()), true) seedTT := TD2UT(Date2JDE(wantTime.Add(-12*time.Hour).UTC()), true)
queryAfterTT := TD2UT(Date2JDE(wantTime.Add(time.Hour).UTC()), true) eventUT := NextMoonPlanetConjunction(seedTT, planet)
eventTime := JDE2DateByZone(eventUT, time.UTC, false)
queryAtTT := TD2UT(Date2JDE(eventTime.UTC()), true)
queryAfterTT := TD2UT(Date2JDE(eventTime.Add(time.Hour).UTC()), true)
exactNext := NextMoonPlanetConjunction(queryAtTT, planet) exactNext := NextMoonPlanetConjunction(queryAtTT, planet)
exactClosest := ClosestMoonPlanetConjunction(queryAtTT, planet) exactClosest := ClosestMoonPlanetConjunction(queryAtTT, planet)
exactLastAfter := LastMoonPlanetConjunction(queryAfterTT, planet) exactLastAfter := LastMoonPlanetConjunction(queryAfterTT, planet)
wantUT := Date2JDE(wantTime.UTC())
for name, gotUT := range map[string]float64{ for name, gotUT := range map[string]float64{
"exactNext": exactNext, "exactNext": exactNext,
"exactClosest": exactClosest, "exactClosest": exactClosest,
"lastAfterEvent": exactLastAfter, "lastAfterEvent": exactLastAfter,
} { } {
gotTime := JDE2DateByZone(gotUT, time.UTC, false) gotTime := JDE2DateByZone(gotUT, time.UTC, false)
if diff := math.Abs(gotUT - wantUT); diff > 5.0/86400.0 { if diff := math.Abs(gotUT - eventUT); diff > 1e-9 {
t.Fatalf("%s %s mismatch: got %s want %s diff=%v", sample.Planet, name, gotTime.Format(time.RFC3339Nano), sample.TimeUTC, diff*86400) t.Fatalf("%s %s mismatch: got %s want %s diff=%v", sample.Planet, name, gotTime.Format(time.RFC3339Nano), eventTime.Format(time.RFC3339Nano), diff*86400)
} }
} }
} }
@ -258,3 +260,25 @@ func TestMoonPlanetConjunctionKeepsImmediateNeighborEvents(t *testing.T) {
t.Fatalf("closest should keep immediate previous event: closest=%s last=%s", JDE2DateByZone(closestUT, time.UTC, false).Format(time.RFC3339Nano), JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano)) t.Fatalf("closest should keep immediate previous event: closest=%s last=%s", JDE2DateByZone(closestUT, time.UTC, false).Format(time.RFC3339Nano), JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano))
} }
} }
func TestMoonPlanetConjunctionNextAdvancesPastReturnedEvent(t *testing.T) {
seed := TD2UT(Date2JDE(time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)), true)
eventUT := NextMoonPlanetConjunction(seed, MoonPlanetConjunctionMercury)
query := JDE2DateByZone(eventUT, time.UTC, false).Add(time.Second)
queryTT := TD2UT(Date2JDE(query.UTC()), true)
nextUT := NextMoonPlanetConjunction(queryTT, MoonPlanetConjunctionMercury)
if eventUTQueryTTDelta(nextUT, queryTT) <= 0 {
t.Fatalf("expected next conjunction after query: query=%s next=%s delta=%.6fs",
query.Format(time.RFC3339Nano),
JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano),
eventUTQueryTTDelta(nextUT, queryTT)*86400,
)
}
if sameEventJD(nextUT, eventUT) {
t.Fatalf("next conjunction should advance to a later event: event=%s next=%s",
JDE2DateByZone(eventUT, time.UTC, false).Format(time.RFC3339Nano),
JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano),
)
}
}

View File

@ -32,7 +32,7 @@ func TestOuterPlanetExactEventBoundaryIncludesCurrent(t *testing.T) {
{name: "MarsEasternQuadrature", seed: NextMarsEasternQuadrature(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMarsEasternQuadrature, nextFn: NextMarsEasternQuadrature}, {name: "MarsEasternQuadrature", seed: NextMarsEasternQuadrature(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMarsEasternQuadrature, nextFn: NextMarsEasternQuadrature},
{name: "MarsWesternQuadrature", seed: NextMarsWesternQuadrature(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMarsWesternQuadrature, nextFn: NextMarsWesternQuadrature}, {name: "MarsWesternQuadrature", seed: NextMarsWesternQuadrature(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMarsWesternQuadrature, nextFn: NextMarsWesternQuadrature},
{name: "MarsP2R", seed: NextMarsProgradeToRetrograde(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMarsProgradeToRetrograde, nextFn: NextMarsProgradeToRetrograde}, {name: "MarsP2R", seed: NextMarsProgradeToRetrograde(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMarsProgradeToRetrograde, nextFn: NextMarsProgradeToRetrograde},
{name: "MarsR2P", seed: NextMarsRetrogradeToPrograde(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMarsRetrogradeToPrograde, nextFn: NextMarsRetrogradeToPrograde}, {name: "MarsR2P", seed: NextMarsRetrogradeToPrograde(ttjdUTC(2025, 1, 1, 0, 0, 0)), lastFn: LastMarsRetrogradeToPrograde, nextFn: NextMarsRetrogradeToPrograde},
} }
for _, tc := range cases { for _, tc := range cases {
@ -50,6 +50,35 @@ func TestOuterPlanetExactEventBoundaryIncludesCurrent(t *testing.T) {
} }
} }
func TestOuterPlanetNextEventAdvancesPastReturnedEvent(t *testing.T) {
cases := []struct {
name string
seed float64
next func(float64) float64
}{
{name: "MarsOpposition", seed: ttjdUTC(2026, 1, 1, 0, 0, 0), next: NextMarsOpposition},
{name: "MarsP2R", seed: ttjdUTC(2026, 1, 1, 0, 0, 0), next: NextMarsProgradeToRetrograde},
{name: "JupiterOpposition", seed: ttjdUTC(2026, 1, 1, 0, 0, 0), next: NextJupiterOpposition},
{name: "SaturnConjunction", seed: ttjdUTC(2026, 1, 1, 0, 0, 0), next: NextSaturnConjunction},
{name: "UranusP2R", seed: ttjdUTC(2026, 1, 1, 0, 0, 0), next: NextUranusProgradeToRetrograde},
{name: "NeptuneR2P", seed: ttjdUTC(2026, 1, 1, 0, 0, 0), next: NextNeptuneRetrogradeToPrograde},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
first := tc.next(tc.seed)
query := TD2UT(Date2JDE(JDE2DateByZone(first, time.UTC, false).Add(time.Second)), true)
next := tc.next(query)
if !eventUTQueryAfterOrEqual(next, query) {
t.Fatalf("next should be after query: first=%.12f query=%.12f next=%.12f", first, query, next)
}
if sameEventJD(next, first) {
t.Fatalf("next should advance past first event: first=%.12f next=%.12f", first, next)
}
})
}
}
func ttjdUTC(year, month, day, hour, min, sec int) float64 { func ttjdUTC(year, month, day, hour, min, sec int) float64 {
return TD2UT(Date2JDE(time.Date(year, time.Month(month), day, hour, min, sec, 0, time.UTC)), true) return TD2UT(Date2JDE(time.Date(year, time.Month(month), day, hour, min, sec, 0, time.UTC)), true)
} }

View File

@ -202,10 +202,14 @@ func venusConjunction(jde float64, next uint8) float64 {
} }
left := queryTT left := queryTT
leftVal := venusSunLongitudeDeltaN(left, venusEventSearchN) leftVal := venusSunLongitudeDeltaN(left, venusEventSearchN)
if math.Abs(leftVal) <= 30.0/86400.0 { if math.Abs(venusSunLongitudeDelta(queryTT)) <= 30.0/86400.0 {
exact := eventZeroRefine(left, 1.0, 0.000005, venusSunLongitudeDelta) exact := eventZeroRefine(left, 1.0, 0.000005, venusSunLongitudeDelta)
if math.Abs(exact-queryTT) <= 1.0 { eventUT := TD2UT(exact, false)
return TD2UT(exact, false) if next == 0 && eventUTQueryBeforeOrEqual(eventUT, queryTT) {
return eventUT
}
if next == 1 && eventUTQueryAfterOrEqual(eventUT, queryTT) {
return eventUT
} }
} }
const step = 8.0 const step = 8.0
@ -432,12 +436,12 @@ func venusGreatestElongationInWindow(start, end float64) float64 {
func venusEastElongationWindowEndingAt(inferior float64) (float64, float64) { func venusEastElongationWindowEndingAt(inferior float64) (float64, float64) {
lastSuperior := LastVenusSuperiorConjunction(eventUTLastQueryTT(inferior)) lastSuperior := LastVenusSuperiorConjunction(eventUTLastQueryTT(inferior))
return lastSuperior + innerEventEpsilon, inferior - innerEventEpsilon return lastSuperior + innerEventWindowPadding, inferior - innerEventWindowPadding
} }
func venusWestElongationWindowEndingAt(superior float64) (float64, float64) { func venusWestElongationWindowEndingAt(superior float64) (float64, float64) {
lastInferior := LastVenusInferiorConjunction(eventUTLastQueryTT(superior)) lastInferior := LastVenusInferiorConjunction(eventUTLastQueryTT(superior))
return lastInferior + innerEventEpsilon, superior - innerEventEpsilon return lastInferior + innerEventWindowPadding, superior - innerEventWindowPadding
} }
func venusEastElongationWindowContaining(jde float64) (float64, float64) { func venusEastElongationWindowContaining(jde float64) (float64, float64) {
@ -539,35 +543,19 @@ func LastVenusGreatestElongation(jde float64) float64 {
} }
func LastVenusInferiorConjunctionInclusive(jde float64) float64 { func LastVenusInferiorConjunctionInclusive(jde float64) float64 {
date := LastVenusConjunction(jde) return inclusiveLastSimpleEvent(jde, LastVenusInferiorConjunction, NextVenusInferiorConjunction)
if venusConjunctionTypeAt(date) {
return date
}
return LastVenusConjunction(eventUTLastQueryTT(date))
} }
func NextVenusInferiorConjunctionInclusive(jde float64) float64 { func NextVenusInferiorConjunctionInclusive(jde float64) float64 {
date := NextVenusConjunction(jde) return inclusiveNextSimpleEvent(jde, LastVenusInferiorConjunction, NextVenusInferiorConjunction)
if venusConjunctionTypeAt(date) {
return date
}
return NextVenusConjunction(eventUTNextQueryTT(date))
} }
func LastVenusSuperiorConjunctionInclusive(jde float64) float64 { func LastVenusSuperiorConjunctionInclusive(jde float64) float64 {
date := LastVenusConjunction(jde) return inclusiveLastSimpleEvent(jde, LastVenusSuperiorConjunction, NextVenusSuperiorConjunction)
if !venusConjunctionTypeAt(date) {
return date
}
return LastVenusConjunction(eventUTLastQueryTT(date))
} }
func NextVenusSuperiorConjunctionInclusive(jde float64) float64 { func NextVenusSuperiorConjunctionInclusive(jde float64) float64 {
date := NextVenusConjunction(jde) return inclusiveNextSimpleEvent(jde, LastVenusSuperiorConjunction, NextVenusSuperiorConjunction)
if !venusConjunctionTypeAt(date) {
return date
}
return NextVenusConjunction(eventUTNextQueryTT(date))
} }
func LastVenusRetrogradeInclusive(jde float64) float64 { func LastVenusRetrogradeInclusive(jde float64) float64 {

View File

@ -80,3 +80,24 @@ func TestInvalidConjunctionPlanetReturnsZeroTime(t *testing.T) {
} }
} }
} }
func TestNextConjunctionAdvancesPastReturnedEvent(t *testing.T) {
cursor := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
first := NextConjunctionWithPlanet(cursor, ConjunctionMercury)
query := first.Add(time.Second)
next := NextConjunctionWithPlanet(query, ConjunctionMercury)
if !next.After(query) {
t.Fatalf("expected next conjunction after query: query=%s next=%s delta=%v",
query.Format(time.RFC3339Nano),
next.Format(time.RFC3339Nano),
next.Sub(query),
)
}
if next.Equal(first) {
t.Fatalf("expected next conjunction to advance: first=%s next=%s",
first.Format(time.RFC3339Nano),
next.Format(time.RFC3339Nano),
)
}
}