1use crate::astro::constants::units::{DEG_TO_RAD, RAD_TO_DEG};
21use crate::astro::math::vec3;
22use crate::validate;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
26pub enum ObservationError {
27 #[error("invalid observation input {field}: {reason}")]
29 InvalidInput {
30 field: &'static str,
32 reason: &'static str,
34 },
35}
36
37fn invalid(field: &'static str, reason: &'static str) -> ObservationError {
38 ObservationError::InvalidInput { field, reason }
39}
40
41fn map_field(error: validate::FieldError) -> ObservationError {
42 invalid(error.field(), error.reason())
43}
44
45#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct SurfacePoint {
48 pub latitude_deg: f64,
50 pub longitude_deg: f64,
52}
53
54fn sub_point(vec_ecef: [f64; 3], field: &'static str) -> Result<SurfacePoint, ObservationError> {
59 validate::finite_vec3(vec_ecef, field).map_err(map_field)?;
60 let [x, y, z] = vec_ecef;
61 let horizontal = (x * x + y * y).sqrt();
62 if horizontal == 0.0 && z == 0.0 {
63 return Err(invalid(field, "zero vector"));
64 }
65 let latitude_deg = z.atan2(horizontal) * RAD_TO_DEG;
66 let longitude_deg = y.atan2(x) * RAD_TO_DEG;
67 Ok(SurfacePoint {
68 latitude_deg,
69 longitude_deg,
70 })
71}
72
73pub fn sub_solar_point(sun_ecef: [f64; 3]) -> Result<SurfacePoint, ObservationError> {
82 sub_point(sun_ecef, "sun_ecef")
83}
84
85pub fn terminator_latitude_deg(
105 sub_solar: SurfacePoint,
106 longitude_deg: f64,
107) -> Result<f64, ObservationError> {
108 let delta = validate::finite(sub_solar.latitude_deg, "sub_solar.latitude_deg")
109 .map_err(map_field)?
110 .to_radians();
111 let lambda_s = validate::finite(sub_solar.longitude_deg, "sub_solar.longitude_deg")
112 .map_err(map_field)?
113 .to_radians();
114 let lon = validate::finite(longitude_deg, "longitude_deg")
115 .map_err(map_field)?
116 .to_radians();
117
118 let tan_delta = delta.tan();
119 let cos_dlon = (lon - lambda_s).cos();
120 const QUADRATURE_EPS: f64 = 1e-9;
127 if cos_dlon.abs() < QUADRATURE_EPS {
128 return Ok(0.0);
129 }
130 let lat = (-cos_dlon / tan_delta).atan();
135 Ok(lat * RAD_TO_DEG)
136}
137
138pub fn parallactic_angle_deg(
154 observer_latitude_deg: f64,
155 hour_angle_deg: f64,
156 declination_deg: f64,
157) -> Result<f64, ObservationError> {
158 let phi = validate::finite(observer_latitude_deg, "observer_latitude_deg")
159 .map_err(map_field)?
160 .to_radians();
161 let h = validate::finite(hour_angle_deg, "hour_angle_deg")
162 .map_err(map_field)?
163 .to_radians();
164 let dec = validate::finite(declination_deg, "declination_deg")
165 .map_err(map_field)?
166 .to_radians();
167
168 let numerator = h.sin();
169 let denominator = phi.tan() * dec.cos() - dec.sin() * h.cos();
170 Ok(numerator.atan2(denominator) * RAD_TO_DEG)
171}
172
173pub fn satellite_visual_magnitude(
201 range_km: f64,
202 phase_angle_deg: f64,
203 standard_magnitude: f64,
204 reference_range_km: f64,
205) -> Result<f64, ObservationError> {
206 let range_km = validate::finite_positive(range_km, "range_km").map_err(map_field)?;
207 let reference_range_km =
208 validate::finite_positive(reference_range_km, "reference_range_km").map_err(map_field)?;
209 let standard_magnitude =
210 validate::finite(standard_magnitude, "standard_magnitude").map_err(map_field)?;
211 let phase_angle_deg =
212 validate::finite(phase_angle_deg, "phase_angle_deg").map_err(map_field)?;
213
214 let phi = phase_angle_deg.clamp(0.0, 180.0) * DEG_TO_RAD;
215 let pi = std::f64::consts::PI;
216 let phase = ((pi - phi) * phi.cos() + phi.sin()) / pi;
217
218 let distance_term = 5.0 * (range_km / reference_range_km).log10();
219 let phase_term = -2.5 * phase.log10();
220 Ok(standard_magnitude + distance_term + phase_term)
221}
222
223pub fn sub_observer_point(
247 observer_from_body: [f64; 3],
248 pole_ra_deg: f64,
249 pole_dec_deg: f64,
250 prime_meridian_deg: f64,
251) -> Result<SurfacePoint, ObservationError> {
252 validate::finite_vec3(observer_from_body, "observer_from_body").map_err(map_field)?;
253 if vec3::norm3(observer_from_body) == 0.0 {
254 return Err(invalid("observer_from_body", "zero vector"));
255 }
256 let alpha0 = validate::finite(pole_ra_deg, "pole_ra_deg")
257 .map_err(map_field)?
258 .to_radians();
259 let delta0 = validate::finite(pole_dec_deg, "pole_dec_deg")
260 .map_err(map_field)?
261 .to_radians();
262 let w = validate::finite(prime_meridian_deg, "prime_meridian_deg")
263 .map_err(map_field)?
264 .to_radians();
265
266 let half_pi = std::f64::consts::FRAC_PI_2;
267 let body_fixed = rot_z(
268 rot_x(
269 rot_z(observer_from_body, half_pi + alpha0),
270 half_pi - delta0,
271 ),
272 w,
273 );
274 sub_point(body_fixed, "observer_from_body")
275}
276
277#[inline]
279fn rot_z(v: [f64; 3], theta: f64) -> [f64; 3] {
280 let (s, c) = theta.sin_cos();
281 [c * v[0] + s * v[1], -s * v[0] + c * v[1], v[2]]
282}
283
284#[inline]
286fn rot_x(v: [f64; 3], theta: f64) -> [f64; 3] {
287 let (s, c) = theta.sin_cos();
288 [v[0], c * v[1] + s * v[2], -s * v[1] + c * v[2]]
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 const AU_KM: f64 = 149_597_870.7;
296
297 fn close(a: f64, b: f64, tol: f64) -> bool {
298 (a - b).abs() <= tol
299 }
300
301 #[test]
302 fn sub_solar_point_reads_declination_and_meridian() {
303 let delta = 23.44_f64.to_radians();
306 let sun = [AU_KM * delta.cos(), 0.0, AU_KM * delta.sin()];
307 let point = sub_solar_point(sun).expect("valid sun vector");
308 assert!(close(point.latitude_deg, 23.44, 1e-9));
309 assert!(close(point.longitude_deg, 0.0, 1e-9));
310
311 let sun_east = [0.0, AU_KM * delta.cos(), AU_KM * delta.sin()];
313 let east = sub_solar_point(sun_east).expect("valid sun vector");
314 assert!(close(east.longitude_deg, 90.0, 1e-9));
315 }
316
317 #[test]
318 fn sub_solar_point_rejects_zero_vector() {
319 let err = sub_solar_point([0.0, 0.0, 0.0]).unwrap_err();
320 assert_eq!(
321 err,
322 ObservationError::InvalidInput {
323 field: "sun_ecef",
324 reason: "zero vector"
325 }
326 );
327 }
328
329 #[test]
330 fn terminator_matches_polar_circles_at_solstice() {
331 let sub_solar = SurfacePoint {
335 latitude_deg: 23.44,
336 longitude_deg: 0.0,
337 };
338 let noon = terminator_latitude_deg(sub_solar, 0.0).expect("valid");
339 let midnight = terminator_latitude_deg(sub_solar, 180.0).expect("valid");
340 assert!(close(noon, -66.56, 1e-9), "noon terminator {noon}");
341 assert!(
342 close(midnight, 66.56, 1e-9),
343 "midnight terminator {midnight}"
344 );
345
346 let quad = terminator_latitude_deg(sub_solar, 90.0).expect("valid");
348 assert!(close(quad, 0.0, 1e-9), "quadrature terminator {quad}");
349 }
350
351 #[test]
352 fn terminator_equinox_quadrature_crosses_equator() {
353 let sub_solar = SurfacePoint {
359 latitude_deg: 0.0,
360 longitude_deg: 0.0,
361 };
362 let east_quad = terminator_latitude_deg(sub_solar, 90.0).expect("valid");
363 let west_quad = terminator_latitude_deg(sub_solar, -90.0).expect("valid");
364 assert!(close(east_quad, 0.0, 1e-9), "east quadrature {east_quad}");
365 assert!(close(west_quad, 0.0, 1e-9), "west quadrature {west_quad}");
366
367 let noon = terminator_latitude_deg(sub_solar, 0.0).expect("valid");
371 let midnight = terminator_latitude_deg(sub_solar, 180.0).expect("valid");
372 assert!(close(noon.abs(), 90.0, 1e-9), "noon poleward limit {noon}");
373 assert!(
374 close(midnight.abs(), 90.0, 1e-9),
375 "midnight poleward limit {midnight}"
376 );
377 }
378
379 #[test]
380 fn terminator_near_equinox_quadrature_crosses_equator() {
381 let sub_solar = SurfacePoint {
385 latitude_deg: 1.0e-4,
386 longitude_deg: 30.0,
387 };
388 let quad = terminator_latitude_deg(sub_solar, 120.0).expect("valid");
389 assert!(close(quad, 0.0, 1e-6), "near-equinox quadrature {quad}");
390 }
391
392 #[test]
393 fn terminator_offset_subsolar_longitude() {
394 let sub_solar = SurfacePoint {
395 latitude_deg: 10.0,
396 longitude_deg: 30.0,
397 };
398 let lat = terminator_latitude_deg(sub_solar, 100.0).expect("valid");
399 assert!(close(lat, -62.726_830_443_196_36, 1e-9), "terminator {lat}");
400 }
401
402 #[test]
403 fn parallactic_angle_zero_on_meridian() {
404 let q = parallactic_angle_deg(45.0, 0.0, 10.0).expect("valid");
405 assert!(close(q, 0.0, 1e-12), "meridian parallactic {q}");
406 }
407
408 #[test]
409 fn parallactic_angle_known_values() {
410 let q = parallactic_angle_deg(45.0, 45.0, 0.0).expect("valid");
413 assert!(close(q, 35.264_389_682_754_654, 1e-9), "{q}");
414
415 let q2 = parallactic_angle_deg(40.0, 30.0, 20.0).expect("valid");
416 assert!(close(q2, 45.444_731_720_286_68, 1e-9), "{q2}");
417
418 let west = parallactic_angle_deg(40.0, 30.0, 20.0).expect("valid");
421 let east = parallactic_angle_deg(40.0, -30.0, 20.0).expect("valid");
422 assert!(close(west, -east, 1e-9), "antisymmetry {west} {east}");
423 }
424
425 #[test]
426 fn visual_magnitude_distance_and_phase_terms() {
427 let base = satellite_visual_magnitude(1000.0, 0.0, 0.0, 1000.0).expect("valid");
429 assert!(close(base, 0.0, 1e-12), "base {base}");
430
431 let farther = satellite_visual_magnitude(2000.0, 0.0, 0.0, 1000.0).expect("valid");
433 assert!(
434 close(farther, 1.505_149_978_319_906, 1e-9),
435 "farther {farther}"
436 );
437
438 let quarter = satellite_visual_magnitude(1000.0, 90.0, 0.0, 1000.0).expect("valid");
440 assert!(
441 close(quarter, 1.242_874_681_735_334_7, 1e-9),
442 "quarter {quarter}"
443 );
444 }
445
446 #[test]
447 fn visual_magnitude_phase_is_monotonic_and_clamped() {
448 let full = satellite_visual_magnitude(1000.0, 0.0, -1.3, 1000.0).expect("valid");
450 let half = satellite_visual_magnitude(1000.0, 90.0, -1.3, 1000.0).expect("valid");
451 let thin = satellite_visual_magnitude(1000.0, 150.0, -1.3, 1000.0).expect("valid");
452 assert!(full < half && half < thin, "{full} {half} {thin}");
453
454 let clamped = satellite_visual_magnitude(1000.0, 200.0, -1.3, 1000.0).expect("valid");
456 let at_180 = satellite_visual_magnitude(1000.0, 180.0, -1.3, 1000.0).expect("valid");
457 assert!(close(clamped, at_180, 1e-12), "clamp {clamped} {at_180}");
458 }
459
460 #[test]
461 fn visual_magnitude_rejects_nonpositive_range() {
462 let err = satellite_visual_magnitude(0.0, 0.0, 0.0, 1000.0).unwrap_err();
463 assert_eq!(
464 err,
465 ObservationError::InvalidInput {
466 field: "range_km",
467 reason: "not positive"
468 }
469 );
470 }
471
472 #[test]
473 fn sub_observer_point_pole_along_z() {
474 let on_pm = sub_observer_point([0.0, 1.0, 0.0], 0.0, 90.0, 0.0).expect("valid");
477 assert!(
478 close(on_pm.latitude_deg, 0.0, 1e-9),
479 "lat {}",
480 on_pm.latitude_deg
481 );
482 assert!(
483 close(on_pm.longitude_deg, 0.0, 1e-9),
484 "lon {}",
485 on_pm.longitude_deg
486 );
487
488 let polar = sub_observer_point([0.0, 0.0, 1.0], 0.0, 90.0, 0.0).expect("valid");
490 assert!(
491 close(polar.latitude_deg, 90.0, 1e-9),
492 "polar lat {}",
493 polar.latitude_deg
494 );
495
496 let before = sub_observer_point([1.0, 0.0, 0.0], 0.0, 90.0, 0.0).expect("valid");
498 assert!(
499 close(before.longitude_deg, -90.0, 1e-9),
500 "lon {}",
501 before.longitude_deg
502 );
503 }
504
505 #[test]
506 fn sub_observer_point_prime_meridian_rotation() {
507 let point = sub_observer_point([0.0, 1.0, 0.0], 0.0, 90.0, 90.0).expect("valid");
510 assert!(
511 close(point.longitude_deg, -90.0, 1e-9),
512 "lon {}",
513 point.longitude_deg
514 );
515 }
516
517 #[test]
518 fn sub_observer_point_iau_mars_orientation() {
519 let point = sub_observer_point([1.0, 0.0, 0.0], 317.68, 52.89, 176.0).expect("valid");
522 assert!(
523 close(point.latitude_deg, 26.494_542_592_970_532, 1e-9),
524 "lat {}",
525 point.latitude_deg
526 );
527 assert!(
528 close(point.longitude_deg, 142.788_019_764_132_46, 1e-9),
529 "lon {}",
530 point.longitude_deg
531 );
532 }
533
534 #[test]
535 fn sub_observer_point_rejects_zero_vector() {
536 let err = sub_observer_point([0.0, 0.0, 0.0], 0.0, 90.0, 0.0).unwrap_err();
537 assert_eq!(
538 err,
539 ObservationError::InvalidInput {
540 field: "observer_from_body",
541 reason: "zero vector"
542 }
543 );
544 }
545}