1use std::sync::Arc;
2
3#[cfg(feature = "packaged-artifact-path")]
4use std::path::Path;
5
6use pleiades_apsides::{apsides, MU_EARTH_MOON_AU3_PER_DAY2};
7use pleiades_backend::{
8 validate_observer_policy, validate_request_policy, validate_zodiac_policy, AccuracyClass,
9 BackendCapabilities, BackendFamily, BackendId, BackendMetadata, BackendProvenance,
10 CelestialBody, CoordinateFrame, EclipticCoordinates, EphemerisBackend, EphemerisError,
11 EphemerisErrorKind, EphemerisRequest, EphemerisResult, Instant, JulianDay, Latitude, Longitude,
12 Motion, QualityAnnotation, TimeScale, ZodiacMode,
13};
14use pleiades_compression::{spherical_state_to_cartesian, CompressedArtifact, SphericalState};
15
16use crate::coverage::packaged_body_coverage_summary;
17#[cfg(feature = "packaged-artifact-path")]
18use crate::data::packaged_artifact_from_path;
19use crate::data::{packaged_artifact, packaged_artifact_from_bytes, PackagedArtifactLoadError};
20use crate::lookup::{
21 packaged_artifact_access_summary_for_report, packaged_artifact_storage_summary_for_report,
22 packaged_frame_treatment_summary_for_report, packaged_request_policy_summary_for_report,
23};
24use crate::regenerate::{artifact_time_range, map_artifact_error, normalize_lookup_instant};
25use crate::PACKAGE_NAME;
26
27#[derive(Debug, Clone)]
29pub struct PackagedDataBackend {
30 artifact: Arc<CompressedArtifact>,
31}
32
33impl Default for PackagedDataBackend {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl PackagedDataBackend {
40 pub fn new() -> Self {
42 Self::from_artifact(packaged_artifact().clone())
43 }
44
45 pub fn from_artifact(artifact: CompressedArtifact) -> Self {
47 Self {
48 artifact: Arc::new(artifact),
49 }
50 }
51
52 pub fn from_bytes(bytes: &[u8]) -> Result<Self, PackagedArtifactLoadError> {
57 Ok(Self::from_artifact(
58 packaged_artifact_from_bytes(bytes).map_err(PackagedArtifactLoadError::Decode)?,
59 ))
60 }
61
62 #[cfg(feature = "packaged-artifact-path")]
63 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, PackagedArtifactLoadError> {
69 Ok(Self::from_artifact(packaged_artifact_from_path(path)?))
70 }
71
72 fn artifact(&self) -> &CompressedArtifact {
73 &self.artifact
74 }
75
76 fn osculating_apsis_position(
77 &self,
78 req: &EphemerisRequest,
79 ) -> Result<EphemerisResult, EphemerisError> {
80 let ecliptic = self.osculating_apsis_ecliptic(&req.body, req.instant)?;
81 let equatorial = ecliptic.to_equatorial(req.instant.mean_obliquity());
82 let motion = self.osculating_apsis_motion(&req.body, req.instant)?;
83
84 let mut result = EphemerisResult::new(
85 BackendId::new(PACKAGE_NAME),
86 req.body.clone(),
87 req.instant,
88 req.frame,
89 req.zodiac_mode.clone(),
90 req.apparent,
91 );
92 result.ecliptic = Some(ecliptic);
93 result.equatorial = Some(equatorial);
94 result.motion = Some(motion);
95 result.quality = QualityAnnotation::Interpolated;
96 Ok(result)
97 }
98
99 fn osculating_apsis_ecliptic(
100 &self,
101 body: &CelestialBody,
102 instant: Instant,
103 ) -> Result<EclipticCoordinates, EphemerisError> {
104 let li = normalize_lookup_instant(instant);
105 let ecl = self
106 .artifact
107 .lookup_ecliptic(&CelestialBody::Moon, li)
108 .map_err(map_artifact_error)?;
109 let mot = self
110 .artifact
111 .lookup_motion(&CelestialBody::Moon, li)
112 .map_err(map_artifact_error)?;
113 let dist = ecl.distance_au.ok_or_else(|| {
114 EphemerisError::new(
115 EphemerisErrorKind::InvalidRequest,
116 "packaged Moon lacks distance for osculating apsis",
117 )
118 })?;
119 let state = SphericalState {
120 lon_rad: ecl.longitude.degrees().to_radians(),
121 lat_rad: ecl.latitude.degrees().to_radians(),
122 dist_au: dist,
123 lon_rate_rad_per_day: mot.longitude_deg_per_day.unwrap_or(0.0).to_radians(),
124 lat_rate_rad_per_day: mot.latitude_deg_per_day.unwrap_or(0.0).to_radians(),
125 dist_rate_au_per_day: mot.distance_au_per_day.unwrap_or(0.0),
126 };
127 let cart = spherical_state_to_cartesian(state);
128 let aps = apsides(cart.pos_au, cart.vel_au_per_day, MU_EARTH_MOON_AU3_PER_DAY2).map_err(
129 |_| {
130 EphemerisError::new(
131 EphemerisErrorKind::InvalidRequest,
132 "osculating apsis undefined for the lunar state at this instant",
133 )
134 },
135 )?;
136 let point = match body {
137 CelestialBody::TrueApogee => aps.apogee,
138 CelestialBody::TruePerigee => aps.perigee,
139 _ => {
140 return Err(EphemerisError::new(
141 EphemerisErrorKind::InvalidRequest,
142 "not an osculating-apsis body",
143 ))
144 }
145 };
146 Ok(EclipticCoordinates::new(
147 Longitude::from_degrees(point.longitude_deg),
148 Latitude::from_degrees(point.latitude_deg),
149 Some(point.distance_au),
150 ))
151 }
152
153 fn osculating_apsis_motion(
154 &self,
155 body: &CelestialBody,
156 instant: Instant,
157 ) -> Result<Motion, EphemerisError> {
158 const HALF_SPAN_DAYS: f64 = 0.5;
159 let shift = |days: f64| {
160 Instant::new(
161 JulianDay::from_days(instant.julian_day.days() + days),
162 instant.scale,
163 )
164 };
165 let before = match self.osculating_apsis_ecliptic(body, shift(-HALF_SPAN_DAYS)) {
166 Ok(e) => e,
167 Err(ref e) if e.kind == EphemerisErrorKind::OutOfRangeInstant => {
168 return Ok(Motion::new(None, None, None));
169 }
170 Err(e) => return Err(e),
171 };
172 let after = match self.osculating_apsis_ecliptic(body, shift(HALF_SPAN_DAYS)) {
173 Ok(e) => e,
174 Err(ref e) if e.kind == EphemerisErrorKind::OutOfRangeInstant => {
175 return Ok(Motion::new(None, None, None));
176 }
177 Err(e) => return Err(e),
178 };
179 let span = 2.0 * HALF_SPAN_DAYS;
180
181 let mut dlon = after.longitude.degrees() - before.longitude.degrees();
182 while dlon > 180.0 {
183 dlon -= 360.0;
184 }
185 while dlon < -180.0 {
186 dlon += 360.0;
187 }
188 let dlon_per_day = dlon / span;
189 let dlat_per_day = (after.latitude.degrees() - before.latitude.degrees()) / span;
190 let ddist_per_day = match (before.distance_au, after.distance_au) {
191 (Some(b), Some(a)) => Some((a - b) / span),
192 _ => None,
193 };
194 Ok(Motion::new(
195 Some(dlon_per_day),
196 Some(dlat_per_day),
197 ddist_per_day,
198 ))
199 }
200}
201
202impl EphemerisBackend for PackagedDataBackend {
203 fn metadata(&self) -> BackendMetadata {
204 let artifact = self.artifact();
205 let bodies = artifact
206 .bodies
207 .iter()
208 .map(|series| series.body.clone())
209 .collect::<Vec<_>>();
210 let range = artifact_time_range(artifact);
211
212 BackendMetadata {
213 id: BackendId::new(PACKAGE_NAME),
214 version: format!(
215 "{} checksum:{:016x}",
216 artifact.header.version, artifact.checksum
217 ),
218 family: BackendFamily::CompressedData,
219 provenance: BackendProvenance {
220 summary: artifact.header.source.clone(),
221 data_sources: vec![
222 packaged_body_coverage_summary(),
223 packaged_request_policy_summary_for_report(),
224 packaged_frame_treatment_summary_for_report(),
225 packaged_artifact_storage_summary_for_report(),
226 packaged_artifact_access_summary_for_report(),
227 ],
228 },
229 nominal_range: range,
230 supported_time_scales: vec![TimeScale::Tt, TimeScale::Tdb],
231 body_claims: {
232 let declared = crate::packaged_body_claims();
233 let mut claims: Vec<_> = bodies
234 .iter()
235 .map(|body| {
236 declared
237 .iter()
238 .find(|c| &c.body == body)
239 .cloned()
240 .unwrap_or_else(|| pleiades_backend::BodyClaim::from(body.clone()))
241 })
242 .collect();
243 claims.extend(crate::apsis_body_claims());
244 claims
245 },
246 supported_frames: vec![CoordinateFrame::Ecliptic, CoordinateFrame::Equatorial],
247 capabilities: BackendCapabilities {
248 geocentric: true,
249 topocentric: false,
250 apparent: false,
251 mean: true,
252 batch: true,
253 native_sidereal: false,
254 },
255 accuracy: AccuracyClass::Approximate,
256 deterministic: true,
257 offline: true,
258 }
259 }
260
261 fn supports_body(&self, body: CelestialBody) -> bool {
262 matches!(body, CelestialBody::TrueApogee | CelestialBody::TruePerigee)
263 || self
264 .artifact
265 .bodies
266 .iter()
267 .any(|series| series.body == body)
268 }
269
270 fn position(&self, req: &EphemerisRequest) -> Result<EphemerisResult, EphemerisError> {
271 if !matches!(req.instant.scale, TimeScale::Tt | TimeScale::Tdb) {
272 return Err(EphemerisError::new(
273 EphemerisErrorKind::UnsupportedTimeScale,
274 "packaged data only supports TT or TDB requests",
275 ));
276 }
277
278 validate_request_policy(
279 req,
280 "packaged data",
281 &[TimeScale::Tt, TimeScale::Tdb],
282 &[CoordinateFrame::Ecliptic, CoordinateFrame::Equatorial],
283 true,
284 false,
285 )?;
286
287 validate_zodiac_policy(req, "packaged data", &[ZodiacMode::Tropical])?;
288
289 validate_observer_policy(req, "packaged data", false)?;
290
291 if matches!(
292 req.body,
293 CelestialBody::TrueApogee | CelestialBody::TruePerigee
294 ) {
295 return self.osculating_apsis_position(req);
296 }
297
298 let lookup_instant = normalize_lookup_instant(req.instant);
299 let ecliptic = self
300 .artifact
301 .lookup_ecliptic(&req.body, lookup_instant)
302 .map_err(map_artifact_error)?;
303 let equatorial = ecliptic.to_equatorial(req.instant.mean_obliquity());
304 let motion = self
305 .artifact
306 .lookup_motion(&req.body, lookup_instant)
307 .map_err(map_artifact_error)?;
308
309 let mut result = EphemerisResult::new(
310 BackendId::new(PACKAGE_NAME),
311 req.body.clone(),
312 req.instant,
313 req.frame,
314 req.zodiac_mode.clone(),
315 req.apparent,
316 );
317 result.ecliptic = Some(ecliptic);
318 result.equatorial = Some(equatorial);
319 result.motion = Some(motion);
320 result.quality = QualityAnnotation::Interpolated;
321 Ok(result)
322 }
323}