1#![allow(
14 clippy::cast_precision_loss,
15 clippy::cast_possible_truncation,
16 clippy::cast_sign_loss,
17 clippy::cast_possible_wrap,
18 clippy::cast_lossless,
19 reason = "计算天文学:f64 与整数(儒略日、角度分段、民用日序)间的换算固有且取值范围受控"
20)]
21#![allow(
22 clippy::unreadable_literal,
23 reason = "天文/历法系数沿用 Meeus 等权威文献的原始字面,加数字分隔符反而失真、难对照"
24)]
25
26mod lunar;
27mod moon;
28mod sun;
29
30pub use lunar::{solar_to_lunar, LunarDate};
31pub use mingli_core::quantizer::{norm180, norm360};
32pub use moon::new_moon_jd_ut;
33pub use sun::{solar_term_jd, solar_term_time_near, sun_apparent_longitude};
34
35#[must_use]
38pub fn julian_day(year: i32, month: u32, day: f64) -> f64 {
39 let (y, m) = if month <= 2 {
40 (year - 1, month as i32 + 12)
41 } else {
42 (year, month as i32)
43 };
44 let a = (y as f64 / 100.0).floor();
45 let b = 2.0 - a + (a / 4.0).floor();
46 (365.25 * (y as f64 + 4716.0)).floor()
47 + (30.6001 * (m as f64 + 1.0)).floor()
48 + day
49 + b
50 - 1524.5
51}
52
53#[must_use]
55pub fn jd_from_local(
56 year: i32,
57 month: u32,
58 day: u32,
59 hour: u32,
60 minute: u32,
61 second: f64,
62 tz_hours: f64,
63) -> f64 {
64 let day_frac = (hour as f64 + minute as f64 / 60.0 + second / 3600.0) / 24.0;
65 let jd_local = julian_day(year, month, day as f64 + day_frac);
66 jd_local - tz_hours / 24.0
67}
68
69#[must_use]
72pub fn civil_day_number(year: i32, month: u32, day: u32) -> i64 {
73 (julian_day(year, month, day as f64) + 0.5).floor() as i64
74}
75
76#[must_use]
78pub fn local_civil_day_of(jd_ut: f64, tz_hours: f64) -> i64 {
79 (jd_ut + tz_hours / 24.0 + 0.5).floor() as i64
80}
81
82#[must_use]
86pub fn delta_t_seconds(year: f64) -> f64 {
87 if year < 1920.0 {
88 let t = year - 1900.0;
89 -2.79 + 1.494119 * t - 0.0598939 * t.powi(2) + 0.0061966 * t.powi(3) - 0.000197 * t.powi(4)
90 } else if year < 1941.0 {
91 let t = year - 1920.0;
92 21.20 + 0.84493 * t - 0.076100 * t.powi(2) + 0.0020936 * t.powi(3)
93 } else if year < 1961.0 {
94 let t = year - 1950.0;
95 29.07 + 0.407 * t - t.powi(2) / 233.0 + t.powi(3) / 2547.0
96 } else if year < 1986.0 {
97 let t = year - 1975.0;
98 45.45 + 1.067 * t - t.powi(2) / 260.0 - t.powi(3) / 718.0
99 } else if year < 2005.0 {
100 let t = year - 2000.0;
101 63.86 + 0.3345 * t - 0.060374 * t.powi(2)
102 + 0.0017275 * t.powi(3)
103 + 0.000651814 * t.powi(4)
104 + 0.00002373599 * t.powi(5)
105 } else if year < 2050.0 {
106 let t = year - 2000.0;
107 62.92 + 0.32217 * t + 0.005589 * t.powi(2)
108 } else {
109 let u = (year - 1820.0) / 100.0;
111 -20.0 + 32.0 * u * u - 0.5628 * (2150.0 - year)
112 }
113}
114
115fn year_of_jd(jd_ut: f64) -> f64 {
117 2000.0 + (jd_ut - 2451545.0) / 365.25
118}
119
120#[must_use]
127pub fn mean_sidereal_time(jd_ut: f64) -> f64 {
128 let d = jd_ut - 2451545.0;
129 let t = d / 36525.0;
130 (280.46061837 + 360.98564736629 * d + 0.000387933 * t * t - t * t * t / 38_710_000.0)
131 .rem_euclid(360.0)
132}
133
134#[must_use]
140pub fn mean_obliquity(jde: f64) -> f64 {
141 let t = (jde - 2451545.0) / 36525.0;
142 let arcsec = 84_381.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t * t * t;
144 arcsec / 3600.0
145}
146
147#[must_use]
149pub fn jd_ut_to_jde(jd_ut: f64) -> f64 {
150 jd_ut + delta_t_seconds(year_of_jd(jd_ut)) / 86400.0
151}
152
153#[derive(Debug, Clone, Copy)]
159pub struct Moment {
160 pub year: i32,
162 pub month: u32,
164 pub day: u32,
166 pub hour: u32,
168 pub minute: u32,
170 pub tz: f64,
172 pub jd_ut: f64,
174 pub jde: f64,
176 pub sun_longitude: f64,
178 pub sidereal_time: f64,
180 pub obliquity: f64,
182 pub civil_day: i64,
184 pub lunar: LunarDate,
186}
187
188impl Moment {
189 #[must_use]
191 pub fn new(year: i32, month: u32, day: u32, hour: u32, minute: u32, tz: f64) -> Self {
192 let jd_ut = jd_from_local(year, month, day, hour, minute, 0.0, tz);
193 let jde = jd_ut_to_jde(jd_ut);
194 Moment {
195 year,
196 month,
197 day,
198 hour,
199 minute,
200 tz,
201 jd_ut,
202 jde,
203 sun_longitude: sun_apparent_longitude(jde),
204 sidereal_time: mean_sidereal_time(jd_ut),
205 obliquity: mean_obliquity(jde),
206 civil_day: civil_day_number(year, month, day),
207 lunar: solar_to_lunar(year, month, day, tz),
208 }
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
218 fn delta_t_all_branches() {
219 for &(y, lo, hi) in &[
220 (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), ] {
228 let dt = delta_t_seconds(y);
229 assert!(dt > lo && dt < hi, "ΔT({y})={dt} 不在 [{lo},{hi}]");
230 }
231 }
232
233 #[test]
235 fn jd_and_civil_day() {
236 assert!((julian_day(2000, 1, 1.5) - 2451545.0).abs() < 1e-6);
238 let jd = jd_from_local(2024, 1, 1, 0, 0, 0.0, 8.0);
240 assert!((jd - (julian_day(2024, 1, 1.0) - 8.0 / 24.0)).abs() < 1e-9);
241 assert_eq!(
243 civil_day_number(2024, 1, 2) - civil_day_number(2024, 1, 1),
244 1
245 );
246 let c = local_civil_day_of(julian_day(2024, 1, 1.0), 8.0);
248 assert_eq!(c, civil_day_number(2024, 1, 1));
249 assert!(jd_ut_to_jde(2451545.0) > 2451545.0);
251 }
252
253 #[test]
255 fn moment_precomputes() {
256 let m = Moment::new(1990, 6, 15, 14, 30, 8.0);
257 assert_eq!((m.lunar.year, m.lunar.month, m.lunar.day), (1990, 5, 23));
258 assert_eq!(m.civil_day, civil_day_number(1990, 6, 15));
259 assert!((0.0..360.0).contains(&m.sun_longitude));
260 assert!(m.jde > m.jd_ut); }
262
263 #[test]
265 fn gmst_matches_meeus_example() {
266 let g = mean_sidereal_time(2446895.5);
268 assert!((g - 197.693195).abs() < 1e-4, "GMST={g},应 ≈197.693195°");
269 let g2 = mean_sidereal_time(2446895.5 + (19.0 + 21.0 / 60.0) / 24.0);
271 assert!((g2 - 128.737873).abs() < 1e-4, "GMST={g2},应 ≈128.737873°");
272 }
273
274 #[test]
276 fn obliquity_matches_meeus_example() {
277 let e = mean_obliquity(2446895.5);
279 assert!((e - 23.440946).abs() < 1e-5, "ε₀={e},应 ≈23.440946°");
280 }
281
282 #[test]
284 fn reexported_angle_utils() {
285 assert!((norm360(370.0) - 10.0).abs() < 1e-9);
286 assert!((norm180(190.0) + 170.0).abs() < 1e-9);
287 }
288
289 #[test]
306 fn solar_terms_2024_bjt() {
307 let cases = [
309 (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),];
315 for (mo, d, hh, mm, lambda) in cases {
316 let jd = solar_term_jd(2024, lambda);
317 let (ry, rmo, rd, rhh, rmm) = jd_ut_to_local_ymdhm(jd, 8.0);
319 let got = format!("{ry:04}-{rmo:02}-{rd:02} {rhh:02}:{rmm:02}");
320 let want = format!("2024-{mo:02}-{d:02} {hh:02}:{mm:02}");
321 let want_min = (d as i64) * 1440 + hh as i64 * 60 + mm as i64;
323 let got_min = (rd as i64) * 1440 + rhh as i64 * 60 + rmm as i64;
324 assert_eq!(rmo, mo, "节气 λ={lambda} 月份不符:got {got} want {want}");
326 assert_eq!(rd, d, "节气 λ={lambda} 日期不符:got {got} want {want}");
327 let diff = got_min - want_min;
328 assert!(
329 (-12..=0).contains(&diff),
330 "节气 λ={lambda}:got {got} want {want},差 {diff} 分钟——\
331 本算应恒偏早 0–12 分钟(低精度太阳黄经的系统偏置)。\
332 偏出这个区间说明模型变了,参照值要重新对源,不是把容差放宽",
333 );
334 }
335 }
336
337 #[test]
339 fn spring_festivals() {
340 let cases = [
341 (2020, 1, 25),
342 (2021, 2, 12),
343 (2022, 2, 1),
344 (2023, 1, 22),
345 (2024, 2, 10),
346 (2025, 1, 29),
347 ];
348 for (y, mo, d) in cases {
349 let ld = solar_to_lunar(y, mo, d, 8.0);
350 assert!(
351 ld.year == y && ld.month == 1 && !ld.leap && ld.day == 1,
352 "{y}-{mo:02}-{d:02} 应为农历 {y} 正月初一,实得 {ld:?}"
353 );
354 }
355 }
356
357 #[test]
359 fn leap_months() {
360 let a = solar_to_lunar(2023, 3, 22, 8.0);
361 assert!(
362 a.month == 2 && a.leap && a.day == 1,
363 "2023-03-22 应为农历闰二月初一,实得 {a:?}"
364 );
365 let b = solar_to_lunar(2023, 4, 20, 8.0);
367 assert!(
368 b.month == 3 && !b.leap && b.day == 1,
369 "2023-04-20 应为农历三月初一,实得 {b:?}"
370 );
371 let c = solar_to_lunar(2020, 5, 23, 8.0);
372 assert!(
373 c.month == 4 && c.leap && c.day == 1,
374 "2020-05-23 应为农历闰四月初一,实得 {c:?}"
375 );
376 }
377
378 #[test]
380 fn lunar_winter_month_year() {
381 let ld = solar_to_lunar(2023, 12, 25, 8.0);
382 assert_eq!(ld.year, 2023);
383 assert!(ld.month == 11 || ld.month == 12, "实得 {ld:?}");
384 }
385
386 #[test]
388 fn lunar_sample_1990() {
389 let ld = solar_to_lunar(1990, 6, 15, 8.0);
390 assert_eq!(
391 (ld.year, ld.month, ld.leap, ld.day),
392 (1990, 5, false, 23),
393 "1990-06-15 应为农历庚午年五月廿三"
394 );
395 }
396
397 pub(super) fn jd_ut_to_local_ymdhm(jd_ut: f64, tz: f64) -> (i32, u32, u32, u32, u32) {
399 let jd = jd_ut + tz / 24.0 + 0.5;
400 let z = jd.floor();
401 let f = jd - z;
402 let mut a = z;
403 if z >= 2299161.0 {
404 let alpha = ((z - 1867216.25) / 36524.25).floor();
405 a = z + 1.0 + alpha - (alpha / 4.0).floor();
406 }
407 let b = a + 1524.0;
408 let c = ((b - 122.1) / 365.25).floor();
409 let d = (365.25 * c).floor();
410 let e = ((b - d) / 30.6001).floor();
411 let day = b - d - (30.6001 * e).floor();
412 let month = if e < 14.0 { e - 1.0 } else { e - 13.0 };
413 let year = if month > 2.0 { c - 4716.0 } else { c - 4715.0 };
414 let total_min = (f * 1440.0).round() as i64;
415 let hh = (total_min / 60) as u32;
416 let mm = (total_min % 60) as u32;
417 (year as i32, month as u32, day as u32, hh, mm)
418 }
419
420 use proptest::prelude::*;
421 #[test]
448 fn the_new_moon_instants_match_two_published_ephemerides() {
449 const PUBLISHED: [(i64, i32, u32, u32, u32, u32); 7] = [
451 (-1224, 1901, 1, 20, 14, 36),
452 (-618, 1950, 1, 18, 7, 59),
453 (300, 2024, 4, 8, 18, 21),
454 (301, 2024, 5, 8, 3, 22),
455 (304, 2024, 8, 4, 11, 13),
456 (309, 2024, 12, 30, 22, 27),
457 (619, 2050, 1, 23, 4, 57),
458 ];
459 const TOLERANCE_MINUTES: f64 = 2.0;
460
461 let mut worst = 0.0f64;
462 for (k, y, m, d, hour, minute) in PUBLISHED {
463 let published =
464 julian_day(y, m, f64::from(d) + (f64::from(hour) + f64::from(minute) / 60.0) / 24.0);
465 let computed = new_moon_jd_ut(k);
466 let off_minutes = (computed - published) * 24.0 * 60.0;
467 assert!(
468 off_minutes.abs() < TOLERANCE_MINUTES,
469 "第 {k} 个朔:算出 JD {computed:.5},两源作 {y}-{m:02}-{d:02} {hour:02}:{minute:02} UT \
470 (JD {published:.5}),差 {off_minutes:.2} 分钟"
471 );
472 worst = worst.max(off_minutes.abs());
473 }
474 assert!(worst < 2.0, "最大偏差 {worst:.3} 分钟,模型已经变了");
478 }
479
480 #[test]
486 fn the_lunar_sequence_has_no_seams_across_two_centuries() {
487 use std::collections::{BTreeMap, BTreeSet};
488 let tz = 8.0;
489 let days_in = |y: i32, m: u32| -> u32 {
490 match m {
491 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
492 4 | 6 | 9 | 11 => 30,
493 _ => u32::from((y % 4 == 0 && y % 100 != 0) || y % 400 == 0) + 28,
494 }
495 };
496
497 let mut prev: Option<(crate::LunarDate, i64)> = None;
498 let mut month_days: BTreeMap<(i32, u32, bool), u32> = BTreeMap::new();
499 let mut new_year_at: Vec<(u32, u32)> = Vec::new();
500 let mut n = 0u32;
501
502 for y in 1900..=2100 {
503 for m in 1..=12u32 {
504 for d in 1..=days_in(y, m) {
505 let l = crate::solar_to_lunar(y, m, d, tz);
506 let cdn = civil_day_number(y, m, d);
507 n += 1;
508
509 assert!((1..=30).contains(&l.day), "{y}-{m:02}-{d:02} 得农历日 {}", l.day);
511 assert!((1..=12).contains(&l.month), "{y}-{m:02}-{d:02} 得农历月 {}", l.month);
513 *month_days.entry((l.year, l.month, l.leap)).or_insert(0) += 1;
514 if l.month == 1 && l.day == 1 && !l.leap {
515 new_year_at.push((m, d));
516 }
517
518 if let Some((p, pc)) = prev
520 && cdn == pc + 1
521 {
522 let same_month = l.month == p.month && l.leap == p.leap;
523 let ok = if same_month {
524 l.day == p.day + 1
525 } else {
526 l.day == 1 && (29..=30).contains(&p.day)
527 };
528 assert!(
529 ok,
530 "{y}-{m:02}-{d:02} 处农历断开:前一日 {}年{}{}月{}日,本日 {}年{}{}月{}日",
531 p.year, if p.leap { "闰" } else { "" }, p.month, p.day,
532 l.year, if l.leap { "闰" } else { "" }, l.month, l.day,
533 );
534 }
535 prev = Some((l, cdn));
536 }
537 }
538 }
539 assert_eq!(n, 73_414, "扫描规模变了,下面几条实测结论要跟着重验");
540
541 let full: BTreeSet<u32> = month_days.values().copied().filter(|&v| v >= 20).collect();
543 assert_eq!(full, BTreeSet::from([29, 30]), "完整农历月的天数集合应恰为 {{29,30}},实得 {full:?}");
544
545 let mut leaps: BTreeMap<i32, u32> = BTreeMap::new();
547 for (yy, _, is_leap) in month_days.keys() {
548 if *is_leap {
549 *leaps.entry(*yy).or_insert(0) += 1;
550 }
551 }
552 assert!(
553 leaps.values().all(|&c| c == 1),
554 "有农历年出现不止一个闰月:{:?}",
555 leaps.iter().filter(|&(_, &c)| c != 1).collect::<Vec<_>>()
556 );
557
558 assert_eq!(new_year_at.len(), 201, "1900–2100 应有 201 个正月初一");
560 let earliest = new_year_at.iter().min().expect("非空");
561 let latest = new_year_at.iter().max().expect("非空");
562 assert_eq!(*earliest, (1, 21), "最早的正月初一应是 1 月 21 日");
563 assert_eq!(*latest, (2, 20), "最晚的正月初一应是 2 月 20 日");
564 }
565
566 proptest! {
567 #[test]
568 fn prop_sidereal_time_in_range(jd in 2_400_000.0f64..2_500_000.0) {
569 prop_assert!((0.0..360.0).contains(&mean_sidereal_time(jd)));
570 }
571 #[test]
572 fn prop_obliquity_modern_range(jde in 2_400_000.0f64..2_500_000.0) {
573 let e = mean_obliquity(jde);
575 prop_assert!(e > 23.0 && e < 24.0);
576 }
577 #[test]
578 fn prop_sun_longitude_in_range(jde in 2_400_000.0f64..2_500_000.0) {
579 prop_assert!((0.0..360.0).contains(&sun_apparent_longitude(jde)));
580 }
581 #[test]
582 fn prop_solar_term_longitude_roundtrip(year in 1950i32..2050, lambda in 1.0f64..359.0) {
583 let l = sun_apparent_longitude(jd_ut_to_jde(solar_term_jd(year, lambda)));
585 let diff = (l - lambda).rem_euclid(360.0);
586 prop_assert!(diff.min(360.0 - diff) < 0.05, "λ={} got {}", lambda, l);
587 }
588 #[test]
589 fn prop_civil_day_consecutive(d in 1u32..28) {
590 prop_assert_eq!(civil_day_number(2000, 1, d + 1), civil_day_number(2000, 1, d) + 1);
591 }
592 }
593}