package basic import ( "math" "testing" "time" ) func TestStarOccultationLatitudeEnvelopePrefilter(t *testing.T) { start := occultationTimeToTT(time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)) end := start + starOccultationSiderealMonthDays polar := StarCoordinate{RA: 0, Dec: 89, Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameICRS} if starOccultationLatitudeEnvelopePass(start, end, polar, nil, 0) { t.Fatal("polar star should be rejected by global ecliptic-latitude envelope") } if !starOccultationLatitudeEnvelopePass(start, end, hr4799OccultationCoordinateForTest(), nil, 0) { t.Fatal("HR 4799 should pass the global ecliptic-latitude envelope") } } func TestStarOccultationScanCandidatesFindsMultipleLocalMinima(t *testing.T) { value := func(tt float64) float64 { return (tt-1)*(tt-1)*(tt-3)*(tt-3) + 0.001 } got := starOccultationScanCandidates(0, 4, 0.5, value, func(float64) bool { return true }) if len(got) != 2 { t.Fatalf("candidate count = %d, want 2: %v", len(got), got) } if math.Abs(got[0]-1) > 1e-6 || math.Abs(got[1]-3) > 1e-6 { t.Fatalf("candidate minima = %v, want [1 3]", got) } } func TestStarOccultationScanCandidatesFindsNarrowWindowMidpoint(t *testing.T) { const ( start = 10.0 end = 10.1 want = (start + end) / 2 ) value := func(tt float64) float64 { return (tt - want) * (tt - want) } got := starOccultationScanCandidates(start, end, starOccultationDefaultStepDays, value, func(float64) bool { return true }) if len(got) != 1 { t.Fatalf("candidate count = %d, want 1: %v", len(got), got) } if math.Abs(got[0]-want) > 1e-7 { t.Fatalf("candidate midpoint = %.12f, want %.12f", got[0], want) } } func TestUniqueOccultationCandidateTimesFiltersSingleOutsideCandidate(t *testing.T) { if got := uniqueOccultationCandidateTimes([]float64{9.9}, 10, 11); len(got) != 0 { t.Fatalf("outside candidate was not filtered: %v", got) } got := uniqueOccultationCandidateTimes([]float64{10.5}, 10, 11) if len(got) != 1 || got[0] != 10.5 { t.Fatalf("inside candidate = %v, want [10.5]", got) } } func TestStarOccultationPropagatesApparentCoordinateProperMotion(t *testing.T) { epoch := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) target := epoch.Add(365*24*time.Hour + 6*time.Hour) star := StarCoordinate{ RA: 10, Dec: 20, Epoch: epoch, Frame: CoordinateFrameApparentOfDate, ProperMotionRACosDecMasPerYear: 360000, ProperMotionDecMasPerYear: -720000, } baseline := star baseline.ProperMotionRACosDecMasPerYear = 0 baseline.ProperMotionDecMasPerYear = 0 baselineRA, baselineDec := starApparentRaDec(occultationTimeToTT(target), baseline, Observer{}) ra, dec := starApparentRaDec(occultationTimeToTT(target), star, Observer{}) raMotion := signedAngleDifference(ra, baselineRA) * math.Cos(baselineDec*math.Pi/180) decMotion := dec - baselineDec if math.Abs(raMotion-0.1) > 0.001 { t.Fatalf("propagated RA*cos(Dec) motion = %.10f deg, want 0.1", raMotion) } if math.Abs(decMotion-(-0.2)) > 0.001 { t.Fatalf("propagated Dec motion = %.10f deg, want -0.2", decMotion) } } func TestStarOccultationApparentOfDateRoundTripsAtEpoch(t *testing.T) { epoch := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) star := StarCoordinate{RA: 189.5, Dec: -6.2, Epoch: epoch, Frame: CoordinateFrameApparentOfDate, ParallaxMas: 100} ra, dec := starApparentRaDecGeocentric(occultationTimeToTT(epoch), star) if math.Abs(signedAngleDifference(ra, star.RA))*3600 > 1e-5 || math.Abs(dec-star.Dec)*3600 > 1e-5 { t.Fatalf("apparent coordinate did not round-trip at epoch: got %.12f %.12f", ra, dec) } } func TestStarOccultationApparentPlaceCorrections(t *testing.T) { location := time.FixedZone("CST", 8*3600) tt := occultationTimeToTT(time.Date(2025, 6, 5, 20, 2, 7, 700000000, location)) star := hr4799OccultationCoordinateForTest() gotRA, gotDec := starApparentRaDecGeocentric(tt, star) if math.Abs(signedAngleDifference(gotRA, 189.527817)) > 0.0002 || math.Abs(gotDec-(-5.973401)) > 0.0002 { t.Fatalf("apparent place = %.9f %.9f, want near 189.527817 -5.973401", gotRA, gotDec) } years := (tt - Date2JDE(star.Epoch.UTC())) / 365.25 meanRA := star.RA + years*star.ProperMotionRACosDecMasPerYear/(3600000*math.Cos(star.Dec*math.Pi/180)) meanDec := star.Dec + years*star.ProperMotionDecMasPerYear/3600000 meanRA, meanDec = Precess(meanRA, meanDec, 2451545, tt) correction := angularSeparationDegrees(meanRA, meanDec, gotRA, gotDec) * 3600 if correction < 5 || correction > 30 { t.Fatalf("apparent-place correction = %.6f arcsec, want a plausible annual correction", correction) } } func TestStarOccultationApparentPlaceAppliesAnnualParallax(t *testing.T) { star := hr4799OccultationCoordinateForTest() tt := occultationTimeToTT(time.Date(2025, 6, 5, 12, 0, 0, 0, time.UTC)) withoutRA, withoutDec := starApparentRaDecGeocentric(tt, star) star.ParallaxMas = 1000 withRA, withDec := starApparentRaDecGeocentric(tt, star) shift := angularSeparationDegrees(withoutRA, withoutDec, withRA, withDec) * 3600 if shift < 0.05 || shift > 1.1 { t.Fatalf("annual parallax shift = %.6f arcsec, want (0.05, 1.1]", shift) } } func TestStarOccultationICRSAppliesJ2000FrameBias(t *testing.T) { epoch := time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC) icrs := StarCoordinate{RA: 0, Dec: 0, Epoch: epoch, Frame: CoordinateFrameICRS} j2000 := icrs j2000.Frame = CoordinateFrameJ2000 tt := occultationTimeToTT(epoch) icrsRA, icrsDec := starApparentRaDecGeocentric(tt, icrs) j2000RA, j2000Dec := starApparentRaDecGeocentric(tt, j2000) raBiasMas := signedAngleDifference(icrsRA, j2000RA) * 3600000 decBiasMas := (icrsDec - j2000Dec) * 3600000 if math.Abs(raBiasMas-14.6) > 0.1 || math.Abs(decBiasMas-(-16.617)) > 0.1 { t.Fatalf("ICRS frame bias = %.6f %.6f mas, want about 14.6 -16.617", raBiasMas, decBiasMas) } } func TestRefinedStarOccultationCenterLineRespectsWidthTolerance(t *testing.T) { star := hr4799OccultationCoordinateForTest() start := time.Date(2025, time.June, 5, 0, 0, 0, 0, time.UTC) paths, err := FindStarOccultationPaths( start, start.Add(24*time.Hour), star, OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 50}, ) if err != nil || len(paths) != 1 { t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) } for index, point := range paths[0].CenterLine { exact, ok := starOccultationPathCenterPoint(centerTimeTT(point.Time), star, time.UTC) if !ok { t.Fatalf("exact center point %d is unavailable", index) } if difference := math.Abs(point.WidthKM - exact.WidthKM); difference > occultationPathWidthToleranceKM { t.Fatalf("center point %d width differs from exact value by %.9f km: got %.9f want %.9f", index, difference, point.WidthKM, exact.WidthKM) } } } func TestRefineOccultationPathWidthsBoundsSmoothInterpolationError(t *testing.T) { start := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC) startTT := occultationTimeToTT(start) widthAt := func(tt float64) (float64, bool) { seconds := (tt - startTT) * 86400 return 3500 + 0.0002*(seconds-50)*(seconds-50), true } points := make([]OccultationPathPoint, 101) for index := range points { points[index].Time = start.Add(time.Duration(index) * time.Second) } points[0].WidthKM, _ = widthAt(centerTimeTT(points[0].Time)) points[len(points)-1].WidthKM, _ = widthAt(centerTimeTT(points[len(points)-1].Time)) refineOccultationPathWidths(points, widthAt) for index, point := range points { exact, _ := widthAt(centerTimeTT(point.Time)) if difference := math.Abs(point.WidthKM - exact); difference > occultationPathWidthToleranceKM { t.Fatalf("interpolated width %d differs by %.9f km, tolerance %.9f", index, difference, occultationPathWidthToleranceKM) } } } func hr4799OccultationCoordinateForTest() StarCoordinate { return StarCoordinate{ ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444, Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000, ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18, } }