#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::cast_lossless,
reason = "计算天文学:f64 与整数(儒略日、角度分段、民用日序)间的换算固有且取值范围受控"
)]
#![allow(
clippy::unreadable_literal,
reason = "天文/历法系数沿用 Meeus 等权威文献的原始字面,加数字分隔符反而失真、难对照"
)]
mod lunar;
mod moon;
mod sun;
pub use lunar::{solar_to_lunar, LunarDate};
pub use mingli_core::quantizer::{norm180, norm360};
pub use moon::new_moon_jd_ut;
pub use sun::{solar_term_jd, solar_term_time_near, sun_apparent_longitude};
#[must_use]
pub fn julian_day(year: i32, month: u32, day: f64) -> f64 {
let (y, m) = if month <= 2 {
(year - 1, month as i32 + 12)
} else {
(year, month as i32)
};
let a = (y as f64 / 100.0).floor();
let b = 2.0 - a + (a / 4.0).floor();
(365.25 * (y as f64 + 4716.0)).floor()
+ (30.6001 * (m as f64 + 1.0)).floor()
+ day
+ b
- 1524.5
}
#[must_use]
pub fn jd_from_local(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: f64,
tz_hours: f64,
) -> f64 {
let day_frac = (hour as f64 + minute as f64 / 60.0 + second / 3600.0) / 24.0;
let jd_local = julian_day(year, month, day as f64 + day_frac);
jd_local - tz_hours / 24.0
}
#[must_use]
pub fn civil_day_number(year: i32, month: u32, day: u32) -> i64 {
(julian_day(year, month, day as f64) + 0.5).floor() as i64
}
#[must_use]
pub fn local_civil_day_of(jd_ut: f64, tz_hours: f64) -> i64 {
(jd_ut + tz_hours / 24.0 + 0.5).floor() as i64
}
#[must_use]
pub fn delta_t_seconds(year: f64) -> f64 {
if year < 1920.0 {
let t = year - 1900.0;
-2.79 + 1.494119 * t - 0.0598939 * t.powi(2) + 0.0061966 * t.powi(3) - 0.000197 * t.powi(4)
} else if year < 1941.0 {
let t = year - 1920.0;
21.20 + 0.84493 * t - 0.076100 * t.powi(2) + 0.0020936 * t.powi(3)
} else if year < 1961.0 {
let t = year - 1950.0;
29.07 + 0.407 * t - t.powi(2) / 233.0 + t.powi(3) / 2547.0
} else if year < 1986.0 {
let t = year - 1975.0;
45.45 + 1.067 * t - t.powi(2) / 260.0 - t.powi(3) / 718.0
} else if year < 2005.0 {
let t = year - 2000.0;
63.86 + 0.3345 * t - 0.060374 * t.powi(2)
+ 0.0017275 * t.powi(3)
+ 0.000651814 * t.powi(4)
+ 0.00002373599 * t.powi(5)
} else if year < 2050.0 {
let t = year - 2000.0;
62.92 + 0.32217 * t + 0.005589 * t.powi(2)
} else {
let u = (year - 1820.0) / 100.0;
-20.0 + 32.0 * u * u - 0.5628 * (2150.0 - year)
}
}
fn year_of_jd(jd_ut: f64) -> f64 {
2000.0 + (jd_ut - 2451545.0) / 365.25
}
#[must_use]
pub fn mean_sidereal_time(jd_ut: f64) -> f64 {
let d = jd_ut - 2451545.0;
let t = d / 36525.0;
(280.46061837 + 360.98564736629 * d + 0.000387933 * t * t - t * t * t / 38_710_000.0)
.rem_euclid(360.0)
}
#[must_use]
pub fn mean_obliquity(jde: f64) -> f64 {
let t = (jde - 2451545.0) / 36525.0;
let arcsec = 84_381.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t * t * t;
arcsec / 3600.0
}
#[must_use]
pub fn jd_ut_to_jde(jd_ut: f64) -> f64 {
jd_ut + delta_t_seconds(year_of_jd(jd_ut)) / 86400.0
}
#[derive(Debug, Clone, Copy)]
pub struct Moment {
pub year: i32,
pub month: u32,
pub day: u32,
pub hour: u32,
pub minute: u32,
pub tz: f64,
pub jd_ut: f64,
pub jde: f64,
pub sun_longitude: f64,
pub sidereal_time: f64,
pub obliquity: f64,
pub civil_day: i64,
pub lunar: LunarDate,
}
impl Moment {
#[must_use]
pub fn new(year: i32, month: u32, day: u32, hour: u32, minute: u32, tz: f64) -> Self {
let jd_ut = jd_from_local(year, month, day, hour, minute, 0.0, tz);
let jde = jd_ut_to_jde(jd_ut);
Moment {
year,
month,
day,
hour,
minute,
tz,
jd_ut,
jde,
sun_longitude: sun_apparent_longitude(jde),
sidereal_time: mean_sidereal_time(jd_ut),
obliquity: mean_obliquity(jde),
civil_day: civil_day_number(year, month, day),
lunar: solar_to_lunar(year, month, day, tz),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn delta_t_all_branches() {
for &(y, lo, hi) in &[
(1910.0, -5.0, 12.0), (1930.0, 22.0, 26.0), (1950.0, 28.0, 32.0), (1975.0, 44.0, 48.0), (1995.0, 60.0, 64.0), (2024.0, 68.0, 76.0), (2100.0, 90.0, 220.0), ] {
let dt = delta_t_seconds(y);
assert!(dt > lo && dt < hi, "ΔT({y})={dt} 不在 [{lo},{hi}]");
}
}
#[test]
fn jd_and_civil_day() {
assert!((julian_day(2000, 1, 1.5) - 2451545.0).abs() < 1e-6);
let jd = jd_from_local(2024, 1, 1, 0, 0, 0.0, 8.0);
assert!((jd - (julian_day(2024, 1, 1.0) - 8.0 / 24.0)).abs() < 1e-9);
assert_eq!(
civil_day_number(2024, 1, 2) - civil_day_number(2024, 1, 1),
1
);
let c = local_civil_day_of(julian_day(2024, 1, 1.0), 8.0);
assert_eq!(c, civil_day_number(2024, 1, 1));
assert!(jd_ut_to_jde(2451545.0) > 2451545.0);
}
#[test]
fn moment_precomputes() {
let m = Moment::new(1990, 6, 15, 14, 30, 8.0);
assert_eq!((m.lunar.year, m.lunar.month, m.lunar.day), (1990, 5, 23));
assert_eq!(m.civil_day, civil_day_number(1990, 6, 15));
assert!((0.0..360.0).contains(&m.sun_longitude));
assert!(m.jde > m.jd_ut); }
#[test]
fn gmst_matches_meeus_example() {
let g = mean_sidereal_time(2446895.5);
assert!((g - 197.693195).abs() < 1e-4, "GMST={g},应 ≈197.693195°");
let g2 = mean_sidereal_time(2446895.5 + (19.0 + 21.0 / 60.0) / 24.0);
assert!((g2 - 128.737873).abs() < 1e-4, "GMST={g2},应 ≈128.737873°");
}
#[test]
fn obliquity_matches_meeus_example() {
let e = mean_obliquity(2446895.5);
assert!((e - 23.440946).abs() < 1e-5, "ε₀={e},应 ≈23.440946°");
}
#[test]
fn reexported_angle_utils() {
assert!((norm360(370.0) - 10.0).abs() < 1e-9);
assert!((norm180(190.0) + 170.0).abs() < 1e-9);
}
#[test]
fn solar_terms_2024_bjt() {
let cases = [
(2, 4, 16, 27, 315.0), (3, 20, 11, 6, 0.0), (6, 21, 4, 51, 90.0), (9, 22, 20, 44, 180.0), (12, 21, 17, 20, 270.0), ];
for (mo, d, hh, mm, lambda) in cases {
let jd = solar_term_jd(2024, lambda);
let (ry, rmo, rd, rhh, rmm) = jd_ut_to_local_ymdhm(jd, 8.0);
let got = format!("{ry:04}-{rmo:02}-{rd:02} {rhh:02}:{rmm:02}");
let want = format!("2024-{mo:02}-{d:02} {hh:02}:{mm:02}");
let want_min = (d as i64) * 1440 + hh as i64 * 60 + mm as i64;
let got_min = (rd as i64) * 1440 + rhh as i64 * 60 + rmm as i64;
assert_eq!(rmo, mo, "节气 λ={lambda} 月份不符:got {got} want {want}");
assert_eq!(rd, d, "节气 λ={lambda} 日期不符:got {got} want {want}");
let diff = got_min - want_min;
assert!(
(-12..=0).contains(&diff),
"节气 λ={lambda}:got {got} want {want},差 {diff} 分钟——\
本算应恒偏早 0–12 分钟(低精度太阳黄经的系统偏置)。\
偏出这个区间说明模型变了,参照值要重新对源,不是把容差放宽",
);
}
}
#[test]
fn spring_festivals() {
let cases = [
(2020, 1, 25),
(2021, 2, 12),
(2022, 2, 1),
(2023, 1, 22),
(2024, 2, 10),
(2025, 1, 29),
];
for (y, mo, d) in cases {
let ld = solar_to_lunar(y, mo, d, 8.0);
assert!(
ld.year == y && ld.month == 1 && !ld.leap && ld.day == 1,
"{y}-{mo:02}-{d:02} 应为农历 {y} 正月初一,实得 {ld:?}"
);
}
}
#[test]
fn leap_months() {
let a = solar_to_lunar(2023, 3, 22, 8.0);
assert!(
a.month == 2 && a.leap && a.day == 1,
"2023-03-22 应为农历闰二月初一,实得 {a:?}"
);
let b = solar_to_lunar(2023, 4, 20, 8.0);
assert!(
b.month == 3 && !b.leap && b.day == 1,
"2023-04-20 应为农历三月初一,实得 {b:?}"
);
let c = solar_to_lunar(2020, 5, 23, 8.0);
assert!(
c.month == 4 && c.leap && c.day == 1,
"2020-05-23 应为农历闰四月初一,实得 {c:?}"
);
}
#[test]
fn lunar_winter_month_year() {
let ld = solar_to_lunar(2023, 12, 25, 8.0);
assert_eq!(ld.year, 2023);
assert!(ld.month == 11 || ld.month == 12, "实得 {ld:?}");
}
#[test]
fn lunar_sample_1990() {
let ld = solar_to_lunar(1990, 6, 15, 8.0);
assert_eq!(
(ld.year, ld.month, ld.leap, ld.day),
(1990, 5, false, 23),
"1990-06-15 应为农历庚午年五月廿三"
);
}
pub(super) fn jd_ut_to_local_ymdhm(jd_ut: f64, tz: f64) -> (i32, u32, u32, u32, u32) {
let jd = jd_ut + tz / 24.0 + 0.5;
let z = jd.floor();
let f = jd - z;
let mut a = z;
if z >= 2299161.0 {
let alpha = ((z - 1867216.25) / 36524.25).floor();
a = z + 1.0 + alpha - (alpha / 4.0).floor();
}
let b = a + 1524.0;
let c = ((b - 122.1) / 365.25).floor();
let d = (365.25 * c).floor();
let e = ((b - d) / 30.6001).floor();
let day = b - d - (30.6001 * e).floor();
let month = if e < 14.0 { e - 1.0 } else { e - 13.0 };
let year = if month > 2.0 { c - 4716.0 } else { c - 4715.0 };
let total_min = (f * 1440.0).round() as i64;
let hh = (total_min / 60) as u32;
let mm = (total_min % 60) as u32;
(year as i32, month as u32, day as u32, hh, mm)
}
use proptest::prelude::*;
#[test]
fn the_new_moon_instants_match_two_published_ephemerides() {
const PUBLISHED: [(i64, i32, u32, u32, u32, u32); 7] = [
(-1224, 1901, 1, 20, 14, 36),
(-618, 1950, 1, 18, 7, 59),
(300, 2024, 4, 8, 18, 21),
(301, 2024, 5, 8, 3, 22),
(304, 2024, 8, 4, 11, 13),
(309, 2024, 12, 30, 22, 27),
(619, 2050, 1, 23, 4, 57),
];
const TOLERANCE_MINUTES: f64 = 2.0;
let mut worst = 0.0f64;
for (k, y, m, d, hour, minute) in PUBLISHED {
let published =
julian_day(y, m, f64::from(d) + (f64::from(hour) + f64::from(minute) / 60.0) / 24.0);
let computed = new_moon_jd_ut(k);
let off_minutes = (computed - published) * 24.0 * 60.0;
assert!(
off_minutes.abs() < TOLERANCE_MINUTES,
"第 {k} 个朔:算出 JD {computed:.5},两源作 {y}-{m:02}-{d:02} {hour:02}:{minute:02} UT \
(JD {published:.5}),差 {off_minutes:.2} 分钟"
);
worst = worst.max(off_minutes.abs());
}
assert!(worst < 2.0, "最大偏差 {worst:.3} 分钟,模型已经变了");
}
#[test]
fn the_lunar_sequence_has_no_seams_across_two_centuries() {
use std::collections::{BTreeMap, BTreeSet};
let tz = 8.0;
let days_in = |y: i32, m: u32| -> u32 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ => u32::from((y % 4 == 0 && y % 100 != 0) || y % 400 == 0) + 28,
}
};
let mut prev: Option<(crate::LunarDate, i64)> = None;
let mut month_days: BTreeMap<(i32, u32, bool), u32> = BTreeMap::new();
let mut new_year_at: Vec<(u32, u32)> = Vec::new();
let mut n = 0u32;
for y in 1900..=2100 {
for m in 1..=12u32 {
for d in 1..=days_in(y, m) {
let l = crate::solar_to_lunar(y, m, d, tz);
let cdn = civil_day_number(y, m, d);
n += 1;
assert!((1..=30).contains(&l.day), "{y}-{m:02}-{d:02} 得农历日 {}", l.day);
assert!((1..=12).contains(&l.month), "{y}-{m:02}-{d:02} 得农历月 {}", l.month);
*month_days.entry((l.year, l.month, l.leap)).or_insert(0) += 1;
if l.month == 1 && l.day == 1 && !l.leap {
new_year_at.push((m, d));
}
if let Some((p, pc)) = prev
&& cdn == pc + 1
{
let same_month = l.month == p.month && l.leap == p.leap;
let ok = if same_month {
l.day == p.day + 1
} else {
l.day == 1 && (29..=30).contains(&p.day)
};
assert!(
ok,
"{y}-{m:02}-{d:02} 处农历断开:前一日 {}年{}{}月{}日,本日 {}年{}{}月{}日",
p.year, if p.leap { "闰" } else { "" }, p.month, p.day,
l.year, if l.leap { "闰" } else { "" }, l.month, l.day,
);
}
prev = Some((l, cdn));
}
}
}
assert_eq!(n, 73_414, "扫描规模变了,下面几条实测结论要跟着重验");
let full: BTreeSet<u32> = month_days.values().copied().filter(|&v| v >= 20).collect();
assert_eq!(full, BTreeSet::from([29, 30]), "完整农历月的天数集合应恰为 {{29,30}},实得 {full:?}");
let mut leaps: BTreeMap<i32, u32> = BTreeMap::new();
for (yy, _, is_leap) in month_days.keys() {
if *is_leap {
*leaps.entry(*yy).or_insert(0) += 1;
}
}
assert!(
leaps.values().all(|&c| c == 1),
"有农历年出现不止一个闰月:{:?}",
leaps.iter().filter(|&(_, &c)| c != 1).collect::<Vec<_>>()
);
assert_eq!(new_year_at.len(), 201, "1900–2100 应有 201 个正月初一");
let earliest = new_year_at.iter().min().expect("非空");
let latest = new_year_at.iter().max().expect("非空");
assert_eq!(*earliest, (1, 21), "最早的正月初一应是 1 月 21 日");
assert_eq!(*latest, (2, 20), "最晚的正月初一应是 2 月 20 日");
}
proptest! {
#[test]
fn prop_sidereal_time_in_range(jd in 2_400_000.0f64..2_500_000.0) {
prop_assert!((0.0..360.0).contains(&mean_sidereal_time(jd)));
}
#[test]
fn prop_obliquity_modern_range(jde in 2_400_000.0f64..2_500_000.0) {
let e = mean_obliquity(jde);
prop_assert!(e > 23.0 && e < 24.0);
}
#[test]
fn prop_sun_longitude_in_range(jde in 2_400_000.0f64..2_500_000.0) {
prop_assert!((0.0..360.0).contains(&sun_apparent_longitude(jde)));
}
#[test]
fn prop_solar_term_longitude_roundtrip(year in 1950i32..2050, lambda in 1.0f64..359.0) {
let l = sun_apparent_longitude(jd_ut_to_jde(solar_term_jd(year, lambda)));
let diff = (l - lambda).rem_euclid(360.0);
prop_assert!(diff.min(360.0 - diff) < 0.05, "λ={} got {}", lambda, l);
}
#[test]
fn prop_civil_day_consecutive(d in 1u32..28) {
prop_assert_eq!(civil_day_number(2000, 1, d + 1), civil_day_number(2000, 1, d) + 1);
}
}
}