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#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::julian::jd_from_date;
261
262    #[test]
263    fn test_tiet_khi_constants() {
264        assert_eq!(TIET_KHI.len(), 24);
265        assert_eq!(TIET_KHI[0].name, "Xuân Phân");
266        assert_eq!(TIET_KHI[0].longitude, 0);
267        assert_eq!(TIET_KHI[12].name, "Thu Phân");
268        assert_eq!(TIET_KHI[12].longitude, 180);
269    }
270
271    #[test]
272    fn test_get_season() {
273        assert_eq!(get_season(0), "Xuân (Spring)");
274        assert_eq!(get_season(5), "Xuân (Spring)");
275        assert_eq!(get_season(6), "Hạ (Summer)");
276        assert_eq!(get_season(11), "Hạ (Summer)");
277        assert_eq!(get_season(12), "Thu (Autumn)");
278        assert_eq!(get_season(17), "Thu (Autumn)");
279        assert_eq!(get_season(18), "Đông (Winter)");
280        assert_eq!(get_season(23), "Đông (Winter)");
281    }
282
283    #[test]
284    fn test_get_tiet_khi_basic() {
285        // Test a random date
286        let jd = jd_from_date(10, 2, 2024);
287        let tiet_khi = get_tiet_khi(jd, 7.0);
288
289        // Should return a valid term
290        assert!(tiet_khi.index < 24);
291        assert!(!tiet_khi.name.is_empty());
292        assert!(tiet_khi.current_longitude >= 0.0 && tiet_khi.current_longitude < 360.0);
293    }
294
295    #[test]
296    fn test_get_tiet_khi_march_equinox() {
297        // March 21, 2024 is the spring equinox (Xuân Phân, index 0)
298        let jd = jd_from_date(21, 3, 2024);
299        let tiet_khi = get_tiet_khi(jd, 7.0);
300
301        // Should be index 0 (Xuân Phân) or possibly index 1 (Thanh Minh) or 23 (just before)
302        assert!(
303            tiet_khi.index == 0 || tiet_khi.index == 1 || tiet_khi.index == 23,
304            "Expected index 0, 1, or 23 for March 21, got {}",
305            tiet_khi.index
306        );
307    }
308
309    #[test]
310    fn test_get_tiet_khi_summer_solstice() {
311        // Around June 21 should be near Hạ Chí (index 6, longitude 90°)
312        let jd = jd_from_date(21, 6, 2024);
313        let tiet_khi = get_tiet_khi(jd, 7.0);
314
315        // Should be close to index 6 (Hạ Chí)
316        assert!(
317            tiet_khi.index >= 5 && tiet_khi.index <= 7,
318            "Expected index 6±1, got {}",
319            tiet_khi.index
320        );
321    }
322
323    #[test]
324    fn test_get_tiet_khi_winter_solstice() {
325        // Around December 21 should be near Đông Chí (index 18, longitude 270°)
326        let jd = jd_from_date(21, 12, 2024);
327        let tiet_khi = get_tiet_khi(jd, 7.0);
328
329        // Should be close to index 18 (Đông Chí)
330        assert!(
331            tiet_khi.index >= 17 && tiet_khi.index <= 19,
332            "Expected index 18±1, got {}",
333            tiet_khi.index
334        );
335    }
336
337    #[test]
338    fn test_get_all_tiet_khi_for_year() {
339        let terms = get_all_tiet_khi_for_year(2024, 7.0);
340
341        // Should have around 24 terms (may have 23-25 depending on year boundaries)
342        assert!(
343            terms.len() >= 23 && terms.len() <= 25,
344            "Expected 23-25 terms, got {}",
345            terms.len()
346        );
347
348        // Terms should be in chronological order
349        for i in 1..terms.len() {
350            assert!(
351                terms[i].jd > terms[i - 1].jd,
352                "Terms not in chronological order"
353            );
354        }
355    }
356
357    #[test]
358    fn test_solar_term_progression() {
359        // Test that solar terms progress correctly through the year
360        let jd_spring = jd_from_date(1, 4, 2024);
361        let jd_summer = jd_from_date(1, 7, 2024);
362        let jd_autumn = jd_from_date(1, 10, 2024);
363        let jd_winter = jd_from_date(1, 1, 2024);
364
365        let term_spring = get_tiet_khi(jd_spring, 7.0);
366        let term_summer = get_tiet_khi(jd_summer, 7.0);
367        let term_autumn = get_tiet_khi(jd_autumn, 7.0);
368        let term_winter = get_tiet_khi(jd_winter, 7.0);
369
370        // Verify seasons match expected months
371        assert!(term_spring.season.contains("Spring") || term_spring.season.contains("Summer"));
372        assert!(term_summer.season.contains("Summer"));
373        assert!(term_autumn.season.contains("Autumn"));
374        assert!(term_winter.season.contains("Winter") || term_winter.season.contains("Spring"));
375    }
376}