package basic import ( "errors" "math" "testing" "time" . "b612.me/astro/tools" ) func TestSunRiseSetDynamicResidual(t *testing.T) { const ( longitude = 116.4074 latitude = 39.9042 timeZone = 8.0 height = 0.0 ) jd := JDECalc(2025, 6, 5) for _, event := range []struct { name string get func(float64, float64, float64, float64, float64, float64) (float64, error) }{ {name: "rise", get: GetSunRiseTime}, {name: "set", get: GetSunSetTime}, } { eventJD, err := event.get(jd, longitude, latitude, timeZone, 1, height) if err != nil { t.Fatalf("%s: %v", event.name, err) } naturalTimeZone := math.Round(longitude / 15) localJD := eventJD + naturalTimeZone/24 - timeZone/24 residual := sunRiseSetResidual(localJD, longitude, latitude, naturalTimeZone, 1, height, -1) if math.Abs(residual) > 0.001 { t.Fatalf("%s dynamic horizon residual = %.9f degrees", event.name, residual) } fixedResidual := SunHeight(localJD, longitude, latitude, naturalTimeZone) - StandardAltitudeSun(1, height, latitude) if math.Abs(fixedResidual) < 0.01 { t.Fatalf("%s still matches the legacy fixed-altitude event: residual %.9f degrees", event.name, fixedResidual) } } } func TestSunApparentStateReusesDistanceWithoutChangingCoordinates(t *testing.T) { const jd = 2460827.5 ra, dec, distanceAU := hSunApparentRaDecDistanceN(jd, -1) wantRA, wantDec := LoBoToRaDec(jd, HSunApparentLoN(jd, -1), HSunTrueBoN(jd, -1)) assertClose(t, "sun state RA", ra, wantRA, 1e-12) assertClose(t, "sun state Dec", dec, wantDec, 1e-12) assertClose(t, "sun state distance", distanceAU, EarthAwayN(jd, -1), 1e-15) } func TestSunRiseSetDynamicGrazingKeepsDateAndDirection(t *testing.T) { date := time.Date(2025, 6, 10, 0, 0, 0, 0, time.UTC) jd := Date2JDE(date) dayStart := math.Floor(jd) + 0.5 rise, err := GetSunRiseTime(jd, 0, 66, 0, 1, 0) if err != nil { t.Fatalf("sunrise: %v", err) } set, err := GetSunSetTime(jd, 0, 66, 0, 1, 0) if err != nil { t.Fatalf("sunset: %v", err) } assertRiseSetEvent(t, "sunrise", rise, dayStart, true, func(eventJD float64) float64 { return sunRiseSetResidual(eventJD, 0, 66, 0, 1, 0, -1) }) assertRiseSetEvent(t, "sunset", set, dayStart, false, func(eventJD float64) float64 { return sunRiseSetResidual(eventJD, 0, 66, 0, 1, 0, -1) }) } func TestMoonSetDynamicGrazingKeepsDirection(t *testing.T) { date := time.Date(2025, 1, 31, 0, 0, 0, 0, time.UTC) jd := Date2JDE(date) dayStart := math.Floor(jd) + 0.5 set, err := GetMoonSetTime(jd, 0, 80, 0, 1, 0) if err != nil { t.Fatalf("moonset: %v", err) } assertRiseSetEvent(t, "moonset", set, dayStart, false, func(eventJD float64) float64 { return moonRiseSetResidual(eventJD, 0, 80, 0, 1, 0, -1) }) } func TestMoonRiseSetDirectionalFallbackPreservesMissingEventError(t *testing.T) { date := time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC) jd := Date2JDE(date) _, err := GetMoonRiseTime(jd, -42.6043, 71.7069, -3, 1, 0) if !errors.Is(err, ErrNeverRise) { t.Fatalf("moonrise error = %v, want %v", err, ErrNeverRise) } } func TestMoonRiseSetDynamicUsesObserverHeightForParallax(t *testing.T) { date := time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC) jd := Date2JDE(date) const ( longitude = 116.4074 latitude = 39.9042 height = 10000.0 ) rise, err := GetMoonRiseTime(jd, longitude, latitude, 0, 1, height) if err != nil { t.Fatalf("moonrise: %v", err) } residual := moonRiseSetResidualAtObserverHeight(rise, longitude, latitude, 0, 1, height) if math.Abs(residual) > 1e-5 { t.Fatalf("moonrise observer-height residual = %.12f degrees", residual) } } func assertRiseSetEvent(t *testing.T, name string, eventJD, dayStart float64, isRise bool, residual func(float64) float64) { t.Helper() if eventJD < dayStart || eventJD >= dayStart+1 { t.Fatalf("%s %.12f is outside civil day [%.12f, %.12f)", name, eventJD, dayStart, dayStart+1) } const step = 1.0 / 1440 slope := (residual(eventJD+step) - residual(eventJD-step)) / (2 * step) if isRise && slope <= 0 { t.Fatalf("%s slope = %.9f degrees/day, want positive", name, slope) } if !isRise && slope >= 0 { t.Fatalf("%s slope = %.9f degrees/day, want negative", name, slope) } if value := residual(eventJD); math.Abs(value) > 1e-4 { t.Fatalf("%s residual = %.12f degrees", name, value) } } func moonRiseSetResidualAtObserverHeight(jd, longitude, latitude, timeZone, zenithShift, height float64) float64 { calculationJD := TD2UT(jd-timeZone/24, true) ra, dec := HMoonTrueRaDecN(calculationJD, -1) distanceKM := HMoonAwayN(calculationJD, -1) topocentricRA, topocentricDec := TopocentricRaDec(ra, dec, latitude, longitude, jd-timeZone/24, distanceKM/angularDiameterAstronomicalUnitKM, height) siderealTime := Limit360(ApparentSiderealTime(jd-timeZone/24)*15 + longitude) hourAngle := Limit360(siderealTime - topocentricRA) altitude := ArcSin(Sin(latitude)*Sin(topocentricDec) + Cos(topocentricDec)*Cos(latitude)*Cos(hourAngle)) residual := altitude + HeightDegreeByLat(height, latitude) if zenithShift != 0 { residual += RefractionFromTrueAltitude(altitude, refractionStandardPressureHPa, refractionStandardTemperatureC) residual += angularSemidiameterArcsec(moonEquatorialRadiusKM, distanceKM) / 3600 } return residual }