Skip to main content

amlich_core/
tietkhi.rs

1use crate::sun::sun_longitude;
2/**
3 * Tiết Khí (24 Solar Terms) Calculations
4 *
5 * Solar terms are based on the sun's ecliptic longitude:
6 * - Each term corresponds to 15° (360° / 24)
7 * - 0° = Xuân Phân (Spring Equinox)
8 * - 15° = Thanh Minh
9 * - ...and so on
10 *
11 * References:
12 * - Based on astronomical calculations from Jean Meeus
13 * - Traditional Vietnamese naming
14 */
15use std::f64::consts::PI;
16
17/// Information about a solar term
18#[derive(Debug, Clone, PartialEq)]
19pub struct SolarTerm {
20    pub index: usize,
21    pub name: String,
22    pub description: String,
23    pub longitude: i32,
24    pub current_longitude: f64,
25    pub season: String,
26}
27
28/// Solar term definition
29#[derive(Debug, Clone)]
30pub struct SolarTermDef {
31    pub name: &'static str,
32    pub description: &'static str,
33    pub longitude: i32,
34}
35
36// 24 Solar Terms in Vietnamese (starting from 0° = Xuân Phân)
37pub const TIET_KHI: [SolarTermDef; 24] = [
38    SolarTermDef {
39        name: "Xuân Phân",
40        description: "Spring Equinox (Xuân Phân)",
41        longitude: 0,
42    },
43    SolarTermDef {
44        name: "Thanh Minh",
45        description: "Pure Brightness (Thanh Minh)",
46        longitude: 15,
47    },
48    SolarTermDef {
49        name: "Cốc Vũ",
50        description: "Grain Rain (Cốc Vũ)",
51        longitude: 30,
52    },
53    SolarTermDef {
54        name: "Lập Hạ",
55        description: "Start of Summer (Lập Hạ)",
56        longitude: 45,
57    },
58    SolarTermDef {
59        name: "Tiểu Mãn",
60        description: "Grain Buds (Tiểu Mãn)",
61        longitude: 60,
62    },
63    SolarTermDef {
64        name: "Mang Chủng",
65        description: "Grain in Ear (Mang Chủng)",
66        longitude: 75,
67    },
68    SolarTermDef {
69        name: "Hạ Chí",
70        description: "Summer Solstice (Hạ Chí)",
71        longitude: 90,
72    },
73    SolarTermDef {
74        name: "Tiểu Thử",
75        description: "Slight Heat (Tiểu Thử)",
76        longitude: 105,
77    },
78    SolarTermDef {
79        name: "Đại Thử",
80        description: "Great Heat (Đại Thử)",
81        longitude: 120,
82    },
83    SolarTermDef {
84        name: "Lập Thu",
85        description: "Start of Autumn (Lập Thu)",
86        longitude: 135,
87    },
88    SolarTermDef {
89        name: "Xử Thử",
90        description: "End of Heat (Xử Thử)",
91        longitude: 150,
92    },
93    SolarTermDef {
94        name: "Bạch Lộ",
95        description: "White Dew (Bạch Lộ)",
96        longitude: 165,
97    },
98    SolarTermDef {
99        name: "Thu Phân",
100        description: "Autumn Equinox (Thu Phân)",
101        longitude: 180,
102    },
103    SolarTermDef {
104        name: "Hàn Lộ",
105        description: "Cold Dew (Hàn Lộ)",
106        longitude: 195,
107    },
108    SolarTermDef {
109        name: "Sương Giáng",
110        description: "Frost Descent (Sương Giáng)",
111        longitude: 210,
112    },
113    SolarTermDef {
114        name: "Lập Đông",
115        description: "Start of Winter (Lập Đông)",
116        longitude: 225,
117    },
118    SolarTermDef {
119        name: "Tiểu Tuyết",
120        description: "Slight Snow (Tiểu Tuyết)",
121        longitude: 240,
122    },
123    SolarTermDef {
124        name: "Đại Tuyết",
125        description: "Great Snow (Đại Tuyết)",
126        longitude: 255,
127    },
128    SolarTermDef {
129        name: "Đông Chí",
130        description: "Winter Solstice (Đông Chí)",
131        longitude: 270,
132    },
133    SolarTermDef {
134        name: "Tiểu Hàn",
135        description: "Slight Cold (Tiểu Hàn)",
136        longitude: 285,
137    },
138    SolarTermDef {
139        name: "Đại Hàn",
140        description: "Great Cold (Đại Hàn)",
141        longitude: 300,
142    },
143    SolarTermDef {
144        name: "Lập Xuân",
145        description: "Start of Spring (Lập Xuân)",
146        longitude: 315,
147    },
148    SolarTermDef {
149        name: "Vũ Thủy",
150        description: "Rain Water (Vũ Thủy)",
151        longitude: 330,
152    },
153    SolarTermDef {
154        name: "Kinh Trập",
155        description: "Awakening of Insects (Kinh Trập)",
156        longitude: 345,
157    },
158];
159
160/// Get season name from term index
161///
162/// # Arguments
163/// * `term_index` - Solar term index (0-23)
164///
165/// # Returns
166/// Season name in Vietnamese
167pub fn get_season(term_index: usize) -> &'static str {
168    match term_index {
169        0..=5 => "Xuân (Spring)",
170        6..=11 => "Hạ (Summer)",
171        12..=17 => "Thu (Autumn)",
172        _ => "Đông (Winter)",
173    }
174}
175
176/// Get Solar Term (Tiết Khí) for a given date
177///
178/// # Arguments
179/// * `jd` - Julian Day Number
180/// * `time_zone` - Timezone offset (default: 7 for Vietnam)
181///
182/// # Returns
183/// Solar term information
184pub fn get_tiet_khi(jd: i32, time_zone: f64) -> SolarTerm {
185    // Calculate sun longitude at local midnight
186    let sun_long_rad = sun_longitude(jd as f64 - 0.5 - time_zone / 24.0);
187
188    // Convert radians to degrees
189    let sun_long_deg = (sun_long_rad * 180.0 / PI) % 360.0;
190
191    // Calculate term index (0-23)
192    let term_index = (sun_long_deg / 15.0).floor() as usize;
193
194    let term = &TIET_KHI[term_index];
195
196    SolarTerm {
197        index: term_index,
198        name: term.name.to_string(),
199        description: term.description.to_string(),
200        longitude: term.longitude,
201        current_longitude: (sun_long_deg * 100.0).round() / 100.0, // Round to 2 decimal places
202        season: get_season(term_index).to_string(),
203    }
204}
205
206/// Solar term with date information
207#[derive(Debug, Clone)]
208pub struct SolarTermWithDate {
209    pub jd: i32,
210    pub index: usize,
211    pub name: String,
212    pub description: String,
213    pub longitude: i32,
214    pub current_longitude: f64,
215    pub season: String,
216}
217
218/// Get all solar terms for a given year
219/// Useful for displaying a full year calendar
220///
221/// # Arguments
222/// * `year` - Solar year
223/// * `time_zone` - Timezone offset
224///
225/// # Returns
226/// Vector of solar terms with dates
227pub fn get_all_tiet_khi_for_year(year: i32, time_zone: f64) -> Vec<SolarTermWithDate> {
228    use crate::julian::jd_from_date;
229
230    let mut terms = Vec::new();
231    let start_jd = jd_from_date(1, 1, year);
232    let end_jd = jd_from_date(31, 12, year);
233
234    let mut prev_term_index: Option<usize> = None;
235
236    for jd in start_jd..=end_jd {
237        let tiet_khi = get_tiet_khi(jd, time_zone);
238
239        // Detect when we cross into a new term
240        if Some(tiet_khi.index) != prev_term_index {
241            terms.push(SolarTermWithDate {
242                jd,
243                index: tiet_khi.index,
244                name: tiet_khi.name.clone(),
245                description: tiet_khi.description.clone(),
246                longitude: tiet_khi.longitude,
247                current_longitude: tiet_khi.current_longitude,
248                season: tiet_khi.season.clone(),
249            });
250            prev_term_index = Some(tiet_khi.index);
251        }
252    }
253
254    terms
255}
256
257/// Returns:
258/// - 0: Input JD is exactly on a Tiết Khí
259/// - Negative: Input JD is before the nearest Tiết Khí boundary
260/// - Positive: Input JD is after the nearest Tiết Khí boundary
261///
262/// # Arguments
263/// * `jd` - Julian Day Number
264///
265/// # Returns
266/// Signed days difference (negative/zero/positive)
267pub fn get_days_to_nearest_tiet_khi(jd: i32) -> i32 {
268    let (_, _, year) = crate::julian::jd_to_date(jd);
269
270    let mut candidate_jds = Vec::new();
271    for candidate_year in (year - 1)..=(year + 1) {
272        for term in get_all_tiet_khi_for_year(candidate_year, 7.0) {
273            candidate_jds.push(term.jd);
274        }
275    }
276    candidate_jds.sort_unstable();
277    candidate_jds.dedup();
278
279    if candidate_jds.binary_search(&jd).is_ok() {
280        return 0;
281    }
282
283    let nearest_term_jd = candidate_jds
284        .into_iter()
285        .min_by_key(|&candidate_jd| (candidate_jd - jd).abs());
286
287    match nearest_term_jd {
288        Some(term_jd) => jd - term_jd,
289        None => 0,
290    }
291}
292
293/// Calculate signed days from input JD to nearest Solar Term (Tiết Khí)
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::julian::jd_from_date;
299
300    #[test]
301    fn test_exact_match_returns_zero() {
302        // JD for a known date that should be exactly on or very near a Tiết Khí
303        // Using March 21, 2024 which should be near or on a term
304        let jd = jd_from_date(21, 3, 2024);
305        let diff = get_days_to_nearest_tiet_khi(jd);
306        // Should be close to 0 (either on term or near it)
307        assert!(
308            diff.abs() <= 2,
309            "Expected exact or near match, got {}",
310            diff
311        );
312    }
313
314    #[test]
315    fn test_before_returns_negative() {
316        // Date 5 days before a Tiết Khí
317        let jd = jd_from_date(16, 3, 2024); // Before spring equinox (March 20)
318        let diff = get_days_to_nearest_tiet_khi(jd);
319        assert!(diff < 0, "Should be negative before term");
320        assert!(diff.abs() <= 5, "Should be within 5 days");
321    }
322
323    #[test]
324    fn test_after_returns_positive() {
325        // Date 3 days after a Tiết Khí
326        let jd = jd_from_date(24, 3, 2024); // After spring equinox (March 20)
327        let diff = get_days_to_nearest_tiet_khi(jd);
328        assert!(diff > 0, "Should be positive after term");
329        assert!(diff.abs() <= 5, "Should be within 5 days");
330    }
331
332    #[test]
333    fn test_equidistant_prefers_after() {
334        // Date exactly halfway between two terms (approximately 7.5 days each)
335        let jd = jd_from_date(24, 3, 2024); // After March equinox
336        let diff = get_days_to_nearest_tiet_khi(jd);
337        assert!(diff > 0, "Should prefer after when equidistant");
338        assert!(diff.abs() <= 8, "Should be within 8 days");
339    }
340
341    #[test]
342    fn test_all_terms_considered() {
343        // Verify function doesn't ignore any of the 24 terms
344        // This test checks multiple dates throughout the year
345        let jd_jan = jd_from_date(15, 1, 2024);
346        let diff_jan = get_days_to_nearest_tiet_khi(jd_jan);
347        // Should return some value, not panic
348        assert!(
349            diff_jan != i32::MAX && diff_jan != i32::MIN,
350            "Function should find a nearest term"
351        );
352    }
353
354    #[test]
355    fn test_term_positions_in_year() {
356        // Test that terms are distributed throughout the year
357        let jd_feb = jd_from_date(15, 2, 2024);
358        let diff_feb = get_days_to_nearest_tiet_khi(jd_feb);
359
360        // Different months should give different signed differences
361        assert!(diff_feb.abs() <= 30, "Terms should be within ~15 days");
362    }
363
364    #[test]
365    fn test_tiet_khi_constants() {
366        assert_eq!(TIET_KHI.len(), 24);
367        assert_eq!(TIET_KHI[0].name, "Xuân Phân");
368        assert_eq!(TIET_KHI[0].longitude, 0);
369        assert_eq!(TIET_KHI[12].name, "Thu Phân");
370        assert_eq!(TIET_KHI[12].longitude, 180);
371    }
372
373    #[test]
374    fn test_get_season() {
375        assert_eq!(get_season(0), "Xuân (Spring)");
376        assert_eq!(get_season(5), "Xuân (Spring)");
377        assert_eq!(get_season(6), "Hạ (Summer)");
378        assert_eq!(get_season(11), "Hạ (Summer)");
379        assert_eq!(get_season(12), "Thu (Autumn)");
380        assert_eq!(get_season(17), "Thu (Autumn)");
381        assert_eq!(get_season(18), "Đông (Winter)");
382        assert_eq!(get_season(23), "Đông (Winter)");
383    }
384
385    #[test]
386    fn test_get_tiet_khi_basic() {
387        // Test a random date
388        let jd = jd_from_date(10, 2, 2024);
389        let tiet_khi = get_tiet_khi(jd, 7.0);
390
391        // Should return a valid term
392        assert!(tiet_khi.index < 24);
393        assert!(!tiet_khi.name.is_empty());
394        assert!(tiet_khi.current_longitude >= 0.0 && tiet_khi.current_longitude < 360.0);
395    }
396
397    #[test]
398    fn test_get_tiet_khi_march_equinox() {
399        // March 21, 2024 is the spring equinox (Xuân Phân, index 0)
400        let jd = jd_from_date(21, 3, 2024);
401        let tiet_khi = get_tiet_khi(jd, 7.0);
402
403        // Should be index 0 (Xuân Phân) or possibly index 1 (Thanh Minh) or 23 (just before)
404        assert!(
405            tiet_khi.index == 0 || tiet_khi.index == 1 || tiet_khi.index == 23,
406            "Expected index 0, 1, or 23 for March 21, got {}",
407            tiet_khi.index
408        );
409    }
410
411    #[test]
412    fn test_get_tiet_khi_summer_solstice() {
413        // Around June 21 should be near Hạ Chí (index 6, longitude 90°)
414        let jd = jd_from_date(21, 6, 2024);
415        let tiet_khi = get_tiet_khi(jd, 7.0);
416
417        // Should be close to index 6 (Hạ Chí)
418        assert!(
419            tiet_khi.index >= 5 && tiet_khi.index <= 7,
420            "Expected index 6±1, got {}",
421            tiet_khi.index
422        );
423    }
424
425    #[test]
426    fn test_get_tiet_khi_winter_solstice() {
427        // Around December 21 should be near Đông Chí (index 18, longitude 270°)
428        let jd = jd_from_date(21, 12, 2024);
429        let tiet_khi = get_tiet_khi(jd, 7.0);
430
431        // Should be close to index 18 (Đông Chí)
432        assert!(
433            tiet_khi.index >= 17 && tiet_khi.index <= 19,
434            "Expected index 18±1, got {}",
435            tiet_khi.index
436        );
437    }
438
439    #[test]
440    fn test_get_all_tiet_khi_for_year() {
441        let terms = get_all_tiet_khi_for_year(2024, 7.0);
442
443        // Should have around 24 terms (may have 23-25 depending on year boundaries)
444        assert!(
445            terms.len() >= 23 && terms.len() <= 25,
446            "Expected 23-25 terms, got {}",
447            terms.len()
448        );
449
450        // Terms should be in chronological order
451        for i in 1..terms.len() {
452            assert!(
453                terms[i].jd > terms[i - 1].jd,
454                "Terms not in chronological order"
455            );
456        }
457    }
458
459    #[test]
460    fn test_solar_term_progression() {
461        // Test that solar terms progress correctly through the year
462        let jd_spring = jd_from_date(1, 4, 2024);
463        let jd_summer = jd_from_date(1, 7, 2024);
464        let jd_autumn = jd_from_date(1, 10, 2024);
465        let jd_winter = jd_from_date(1, 1, 2024);
466
467        let term_spring = get_tiet_khi(jd_spring, 7.0);
468        let term_summer = get_tiet_khi(jd_summer, 7.0);
469        let term_autumn = get_tiet_khi(jd_autumn, 7.0);
470        let term_winter = get_tiet_khi(jd_winter, 7.0);
471
472        // Verify seasons match expected months
473        assert!(term_spring.season.contains("Spring") || term_spring.season.contains("Summer"));
474        assert!(term_summer.season.contains("Summer"));
475        assert!(term_autumn.season.contains("Autumn"));
476        assert!(term_winter.season.contains("Winter") || term_winter.season.contains("Spring"));
477    }
478}