1use crate::sun::sun_longitude;
2use std::f64::consts::PI;
16
17#[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#[derive(Debug, Clone)]
30pub struct SolarTermDef {
31 pub name: &'static str,
32 pub description: &'static str,
33 pub longitude: i32,
34}
35
36pub 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
160pub 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
176pub fn get_tiet_khi(jd: i32, time_zone: f64) -> SolarTerm {
185 let sun_long_rad = sun_longitude(jd as f64 - 0.5 - time_zone / 24.0);
187
188 let sun_long_deg = (sun_long_rad * 180.0 / PI) % 360.0;
190
191 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, season: get_season(term_index).to_string(),
203 }
204}
205
206#[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
218pub 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 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
257pub 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#[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 let jd = jd_from_date(21, 3, 2024);
305 let diff = get_days_to_nearest_tiet_khi(jd);
306 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 let jd = jd_from_date(16, 3, 2024); 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 let jd = jd_from_date(24, 3, 2024); 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 let jd = jd_from_date(24, 3, 2024); 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 let jd_jan = jd_from_date(15, 1, 2024);
346 let diff_jan = get_days_to_nearest_tiet_khi(jd_jan);
347 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 let jd_feb = jd_from_date(15, 2, 2024);
358 let diff_feb = get_days_to_nearest_tiet_khi(jd_feb);
359
360 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 let jd = jd_from_date(10, 2, 2024);
389 let tiet_khi = get_tiet_khi(jd, 7.0);
390
391 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 let jd = jd_from_date(21, 3, 2024);
401 let tiet_khi = get_tiet_khi(jd, 7.0);
402
403 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 let jd = jd_from_date(21, 6, 2024);
415 let tiet_khi = get_tiet_khi(jd, 7.0);
416
417 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 let jd = jd_from_date(21, 12, 2024);
429 let tiet_khi = get_tiet_khi(jd, 7.0);
430
431 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 assert!(
445 terms.len() >= 23 && terms.len() <= 25,
446 "Expected 23-25 terms, got {}",
447 terms.len()
448 );
449
450 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 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 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}