rust-astro-engine 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. rust_astro_engine-0.1.0/Cargo.lock +264 -0
  2. rust_astro_engine-0.1.0/Cargo.toml +18 -0
  3. rust_astro_engine-0.1.0/PKG-INFO +9 -0
  4. rust_astro_engine-0.1.0/crates/ephemeris-core/Cargo.toml +10 -0
  5. rust_astro_engine-0.1.0/crates/ephemeris-core/src/events.rs +94 -0
  6. rust_astro_engine-0.1.0/crates/ephemeris-core/src/horizon.rs +227 -0
  7. rust_astro_engine-0.1.0/crates/ephemeris-core/src/houses.rs +818 -0
  8. rust_astro_engine-0.1.0/crates/ephemeris-core/src/lib.rs +1167 -0
  9. rust_astro_engine-0.1.0/crates/ephemeris-core/src/observer.rs +512 -0
  10. rust_astro_engine-0.1.0/crates/ephemeris-core/src/sidereal.rs +202 -0
  11. rust_astro_engine-0.1.0/crates/ephemeris-core/tests/independence_boundary.rs +94 -0
  12. rust_astro_engine-0.1.0/crates/ephemeris-python/Cargo.toml +26 -0
  13. rust_astro_engine-0.1.0/crates/ephemeris-python/python/tests/test_ephemeris.py +517 -0
  14. rust_astro_engine-0.1.0/crates/ephemeris-python/src/convert.rs +335 -0
  15. rust_astro_engine-0.1.0/crates/ephemeris-python/src/lib.rs +1193 -0
  16. rust_astro_engine-0.1.0/crates/ephemeris-spk/Cargo.toml +15 -0
  17. rust_astro_engine-0.1.0/crates/ephemeris-spk/examples/benchmark.rs +204 -0
  18. rust_astro_engine-0.1.0/crates/ephemeris-spk/examples/inspect.rs +30 -0
  19. rust_astro_engine-0.1.0/crates/ephemeris-spk/src/kernel_source.rs +87 -0
  20. rust_astro_engine-0.1.0/crates/ephemeris-spk/src/lib.rs +3880 -0
  21. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_earth_heliocentric_ecliptic_chebyshev_record_boundary.rs +107 -0
  22. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_earth_heliocentric_ecliptic_j2000.rs +94 -0
  23. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_earth_heliocentric_ecliptic_j2000_tt.rs +48 -0
  24. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_earth_heliocentric_ecliptic_leap_year_boundary.rs +48 -0
  25. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_earth_heliocentric_ecliptic_light_time.rs +72 -0
  26. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_earth_heliocentric_ecliptic_modern_tdb.rs +48 -0
  27. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_earth_heliocentric_ecliptic_modern_tt.rs +48 -0
  28. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_earth_heliocentric_j2000.rs +47 -0
  29. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_earth_j2000.rs +43 -0
  30. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_event_search.rs +492 -0
  31. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_sidereal.rs +128 -0
  32. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_sun_geocentric_ecliptic_light_time_aberration.rs +79 -0
  33. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_sun_geocentric_ecliptic_of_date.rs +154 -0
  34. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_sun_geocentric_equatorial_apparent.rs +81 -0
  35. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_sun_geocentric_equatorial_light_time.rs +49 -0
  36. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_sun_moon_rise_set.rs +297 -0
  37. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_sun_topocentric_apparent.rs +227 -0
  38. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_sun_topocentric_geometric.rs +199 -0
  39. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_ut1_eop.rs +141 -0
  40. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de440s_venus_geocentric_equatorial_apparent.rs +92 -0
  41. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/de441_earth_long_range.rs +237 -0
  42. rust_astro_engine-0.1.0/crates/ephemeris-spk/tests/planet_centers.rs +207 -0
  43. rust_astro_engine-0.1.0/crates/ephemeris-time/Cargo.toml +13 -0
  44. rust_astro_engine-0.1.0/crates/ephemeris-time/src/eop.rs +350 -0
  45. rust_astro_engine-0.1.0/crates/ephemeris-time/src/lib.rs +810 -0
  46. rust_astro_engine-0.1.0/crates/ephemeris-time/tests/fixtures/iers_eop20c04_1978_feb.txt +8 -0
  47. rust_astro_engine-0.1.0/crates/ephemeris-time/tests/fixtures/naif0012.tls +152 -0
  48. rust_astro_engine-0.1.0/crates/ephemeris-time/tests/iers_eop20c04_dut1.rs +71 -0
  49. rust_astro_engine-0.1.0/crates/ephemeris-time/tests/naif0012_leap_seconds.rs +61 -0
  50. rust_astro_engine-0.1.0/pyproject.toml +22 -0
@@ -0,0 +1,264 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "astro-models"
7
+ version = "0.1.0"
8
+
9
+ [[package]]
10
+ name = "autocfg"
11
+ version = "1.5.1"
12
+ source = "registry+https://github.com/rust-lang/crates.io-index"
13
+ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
14
+
15
+ [[package]]
16
+ name = "ephemeris-core"
17
+ version = "0.1.0"
18
+
19
+ [[package]]
20
+ name = "ephemeris-python"
21
+ version = "0.1.0"
22
+ dependencies = [
23
+ "ephemeris-core",
24
+ "ephemeris-spk",
25
+ "ephemeris-time",
26
+ "numpy",
27
+ "pyo3",
28
+ ]
29
+
30
+ [[package]]
31
+ name = "ephemeris-spk"
32
+ version = "0.1.0"
33
+ dependencies = [
34
+ "ephemeris-core",
35
+ "ephemeris-time",
36
+ "memmap2",
37
+ ]
38
+
39
+ [[package]]
40
+ name = "ephemeris-time"
41
+ version = "0.1.0"
42
+ dependencies = [
43
+ "ephemeris-core",
44
+ ]
45
+
46
+ [[package]]
47
+ name = "heck"
48
+ version = "0.5.0"
49
+ source = "registry+https://github.com/rust-lang/crates.io-index"
50
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
51
+
52
+ [[package]]
53
+ name = "libc"
54
+ version = "0.2.189"
55
+ source = "registry+https://github.com/rust-lang/crates.io-index"
56
+ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
57
+
58
+ [[package]]
59
+ name = "matrixmultiply"
60
+ version = "0.3.11"
61
+ source = "registry+https://github.com/rust-lang/crates.io-index"
62
+ checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
63
+ dependencies = [
64
+ "autocfg",
65
+ "rawpointer",
66
+ ]
67
+
68
+ [[package]]
69
+ name = "memmap2"
70
+ version = "0.9.11"
71
+ source = "registry+https://github.com/rust-lang/crates.io-index"
72
+ checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
73
+ dependencies = [
74
+ "libc",
75
+ ]
76
+
77
+ [[package]]
78
+ name = "ndarray"
79
+ version = "0.16.1"
80
+ source = "registry+https://github.com/rust-lang/crates.io-index"
81
+ checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
82
+ dependencies = [
83
+ "matrixmultiply",
84
+ "num-complex",
85
+ "num-integer",
86
+ "num-traits",
87
+ "portable-atomic",
88
+ "portable-atomic-util",
89
+ "rawpointer",
90
+ ]
91
+
92
+ [[package]]
93
+ name = "num-complex"
94
+ version = "0.4.6"
95
+ source = "registry+https://github.com/rust-lang/crates.io-index"
96
+ checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
97
+ dependencies = [
98
+ "num-traits",
99
+ ]
100
+
101
+ [[package]]
102
+ name = "num-integer"
103
+ version = "0.1.47"
104
+ source = "registry+https://github.com/rust-lang/crates.io-index"
105
+ checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
106
+ dependencies = [
107
+ "num-traits",
108
+ ]
109
+
110
+ [[package]]
111
+ name = "num-traits"
112
+ version = "0.2.19"
113
+ source = "registry+https://github.com/rust-lang/crates.io-index"
114
+ checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
115
+ dependencies = [
116
+ "autocfg",
117
+ ]
118
+
119
+ [[package]]
120
+ name = "numpy"
121
+ version = "0.29.0"
122
+ source = "registry+https://github.com/rust-lang/crates.io-index"
123
+ checksum = "6a5b15d63a5ff39e378daed0e1340d3a5964703ea9712eb09a0dc66fade996f4"
124
+ dependencies = [
125
+ "libc",
126
+ "ndarray",
127
+ "num-complex",
128
+ "num-integer",
129
+ "num-traits",
130
+ "pyo3",
131
+ "pyo3-build-config",
132
+ "rustc-hash",
133
+ ]
134
+
135
+ [[package]]
136
+ name = "once_cell"
137
+ version = "1.21.4"
138
+ source = "registry+https://github.com/rust-lang/crates.io-index"
139
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
140
+
141
+ [[package]]
142
+ name = "portable-atomic"
143
+ version = "1.15.0"
144
+ source = "registry+https://github.com/rust-lang/crates.io-index"
145
+ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
146
+
147
+ [[package]]
148
+ name = "portable-atomic-util"
149
+ version = "0.2.7"
150
+ source = "registry+https://github.com/rust-lang/crates.io-index"
151
+ checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
152
+ dependencies = [
153
+ "portable-atomic",
154
+ ]
155
+
156
+ [[package]]
157
+ name = "proc-macro2"
158
+ version = "1.0.107"
159
+ source = "registry+https://github.com/rust-lang/crates.io-index"
160
+ checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
161
+ dependencies = [
162
+ "unicode-ident",
163
+ ]
164
+
165
+ [[package]]
166
+ name = "pyo3"
167
+ version = "0.29.2"
168
+ source = "registry+https://github.com/rust-lang/crates.io-index"
169
+ checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b"
170
+ dependencies = [
171
+ "libc",
172
+ "once_cell",
173
+ "portable-atomic",
174
+ "pyo3-build-config",
175
+ "pyo3-ffi",
176
+ "pyo3-macros",
177
+ ]
178
+
179
+ [[package]]
180
+ name = "pyo3-build-config"
181
+ version = "0.29.2"
182
+ source = "registry+https://github.com/rust-lang/crates.io-index"
183
+ checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9"
184
+ dependencies = [
185
+ "target-lexicon",
186
+ ]
187
+
188
+ [[package]]
189
+ name = "pyo3-ffi"
190
+ version = "0.29.2"
191
+ source = "registry+https://github.com/rust-lang/crates.io-index"
192
+ checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327"
193
+ dependencies = [
194
+ "libc",
195
+ "pyo3-build-config",
196
+ ]
197
+
198
+ [[package]]
199
+ name = "pyo3-macros"
200
+ version = "0.29.2"
201
+ source = "registry+https://github.com/rust-lang/crates.io-index"
202
+ checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c"
203
+ dependencies = [
204
+ "proc-macro2",
205
+ "pyo3-macros-backend",
206
+ "quote",
207
+ "syn",
208
+ ]
209
+
210
+ [[package]]
211
+ name = "pyo3-macros-backend"
212
+ version = "0.29.2"
213
+ source = "registry+https://github.com/rust-lang/crates.io-index"
214
+ checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952"
215
+ dependencies = [
216
+ "heck",
217
+ "proc-macro2",
218
+ "quote",
219
+ "syn",
220
+ ]
221
+
222
+ [[package]]
223
+ name = "quote"
224
+ version = "1.0.47"
225
+ source = "registry+https://github.com/rust-lang/crates.io-index"
226
+ checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
227
+ dependencies = [
228
+ "proc-macro2",
229
+ ]
230
+
231
+ [[package]]
232
+ name = "rawpointer"
233
+ version = "0.2.1"
234
+ source = "registry+https://github.com/rust-lang/crates.io-index"
235
+ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
236
+
237
+ [[package]]
238
+ name = "rustc-hash"
239
+ version = "2.1.3"
240
+ source = "registry+https://github.com/rust-lang/crates.io-index"
241
+ checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
242
+
243
+ [[package]]
244
+ name = "syn"
245
+ version = "2.0.119"
246
+ source = "registry+https://github.com/rust-lang/crates.io-index"
247
+ checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
248
+ dependencies = [
249
+ "proc-macro2",
250
+ "quote",
251
+ "unicode-ident",
252
+ ]
253
+
254
+ [[package]]
255
+ name = "target-lexicon"
256
+ version = "0.13.5"
257
+ source = "registry+https://github.com/rust-lang/crates.io-index"
258
+ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
259
+
260
+ [[package]]
261
+ name = "unicode-ident"
262
+ version = "1.0.24"
263
+ source = "registry+https://github.com/rust-lang/crates.io-index"
264
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
@@ -0,0 +1,18 @@
1
+ [workspace]
2
+ members = ["crates/ephemeris-core", "crates/ephemeris-spk", "crates/ephemeris-time", "crates/ephemeris-python"]
3
+ resolver = "2"
4
+
5
+ [workspace.package]
6
+ version = "0.1.0"
7
+ edition = "2021"
8
+ license = "MIT OR Apache-2.0"
9
+ rust-version = "1.75"
10
+
11
+ [workspace.lints.rust]
12
+ # `deny` rather than `forbid` so `ephemeris-spk` can memory-map kernels
13
+ # (`memmap2::Mmap::map` is unsafe). Other crates still `#![forbid(unsafe_code)]`.
14
+ unsafe_code = "deny"
15
+
16
+ [workspace.lints.clippy]
17
+ all = "warn"
18
+ pedantic = "warn"
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: rust-astro-engine
3
+ Version: 0.1.0
4
+ Requires-Dist: numpy>=1.24
5
+ Requires-Dist: pytest>=7.0 ; extra == 'dev'
6
+ Provides-Extra: dev
7
+ Summary: Python bindings for Rust Astro Engine (thin PyO3 slice)
8
+ License: MIT OR Apache-2.0
9
+ Requires-Python: >=3.9
@@ -0,0 +1,10 @@
1
+ [package]
2
+ name = "ephemeris-core"
3
+ description = "Core types and contracts for Rust Astro Engine"
4
+ version.workspace = true
5
+ edition.workspace = true
6
+ license.workspace = true
7
+ rust-version.workspace = true
8
+
9
+ [lints]
10
+ workspace = true
@@ -0,0 +1,94 @@
1
+ //! Named astronomical events on top of existing spherical positions.
2
+ //!
3
+ //! Horizon kinds label rise, set, and meridian transit. Season and lunation
4
+ //! kinds label ecliptic-longitude targets (Sun at 0°/90°/180°/270°, or
5
+ //! Sun–Moon elongation 0°/180°). Searching those zeros lives with the SPK
6
+ //! backend; this module is the public vocabulary.
7
+
8
+ /// Rise, set, or apparent meridian transit of the Sun or Moon.
9
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
10
+ pub enum HorizonEventKind {
11
+ Rise,
12
+ Set,
13
+ Transit,
14
+ }
15
+
16
+ /// One horizon event, as a UT1 Julian day.
17
+ #[derive(Clone, Copy, Debug, PartialEq)]
18
+ pub struct HorizonEvent {
19
+ pub kind: HorizonEventKind,
20
+ pub ut1_julian_day: f64,
21
+ }
22
+
23
+ /// Equinoxes and solstices: geocentric apparent true ecliptic longitude of
24
+ /// the Sun.
25
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
26
+ pub enum SeasonEvent {
27
+ /// Sun longitude 0°.
28
+ MarchEquinox,
29
+ /// Sun longitude 90°.
30
+ JuneSolstice,
31
+ /// Sun longitude 180°.
32
+ SeptemberEquinox,
33
+ /// Sun longitude 270°.
34
+ DecemberSolstice,
35
+ }
36
+
37
+ impl SeasonEvent {
38
+ /// Target apparent ecliptic longitude of the Sun, degrees.
39
+ #[must_use]
40
+ pub const fn sun_longitude_deg(self) -> f64 {
41
+ match self {
42
+ Self::MarchEquinox => 0.0,
43
+ Self::JuneSolstice => 90.0,
44
+ Self::SeptemberEquinox => 180.0,
45
+ Self::DecemberSolstice => 270.0,
46
+ }
47
+ }
48
+ }
49
+
50
+ /// Lunation phases from apparent geocentric ecliptic elongation.
51
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
52
+ pub enum LunationEvent {
53
+ /// Moon − Sun longitude 0°.
54
+ NewMoon,
55
+ /// Moon − Sun longitude 180°.
56
+ FullMoon,
57
+ }
58
+
59
+ impl LunationEvent {
60
+ /// Target Moon minus Sun apparent ecliptic elongation, degrees.
61
+ #[must_use]
62
+ pub const fn elongation_deg(self) -> f64 {
63
+ match self {
64
+ Self::NewMoon => 0.0,
65
+ Self::FullMoon => 180.0,
66
+ }
67
+ }
68
+ }
69
+
70
+ /// Forward residual in `[0, 360)` from `current_deg` to `target_deg`.
71
+ #[must_use]
72
+ pub fn forward_angle_deg(current_deg: f64, target_deg: f64) -> f64 {
73
+ (target_deg - current_deg).rem_euclid(360.0)
74
+ }
75
+
76
+ #[cfg(test)]
77
+ mod tests {
78
+ use super::*;
79
+
80
+ #[test]
81
+ fn season_targets_are_the_four_cardinal_longitudes() {
82
+ assert!((SeasonEvent::MarchEquinox.sun_longitude_deg() - 0.0).abs() < 1.0e-15);
83
+ assert!((SeasonEvent::JuneSolstice.sun_longitude_deg() - 90.0).abs() < 1.0e-15);
84
+ assert!((SeasonEvent::SeptemberEquinox.sun_longitude_deg() - 180.0).abs() < 1.0e-15);
85
+ assert!((SeasonEvent::DecemberSolstice.sun_longitude_deg() - 270.0).abs() < 1.0e-15);
86
+ }
87
+
88
+ #[test]
89
+ fn forward_angle_wraps_across_zero() {
90
+ assert!((forward_angle_deg(350.0, 10.0) - 20.0).abs() < 1.0e-12);
91
+ assert!((forward_angle_deg(10.0, 10.0)).abs() < 1.0e-12);
92
+ assert!((forward_angle_deg(0.0, 180.0) - 180.0).abs() < 1.0e-12);
93
+ }
94
+ }
@@ -0,0 +1,227 @@
1
+ //! Rise, set, and meridian transit on the local horizon.
2
+ //!
3
+ //! Hour-angle formulas follow Meeus, *Astronomical Algorithms* (2nd ed.),
4
+ //! Chapter 15. Standard geometric altitudes: mean Sun/Moon upper limb −50′
5
+ //! (refraction 34′ plus mean semi-diameter 16′); stellar/mean-refraction
6
+ //! center −34′.
7
+
8
+ #![allow(clippy::module_name_repetitions)]
9
+
10
+ use crate::CoordinateError;
11
+ use std::f64::consts::{PI, TAU};
12
+
13
+ /// IERS Conventions (2010) Eq. 5.15: one UT1 day plus the ERA linear rate.
14
+ pub const SIDEREAL_TURNS_PER_UT1_DAY: f64 = 1.0 + 0.002_737_811_911_354_48;
15
+
16
+ /// Mean refraction for a geometric center, degrees (Meeus: −34′).
17
+ pub const MEAN_REFRACTION_ALTITUDE_DEG: f64 = -34.0 / 60.0;
18
+
19
+ /// Sun upper limb at rise/set, degrees (Meeus: −50′ = −0.8333…).
20
+ pub const SUN_UPPER_LIMB_ALTITUDE_DEG: f64 = -50.0 / 60.0;
21
+
22
+ /// Mean Moon upper limb at rise/set, degrees (−50′: 34′ refraction plus a
23
+ /// mean 16′ semi-diameter). Precise limb work should use the date-dependent
24
+ /// apparent lunar semi-diameter as the caller-supplied altitude instead.
25
+ pub const MOON_MEAN_UPPER_LIMB_ALTITUDE_DEG: f64 = -50.0 / 60.0;
26
+
27
+ /// UT1 epochs of meridian transit and, when they exist, rise and set.
28
+ #[derive(Clone, Copy, Debug, PartialEq)]
29
+ pub struct RiseSetTransit {
30
+ pub transit_ut1_julian_day: f64,
31
+ pub rise_ut1_julian_day: Option<f64>,
32
+ pub set_ut1_julian_day: Option<f64>,
33
+ }
34
+
35
+ /// Whether a body crosses a chosen geometric altitude.
36
+ #[derive(Clone, Copy, Debug, PartialEq)]
37
+ pub enum RiseSetHourAngle {
38
+ /// Hour angle at setting, radians in `(0, π)`. Rise is at `−hour_angle_rad`.
39
+ Crosses { hour_angle_rad: f64 },
40
+ /// Always above the chosen altitude (circumpolar).
41
+ AlwaysAbove,
42
+ /// Always below the chosen altitude.
43
+ AlwaysBelow,
44
+ }
45
+
46
+ /// Converts apparent equatorial coordinates to local azimuth (degrees from
47
+ /// north toward east, `[0, 360)`) and altitude (degrees).
48
+ ///
49
+ /// `local_sidereal_time_rad` is LAST; `right_ascension_rad` is apparent RA.
50
+ ///
51
+ /// # Errors
52
+ ///
53
+ /// Returns [`CoordinateError::NonFiniteState`] when an input is not finite.
54
+ pub fn equatorial_to_horizontal(
55
+ latitude_rad: f64,
56
+ local_sidereal_time_rad: f64,
57
+ right_ascension_rad: f64,
58
+ declination_rad: f64,
59
+ ) -> Result<(f64, f64), CoordinateError> {
60
+ if ![
61
+ latitude_rad,
62
+ local_sidereal_time_rad,
63
+ right_ascension_rad,
64
+ declination_rad,
65
+ ]
66
+ .into_iter()
67
+ .all(f64::is_finite)
68
+ {
69
+ return Err(CoordinateError::NonFiniteState);
70
+ }
71
+ let hour_angle = local_sidereal_time_rad - right_ascension_rad;
72
+ let (sin_phi, cos_phi) = latitude_rad.sin_cos();
73
+ let (sin_dec, cos_dec) = declination_rad.sin_cos();
74
+ let (sin_ha, cos_ha) = hour_angle.sin_cos();
75
+ let sin_alt = sin_phi.mul_add(sin_dec, cos_phi * cos_dec * cos_ha);
76
+ let altitude_rad = sin_alt.clamp(-1.0, 1.0).asin();
77
+ let y_east = -cos_dec * sin_ha;
78
+ let x_north = cos_phi.mul_add(sin_dec, -sin_phi * cos_dec * cos_ha);
79
+ let azimuth_deg = y_east.atan2(x_north).to_degrees().rem_euclid(360.0);
80
+ Ok((azimuth_deg, altitude_rad.to_degrees()))
81
+ }
82
+
83
+ /// Hour angle at which a body at declination `declination_rad` crosses
84
+ /// geometric altitude `altitude_rad` for an observer at `latitude_rad`.
85
+ ///
86
+ /// Meeus (15.1): `cos H0 = (sin h0 − sin φ sin δ) / (cos φ cos δ)`.
87
+ ///
88
+ /// # Errors
89
+ ///
90
+ /// Returns [`CoordinateError::NonFiniteState`] when an input is not finite.
91
+ pub fn rise_set_hour_angle(
92
+ latitude_rad: f64,
93
+ declination_rad: f64,
94
+ altitude_rad: f64,
95
+ ) -> Result<RiseSetHourAngle, CoordinateError> {
96
+ if ![latitude_rad, declination_rad, altitude_rad]
97
+ .into_iter()
98
+ .all(f64::is_finite)
99
+ {
100
+ return Err(CoordinateError::NonFiniteState);
101
+ }
102
+ let (sin_phi, cos_phi) = latitude_rad.sin_cos();
103
+ let (sin_dec, cos_dec) = declination_rad.sin_cos();
104
+ let denominator = cos_phi * cos_dec;
105
+ if denominator.abs() < 1.0e-18 {
106
+ let sin_alt = sin_phi * sin_dec;
107
+ return Ok(if sin_alt >= altitude_rad.sin() {
108
+ RiseSetHourAngle::AlwaysAbove
109
+ } else {
110
+ RiseSetHourAngle::AlwaysBelow
111
+ });
112
+ }
113
+ let cos_h0 = (altitude_rad.sin() - sin_phi * sin_dec) / denominator;
114
+ if cos_h0 > 1.0 {
115
+ Ok(RiseSetHourAngle::AlwaysBelow)
116
+ } else if cos_h0 < -1.0 {
117
+ Ok(RiseSetHourAngle::AlwaysAbove)
118
+ } else {
119
+ Ok(RiseSetHourAngle::Crosses {
120
+ hour_angle_rad: cos_h0.acos(),
121
+ })
122
+ }
123
+ }
124
+
125
+ /// UT1-day offset that advances local sidereal time by `delta_sidereal_rad`.
126
+ #[must_use]
127
+ pub fn ut1_days_from_sidereal_radians(delta_sidereal_rad: f64) -> f64 {
128
+ delta_sidereal_rad / (TAU * SIDEREAL_TURNS_PER_UT1_DAY)
129
+ }
130
+
131
+ /// Wraps an angle to `(-π, π]`.
132
+ #[must_use]
133
+ pub fn wrap_pi(radians: f64) -> f64 {
134
+ let wrapped = (radians + PI).rem_euclid(TAU) - PI;
135
+ if wrapped <= -PI {
136
+ PI
137
+ } else {
138
+ wrapped
139
+ }
140
+ }
141
+
142
+ #[cfg(test)]
143
+ mod tests {
144
+ use super::*;
145
+
146
+ fn assert_close(actual: f64, expected: f64, tolerance: f64) {
147
+ assert!(
148
+ (actual - expected).abs() < tolerance,
149
+ "actual={actual} expected={expected} tolerance={tolerance}"
150
+ );
151
+ }
152
+
153
+ #[test]
154
+ fn equator_zero_declination_sets_six_hours_from_transit() {
155
+ let hour = rise_set_hour_angle(0.0, 0.0, 0.0).expect("finite");
156
+ match hour {
157
+ RiseSetHourAngle::Crosses { hour_angle_rad } => {
158
+ assert_close(hour_angle_rad, PI / 2.0, 1.0e-14);
159
+ }
160
+ other => panic!("expected a crossing, got {other:?}"),
161
+ }
162
+ }
163
+
164
+ #[test]
165
+ fn sun_upper_limb_lengthens_the_day_versus_the_geometric_center() {
166
+ let latitude = 51.477_8_f64.to_radians();
167
+ let declination = 0.0;
168
+ let geometric = rise_set_hour_angle(latitude, declination, 0.0).expect("finite");
169
+ let limb = rise_set_hour_angle(
170
+ latitude,
171
+ declination,
172
+ SUN_UPPER_LIMB_ALTITUDE_DEG.to_radians(),
173
+ )
174
+ .expect("finite");
175
+ let RiseSetHourAngle::Crosses {
176
+ hour_angle_rad: h_geo,
177
+ } = geometric
178
+ else {
179
+ panic!("geometric should cross");
180
+ };
181
+ let RiseSetHourAngle::Crosses {
182
+ hour_angle_rad: h_limb,
183
+ } = limb
184
+ else {
185
+ panic!("limb should cross");
186
+ };
187
+ assert!(h_limb > h_geo);
188
+ }
189
+
190
+ #[test]
191
+ fn high_declination_is_circumpolar_or_never_rises() {
192
+ let latitude = 80.0_f64.to_radians();
193
+ assert_eq!(
194
+ rise_set_hour_angle(latitude, 30.0_f64.to_radians(), 0.0).expect("finite"),
195
+ RiseSetHourAngle::AlwaysAbove
196
+ );
197
+ assert_eq!(
198
+ rise_set_hour_angle(latitude, -30.0_f64.to_radians(), 0.0).expect("finite"),
199
+ RiseSetHourAngle::AlwaysBelow
200
+ );
201
+ }
202
+
203
+ #[test]
204
+ fn equatorial_to_horizontal_puts_ramc_on_the_meridian() {
205
+ let latitude = 51.477_8_f64.to_radians();
206
+ let last = 1.2;
207
+ let (azimuth, altitude) =
208
+ equatorial_to_horizontal(latitude, last, last, 0.0).expect("finite");
209
+ assert_close(azimuth, 180.0, 1.0e-8);
210
+ let expected_alt = (latitude.cos()).asin().to_degrees();
211
+ assert_close(altitude, expected_alt, 1.0e-10);
212
+ }
213
+
214
+ #[test]
215
+ fn ut1_offset_inverts_the_iers_era_rate() {
216
+ let one_turn = ut1_days_from_sidereal_radians(TAU);
217
+ assert_close(one_turn * SIDEREAL_TURNS_PER_UT1_DAY, 1.0, 1.0e-15);
218
+ }
219
+
220
+ #[test]
221
+ fn rejects_non_finite_inputs() {
222
+ assert_eq!(
223
+ rise_set_hour_angle(f64::NAN, 0.0, 0.0),
224
+ Err(CoordinateError::NonFiniteState)
225
+ );
226
+ }
227
+ }