fix(amlich): correct lunation overshoot returning lunar day 0 for 4 dates

The mean-cycle lunation estimate in solarToLunar can overshoot by one when
the target day falls just before a new moon whose UTC+7 calendar day rounds
forward; the reference implementation steps back only once and returns day 0
for 13/4/1877, 16/3/1885, 7/5/2054 and 9/4/2062. Loop the step-back until the
month start is on or before the target day.

Tests: extend round-trip to the full 1800-2199 range with day-continuity
checks, pin the 4 overshoot dates, anchor leap-month placement to published
tables (15 leap + 5 no-leap years), and pin the two razor-edge lunations
(20/6/1944, 7/7/1967) verified against published Vietnamese calendars.
This commit is contained in:
2026-08-08 23:27:14 +07:00
parent 0c30910547
commit 8415191e1b
2 changed files with 83 additions and 7 deletions
+8 -2
View File
@@ -169,9 +169,15 @@ func getLeapMonthOffset(a11 int) int {
func solarToLunar(dd, mm, yy int) (day, month, year int, leap bool) {
dayNumber := jdFromDate(dd, mm, yy)
k := floorInt((float64(dayNumber) - jdNewMoonEpoch) / newMoonCycle)
// The mean-cycle estimate k can overshoot the true lunation by one when
// dayNumber falls just before a new moon. The reference implementation
// steps back only once and thus returns day 0 for a handful of days
// (e.g. 13/4/1877, 9/4/2062); loop until the month start is on or before
// the target day.
monthStart := getNewMoonDay(k + 1)
if monthStart > dayNumber {
monthStart = getNewMoonDay(k)
for monthStart > dayNumber {
k--
monthStart = getNewMoonDay(k + 1)
}
a11 := getLunarMonth11(yy)
b11 := a11
+75 -5
View File
@@ -19,6 +19,14 @@ var knownDates = []struct {
{"start of leap month 4, Canh Tý", 23, 5, 2020, 1, 4, 2020, true},
{"Quốc khánh 1945 — 26/7 Ất Dậu", 2, 9, 1945, 26, 7, 1945, false},
{"30/4/1975 — 20/3 Ất Mão", 30, 4, 1975, 20, 3, 1975, false},
// Razor-edge lunations: the new moon falls within ~2 minutes of UTC+7
// midnight, so ephemeris precision decides the day. Published Vietnamese
// calendars (and the pre-1968 Chinese-calendar convention then in force)
// start the next month a day later — the truncated series agrees; a
// higher-precision Meeus ch. 49 implementation flips these and is wrong
// against the published record.
{"NM 20/6/1944 ~17:00 UT — 30/4 nhuận", 20, 6, 1944, 30, 4, 1944, true},
{"NM 7/7/1967 ~17:00 UT — 30/5 Đinh Mùi", 7, 7, 1967, 30, 5, 1967, false},
}
func TestSolarToLunar_KnownDates(t *testing.T) {
@@ -51,12 +59,14 @@ func TestLunarToSolar_KnownDates(t *testing.T) {
}
}
// TestSolarLunarRoundTrip converts every day of 19502050 solar→lunar→solar.
// Identity across a century (including ~37 leap months) rules out whole
// classes of boundary bugs without needing external truth per day.
// TestSolarLunarRoundTrip converts every day of the supported 18002199 range
// solar→lunar→solar and checks day-to-day continuity. Identity across four
// centuries (including ~147 leap months) rules out whole classes of boundary
// bugs without needing external truth per day.
func TestSolarLunarRoundTrip(t *testing.T) {
start := jdFromDate(1, 1, 1950)
end := jdFromDate(31, 12, 2050)
start := jdFromDate(1, 1, minLunarYear)
end := jdFromDate(31, 12, maxLunarYear)
prevDay := 0
for jd := start; jd <= end; jd++ {
solarDay, solarMonth, solarYear := jdToDate(jd)
lunarDay, lunarMonth, lunarYear, leap := solarToLunar(solarDay, solarMonth, solarYear)
@@ -64,6 +74,13 @@ func TestSolarLunarRoundTrip(t *testing.T) {
t.Fatalf("solarToLunar(%d/%d/%d) out of range: %d/%d/%d",
solarDay, solarMonth, solarYear, lunarDay, lunarMonth, lunarYear)
}
// Consecutive solar days are either consecutive lunar days or the
// first day after a month of 29 or 30 days.
if jd > start && lunarDay != prevDay+1 && !(lunarDay == 1 && (prevDay == 29 || prevDay == 30)) {
t.Fatalf("discontinuity at %d/%d/%d: lunar day %d after %d",
solarDay, solarMonth, solarYear, lunarDay, prevDay)
}
prevDay = lunarDay
gotDay, gotMonth, gotYear, err := lunarToSolar(lunarDay, lunarMonth, lunarYear, leap)
if err != nil {
t.Fatalf("round trip %d/%d/%d → %d/%d/%d leap=%v: %v",
@@ -77,6 +94,31 @@ func TestSolarLunarRoundTrip(t *testing.T) {
}
}
// TestSolarToLunar_LunationOvershoot pins the four days in the supported range
// where the mean-cycle lunation estimate overshoots by one and the reference
// implementation returns lunar day 0. All four are the 30th (last) day of
// their lunar month; lunarToSolar, whose index is rounded rather than floored,
// already agreed with these values.
func TestSolarToLunar_LunationOvershoot(t *testing.T) {
cases := []struct {
solarDay, solarMonth, solarYear int
lunarDay, lunarMonth, lunarYear int
}{
{13, 4, 1877, 30, 2, 1877},
{16, 3, 1885, 30, 1, 1885},
{7, 5, 2054, 30, 3, 2054},
{9, 4, 2062, 30, 2, 2062},
}
for _, tc := range cases {
d, m, y, leap := solarToLunar(tc.solarDay, tc.solarMonth, tc.solarYear)
if d != tc.lunarDay || m != tc.lunarMonth || y != tc.lunarYear || leap {
t.Errorf("solarToLunar(%d/%d/%d) = %d/%d/%d leap=%v, want %d/%d/%d leap=false",
tc.solarDay, tc.solarMonth, tc.solarYear, d, m, y, leap,
tc.lunarDay, tc.lunarMonth, tc.lunarYear)
}
}
}
func TestLunarToSolar_LeapMonth2025RoundTrip(t *testing.T) {
// Ất Tỵ 2025 has a leap month 6.
solarDay, solarMonth, solarYear, err := lunarToSolar(1, 6, 2025, true)
@@ -132,6 +174,34 @@ func TestLunarToSolar_RejectsDay30OfShortMonth(t *testing.T) {
}
}
// TestLeapMonthTable anchors leap-month placement — the trickiest part of the
// algorithm — to published Vietnamese calendar tables: each listed year must
// accept exactly its documented leap month and reject leap for all others.
func TestLeapMonthTable(t *testing.T) {
leapMonths := map[int]int{
1995: 8, 1998: 5, 2001: 4, 2004: 2, 2006: 7, 2009: 5, 2012: 4,
2014: 9, 2017: 6, 2020: 4, 2023: 2, 2025: 6, 2028: 5, 2031: 3, 2033: 11,
}
for year, leapMonth := range leapMonths {
for m := 1; m <= 12; m++ {
_, _, _, err := lunarToSolar(1, m, year, true)
if m == leapMonth && err != nil {
t.Errorf("year %d rejected documented leap month %d: %v", year, m, err)
}
if m != leapMonth && err == nil {
t.Errorf("year %d accepted leap month %d; tables say %d", year, m, leapMonth)
}
}
}
for _, year := range []int{2021, 2022, 2024, 2026, 2027} {
for m := 1; m <= 12; m++ {
if _, _, _, err := lunarToSolar(1, m, year, true); err == nil {
t.Errorf("year %d accepted leap month %d; year has no leap month", year, m)
}
}
}
}
func TestCanChiYear(t *testing.T) {
for year, want := range map[int]string{
2024: "Giáp Thìn",