From 027a87f3c0165a3bbbdf021443e2aca3359f4dba Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 18 Aug 2026 23:00:31 +0700 Subject: [PATCH] feat(amlich): hint ambiguous leap-month input and flag disputed month boundaries - /duonglich appends a nhuan hint when the queried month is also that lunar year's leap month and the exact leap date exists - both commands append a caveat when the result falls in a lunar month starting or ending on one of the seven razor-edge boundaries from 2072 on (new moon within ~2 minutes of UTC+7 midnight) - freeze the verified 1800-2199 month structure as golden testdata so a self-consistent engine change cannot silently shift boundaries - close known-issues open questions 1 and 2 --- docs/amlich-known-issues.md | 35 +- internal/modules/amlich/golden_test.go | 105 +++++ internal/modules/amlich/handlers.go | 23 + internal/modules/amlich/handlers_test.go | 50 +++ internal/modules/amlich/lunar.go | 30 ++ internal/modules/amlich/lunar_test.go | 38 ++ .../amlich/testdata/lunar-years-1800-2199.txt | 400 ++++++++++++++++++ 7 files changed, 669 insertions(+), 12 deletions(-) create mode 100644 internal/modules/amlich/golden_test.go create mode 100644 internal/modules/amlich/testdata/lunar-years-1800-2199.txt diff --git a/docs/amlich-known-issues.md b/docs/amlich-known-issues.md index c1e80e1..0697a15 100644 --- a/docs/amlich-known-issues.md +++ b/docs/amlich-known-issues.md @@ -35,8 +35,11 @@ Both dates are pinned in `lunar_test.go` `knownDates` so a future **Razor-edge lunations from 2072 on.** New moon within ~2 minutes of UTC+7 midnight, so the month boundary — and therefore output within roughly 30 days of it — may differ by one day from future official tables: 09/12/2072, -15/11/2077, 07/05/2130, 26/05/2150, 17/05/2159, 22/01/2175, 26/01/2199. Not -fixable today, only documentable. The bot gives no caveat for these dates. +15/11/2077, 07/05/2130, 26/05/2150, 17/05/2159, 22/01/2175, 26/01/2199. Those +are the disputed new-moon days themselves; the engine begins each of these +months on the following day (`disputedMonthStarts` in `lunar.go`). Not fixable +today. Both commands append a caveat when the result falls in a lunar month +that starts or ends on one of these boundaries. **ΔT extrapolation drift.** The two-branch polynomial diverges from actual Earth rotation (ΔT has been roughly flat near 69 s since ~2016 against @@ -64,7 +67,9 @@ source will see a mismatch that is not an error. **`/duonglich` defaults inside leap months.** A bare day argument fills in the current month as a regular month; the leap month requires the explicit `nhuan` flag. In leap years such as 2028 (leap 5) and 2031 (leap 3) this is ambiguous -to the user, but the output is correct for what was entered. +to the user, but the output is correct for what was entered. When the queried +month is also that year's leap month (and the exact leap date exists), the +reply appends a hint suggesting the `nhuan` flag. ## Handled, with regression tests @@ -77,14 +82,20 @@ to the user, but the output is correct for what was entered. ## Open questions -1. Should the seven razor-edge lunations from 2072 on carry a caveat in the - bot's reply? Currently silent. -2. Should pre-1968 UTC+8 be modelled explicitly? The current algorithm matches - the published record on both known razor-edge cases by numeric coincidence - rather than by rule. An explicit UTC+8 mode would change roughly 1/24 of - pre-1968 month boundaries and diverge from the standard everyone else uses — - not worth doing unless a user reports a concrete mismatch. +1. ~~Should the seven razor-edge lunations carry a caveat?~~ Resolved: both + commands append a caveat for results in the lunar months touching those + boundaries — months only, not the whole lunar year. +2. ~~Should pre-1968 UTC+8 be modelled explicitly?~~ Closed: don't build. Per + Hồ Ngọc Đức's historic-calendar notes it is not even a single rule — the + North used UTC+8 from 1945–67, but the South used UTC+7 until 1959 and + UTC+8 only from 1960–67, with dynastic tables before 1945. Any concrete + user-reported mismatch gets a doc note, not a mode. 3. Revisit the ΔT model if the flat IERS trend persists. A data-driven update - only pays off near razor-edge boundaries. + only pays off near razor-edge boundaries — and would diverge from the + reference algorithm the whole ecosystem runs, so it needs official tables + as cover before it is worth it. 4. Watch for state calendar bureau tables published past 2100; they would be - the first ground truth for the 2072+ disputes. + the first ground truth for the 2072+ disputes. Related far-future + uncertainty in the same bucket: the CGPM votes in late 2026 on abolishing + the leap second, which would let civil UTC+7 drift slowly from the + astronomical time the algorithm models. diff --git a/internal/modules/amlich/golden_test.go b/internal/modules/amlich/golden_test.go new file mode 100644 index 0000000..aafeea1 --- /dev/null +++ b/internal/modules/amlich/golden_test.go @@ -0,0 +1,105 @@ +package amlich + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +var update = flag.Bool("update", false, "regenerate golden files") + +// goldenTable renders every lunar year in the supported range as one line: +// "YYYY L n1 n2 ... nN" — L is the leap month number (0 = none) and n* are the +// month lengths in chronological order starting at tháng 1, with the leap +// month in sequence (12 entries for a normal year, 13 for a leap year). +func goldenTable(t *testing.T) string { + t.Helper() + var b strings.Builder + for year := minLunarYear; year <= maxLunarYear; year++ { + type monthStart struct { + jd, month int + leap bool + } + var starts []monthStart + for m := 1; m <= 12; m++ { + dd, mm, yy, err := lunarToSolar(1, m, year, false) + if err != nil { + t.Fatalf("lunarToSolar(1, %d, %d): %v", m, year, err) + } + starts = append(starts, monthStart{jdFromDate(dd, mm, yy), m, false}) + if dd, mm, yy, err := lunarToSolar(1, m, year, true); err == nil { + starts = append(starts, monthStart{jdFromDate(dd, mm, yy), m, true}) + } + } + sort.Slice(starts, func(i, j int) bool { return starts[i].jd < starts[j].jd }) + // The last month of year Y ends where tháng 1 of Y+1 begins. For + // maxLunarYear this evaluates the engine one year past the supported + // range, which the astronomy handles fine — only the bot commands + // enforce the bounds. + dd, mm, yy, err := lunarToSolar(1, 1, year+1, false) + if err != nil { + t.Fatalf("lunarToSolar(1, 1, %d): %v", year+1, err) + } + nextYearStart := jdFromDate(dd, mm, yy) + leapMonth := 0 + for _, s := range starts { + if s.leap { + leapMonth = s.month + } + } + if want := 12; (leapMonth == 0 && len(starts) != want) || (leapMonth != 0 && len(starts) != want+1) { + t.Fatalf("year %d: %d month starts with leap month %d", year, len(starts), leapMonth) + } + fmt.Fprintf(&b, "%d %d", year, leapMonth) + for i, s := range starts { + end := nextYearStart + if i+1 < len(starts) { + end = starts[i+1].jd + } + if length := end - s.jd; length != 29 && length != 30 { + t.Fatalf("year %d month %d (leap=%v): length %d days", year, s.month, s.leap, length) + } else { + fmt.Fprintf(&b, " %d", length) + } + } + b.WriteByte('\n') + } + return b.String() +} + +// TestGoldenTable compares the engine's full 1800–2199 month structure against +// the committed golden file. The round-trip test proves the two conversion +// directions agree with each other; this pins the verified boundary placement +// itself, so a future engine change cannot shift months while staying +// self-consistent. Regenerate deliberately with: +// +// go test ./internal/modules/amlich/ -run TestGoldenTable -update +func TestGoldenTable(t *testing.T) { + got := goldenTable(t) + golden := filepath.Join("testdata", "lunar-years-1800-2199.txt") + if *update { + if err := os.WriteFile(golden, []byte(got), 0o644); err != nil { + t.Fatalf("write golden file: %v", err) + } + return + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden file (regenerate with -update): %v", err) + } + if got == string(want) { + return + } + gotLines := strings.Split(got, "\n") + wantLines := strings.Split(string(want), "\n") + for i := 0; i < len(gotLines) && i < len(wantLines); i++ { + if gotLines[i] != wantLines[i] { + t.Fatalf("golden mismatch at line %d:\n got: %s\nwant: %s", i+1, gotLines[i], wantLines[i]) + } + } + t.Fatalf("golden mismatch: got %d lines, want %d", len(gotLines), len(wantLines)) +} diff --git a/internal/modules/amlich/handlers.go b/internal/modules/amlich/handlers.go index 8eeb33a..94cdec5 100644 --- a/internal/modules/amlich/handlers.go +++ b/internal/modules/amlich/handlers.go @@ -27,6 +27,15 @@ const duonglichUsage = `Cách dùng: /duonglich [nhuan] — đ // next to the algorithm it protects. var yearRangeMessage = fmt.Sprintf("Chỉ hỗ trợ các năm từ %d đến %d.", minLunarYear, maxLunarYear) +// leapHintFormat is appended to a /duonglich reply when the queried month is +// also that year's leap month and the user gave no "nhuan" flag — the input +// was ambiguous, and the answer shown is for the regular month. +const leapHintFormat = `Lưu ý: năm âm lịch %d có tháng %d nhuận — thêm "nhuan" nếu ý bạn là tháng nhuận.` + +// disputedCaveat is appended when the result falls in a lunar month whose +// boundary is astronomically too close to call (see disputedMonthStarts). +const disputedCaveat = "Lưu ý: ngày này gần ranh giới tháng âm lịch chưa chắc chắn; kết quả có thể lệch 1 ngày so với lịch chính thức sau này." + func amlichCommand() modules.Command { return modules.Command{ Name: "amlich", @@ -55,6 +64,9 @@ func amlichCommand() modules.Command { lunarDay, lunarMonth, lunarYear, leap := solarToLunar(day, month, year) text := fmt.Sprintf("Dương lịch %02d/%02d/%d là ngày %d tháng %d%s năm %s %d âm lịch.", day, month, year, lunarDay, lunarMonth, leapLabel(leap), canChiYear(lunarYear), lunarYear) + if nearDisputedBoundary(jdFromDate(day, month, year)) { + text += "\n" + disputedCaveat + } return chathelper.Reply(ctx, b, update.Message, text) }, } @@ -102,6 +114,17 @@ func duonglichCommand() modules.Command { } text := fmt.Sprintf("Âm lịch ngày %d tháng %d%s năm %s %d là %02d/%02d/%d dương lịch.", day, month, leapLabel(leap), canChiYear(year), year, solarDay, solarMonth, solarYear) + // A bare month is ambiguous when the year also has that month as a + // leap month and the exact leap date exists; probing lunarToSolar + // reuses its validation (a 29-day leap month can't hide a day 30). + if !leap { + if _, _, _, hintErr := lunarToSolar(day, month, year, true); hintErr == nil { + text += "\n" + fmt.Sprintf(leapHintFormat, year, month) + } + } + if nearDisputedBoundary(jdFromDate(solarDay, solarMonth, solarYear)) { + text += "\n" + disputedCaveat + } return chathelper.Reply(ctx, b, update.Message, text) }, } diff --git a/internal/modules/amlich/handlers_test.go b/internal/modules/amlich/handlers_test.go index 1335900..b4d6d55 100644 --- a/internal/modules/amlich/handlers_test.go +++ b/internal/modules/amlich/handlers_test.go @@ -202,6 +202,56 @@ func TestDuonglich_RejectsWrongLeapMonth(t *testing.T) { } } +// A bare month that is also the year's leap month is ambiguous input; the +// reply must carry the disambiguation hint — and only then. +func TestDuonglich_LeapMonthHint(t *testing.T) { + solarDay, solarMonth, solarYear, err := lunarToSolar(5, 5, 2028, false) + if err != nil { + t.Fatalf("lunarToSolar(5/5/2028): %v", err) + } + want := fmt.Sprintf("Âm lịch ngày 5 tháng 5 năm Mậu Thân 2028 là %02d/%02d/%d dương lịch.\n", solarDay, solarMonth, solarYear) + + `Lưu ý: năm âm lịch 2028 có tháng 5 nhuận — thêm "nhuan" nếu ý bạn là tháng nhuận.` + if got := replyFor(t, "/duonglich 5/5/2028"); got != want { + t.Errorf("reply = %q, want %q", got, want) + } +} + +func TestDuonglich_NoHintWhenNhuanExplicit(t *testing.T) { + if got := replyFor(t, "/duonglich 5/5/2028 nhuan"); strings.Contains(got, "Lưu ý") { + t.Errorf("explicit nhuan reply = %q, want no hint", got) + } +} + +func TestDuonglich_NoHintWithoutLeapMonth(t *testing.T) { + if got := replyFor(t, "/duonglich 5/5/2027"); strings.Contains(got, "Lưu ý") { + t.Errorf("non-leap-year reply = %q, want no hint", got) + } +} + +// Dates inside a lunar month touching a disputed boundary (see +// disputedMonthStarts) must carry the caveat; nearby ordinary dates must not. +// Absence on modern dates is also pinned by the exact-string assertions in +// TestAmlich_ConvertsKnownDate and TestDuonglich_ConvertsKnownDate. +func TestAmlich_DisputedBoundaryCaveat(t *testing.T) { + if got := replyFor(t, "/amlich 20/12/2072"); !strings.HasSuffix(got, "\n"+disputedCaveat) { + t.Errorf("reply = %q, want disputed-boundary caveat", got) + } + if got := replyFor(t, "/amlich 01/06/2072"); strings.Contains(got, "Lưu ý") { + t.Errorf("reply = %q, want no caveat", got) + } +} + +func TestDuonglich_DisputedBoundaryCaveat(t *testing.T) { + lunarDay, lunarMonth, lunarYear, leap := solarToLunar(20, 12, 2072) + cmd := fmt.Sprintf("/duonglich %d/%d/%d", lunarDay, lunarMonth, lunarYear) + if leap { + cmd += " nhuan" + } + if got := replyFor(t, cmd); !strings.HasSuffix(got, "\n"+disputedCaveat) { + t.Errorf("reply for %q = %q, want disputed-boundary caveat", cmd, got) + } +} + func TestDuonglich_InvalidInputRepliesUsage(t *testing.T) { for _, text := range []string{ "/duonglich", diff --git a/internal/modules/amlich/lunar.go b/internal/modules/amlich/lunar.go index bc59ed6..166f4a4 100644 --- a/internal/modules/amlich/lunar.go +++ b/internal/modules/amlich/lunar.go @@ -251,6 +251,36 @@ func lunarToSolar(day, month, year int, leap bool) (dd, mm, yy int, err error) { return dd, mm, yy, nil } +// disputedMonthStarts holds the Julian day numbers of the lunar-month starts +// whose defining new moon falls within ~2 minutes of UTC+7 midnight, where +// ΔT uncertainty exceeds the margin — the true boundary may sit one day +// earlier in future official tables (see docs/amlich-known-issues.md, which +// lists the disputed new-moon days themselves; this engine begins each of +// these months on the following day). Conversions touching these months carry +// a caveat in the bot's reply. +var disputedMonthStarts = map[int]bool{ + jdFromDate(10, 12, 2072): true, + jdFromDate(16, 11, 2077): true, + jdFromDate(8, 5, 2130): true, + jdFromDate(27, 5, 2150): true, + jdFromDate(18, 5, 2159): true, + jdFromDate(23, 1, 2175): true, + jdFromDate(27, 1, 2199): true, +} + +// nearDisputedBoundary reports whether the lunar month containing the solar +// day jdn starts or ends on a disputed boundary — i.e. whether the conversion +// result for that day could shift by one day against future official tables. +func nearDisputedBoundary(jdn int) bool { + k := floorInt((float64(jdn) - jdNewMoonEpoch) / newMoonCycle) + monthStart := getNewMoonDay(k + 1) + for monthStart > jdn { + k-- + monthStart = getNewMoonDay(k + 1) + } + return disputedMonthStarts[monthStart] || disputedMonthStarts[getNewMoonDay(k+2)] +} + // canNames and chiNames are the sexagesimal-cycle stems and branches used to // name lunar years (Giáp Thìn, Ất Tỵ, …). var canNames = [...]string{"Giáp", "Ất", "Bính", "Đinh", "Mậu", "Kỷ", "Canh", "Tân", "Nhâm", "Quý"} diff --git a/internal/modules/amlich/lunar_test.go b/internal/modules/amlich/lunar_test.go index 9953742..2d3d4d3 100644 --- a/internal/modules/amlich/lunar_test.go +++ b/internal/modules/amlich/lunar_test.go @@ -203,6 +203,44 @@ func TestLeapMonthTable(t *testing.T) { } } +// TestDisputedMonthStarts_AreMonthStarts verifies every entry in the disputed +// set is an actual lunar-month start per the engine — a mistyped or off-by-one +// date would silently flag the wrong month. +func TestDisputedMonthStarts_AreMonthStarts(t *testing.T) { + if len(disputedMonthStarts) != 7 { + t.Fatalf("disputed set has %d entries, want 7", len(disputedMonthStarts)) + } + for jd := range disputedMonthStarts { + dd, mm, yy := jdToDate(jd) + day, _, _, _ := solarToLunar(dd, mm, yy) + if day != 1 { + t.Errorf("%02d/%02d/%d (jd %d) is lunar day %d, want month start (day 1)", dd, mm, yy, jd, day) + } + } +} + +// TestNearDisputedBoundary covers the month starting on a disputed boundary, +// the month ending on one, and ordinary dates on both sides. +func TestNearDisputedBoundary(t *testing.T) { + cases := []struct { + dd, mm, yy int + want bool + }{ + {10, 12, 2072, true}, // first day of the disputed month + {20, 12, 2072, true}, // inside the month starting on the disputed boundary + {9, 12, 2072, true}, // last day of the month ending on the disputed boundary + {1, 12, 2072, true}, // inside the month ending on the disputed boundary + {1, 6, 2072, false}, // same year, far from the boundary + {1, 2, 2073, false}, // after the disputed month + {29, 1, 2025, false}, // ordinary modern date + } + for _, tc := range cases { + if got := nearDisputedBoundary(jdFromDate(tc.dd, tc.mm, tc.yy)); got != tc.want { + t.Errorf("nearDisputedBoundary(%02d/%02d/%d) = %v, want %v", tc.dd, tc.mm, tc.yy, got, tc.want) + } + } +} + func TestCanChiYear(t *testing.T) { for year, want := range map[int]string{ 2024: "Giáp Thìn", diff --git a/internal/modules/amlich/testdata/lunar-years-1800-2199.txt b/internal/modules/amlich/testdata/lunar-years-1800-2199.txt new file mode 100644 index 0000000..bcfc88f --- /dev/null +++ b/internal/modules/amlich/testdata/lunar-years-1800-2199.txt @@ -0,0 +1,400 @@ +1800 4 30 29 30 29 30 30 29 30 29 30 29 29 30 +1801 0 29 30 30 29 30 29 30 30 29 30 29 30 +1802 0 29 29 30 29 30 29 30 30 29 30 30 29 +1803 2 30 29 29 30 29 30 29 30 30 29 30 30 29 +1804 0 30 29 29 30 29 29 30 30 29 30 30 30 +1805 7 29 30 29 29 30 29 29 30 29 30 30 30 29 +1806 0 30 30 29 29 30 29 29 30 29 30 30 29 +1807 0 30 30 30 29 29 30 29 29 30 29 30 29 +1808 5 30 30 30 29 30 29 30 29 29 30 29 30 29 +1809 0 30 30 29 30 30 29 30 29 30 29 29 30 +1810 0 29 30 29 30 30 29 30 29 30 30 29 30 +1811 3 29 29 30 29 30 29 30 30 29 30 30 29 30 +1812 0 29 29 30 29 30 29 30 29 30 30 30 29 +1813 0 30 29 29 30 29 29 30 29 30 30 30 29 +1814 2 30 30 29 29 30 29 29 30 29 30 30 29 30 +1815 0 30 30 29 29 30 29 29 30 29 30 29 30 +1816 6 30 30 29 30 29 30 29 29 30 29 30 29 30 +1817 0 30 29 30 30 29 30 29 29 30 29 30 29 +1818 0 30 29 30 30 29 30 30 29 30 29 29 30 +1819 4 29 30 29 30 29 30 30 29 30 30 29 30 29 +1820 0 29 30 29 30 29 30 29 30 30 29 30 30 +1821 0 29 29 30 29 29 30 29 30 30 30 29 30 +1822 3 30 29 29 30 29 29 30 29 30 30 29 30 30 +1823 0 30 29 29 30 29 29 30 29 30 29 30 30 +1824 7 30 29 30 29 30 29 29 30 29 30 29 30 30 +1825 0 29 30 30 29 30 29 29 30 29 30 29 30 +1826 0 29 30 30 30 29 30 29 29 30 29 30 29 +1827 5 30 29 30 30 29 30 29 30 29 30 29 30 29 +1828 0 30 29 30 29 30 29 30 30 29 30 29 30 +1829 0 29 30 29 30 29 30 29 30 30 29 30 30 +1830 4 29 29 30 29 29 30 29 30 30 29 30 30 30 +1831 0 29 29 30 29 29 30 29 30 29 30 30 30 +1832 9 29 30 29 30 29 29 30 29 30 29 30 30 30 +1833 0 29 30 29 30 29 29 30 29 30 29 30 30 +1834 0 29 30 30 29 30 29 29 30 29 30 29 30 +1835 6 29 30 30 29 30 30 29 29 30 29 30 29 30 +1836 0 29 30 29 30 30 29 30 29 30 29 30 29 +1837 0 30 29 30 29 30 29 30 30 29 30 29 30 +1838 4 29 30 29 29 30 29 30 30 29 30 30 29 30 +1839 0 29 30 29 29 30 29 30 29 30 30 30 29 +1840 0 30 29 30 29 29 30 29 30 29 30 30 30 +1841 3 29 30 29 30 29 29 30 29 29 30 30 29 30 +1842 0 30 30 29 30 29 29 30 29 29 30 30 29 +1843 7 30 30 29 30 30 29 29 30 29 30 29 30 29 +1844 0 30 29 30 30 29 30 29 30 29 30 29 30 +1845 0 29 30 29 30 29 30 30 29 30 29 30 29 +1846 5 30 29 29 30 30 29 30 29 30 30 29 30 29 +1847 0 30 29 29 30 29 30 29 30 30 30 29 30 +1848 0 29 30 29 29 30 29 30 29 30 30 29 30 +1849 4 30 29 30 29 29 30 29 29 30 30 29 30 30 +1850 0 30 29 30 29 29 30 29 29 30 30 29 30 +1851 8 30 30 29 30 29 29 30 29 29 30 29 30 30 +1852 0 30 29 30 29 30 29 30 29 29 30 29 30 +1853 0 30 29 30 30 29 30 29 30 29 30 29 30 +1854 7 29 29 30 30 29 30 30 29 30 29 30 29 30 +1855 0 29 29 30 29 30 30 29 30 30 29 30 29 +1856 0 30 29 29 30 29 30 29 30 30 29 30 30 +1857 5 29 30 29 29 30 29 29 30 30 29 30 30 30 +1858 0 29 30 29 29 30 29 29 30 29 30 30 30 +1859 0 30 29 30 29 29 30 29 29 30 29 30 30 +1860 3 30 29 30 30 29 29 30 29 29 30 29 30 30 +1861 0 29 30 30 29 30 29 30 29 29 30 29 30 +1862 8 29 30 30 29 30 30 29 30 29 30 29 29 30 +1863 0 29 30 29 30 30 29 30 30 29 30 29 30 +1864 0 29 29 30 29 30 29 30 30 29 30 30 29 +1865 5 30 29 29 30 29 30 29 30 29 30 30 30 29 +1866 0 30 29 29 30 29 29 30 29 30 30 30 30 +1867 0 29 30 29 29 30 29 29 30 29 30 30 30 +1868 4 29 30 30 29 29 30 29 29 30 29 30 30 29 +1869 0 30 30 29 30 29 30 29 29 30 29 30 29 +1870 10 30 30 30 29 30 29 30 29 29 30 29 30 29 +1871 0 30 30 29 30 30 29 30 29 30 29 29 30 +1872 0 29 30 29 30 30 29 30 29 30 30 29 30 +1873 6 29 29 30 29 30 29 30 30 29 30 30 29 30 +1874 0 29 29 30 29 29 30 30 29 30 30 30 29 +1875 0 30 29 29 30 29 29 30 29 30 30 30 29 +1876 5 30 30 29 29 30 29 29 30 29 30 30 29 30 +1877 0 30 30 29 29 30 29 29 30 29 30 29 30 +1878 0 30 30 29 30 29 30 29 29 30 29 30 29 +1879 3 30 30 29 30 30 29 30 29 29 30 29 30 29 +1880 0 30 29 30 30 29 30 29 30 29 30 29 30 +1881 7 29 30 29 30 29 30 30 29 30 29 30 29 30 +1882 0 29 30 29 30 29 30 29 30 30 29 30 30 +1883 0 29 29 30 29 29 30 29 30 30 30 29 30 +1884 5 30 29 29 30 29 29 30 29 30 30 29 30 30 +1885 0 30 29 29 30 29 29 30 29 30 29 30 30 +1886 0 30 29 30 29 30 29 29 30 29 30 29 30 +1887 4 30 29 30 30 29 30 29 29 30 29 30 29 30 +1888 0 29 30 30 29 30 30 29 29 30 29 30 29 +1889 0 30 29 30 30 29 30 29 30 29 30 29 30 +1890 2 29 30 29 30 29 30 29 30 30 29 30 29 30 +1891 0 29 30 29 29 30 30 29 30 30 29 30 29 +1892 6 30 29 30 29 29 30 29 30 30 29 30 30 29 +1893 0 30 29 30 29 29 30 29 30 29 30 30 30 +1894 0 29 30 29 30 29 29 30 29 30 29 30 30 +1895 5 29 30 30 29 30 29 29 30 29 30 29 30 29 +1896 0 30 30 30 29 30 29 29 30 29 30 29 30 +1897 0 29 30 30 29 30 29 30 29 30 29 30 29 +1898 3 30 29 30 29 30 30 29 30 29 30 29 30 29 +1899 0 30 29 30 29 30 29 30 30 29 30 29 30 +1900 8 29 30 29 29 30 29 30 30 29 30 30 29 30 +1901 0 29 30 29 29 30 29 30 29 30 30 30 29 +1902 0 30 29 30 29 29 30 29 30 29 30 30 29 +1903 5 30 30 29 30 29 29 30 29 29 30 30 29 30 +1904 0 30 30 29 30 29 29 30 29 29 30 30 29 +1905 0 30 30 29 30 30 29 29 30 29 29 30 30 +1906 4 29 30 29 30 30 29 30 29 30 29 30 29 30 +1907 0 29 30 29 30 29 30 30 29 30 29 30 29 +1908 0 30 29 29 30 29 30 30 29 30 30 29 30 +1909 2 29 30 29 29 30 29 30 29 30 30 30 29 30 +1910 0 29 30 29 29 30 29 30 29 30 30 29 30 +1911 6 30 29 30 29 29 30 29 29 30 30 29 30 30 +1912 0 30 29 30 29 29 30 29 29 30 30 29 30 +1913 0 30 30 29 30 29 29 30 29 29 30 29 30 +1914 5 30 30 29 30 29 30 29 30 29 29 30 29 30 +1915 0 30 29 30 30 29 30 29 30 29 30 29 29 +1916 0 30 29 30 30 29 30 30 29 30 29 30 29 +1917 3 30 29 29 30 29 30 30 29 30 30 29 30 29 +1918 0 30 29 29 30 29 30 29 30 30 29 30 30 +1919 7 29 30 29 29 30 29 29 30 30 29 30 30 30 +1920 0 29 30 29 29 30 29 29 30 29 30 30 30 +1921 0 30 29 30 29 29 30 29 29 30 29 30 30 +1922 6 30 29 30 30 29 29 30 29 29 30 29 30 30 +1923 0 29 30 30 29 30 29 30 29 29 30 29 30 +1924 0 29 30 30 29 30 30 29 30 29 30 29 29 +1925 4 30 29 30 29 30 30 29 30 30 29 30 29 30 +1926 0 29 29 30 29 30 29 30 30 29 30 30 29 +1927 0 30 29 29 30 29 30 29 30 29 30 30 30 +1928 2 29 30 29 29 30 29 29 30 29 30 30 30 30 +1929 0 29 30 29 29 30 29 29 30 29 30 30 30 +1930 6 29 30 30 29 29 30 29 29 30 29 30 30 29 +1931 0 30 30 29 30 29 30 29 29 30 29 30 29 +1932 0 30 30 30 29 30 29 30 29 29 30 29 30 +1933 5 29 30 30 29 30 29 30 30 29 29 30 29 30 +1934 0 29 30 29 30 30 29 30 29 30 30 29 29 +1935 0 30 29 30 29 30 29 30 30 29 30 30 29 +1936 3 30 29 29 30 29 29 30 30 29 30 30 29 30 +1937 0 30 29 29 30 29 29 30 29 30 30 30 29 +1938 8 30 30 29 29 30 29 29 30 29 30 30 29 30 +1939 0 30 29 30 29 30 29 29 30 29 30 29 30 +1940 0 30 30 29 30 29 30 29 29 30 29 30 29 +1941 6 30 30 29 30 30 29 30 29 29 30 29 30 29 +1942 0 30 29 30 30 29 30 29 30 29 30 29 30 +1943 0 29 30 29 30 29 30 30 29 30 29 30 29 +1944 4 30 29 30 29 30 29 30 29 30 30 29 30 30 +1945 0 29 29 30 29 29 30 29 30 30 30 29 30 +1946 0 30 29 29 30 29 29 30 29 30 30 29 30 +1947 2 30 29 30 29 30 29 29 30 29 30 29 30 30 +1948 0 30 29 30 29 30 29 29 30 29 30 29 30 +1949 7 30 29 30 30 29 30 29 29 30 29 30 29 30 +1950 0 29 30 30 29 30 29 30 29 30 29 30 29 +1951 0 30 29 30 29 30 30 29 30 29 30 29 30 +1952 5 29 30 29 30 29 30 29 30 30 29 30 29 30 +1953 0 29 30 29 29 30 29 30 30 30 29 30 29 +1954 0 30 29 30 29 29 30 29 30 30 29 30 30 +1955 3 29 30 29 30 29 29 30 29 30 29 30 30 30 +1956 0 29 30 29 30 29 29 30 29 29 30 30 30 +1957 8 29 30 30 29 30 29 29 30 29 29 30 30 29 +1958 0 30 30 30 29 30 29 29 30 29 30 29 30 +1959 0 29 30 30 29 30 29 30 29 30 29 30 29 +1960 6 30 29 30 29 30 30 29 30 29 30 29 30 29 +1961 0 30 29 29 30 30 29 30 30 29 30 29 30 +1962 0 29 30 29 29 30 29 30 30 29 30 30 29 +1963 4 30 29 30 29 29 30 29 30 29 30 30 30 29 +1964 0 30 29 30 29 29 30 29 29 30 30 30 29 +1965 0 30 30 29 30 29 29 30 29 29 30 30 29 +1966 3 30 30 30 29 30 29 29 30 29 29 30 30 29 +1967 0 30 30 29 30 30 29 29 30 29 29 30 29 +1968 7 30 30 29 30 30 29 30 29 30 29 30 29 29 +1969 0 30 30 29 30 29 30 30 29 30 29 30 29 +1970 0 30 29 29 30 29 30 30 29 30 30 29 30 +1971 5 29 30 29 29 30 29 30 29 30 30 30 29 30 +1972 0 29 30 29 29 30 29 30 29 30 30 29 30 +1973 0 30 29 30 29 29 30 29 29 30 30 29 30 +1974 4 30 30 29 30 29 29 30 29 29 30 29 30 30 +1975 0 30 29 30 30 29 29 30 29 29 30 29 30 +1976 8 30 30 29 30 29 30 29 30 29 29 30 29 30 +1977 0 30 29 30 30 29 30 29 30 29 30 29 29 +1978 0 30 29 30 30 29 30 29 30 30 29 30 29 +1979 6 29 30 29 30 29 30 30 29 30 30 29 30 29 +1980 0 30 29 29 30 29 30 29 30 30 29 30 30 +1981 0 29 30 29 29 30 29 29 30 30 29 30 30 +1982 4 30 29 30 29 29 30 29 29 30 29 30 30 30 +1983 0 30 29 30 29 29 30 29 29 30 29 30 30 +1984 0 30 29 30 29 30 29 30 29 29 30 29 30 +1985 2 30 29 30 30 29 30 29 30 29 29 30 29 30 +1986 0 29 30 30 29 30 30 29 30 29 29 30 29 +1987 7 30 29 30 29 30 30 29 30 30 29 30 29 29 +1988 0 30 29 30 29 30 29 30 30 29 30 30 29 +1989 0 30 29 29 30 29 29 30 30 29 30 30 30 +1990 5 29 30 29 29 30 29 29 30 29 30 30 30 30 +1991 0 29 30 29 29 30 29 29 30 29 30 30 30 +1992 0 29 30 30 29 29 30 29 29 30 29 30 30 +1993 3 29 30 30 29 30 29 30 29 29 30 29 30 29 +1994 0 30 30 30 29 30 29 30 29 29 30 29 30 +1995 8 29 30 30 29 30 29 30 29 30 29 30 29 30 +1996 0 29 30 29 30 29 30 30 29 30 29 30 29 +1997 0 30 29 30 29 30 29 30 29 30 30 29 30 +1998 5 30 29 29 30 29 29 30 30 29 30 30 29 30 +1999 0 30 29 29 30 29 29 30 29 30 30 30 29 +2000 0 30 30 29 29 30 29 29 30 29 30 30 29 +2001 4 30 30 29 30 29 30 29 29 30 29 30 29 30 +2002 0 30 30 29 30 29 30 29 29 30 29 30 29 +2003 0 30 30 29 30 30 29 30 29 29 30 29 30 +2004 2 29 30 29 30 30 29 30 29 30 29 30 29 30 +2005 0 29 30 29 30 29 30 30 29 30 29 30 29 +2006 7 30 29 30 29 29 30 30 29 30 30 29 30 29 +2007 0 30 29 30 29 29 30 29 30 30 30 29 30 +2008 0 30 29 29 30 29 29 30 29 30 29 30 30 +2009 5 30 29 30 29 30 29 29 30 29 30 29 30 30 +2010 0 30 29 30 29 30 29 29 30 29 30 29 30 +2011 0 30 29 30 30 29 30 29 29 30 29 30 29 +2012 4 30 29 30 30 29 30 29 30 29 30 29 30 29 +2013 0 30 29 30 29 30 30 29 30 29 30 29 30 +2014 9 29 30 29 30 29 30 29 30 30 29 30 29 30 +2015 0 29 30 29 29 30 29 30 30 30 29 30 29 +2016 0 30 29 30 29 29 30 29 30 30 29 30 30 +2017 6 29 30 29 30 29 29 30 29 30 29 30 30 30 +2018 0 29 30 29 30 29 29 30 29 29 30 30 30 +2019 0 29 30 30 29 30 29 29 30 29 29 30 30 +2020 4 29 30 30 30 29 30 29 29 30 29 29 30 30 +2021 0 29 30 30 29 30 29 30 29 30 29 30 29 +2022 0 30 29 30 29 30 30 29 30 29 30 29 30 +2023 2 29 30 29 29 30 30 29 30 30 29 30 29 30 +2024 0 29 30 29 29 30 29 30 30 29 30 30 29 +2025 6 30 29 30 29 29 30 29 30 29 30 30 30 29 +2026 0 30 29 30 29 29 30 29 29 30 30 30 29 +2027 0 30 30 29 30 29 29 30 29 29 30 30 29 +2028 5 30 30 30 29 30 29 29 30 29 29 30 30 29 +2029 0 30 30 29 30 29 30 29 30 29 29 30 29 +2030 0 30 30 29 30 30 29 30 29 30 29 30 29 +2031 3 29 30 29 30 30 29 30 30 29 30 29 30 29 +2032 0 29 30 29 30 29 30 30 29 30 30 29 30 +2033 11 29 30 29 29 30 29 30 29 30 30 30 29 30 +2034 0 29 30 29 29 30 29 29 30 30 30 29 30 +2035 0 30 29 30 29 29 30 29 29 30 30 29 30 +2036 6 30 30 29 30 29 29 30 29 29 30 29 30 30 +2037 0 30 29 30 30 29 29 30 29 29 30 29 30 +2038 0 30 29 30 30 29 30 29 30 29 29 30 29 +2039 5 30 30 29 30 30 29 30 29 30 29 29 30 29 +2040 0 30 29 30 30 29 30 29 30 30 29 30 29 +2041 0 29 30 29 30 29 30 29 30 30 30 29 30 +2042 2 29 30 29 29 30 29 30 29 30 30 29 30 30 +2043 0 29 30 29 29 30 29 29 30 30 29 30 30 +2044 7 30 29 30 29 29 30 29 29 30 29 30 30 30 +2045 0 30 29 30 29 29 30 29 29 30 29 30 30 +2046 0 30 29 30 29 30 29 30 29 29 30 29 30 +2047 5 30 29 30 30 29 30 29 30 29 29 30 29 30 +2048 0 29 30 30 29 30 30 29 30 29 29 30 29 +2049 0 30 29 30 29 30 30 29 30 29 30 30 29 +2050 3 29 30 29 30 29 30 29 30 30 29 30 30 29 +2051 0 29 30 29 30 29 29 30 30 29 30 30 30 +2052 8 29 30 29 29 30 29 29 30 29 30 30 30 29 +2053 0 30 30 29 29 30 29 29 30 29 30 30 30 +2054 0 29 30 30 29 29 30 29 29 30 29 30 30 +2055 6 29 30 30 29 30 29 30 29 29 30 29 30 29 +2056 0 30 30 29 30 30 29 30 29 29 30 29 30 +2057 0 29 30 29 30 30 29 30 29 30 29 30 29 +2058 4 30 29 30 29 30 29 30 30 29 30 29 30 29 +2059 0 30 29 30 29 30 29 30 29 30 30 29 30 +2060 0 29 30 29 30 29 29 30 29 30 30 30 29 +2061 3 30 30 29 29 30 29 29 30 29 30 30 30 29 +2062 0 30 30 29 29 30 29 29 30 29 30 29 30 +2063 7 30 30 29 30 29 30 29 29 30 29 30 29 30 +2064 0 30 30 29 30 29 30 29 29 30 29 30 29 +2065 0 30 30 29 30 30 29 29 30 29 30 29 30 +2066 5 29 30 29 30 30 29 30 29 30 29 30 29 30 +2067 0 29 30 29 30 29 30 30 29 30 29 30 29 +2068 0 30 29 30 29 29 30 30 29 30 30 29 30 +2069 4 29 30 29 30 29 29 30 29 30 30 30 29 30 +2070 0 29 30 29 30 29 29 30 29 30 29 30 30 +2071 8 30 29 30 29 30 29 29 30 29 30 29 30 30 +2072 0 30 29 30 29 30 29 29 30 29 30 29 30 +2073 0 30 29 30 30 29 30 29 29 30 29 29 30 +2074 6 30 29 30 30 29 30 29 30 29 30 29 30 29 +2075 0 30 29 30 29 30 30 29 30 29 30 29 30 +2076 0 29 30 29 30 29 30 29 30 30 29 30 29 +2077 4 30 29 30 29 29 30 29 30 30 30 29 30 29 +2078 0 30 29 30 29 29 30 29 30 29 30 30 30 +2079 0 29 30 29 30 29 29 30 29 30 29 30 30 +2080 3 30 29 30 29 30 29 29 29 30 29 30 30 30 +2081 0 29 30 30 29 30 29 29 30 29 29 30 30 +2082 7 29 30 30 29 30 29 30 29 30 29 29 30 30 +2083 0 29 30 30 29 30 29 30 29 30 29 30 29 +2084 0 30 29 30 29 30 30 29 30 29 30 29 30 +2085 5 29 29 30 29 30 30 29 30 29 30 30 29 30 +2086 0 29 29 30 29 30 29 30 30 29 30 30 29 +2087 0 30 29 30 29 29 30 29 30 29 30 30 30 +2088 4 29 30 29 30 29 29 29 30 29 30 30 30 29 +2089 0 30 30 29 30 29 29 29 30 29 30 30 29 +2090 8 30 30 30 29 30 29 29 30 29 29 30 29 30 +2091 0 30 30 29 30 29 30 29 30 29 29 30 29 +2092 0 30 30 29 30 30 29 30 29 30 29 29 30 +2093 6 29 30 29 30 30 29 30 30 29 30 29 30 29 +2094 0 29 30 29 30 29 30 30 29 30 30 29 30 +2095 0 29 29 30 29 30 29 30 29 30 30 30 29 +2096 4 30 29 30 29 29 30 29 29 30 30 30 29 30 +2097 0 30 29 30 29 29 29 30 29 30 30 29 30 +2098 0 30 30 29 30 29 29 29 30 29 30 29 30 +2099 2 30 30 29 30 29 30 29 29 30 29 30 29 30 +2100 0 30 29 30 30 29 30 29 30 29 29 30 29 +2101 7 30 30 29 30 29 30 30 29 30 29 29 30 29 +2102 0 30 29 30 29 30 30 29 30 30 29 30 29 +2103 0 29 30 29 30 29 30 29 30 30 29 30 30 +2104 5 29 29 30 29 30 29 30 29 30 30 29 30 30 +2105 0 29 30 29 29 29 30 29 30 30 29 30 30 +2106 0 30 29 30 29 29 29 30 29 30 29 30 30 +2107 4 30 29 30 30 29 29 29 30 29 30 29 30 30 +2108 0 30 29 30 29 30 29 29 30 29 30 29 30 +2109 9 30 29 30 30 29 30 29 30 29 29 30 29 30 +2110 0 29 30 30 29 30 29 30 30 29 29 30 29 +2111 0 30 29 30 29 30 30 29 30 29 30 29 30 +2112 6 29 30 29 30 29 30 29 30 30 29 30 30 29 +2113 0 29 30 29 30 29 29 30 30 29 30 30 30 +2114 0 29 30 29 29 29 30 29 30 29 30 30 30 +2115 4 29 30 30 29 29 29 30 29 30 29 30 30 30 +2116 0 29 30 29 30 29 29 30 29 30 29 30 30 +2117 0 29 30 30 29 30 29 29 30 29 30 29 30 +2118 3 29 30 30 29 30 30 29 29 30 29 30 29 30 +2119 0 29 30 29 30 30 29 30 29 30 29 30 29 +2120 7 30 29 30 29 30 29 30 30 29 30 29 30 29 +2121 0 30 29 30 29 29 30 30 29 30 30 29 30 +2122 0 29 30 29 29 30 29 30 29 30 30 30 29 +2123 5 30 29 30 29 29 30 29 30 29 30 30 30 29 +2124 0 30 29 30 29 29 30 29 30 29 30 29 30 +2125 0 30 30 29 30 29 29 30 29 30 29 30 29 +2126 4 30 30 30 29 30 29 29 30 29 30 29 30 29 +2127 0 30 30 29 30 29 30 29 30 29 30 29 30 +2128 11 29 30 29 30 30 29 30 29 30 29 30 29 30 +2129 0 29 30 29 30 29 30 30 29 30 29 30 29 +2130 0 30 29 30 29 29 30 30 29 30 30 29 30 +2131 6 29 30 29 29 30 29 30 29 30 30 29 30 30 +2132 0 29 30 29 29 30 29 30 29 30 29 30 30 +2133 0 30 29 30 29 30 29 29 29 30 30 29 30 +2134 5 30 30 29 30 29 29 30 29 29 30 29 30 30 +2135 0 30 29 30 30 29 29 30 29 30 29 29 30 +2136 0 30 29 30 30 29 30 29 30 29 30 29 30 +2137 2 29 30 29 30 29 30 30 29 30 29 30 29 30 +2138 0 29 30 29 29 30 30 29 30 30 29 30 29 +2139 7 30 29 29 30 29 30 29 30 30 29 30 30 29 +2140 0 30 29 29 30 29 30 29 30 29 30 30 30 +2141 0 29 30 29 29 30 29 29 30 30 29 30 30 +2142 5 30 29 30 29 30 29 29 29 30 29 30 30 30 +2143 0 29 30 30 29 29 30 29 29 30 29 30 30 +2144 0 29 30 30 29 30 29 30 29 30 29 29 30 +2145 4 30 29 30 30 29 30 29 30 29 30 29 29 30 +2146 0 29 30 30 29 30 29 30 30 29 30 29 30 +2147 0 29 29 30 29 30 29 30 30 29 30 30 29 +2148 1 30 29 29 30 29 30 29 30 30 29 30 30 29 +2149 0 30 29 29 30 29 30 29 30 29 30 30 30 +2150 6 29 30 29 30 29 29 29 30 29 30 30 30 29 +2151 0 30 30 29 30 29 29 29 30 29 30 30 29 +2152 0 30 30 30 29 29 30 29 29 30 29 30 29 +2153 5 30 30 30 29 30 29 30 29 30 29 29 30 29 +2154 0 30 30 29 30 30 29 30 29 30 29 29 30 +2155 0 29 30 29 30 30 29 30 30 29 30 29 30 +2156 3 29 29 30 29 30 29 30 30 29 30 30 29 30 +2157 0 29 29 30 29 30 29 30 29 30 30 30 29 +2158 7 30 29 29 30 29 29 30 29 30 30 30 29 30 +2159 0 30 29 30 29 29 29 30 29 30 30 29 30 +2160 0 30 30 29 29 30 29 29 30 29 30 29 30 +2161 6 30 30 29 30 29 30 29 29 30 29 30 29 30 +2162 0 30 29 30 30 29 30 29 29 30 29 30 29 +2163 0 30 29 30 30 29 30 30 29 30 29 29 30 +2164 4 29 30 29 30 29 30 30 29 30 30 29 29 30 +2165 0 29 30 29 30 29 30 29 30 30 29 30 30 +2166 10 29 29 30 29 29 30 29 30 30 30 29 30 30 +2167 0 29 29 30 29 29 30 29 30 30 29 30 30 +2168 0 30 29 29 30 29 29 30 29 30 29 30 30 +2169 6 30 29 30 29 30 29 29 30 29 30 29 30 30 +2170 0 29 30 30 29 30 29 29 30 29 30 29 30 +2171 0 29 30 30 30 29 30 29 29 30 29 30 29 +2172 5 30 29 30 30 29 30 29 30 29 30 29 30 29 +2173 0 30 29 30 29 30 30 29 30 29 30 29 30 +2174 0 29 30 29 30 29 30 29 30 30 29 30 30 +2175 3 29 29 30 29 29 30 29 30 30 29 30 30 30 +2176 0 29 29 30 29 29 30 29 30 29 30 30 30 +2177 7 29 30 29 30 29 29 30 29 30 29 30 30 30 +2178 0 29 30 29 30 29 29 30 29 30 29 30 30 +2179 0 29 30 30 29 30 29 29 30 29 30 29 30 +2180 6 29 30 30 29 30 30 29 29 30 29 30 29 30 +2181 0 29 30 29 30 30 29 30 29 30 29 30 29 +2182 0 30 29 30 29 30 29 30 30 29 30 29 30 +2183 4 29 30 29 30 29 29 30 30 29 30 30 29 30 +2184 0 29 30 29 29 30 29 30 29 30 30 30 29 +2185 0 30 29 30 29 29 30 29 30 29 30 30 30 +2186 2 29 30 29 30 29 29 30 29 29 30 30 29 30 +2187 0 30 30 29 30 29 29 30 29 29 30 30 29 +2188 6 30 30 30 29 30 29 29 30 29 30 29 30 29 +2189 0 30 30 29 30 29 30 29 30 29 30 29 30 +2190 0 29 30 29 30 30 29 30 29 30 29 30 29 +2191 5 30 29 30 29 30 29 30 29 30 30 29 30 29 +2192 0 30 29 29 30 29 30 29 30 30 30 29 30 +2193 0 29 30 29 29 30 29 30 29 30 30 29 30 +2194 3 30 29 30 29 29 30 29 29 30 30 29 30 30 +2195 0 30 29 30 29 29 30 29 29 30 30 29 30 +2196 7 30 30 29 30 29 29 30 29 29 30 29 30 30 +2197 0 30 29 30 30 29 29 30 29 29 30 29 30 +2198 0 30 29 30 30 29 30 29 30 29 30 29 30 +2199 6 29 30 29 30 29 30 30 29 30 29 30 29 30