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