Skip to main content

lox_analysis/
assets.rs

1// SPDX-FileCopyrightText: 2025 Helge Eichhorn <git@helgeeichhorn.de>
2//
3// SPDX-License-Identifier: MPL-2.0
4
5use std::collections::HashMap;
6use std::fmt;
7
8use lox_bodies::{DynOrigin, Origin};
9use lox_core::units::AngularRate;
10
11#[cfg(feature = "imaging")]
12use crate::imaging::OpticalPayload;
13#[cfg(feature = "imaging")]
14use crate::imaging::SarPayload;
15#[cfg(feature = "imaging")]
16use crate::imaging::analysis::PayloadAccessor;
17use lox_frames::rotations::TryRotation;
18use lox_frames::{DynFrame, ReferenceFrame};
19use lox_time::Time;
20use lox_time::intervals::TimeInterval;
21use lox_time::time_scales::{DynTimeScale, Tai};
22use rayon::prelude::*;
23
24#[cfg(feature = "comms")]
25use lox_comms::system::CommunicationSystem;
26
27use crate::visibility::ElevationMask;
28use lox_orbits::constellations::{ConstellationPropagator, DynConstellation};
29use lox_orbits::ground::DynGroundLocation;
30use lox_orbits::orbits::{Ensemble, KeplerianOrbit};
31use lox_orbits::propagators::j2::DynJ2Propagator;
32use lox_orbits::propagators::j4::DynJ4Propagator;
33use lox_orbits::propagators::numerical::DynNumericalPropagator;
34use lox_orbits::propagators::semi_analytical::DynVallado;
35use lox_orbits::propagators::{OrbitSource, PropagateError};
36
37/// Unique identifier for a ground station or spacecraft.
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40pub struct AssetId(String);
41
42impl AssetId {
43    /// Creates a new asset identifier.
44    pub fn new(id: impl Into<String>) -> Self {
45        Self(id.into())
46    }
47
48    /// Returns the identifier as a string slice.
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54impl fmt::Display for AssetId {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "{}", self.0)
57    }
58}
59
60/// Unique identifier for a satellite constellation.
61#[derive(Debug, Clone, PartialEq, Eq, Hash)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63pub struct ConstellationId(String);
64
65impl ConstellationId {
66    /// Creates a new constellation identifier.
67    pub fn new(id: impl Into<String>) -> Self {
68        Self(id.into())
69    }
70
71    /// Returns the identifier as a string slice.
72    pub fn as_str(&self) -> &str {
73        &self.0
74    }
75}
76
77impl fmt::Display for ConstellationId {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        write!(f, "{}", self.0)
80    }
81}
82
83/// Unique identifier for a ground station network.
84#[derive(Debug, Clone, PartialEq, Eq, Hash)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub struct NetworkId(String);
87
88impl NetworkId {
89    /// Creates a new network identifier.
90    pub fn new(id: impl Into<String>) -> Self {
91        Self(id.into())
92    }
93
94    /// Returns the identifier as a string slice.
95    pub fn as_str(&self) -> &str {
96        &self.0
97    }
98}
99
100impl fmt::Display for NetworkId {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        write!(f, "{}", self.0)
103    }
104}
105
106/// A ground station with location, elevation mask, and optional network membership.
107#[derive(Debug, Clone)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
109pub struct GroundStation {
110    id: AssetId,
111    location: DynGroundLocation,
112    mask: ElevationMask,
113    body_fixed_frame: DynFrame,
114    network: Option<NetworkId>,
115    #[cfg(feature = "comms")]
116    communication_systems: Vec<CommunicationSystem>,
117}
118
119impl GroundStation {
120    /// Creates a new ground station with the given location and elevation mask.
121    pub fn new(id: impl Into<String>, location: DynGroundLocation, mask: ElevationMask) -> Self {
122        let body_fixed_frame = DynFrame::Iau(location.origin());
123        Self {
124            id: AssetId::new(id),
125            location,
126            mask,
127            body_fixed_frame,
128            network: None,
129            #[cfg(feature = "comms")]
130            communication_systems: Vec::new(),
131        }
132    }
133
134    /// Overrides the body-fixed frame (defaults to IAU frame of the location's origin).
135    pub fn with_body_fixed_frame(mut self, frame: impl Into<DynFrame>) -> Self {
136        self.body_fixed_frame = frame.into();
137        self
138    }
139
140    /// Assigns this ground station to a network.
141    pub fn with_network_id(mut self, id: impl Into<String>) -> Self {
142        self.network = Some(NetworkId(id.into()));
143        self
144    }
145
146    /// Adds a communication system to this ground station.
147    #[cfg(feature = "comms")]
148    pub fn with_communication_system(mut self, system: CommunicationSystem) -> Self {
149        self.communication_systems.push(system);
150        self
151    }
152
153    /// Returns the asset identifier.
154    pub fn id(&self) -> &AssetId {
155        &self.id
156    }
157
158    /// Returns the ground location.
159    pub fn location(&self) -> &DynGroundLocation {
160        &self.location
161    }
162
163    /// Returns the elevation mask.
164    pub fn mask(&self) -> &ElevationMask {
165        &self.mask
166    }
167
168    /// Returns the network identifier, if assigned.
169    pub fn network_id(&self) -> Option<&NetworkId> {
170        self.network.as_ref()
171    }
172
173    /// Returns the body-fixed reference frame.
174    pub fn body_fixed_frame(&self) -> DynFrame {
175        self.body_fixed_frame
176    }
177
178    /// Returns the communication systems attached to this ground station.
179    #[cfg(feature = "comms")]
180    pub fn communication_systems(&self) -> &[CommunicationSystem] {
181        &self.communication_systems
182    }
183}
184
185/// A spacecraft with an orbit source, optional slew rate, and constellation membership.
186#[derive(Debug, Clone)]
187#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
188pub struct Spacecraft {
189    id: AssetId,
190    orbit: OrbitSource,
191    max_slew_rate: Option<AngularRate>,
192    constellation: Option<ConstellationId>,
193    #[cfg(feature = "imaging")]
194    optical_payload: Option<OpticalPayload>,
195    #[cfg(feature = "imaging")]
196    sar_payload: Option<SarPayload>,
197    #[cfg(feature = "comms")]
198    communication_systems: Vec<CommunicationSystem>,
199}
200
201impl Spacecraft {
202    /// Creates a new spacecraft with the given orbit source.
203    pub fn new(id: impl Into<String>, orbit: OrbitSource) -> Self {
204        Self {
205            id: AssetId::new(id),
206            orbit,
207            max_slew_rate: None,
208            constellation: None,
209            #[cfg(feature = "imaging")]
210            optical_payload: None,
211            #[cfg(feature = "imaging")]
212            sar_payload: None,
213            #[cfg(feature = "comms")]
214            communication_systems: Vec::new(),
215        }
216    }
217
218    /// Sets the maximum slew rate for the spacecraft.
219    pub fn with_max_slew_rate(mut self, rate: AngularRate) -> Self {
220        self.max_slew_rate = Some(rate);
221        self
222    }
223
224    /// Assigns this spacecraft to a constellation.
225    pub fn with_constellation_id(mut self, id: impl Into<String>) -> Self {
226        self.constellation = Some(ConstellationId(id.into()));
227        self
228    }
229
230    /// Sets the optical payload for this spacecraft.
231    #[cfg(feature = "imaging")]
232    pub fn with_optical_payload(mut self, payload: OpticalPayload) -> Self {
233        self.optical_payload = Some(payload);
234        self
235    }
236
237    /// Sets the SAR payload for this spacecraft.
238    #[cfg(feature = "imaging")]
239    pub fn with_sar_payload(mut self, payload: SarPayload) -> Self {
240        self.sar_payload = Some(payload);
241        self
242    }
243
244    /// Adds a communication system to this spacecraft.
245    #[cfg(feature = "comms")]
246    pub fn with_communication_system(mut self, system: CommunicationSystem) -> Self {
247        self.communication_systems.push(system);
248        self
249    }
250
251    /// Returns the asset identifier.
252    pub fn id(&self) -> &AssetId {
253        &self.id
254    }
255
256    /// Returns the orbit source.
257    pub fn orbit(&self) -> &OrbitSource {
258        &self.orbit
259    }
260
261    /// Returns the constellation identifier, if assigned.
262    pub fn constellation_id(&self) -> Option<&ConstellationId> {
263        self.constellation.as_ref()
264    }
265
266    /// Returns the maximum slew rate, if set.
267    pub fn max_slew_rate(&self) -> Option<AngularRate> {
268        self.max_slew_rate
269    }
270
271    /// Returns the optical payload, if set.
272    #[cfg(feature = "imaging")]
273    pub fn optical_payload(&self) -> Option<OpticalPayload> {
274        self.optical_payload
275    }
276
277    /// Returns the SAR payload, if set.
278    #[cfg(feature = "imaging")]
279    pub fn sar_payload(&self) -> Option<SarPayload> {
280        self.sar_payload
281    }
282
283    /// Returns the communication systems attached to this spacecraft.
284    #[cfg(feature = "comms")]
285    pub fn communication_systems(&self) -> &[CommunicationSystem] {
286        &self.communication_systems
287    }
288}
289
290/// A scenario declaring the analysis origin, reference frame, time interval,
291/// and the assets (ground stations and spacecraft) involved.
292///
293/// The type parameters `O` and `R` specify the "native" origin body and
294/// reference frame. For dynamic dispatch (e.g. via Python), use `DynScenario`.
295#[derive(Debug, Clone)]
296#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
297pub struct Scenario<O: Origin, R: ReferenceFrame> {
298    interval: TimeInterval<Tai>,
299    origin: O,
300    frame: R,
301    ground_stations: Vec<GroundStation>,
302    spacecraft: Vec<Spacecraft>,
303    constellations: Vec<DynConstellation>,
304}
305
306/// Dynamic scenario — preserves backward compatibility and serves the Python API.
307pub type DynScenario = Scenario<DynOrigin, DynFrame>;
308
309/// Errors from converting a constellation into individual [`Spacecraft`].
310#[derive(Debug, thiserror::Error)]
311pub enum ConstellationConvertError {
312    /// Failed to create a Keplerian orbit from satellite elements.
313    #[error("failed to create Keplerian orbit: {0}")]
314    KeplerianOrbit(String),
315    /// Failed to convert Keplerian orbit to Cartesian state.
316    #[error("failed to convert to Cartesian orbit: {0}")]
317    CartesianConversion(String),
318    /// Failed to create an orbit propagator.
319    #[error("failed to create propagator: {0}")]
320    Propagator(String),
321}
322
323/// Errors from propagating spacecraft trajectories within a scenario.
324#[derive(Debug, thiserror::Error)]
325pub enum ScenarioPropagateError {
326    /// Orbit propagation failed for the named spacecraft.
327    #[error("propagation failed for spacecraft \"{0}\": {1}")]
328    Propagate(AssetId, PropagateError),
329    /// Frame transformation failed for the named spacecraft.
330    #[error("frame transformation failed for spacecraft \"{0}\": {1}")]
331    FrameTransformation(AssetId, String),
332}
333
334impl<O: Origin + Copy + Send + Sync, R: ReferenceFrame + Copy + Send + Sync> Scenario<O, R> {
335    /// Creates a new scenario from start/end times, origin, and frame.
336    pub fn new(start_time: Time<Tai>, end_time: Time<Tai>, origin: O, frame: R) -> Self {
337        let interval = TimeInterval::new(start_time, end_time);
338        Self::with_interval(interval, origin, frame)
339    }
340
341    /// Creates a new scenario from a time interval, origin, and frame.
342    pub fn with_interval(interval: TimeInterval<Tai>, origin: O, frame: R) -> Self {
343        Self {
344            interval,
345            origin,
346            frame,
347            ground_stations: Vec::new(),
348            spacecraft: Vec::new(),
349            constellations: Vec::new(),
350        }
351    }
352
353    /// Sets the spacecraft for this scenario.
354    pub fn with_spacecraft(mut self, spacecraft: &[Spacecraft]) -> Self {
355        self.spacecraft = spacecraft.into();
356        self
357    }
358
359    /// Sets the ground stations for this scenario.
360    pub fn with_ground_stations(mut self, ground_stations: &[GroundStation]) -> Self {
361        self.ground_stations = ground_stations.into();
362        self
363    }
364
365    /// Returns the scenario time interval.
366    pub fn interval(&self) -> &TimeInterval<Tai> {
367        &self.interval
368    }
369
370    /// Returns the central body origin.
371    pub fn origin(&self) -> O {
372        self.origin
373    }
374
375    /// Returns the reference frame.
376    pub fn frame(&self) -> R {
377        self.frame
378    }
379
380    /// Add a constellation to the scenario, converting each satellite into a
381    /// [`Spacecraft`] using the constellation's selected propagator.
382    pub fn with_constellation(
383        mut self,
384        constellation: DynConstellation,
385    ) -> Result<Self, ConstellationConvertError> {
386        let epoch = constellation.epoch();
387        let origin = constellation.origin();
388        let frame = constellation.frame();
389        let propagator_kind = constellation.propagator();
390        let name = constellation.name().to_string();
391
392        for sat in constellation.satellites() {
393            let keplerian_orbit =
394                KeplerianOrbit::try_from_keplerian(sat.elements, epoch, origin, frame)
395                    .map_err(|e| ConstellationConvertError::KeplerianOrbit(e.to_string()))?;
396            let cartesian_orbit = keplerian_orbit
397                .try_to_cartesian()
398                .map_err(|e| ConstellationConvertError::CartesianConversion(e.to_string()))?;
399
400            let orbit_source = match propagator_kind {
401                ConstellationPropagator::Vallado => {
402                    let v = DynVallado::try_new(cartesian_orbit)
403                        .map_err(|e| ConstellationConvertError::Propagator(e.to_string()))?;
404                    OrbitSource::Vallado(v)
405                }
406                ConstellationPropagator::Numerical => {
407                    let n = DynNumericalPropagator::try_new(cartesian_orbit)
408                        .map_err(|e| ConstellationConvertError::Propagator(e.to_string()))?;
409                    OrbitSource::Numerical(n)
410                }
411                ConstellationPropagator::J2 => {
412                    let p = DynJ2Propagator::try_new(cartesian_orbit)
413                        .map_err(|e| ConstellationConvertError::Propagator(e.to_string()))?;
414                    OrbitSource::J2(p)
415                }
416                ConstellationPropagator::J2Osc => {
417                    let p = DynJ2Propagator::try_new(cartesian_orbit)
418                        .map(|p| p.with_osculating(true))
419                        .map_err(|e| ConstellationConvertError::Propagator(e.to_string()))?;
420                    OrbitSource::J2(p)
421                }
422                ConstellationPropagator::J4 => {
423                    let p = DynJ4Propagator::try_new(cartesian_orbit)
424                        .map_err(|e| ConstellationConvertError::Propagator(e.to_string()))?;
425                    OrbitSource::J4(p)
426                }
427                ConstellationPropagator::J4Osc => {
428                    let p = DynJ4Propagator::try_new(cartesian_orbit)
429                        .map(|p| p.with_osculating(true))
430                        .map_err(|e| ConstellationConvertError::Propagator(e.to_string()))?;
431                    OrbitSource::J4(p)
432                }
433            };
434
435            let sc_id = format!("{} P{} S{}", name, sat.plane + 1, sat.index_in_plane + 1);
436            let sc = Spacecraft::new(sc_id, orbit_source).with_constellation_id(&name);
437            self.spacecraft.push(sc);
438        }
439
440        self.constellations.push(constellation);
441        Ok(self)
442    }
443
444    /// Returns the constellations in this scenario.
445    pub fn constellations(&self) -> &[DynConstellation] {
446        &self.constellations
447    }
448
449    /// Returns the ground stations in this scenario.
450    pub fn ground_stations(&self) -> &[GroundStation] {
451        &self.ground_stations
452    }
453
454    /// Returns the spacecraft in this scenario.
455    pub fn spacecraft(&self) -> &[Spacecraft] {
456        &self.spacecraft
457    }
458
459    /// Propagate all spacecraft over the scenario interval, transforming
460    /// trajectories to the scenario's frame using the provided rotation
461    /// `provider`.
462    ///
463    /// Internally, each spacecraft's `OrbitSource` produces a `DynTrajectory`
464    /// which is then rotated into the concrete frame `R` via the mixed
465    /// `TryRotation<DynFrame, R, T>` impls, and finally re-tagged to
466    /// `Trajectory<Tai, O, R>`.
467    pub fn propagate<P>(
468        &self,
469        provider: &P,
470    ) -> Result<Ensemble<AssetId, Tai, O, R>, ScenarioPropagateError>
471    where
472        R: Into<DynFrame>,
473        P: TryRotation<DynFrame, R, DynTimeScale> + Send + Sync,
474        P::Error: std::fmt::Display,
475    {
476        let dyn_interval = TimeInterval::new(
477            self.interval.start().into_dyn(),
478            self.interval.end().into_dyn(),
479        );
480        let origin = self.origin;
481        let frame = self.frame;
482        let entries: Result<HashMap<_, _>, _> = self
483            .spacecraft
484            .par_iter()
485            .map(|sc| {
486                let traj = sc
487                    .orbit
488                    .propagate(dyn_interval)
489                    .map_err(|e| ScenarioPropagateError::Propagate(sc.id.clone(), e))?;
490                // Rotate DynTrajectory directly into concrete frame R
491                // (uses mixed TryRotation<DynFrame, R, DynTimeScale>).
492                let rotated = traj.into_frame(frame, provider).map_err(|e| {
493                    ScenarioPropagateError::FrameTransformation(sc.id.clone(), e.to_string())
494                })?;
495                // Re-tag origin and time scale (data unchanged, just type markers).
496                let (epoch, _origin, frame, data) = rotated.into_parts();
497                let typed = lox_orbits::orbits::Trajectory::from_parts(
498                    epoch.with_scale(Tai),
499                    origin,
500                    frame,
501                    data,
502                );
503                Ok((sc.id.clone(), typed))
504            })
505            .collect();
506        Ok(Ensemble::new(entries?))
507    }
508
509    /// Returns a new scenario containing only spacecraft belonging to the given constellations.
510    pub fn filter_by_constellations(&self, constellations: &[ConstellationId]) -> Self {
511        let spacecraft = self
512            .spacecraft
513            .clone()
514            .into_iter()
515            .filter(|s| s.constellation.is_some())
516            .filter(|s| constellations.contains(s.constellation.as_ref().unwrap()))
517            .collect();
518        Scenario {
519            spacecraft,
520            ..self.clone()
521        }
522    }
523
524    /// Returns a new scenario containing only ground stations belonging to the given networks.
525    pub fn filter_by_networks(&self, networks: &[NetworkId]) -> Self {
526        let ground_stations = self
527            .ground_stations
528            .clone()
529            .into_iter()
530            .filter(|s| s.network.is_some())
531            .filter(|s| networks.contains(s.network.as_ref().unwrap()))
532            .collect();
533        Scenario {
534            ground_stations,
535            ..self.clone()
536        }
537    }
538}
539
540#[cfg(feature = "imaging")]
541impl PayloadAccessor<OpticalPayload> for Spacecraft {
542    fn extract(&self) -> Option<OpticalPayload> {
543        self.optical_payload
544    }
545}
546
547#[cfg(feature = "imaging")]
548impl PayloadAccessor<SarPayload> for Spacecraft {
549    fn extract(&self) -> Option<SarPayload> {
550        self.sar_payload
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use lox_core::coords::LonLatAlt;
558    use lox_frames::DynFrame;
559    use lox_orbits::ground::GroundLocation;
560    use lox_time::deltas::TimeDelta;
561
562    fn dummy_location() -> DynGroundLocation {
563        let coords = LonLatAlt::from_degrees(-4.3676, 40.4527, 0.0).unwrap();
564        GroundLocation::try_new(coords, DynOrigin::Earth).unwrap()
565    }
566
567    fn dummy_mask() -> ElevationMask {
568        ElevationMask::with_fixed_elevation(0.0)
569    }
570
571    // --- ID types ---
572
573    #[test]
574    fn test_asset_id() {
575        let id = AssetId::new("station-1");
576        assert_eq!(id.as_str(), "station-1");
577        assert_eq!(format!("{id}"), "station-1");
578        assert_eq!(id, AssetId::new("station-1"));
579        assert_ne!(id, AssetId::new("station-2"));
580    }
581
582    #[test]
583    fn test_constellation_id() {
584        let id = ConstellationId::new("oneweb");
585        assert_eq!(id.as_str(), "oneweb");
586        assert_eq!(format!("{id}"), "oneweb");
587    }
588
589    #[test]
590    fn test_network_id() {
591        let id = NetworkId::new("estrack");
592        assert_eq!(id.as_str(), "estrack");
593        assert_eq!(format!("{id}"), "estrack");
594    }
595
596    // --- GroundStation ---
597
598    #[test]
599    fn test_ground_station_new() {
600        let loc = dummy_location();
601        let mask = dummy_mask();
602        let gs = GroundStation::new("gs1", loc, mask);
603        assert_eq!(gs.id().as_str(), "gs1");
604        assert_eq!(gs.body_fixed_frame(), DynFrame::Iau(DynOrigin::Earth));
605    }
606
607    #[test]
608    fn test_ground_station_with_body_fixed_frame() {
609        let gs = GroundStation::new("gs1", dummy_location(), dummy_mask())
610            .with_body_fixed_frame(DynFrame::Itrf);
611        assert_eq!(gs.body_fixed_frame(), DynFrame::Itrf);
612    }
613
614    #[test]
615    fn test_ground_station_network_id_none_by_default() {
616        let gs = GroundStation::new("gs1", dummy_location(), dummy_mask());
617        assert!(gs.network_id().is_none());
618    }
619
620    #[test]
621    fn test_ground_station_with_network_id() {
622        let gs =
623            GroundStation::new("gs1", dummy_location(), dummy_mask()).with_network_id("estrack");
624        assert_eq!(gs.network_id(), Some(&NetworkId::new("estrack")));
625        // Verify network via filter_by_networks round-trip.
626        let start = Time::j2000(Tai);
627        let end = start + TimeDelta::from_seconds(86400);
628        let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf)
629            .with_ground_stations(&[gs]);
630        let filtered = scenario.filter_by_networks(&[NetworkId::new("estrack")]);
631        assert_eq!(filtered.ground_stations().len(), 1);
632    }
633
634    #[test]
635    fn test_ground_station_location_getter() {
636        let loc = dummy_location();
637        let gs = GroundStation::new("gs1", loc.clone(), dummy_mask());
638        let _ = gs.location(); // verify it compiles and returns
639    }
640
641    #[test]
642    fn test_ground_station_mask_getter() {
643        let mask = ElevationMask::with_fixed_elevation(0.1);
644        let gs = GroundStation::new("gs1", dummy_location(), mask.clone());
645        assert_eq!(gs.mask().min_elevation(0.0), 0.1);
646    }
647
648    // --- Spacecraft ---
649
650    #[test]
651    fn test_spacecraft_new() {
652        let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
653            &lox_test_utils::read_data_file("trajectory_lunar.csv"),
654            DynOrigin::Earth,
655            DynFrame::Icrf,
656        )
657        .unwrap();
658        let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(traj));
659        assert_eq!(sc.id().as_str(), "sc1");
660        assert!(sc.max_slew_rate().is_none());
661        assert!(sc.constellation_id().is_none());
662    }
663
664    #[test]
665    fn test_spacecraft_with_max_slew_rate() {
666        let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
667            &lox_test_utils::read_data_file("trajectory_lunar.csv"),
668            DynOrigin::Earth,
669            DynFrame::Icrf,
670        )
671        .unwrap();
672        let rate = AngularRate::degrees_per_second(5.0);
673        let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(traj)).with_max_slew_rate(rate);
674        assert!(sc.max_slew_rate().is_some());
675    }
676
677    #[test]
678    fn test_spacecraft_with_constellation_id() {
679        let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
680            &lox_test_utils::read_data_file("trajectory_lunar.csv"),
681            DynOrigin::Earth,
682            DynFrame::Icrf,
683        )
684        .unwrap();
685        let sc =
686            Spacecraft::new("sc1", OrbitSource::Trajectory(traj)).with_constellation_id("oneweb");
687        assert_eq!(sc.constellation_id(), Some(&ConstellationId::new("oneweb")));
688    }
689
690    #[test]
691    fn test_spacecraft_orbit_getter() {
692        let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
693            &lox_test_utils::read_data_file("trajectory_lunar.csv"),
694            DynOrigin::Earth,
695            DynFrame::Icrf,
696        )
697        .unwrap();
698        let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(traj));
699        assert!(matches!(sc.orbit(), OrbitSource::Trajectory(_)));
700    }
701
702    // --- Scenario ---
703
704    #[test]
705    fn test_scenario_construction() {
706        let start = Time::j2000(Tai);
707        let end = start + TimeDelta::from_seconds(86400);
708        let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf);
709        assert_eq!(scenario.origin(), DynOrigin::Earth);
710        assert_eq!(scenario.frame(), DynFrame::Icrf);
711        assert!(scenario.spacecraft().is_empty());
712        assert!(scenario.ground_stations().is_empty());
713    }
714
715    #[test]
716    fn test_scenario_with_assets() {
717        let start = Time::j2000(Tai);
718        let end = start + TimeDelta::from_seconds(86400);
719        let gs = GroundStation::new("gs1", dummy_location(), dummy_mask());
720        let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
721            &lox_test_utils::read_data_file("trajectory_lunar.csv"),
722            DynOrigin::Earth,
723            DynFrame::Icrf,
724        )
725        .unwrap();
726        let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(traj));
727        let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf)
728            .with_ground_stations(&[gs])
729            .with_spacecraft(&[sc]);
730        assert_eq!(scenario.ground_stations().len(), 1);
731        assert_eq!(scenario.spacecraft().len(), 1);
732    }
733
734    #[test]
735    fn test_scenario_filter_by_constellations() {
736        let start = Time::j2000(Tai);
737        let end = start + TimeDelta::from_seconds(86400);
738        let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
739            &lox_test_utils::read_data_file("trajectory_lunar.csv"),
740            DynOrigin::Earth,
741            DynFrame::Icrf,
742        )
743        .unwrap();
744        let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(traj.clone()))
745            .with_constellation_id("oneweb");
746        let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(traj));
747        let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf)
748            .with_spacecraft(&[sc1, sc2]);
749        let filtered = scenario.filter_by_constellations(&[ConstellationId::new("oneweb")]);
750        assert_eq!(filtered.spacecraft().len(), 1);
751        assert_eq!(filtered.spacecraft()[0].id().as_str(), "sc1");
752    }
753
754    #[test]
755    fn test_scenario_filter_by_networks() {
756        let start = Time::j2000(Tai);
757        let end = start + TimeDelta::from_seconds(86400);
758        let gs1 =
759            GroundStation::new("gs1", dummy_location(), dummy_mask()).with_network_id("estrack");
760        let gs2 = GroundStation::new("gs2", dummy_location(), dummy_mask());
761        let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf)
762            .with_ground_stations(&[gs1, gs2]);
763        let filtered = scenario.filter_by_networks(&[NetworkId::new("estrack")]);
764        assert_eq!(filtered.ground_stations().len(), 1);
765        assert_eq!(filtered.ground_stations()[0].id().as_str(), "gs1");
766    }
767
768    #[test]
769    fn test_scenario_with_constellation() {
770        use lox_core::units::{AngleUnits, DistanceUnits};
771        use lox_orbits::constellations::WalkerDeltaBuilder;
772
773        let start = Time::j2000(Tai);
774        let end = start + TimeDelta::from_seconds(86400);
775        let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf);
776
777        let constellation = WalkerDeltaBuilder::new(6, 3)
778            .with_semi_major_axis(7000.0_f64.km(), 0.0)
779            .with_inclination(53.0_f64.deg())
780            .build_constellation("test", start, DynOrigin::Earth, DynFrame::Icrf)
781            .unwrap()
782            .into_dyn();
783
784        let scenario = scenario.with_constellation(constellation).unwrap();
785        assert_eq!(scenario.spacecraft().len(), 6);
786        assert_eq!(scenario.constellations().len(), 1);
787        assert_eq!(scenario.constellations()[0].name(), "test");
788        // Verify spacecraft IDs contain constellation name
789        assert!(scenario.spacecraft()[0].id().as_str().contains("test"));
790    }
791
792    #[test]
793    fn test_scenario_interval() {
794        let start = Time::j2000(Tai);
795        let end = start + TimeDelta::from_seconds(86400);
796        let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf);
797        assert_eq!(scenario.interval().start(), start);
798        assert_eq!(scenario.interval().end(), end);
799    }
800
801    #[test]
802    fn test_scenario_propagate() {
803        let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
804            &lox_test_utils::read_data_file("trajectory_lunar.csv"),
805            DynOrigin::Earth,
806            DynFrame::Icrf,
807        )
808        .unwrap();
809        let start = traj.start_time().to_scale(Tai);
810        let end = traj.end_time().to_scale(Tai);
811        let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(traj));
812        let scenario =
813            DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf).with_spacecraft(&[sc]);
814        let ensemble = scenario
815            .propagate(&lox_frames::providers::DefaultRotationProvider)
816            .unwrap();
817        assert_eq!(ensemble.len(), 1);
818        assert!(ensemble.get(&AssetId::new("sc1")).is_some());
819    }
820}