Skip to content

Commit 24a0bb2

Browse files
Add Series vs BSP integration tests and CI
Add a new integration test suite that compares the internal "series" ephemeris against a JPL BSP reference to validate solar terms, lunar phases/months, calendar day/ganzhi, lunar/solar eclipses, and solar zodiac intervals (tests/test_series_vs_bsp.cpp). Introduce test helpers in tests/test_common.cpp/hpp to locate a reference BSP via LUNAR_TEST_BSP or repo-local BSP files and expose reference_bsp()/has_reference_bsp(). Add the new test file to tests/CMakeLists.txt. Update CI (.github/workflows/CI.yml) to exclude SeriesVsBsp from the main ctest run, download de440s.bsp during the job if needed, and run the SeriesVsBsp tests separately with LUNAR_TEST_BSP set. Also adjust eclipse regression expectations and expand solar_zodiac tests to use multiple samples and stricter comparisons to the BSP reference. These changes enable automated validation of the series fallback against a authoritative BSP source in CI.
1 parent 6c441e9 commit 24a0bb2

File tree

7 files changed

+404
-34
lines changed

7 files changed

+404
-34
lines changed

.github/workflows/CI.yml

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,29 @@ jobs:
6565
run: cmake --build build --config ${{ matrix.build_type }} --parallel
6666

6767
- name: Test
68-
run: ctest --test-dir build -C ${{ matrix.build_type }} --output-on-failure
68+
run: >
69+
ctest --test-dir build -C ${{ matrix.build_type }}
70+
--output-on-failure
71+
-E SeriesVsBsp
72+
73+
- name: Download BSP Reference
74+
shell: pwsh
75+
run: |
76+
$out = Join-Path $env:GITHUB_WORKSPACE 'ci-de440s.bsp'
77+
if (!(Test-Path $out)) {
78+
Invoke-WebRequest `
79+
'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/de440s.bsp' `
80+
-OutFile $out
81+
}
82+
if ((Get-Item $out).Length -le 0) {
83+
throw "downloaded BSP file is empty: $out"
84+
}
85+
Write-Host "BSP reference: $out"
86+
87+
- name: Test Series vs BSP
88+
env:
89+
LUNAR_TEST_BSP: ${{ github.workspace }}/ci-de440s.bsp
90+
run: >
91+
ctest --test-dir build -C ${{ matrix.build_type }}
92+
--output-on-failure
93+
-R SeriesVsBsp

tests/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ add_executable(lunar_tests
2020
test_common.cpp
2121
test_core_cli.cpp
2222
test_eclipse.cpp
23+
test_series_vs_bsp.cpp
2324
test_solar_zodiac.cpp
2425
test_almanac_i18n.cpp
2526
test_interact.cpp

tests/test_common.cpp

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ std::string test_bsp_from_env(){
2323
return raw;
2424
}
2525

26+
std::string repo_local_bsp(){
27+
for(const char*name : {"de442.bsp","de440s.bsp"}){
28+
const std::filesystem::path path=name;
29+
std::error_code ec;
30+
if(std::filesystem::exists(path,ec)&&!ec){
31+
return path.string();
32+
}
33+
}
34+
return "";
35+
}
36+
2637
}
2738

2839
std::string test_ephem(){
@@ -41,6 +52,18 @@ bool has_test_ephem(){
4152
return !test_ephem().empty();
4253
}
4354

55+
std::string reference_bsp(){
56+
const std::string env_bsp=test_bsp_from_env();
57+
if(!env_bsp.empty()){
58+
return env_bsp;
59+
}
60+
return repo_local_bsp();
61+
}
62+
63+
bool has_reference_bsp(){
64+
return !reference_bsp().empty();
65+
}
66+
4467
std::filesystem::path make_temp_path(const char*stem,const char*ext){
4568
static std::atomic<unsigned long long> seq{0};
4669
const unsigned long long tick=

tests/test_common.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
std::string test_ephem();
77
bool has_test_ephem();
8+
std::string reference_bsp();
9+
bool has_reference_bsp();
810

911
std::filesystem::path make_temp_path(const char*stem,const char*ext);
1012

tests/test_eclipse.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ TEST(LunarEclipseSeries, TotalEclipseRegression){
2424
EXPECT_LT(ecl.jd_tdb_max,ecl.jd_tdb_u3);
2525
EXPECT_LT(ecl.jd_tdb_u3,ecl.jd_tdb_u4);
2626
if(is_series_ephem(test_ephem())){
27-
EXPECT_NEAR(ecl.lib.l_deg,-3.1472655820,1e-6);
28-
EXPECT_NEAR(ecl.lib.b_deg,-5.5609146682,1e-6);
29-
EXPECT_NEAR(ecl.lib.c_deg,-21.2163490009,1e-6);
27+
EXPECT_NEAR(ecl.lib.l_deg,-4.0686082308,1e-5);
28+
EXPECT_NEAR(ecl.lib.b_deg,0.3789342709,1e-5);
29+
EXPECT_NEAR(ecl.lib.c_deg,-21.2439672118,1e-5);
3030
}
3131
}
3232

tests/test_series_vs_bsp.cpp

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
#include<gtest/gtest.h>
2+
3+
#include<array>
4+
#include<string>
5+
#include<vector>
6+
7+
#include "lunar/calendar.hpp"
8+
#include "lunar/core.hpp"
9+
#include "lunar/lunar_eclipse.hpp"
10+
#include "lunar/solar_eclipse.hpp"
11+
#include "lunar/time_scale.hpp"
12+
13+
#include "test_common.hpp"
14+
15+
namespace{
16+
17+
constexpr double kSolarTermTolSec=15.0*60.0;
18+
constexpr double kLunarPhaseTolSec=20.0*60.0;
19+
constexpr double kLunarMonthTolSec=20.0*60.0;
20+
constexpr double kIllPctTol=0.2;
21+
constexpr double kAngleTolDeg=0.1;
22+
constexpr double kEclipseTimeTolSec=20.0*60.0;
23+
constexpr double kEclipseScalarTol=0.02;
24+
constexpr std::array<const char*,6> kStableDates={{
25+
"2025-01-30",
26+
"2025-03-06",
27+
"2025-04-25",
28+
"2025-06-10",
29+
"2025-08-25",
30+
"2025-11-10",
31+
}};
32+
33+
double cst_to_utc_jd(int year,int month,int day,int hour=0,int minute=0,
34+
double second=0.0){
35+
return greg2jd(year,month,day,hour,minute,second)-UTC8DAY;
36+
}
37+
38+
void expect_close_jd(double lhs,double rhs,double tol_sec){
39+
EXPECT_NEAR(lhs,rhs,tol_sec/SEC_DAY);
40+
}
41+
42+
lunar::core::DayComputeOptions make_day_opt(const std::string&ephem,
43+
const std::string&date_text){
44+
lunar::core::DayComputeOptions opt;
45+
opt.ephem=ephem;
46+
opt.date_text=date_text;
47+
opt.at_time="12:00:00";
48+
opt.tz="+08:00";
49+
opt.lunar_day_tz="+08:00";
50+
opt.include_events=false;
51+
opt.include_astro=false;
52+
return opt;
53+
}
54+
55+
lunar::core::GanzhiComputeOptions make_ganzhi_opt(const std::string&ephem,
56+
const std::string&date_text){
57+
lunar::core::GanzhiComputeOptions opt;
58+
opt.ephem=ephem;
59+
opt.date_text=date_text;
60+
opt.at_time="12:00:00";
61+
opt.tz="+08:00";
62+
opt.lunar_day_tz="+08:00";
63+
return opt;
64+
}
65+
66+
void expect_same_lunar_date(const LunDate&lhs,const LunDate&rhs){
67+
EXPECT_EQ(lhs.lunar_year,rhs.lunar_year);
68+
EXPECT_EQ(lhs.lun_mno,rhs.lun_mno);
69+
EXPECT_EQ(lhs.is_leap,rhs.is_leap);
70+
EXPECT_EQ(lhs.lunar_day,rhs.lunar_day);
71+
EXPECT_EQ(lhs.lun_mlab,rhs.lun_mlab);
72+
EXPECT_EQ(lhs.lun_label,rhs.lun_label);
73+
}
74+
75+
void expect_same_gz(const GzNode&lhs,const GzNode&rhs){
76+
EXPECT_EQ(lhs.stem,rhs.stem);
77+
EXPECT_EQ(lhs.branch,rhs.branch);
78+
EXPECT_EQ(lhs.text,rhs.text);
79+
}
80+
81+
}
82+
83+
TEST(SeriesVsBspCalendar, SolarTermsTrackReference){
84+
if(!has_reference_bsp()){
85+
GTEST_SKIP()<<"requires LUNAR_TEST_BSP or a repo-local BSP file";
86+
}
87+
#if !LUNAR_ENABLE_SERIES_FALLBACK
88+
GTEST_SKIP()<<"requires series fallback";
89+
#else
90+
const std::string ref_bsp=reference_bsp();
91+
EphRead series_eph("series");
92+
EphRead bsp_eph(ref_bsp);
93+
SolLunCal series_solver(series_eph);
94+
SolLunCal bsp_solver(bsp_eph);
95+
96+
for(const auto&it : SolLunCal::st_defs()){
97+
const std::string&code=it.first;
98+
const LocalDT series_dt=series_solver.find_st(code,2025);
99+
const LocalDT bsp_dt=bsp_solver.find_st(code,2025);
100+
SCOPED_TRACE(code);
101+
expect_close_jd(series_dt.toUtcJD(),bsp_dt.toUtcJD(),kSolarTermTolSec);
102+
}
103+
#endif
104+
}
105+
106+
TEST(SeriesVsBspCalendar, LunarPhasesTrackReference){
107+
if(!has_reference_bsp()){
108+
GTEST_SKIP()<<"requires LUNAR_TEST_BSP or a repo-local BSP file";
109+
}
110+
#if !LUNAR_ENABLE_SERIES_FALLBACK
111+
GTEST_SKIP()<<"requires series fallback";
112+
#else
113+
const std::string ref_bsp=reference_bsp();
114+
EphRead series_eph("series");
115+
EphRead bsp_eph(ref_bsp);
116+
SolLunCal series_solver(series_eph);
117+
SolLunCal bsp_solver(bsp_eph);
118+
119+
const YearResult series_year=series_solver.compute_year(2025,nullptr);
120+
const YearResult bsp_year=bsp_solver.compute_year(2025,nullptr);
121+
ASSERT_EQ(series_year.lun_phase.size(),bsp_year.lun_phase.size());
122+
123+
for(std::size_t i=0;i<series_year.lun_phase.size();++i){
124+
const MoonPhMon&series_item=series_year.lun_phase[i];
125+
const MoonPhMon&bsp_item=bsp_year.lun_phase[i];
126+
SCOPED_TRACE(i);
127+
expect_close_jd(series_item.new_moon.toUtcJD(),bsp_item.new_moon.toUtcJD(),
128+
kLunarPhaseTolSec);
129+
expect_close_jd(series_item.fst_qtr.toUtcJD(),bsp_item.fst_qtr.toUtcJD(),
130+
kLunarPhaseTolSec);
131+
expect_close_jd(series_item.full_moon.toUtcJD(),bsp_item.full_moon.toUtcJD(),
132+
kLunarPhaseTolSec);
133+
expect_close_jd(series_item.lst_qtr.toUtcJD(),bsp_item.lst_qtr.toUtcJD(),
134+
kLunarPhaseTolSec);
135+
}
136+
#endif
137+
}
138+
139+
TEST(SeriesVsBspCalendar, LunarMonthsTrackReference){
140+
if(!has_reference_bsp()){
141+
GTEST_SKIP()<<"requires LUNAR_TEST_BSP or a repo-local BSP file";
142+
}
143+
#if !LUNAR_ENABLE_SERIES_FALLBACK
144+
GTEST_SKIP()<<"requires series fallback";
145+
#else
146+
const std::string ref_bsp=reference_bsp();
147+
EphRead series_eph("series");
148+
EphRead bsp_eph(ref_bsp);
149+
LunCal6 series_calc(series_eph);
150+
LunCal6 bsp_calc(bsp_eph);
151+
152+
const std::vector<LunarMonth>&series_months=series_calc.get_months(2025);
153+
const std::vector<LunarMonth>&bsp_months=bsp_calc.get_months(2025);
154+
ASSERT_EQ(series_months.size(),bsp_months.size());
155+
156+
for(std::size_t i=0;i<series_months.size();++i){
157+
const LunarMonth&series_item=series_months[i];
158+
const LunarMonth&bsp_item=bsp_months[i];
159+
SCOPED_TRACE(series_item.label);
160+
EXPECT_EQ(series_item.month_no,bsp_item.month_no);
161+
EXPECT_EQ(series_item.is_leap,bsp_item.is_leap);
162+
EXPECT_EQ(series_item.label,bsp_item.label);
163+
expect_close_jd(series_item.start_dt.toUtcJD(),bsp_item.start_dt.toUtcJD(),
164+
kLunarMonthTolSec);
165+
expect_close_jd(series_item.end_dt.toUtcJD(),bsp_item.end_dt.toUtcJD(),
166+
kLunarMonthTolSec);
167+
}
168+
#endif
169+
}
170+
171+
TEST(SeriesVsBspCoreDay, StableDatesTrackReference){
172+
if(!has_reference_bsp()){
173+
GTEST_SKIP()<<"requires LUNAR_TEST_BSP or a repo-local BSP file";
174+
}
175+
#if !LUNAR_ENABLE_SERIES_FALLBACK
176+
GTEST_SKIP()<<"requires series fallback";
177+
#else
178+
const std::string ref_bsp=reference_bsp();
179+
for(const char*date_text : kStableDates){
180+
const DayResult series_day=
181+
lunar::core::compute_day(make_day_opt("series",date_text));
182+
const DayResult bsp_day=
183+
lunar::core::compute_day(make_day_opt(ref_bsp,date_text));
184+
SCOPED_TRACE(date_text);
185+
expect_same_lunar_date(series_day.at_data.lunar_date,bsp_day.at_data.lunar_date);
186+
EXPECT_EQ(series_day.at_data.phase_name,bsp_day.at_data.phase_name);
187+
EXPECT_EQ(series_day.at_data.waxing,bsp_day.at_data.waxing);
188+
EXPECT_NEAR(series_day.at_data.ill_pct,bsp_day.at_data.ill_pct,kIllPctTol);
189+
EXPECT_NEAR(series_day.at_data.elong_deg,bsp_day.at_data.elong_deg,
190+
kAngleTolDeg);
191+
}
192+
#endif
193+
}
194+
195+
TEST(SeriesVsBspGanzhi, StableDatesTrackReference){
196+
if(!has_reference_bsp()){
197+
GTEST_SKIP()<<"requires LUNAR_TEST_BSP or a repo-local BSP file";
198+
}
199+
#if !LUNAR_ENABLE_SERIES_FALLBACK
200+
GTEST_SKIP()<<"requires series fallback";
201+
#else
202+
const std::string ref_bsp=reference_bsp();
203+
for(const char*date_text : kStableDates){
204+
const lunar::core::GanzhiSummary series_sum=
205+
lunar::core::compute_ganzhi(make_ganzhi_opt("series",date_text));
206+
const lunar::core::GanzhiSummary bsp_sum=
207+
lunar::core::compute_ganzhi(make_ganzhi_opt(ref_bsp,date_text));
208+
SCOPED_TRACE(date_text);
209+
expect_same_gz(series_sum.year,bsp_sum.year);
210+
expect_same_gz(series_sum.month,bsp_sum.month);
211+
expect_same_gz(series_sum.day,bsp_sum.day);
212+
}
213+
#endif
214+
}
215+
216+
TEST(SeriesVsBspLunarEclipse, TotalEclipseTracksReference){
217+
if(!has_reference_bsp()){
218+
GTEST_SKIP()<<"requires LUNAR_TEST_BSP or a repo-local BSP file";
219+
}
220+
#if !LUNAR_ENABLE_SERIES_FALLBACK
221+
GTEST_SKIP()<<"requires series fallback";
222+
#else
223+
const double jd_tdb=TimeScale::utc_to_tdb(cst_to_utc_jd(2025,9,7));
224+
const std::string ref_bsp=reference_bsp();
225+
EphRead series_eph("series");
226+
EphRead bsp_eph(ref_bsp);
227+
LunarEclipse series_ecl;
228+
LunarEclipse bsp_ecl;
229+
ASSERT_TRUE(calc_lunar_eclipse(series_eph,jd_tdb,&series_ecl));
230+
ASSERT_TRUE(calc_lunar_eclipse(bsp_eph,jd_tdb,&bsp_ecl));
231+
ASSERT_TRUE(series_ecl.has);
232+
ASSERT_TRUE(bsp_ecl.has);
233+
EXPECT_EQ(series_ecl.type,bsp_ecl.type);
234+
expect_close_jd(series_ecl.jd_tdb_p1,bsp_ecl.jd_tdb_p1,kEclipseTimeTolSec);
235+
expect_close_jd(series_ecl.jd_tdb_u1,bsp_ecl.jd_tdb_u1,kEclipseTimeTolSec);
236+
expect_close_jd(series_ecl.jd_tdb_max,bsp_ecl.jd_tdb_max,kEclipseTimeTolSec);
237+
expect_close_jd(series_ecl.jd_tdb_u4,bsp_ecl.jd_tdb_u4,kEclipseTimeTolSec);
238+
expect_close_jd(series_ecl.jd_tdb_p4,bsp_ecl.jd_tdb_p4,kEclipseTimeTolSec);
239+
EXPECT_NEAR(series_ecl.pen_mag,bsp_ecl.pen_mag,kEclipseScalarTol);
240+
EXPECT_NEAR(series_ecl.umb_mag,bsp_ecl.umb_mag,kEclipseScalarTol);
241+
EXPECT_NEAR(series_ecl.gamma,bsp_ecl.gamma,kEclipseScalarTol);
242+
EXPECT_NEAR(series_ecl.lib.l_deg,bsp_ecl.lib.l_deg,kAngleTolDeg);
243+
EXPECT_NEAR(series_ecl.lib.b_deg,bsp_ecl.lib.b_deg,kAngleTolDeg);
244+
EXPECT_NEAR(series_ecl.lib.c_deg,bsp_ecl.lib.c_deg,kAngleTolDeg);
245+
#endif
246+
}
247+
248+
TEST(SeriesVsBspSolarEclipse, TotalEclipseTracksReference){
249+
if(!has_reference_bsp()){
250+
GTEST_SKIP()<<"requires LUNAR_TEST_BSP or a repo-local BSP file";
251+
}
252+
#if !LUNAR_ENABLE_SERIES_FALLBACK
253+
GTEST_SKIP()<<"requires series fallback";
254+
#else
255+
const double jd_tdb=TimeScale::utc_to_tdb(cst_to_utc_jd(2026,8,12));
256+
const std::string ref_bsp=reference_bsp();
257+
EphRead series_eph("series");
258+
EphRead bsp_eph(ref_bsp);
259+
SolarEclipse series_ecl;
260+
SolarEclipse bsp_ecl;
261+
ASSERT_TRUE(calc_solar_eclipse(series_eph,jd_tdb,&series_ecl));
262+
ASSERT_TRUE(calc_solar_eclipse(bsp_eph,jd_tdb,&bsp_ecl));
263+
ASSERT_TRUE(series_ecl.has);
264+
ASSERT_TRUE(bsp_ecl.has);
265+
EXPECT_EQ(series_ecl.type,bsp_ecl.type);
266+
expect_close_jd(series_ecl.jd_tdb_c1,bsp_ecl.jd_tdb_c1,kEclipseTimeTolSec);
267+
expect_close_jd(series_ecl.jd_tdb_c2,bsp_ecl.jd_tdb_c2,kEclipseTimeTolSec);
268+
expect_close_jd(series_ecl.jd_tdb_max,bsp_ecl.jd_tdb_max,kEclipseTimeTolSec);
269+
expect_close_jd(series_ecl.jd_tdb_c3,bsp_ecl.jd_tdb_c3,kEclipseTimeTolSec);
270+
expect_close_jd(series_ecl.jd_tdb_c4,bsp_ecl.jd_tdb_c4,kEclipseTimeTolSec);
271+
EXPECT_NEAR(series_ecl.mag,bsp_ecl.mag,kEclipseScalarTol);
272+
EXPECT_NEAR(series_ecl.obscuration,bsp_ecl.obscuration,kEclipseScalarTol);
273+
EXPECT_NEAR(series_ecl.gamma,bsp_ecl.gamma,kEclipseScalarTol);
274+
EXPECT_NEAR(series_ecl.sep_max_deg,bsp_ecl.sep_max_deg,kAngleTolDeg);
275+
#endif
276+
}

0 commit comments

Comments
 (0)