From 4da6a9efc6c9d82754901485c08173b87320a0ba Mon Sep 17 00:00:00 2001 From: Helge Eichhorn Date: Tue, 28 Jan 2025 20:54:46 +0100 Subject: [PATCH] feat(lox-orbits): implement line-of-sight calculations --- crates/lox-orbits/src/analysis.rs | 217 +++++++++++++++++++++- crates/lox-orbits/src/events.rs | 48 +++++ crates/lox-orbits/src/ground.rs | 12 +- crates/lox-orbits/src/python.rs | 32 +++- crates/lox-orbits/src/trajectories.rs | 70 +++++++ crates/lox-space/tests/conftest.py | 12 +- crates/lox-space/tests/test_benchmarks.py | 8 +- crates/lox-space/tests/test_states.py | 9 - data/contacts_combined.csv | 34 ++++ 9 files changed, 413 insertions(+), 29 deletions(-) create mode 100755 data/contacts_combined.csv diff --git a/crates/lox-orbits/src/analysis.rs b/crates/lox-orbits/src/analysis.rs index 3895fa68..48eecd92 100644 --- a/crates/lox-orbits/src/analysis.rs +++ b/crates/lox-orbits/src/analysis.rs @@ -6,24 +6,70 @@ * file, you can obtain one at https://mozilla.org/MPL/2.0/. */ -use lox_bodies::{Origin, RotationalElements, Spheroid, TrySpheroid}; +use glam::DVec3; +use itertools::Itertools; +use lox_bodies::{ + DynOrigin, Origin, RotationalElements, Spheroid, TryMeanRadius, TrySpheroid, + UndefinedOriginPropertyError, +}; +use lox_ephem::{path_from_ids, Ephemeris}; use lox_math::roots::Brent; use lox_math::series::{Series, SeriesError}; use lox_math::types::units::Radians; use lox_time::deltas::TimeDelta; +use lox_time::julian_dates::JulianDate; use lox_time::time_scales::{DynTimeScale, TryToScale}; use lox_time::time_scales::{Tdb, TimeScale}; use lox_time::ut1::DeltaUt1TaiProvider; use lox_time::{DynTime, Time}; +use rayon::prelude::*; use std::f64::consts::PI; use thiserror::Error; -use crate::events::{find_windows, Window}; +use crate::events::{find_windows, intersect_windows, Window}; use crate::frames::{DynFrame, Iau, Icrf, TryRotateTo}; -use crate::ground::{DynGroundLocation, GroundLocation}; +use crate::ground::{DynGroundLocation, DynGroundPropagator, GroundLocation}; use crate::states::State; use crate::trajectories::{DynTrajectory, Trajectory}; +// Salvatore Alfano, David Negron, Jr., and Jennifer L. Moore +// Rapid Determination of Satellite Visibility Periods +// The Journal of the Astronautical Sciences. Vol. 40, No. 2, April-June 1992, pp. 281-296 +pub fn line_of_sight(radius: f64, r1: DVec3, r2: DVec3) -> f64 { + let r1n = r1.length(); + let r2n = r2.length(); + let theta1 = radius / r1n; + let theta2 = radius / r2n; + let theta = r1.dot(r2) / r1n / r2n; + theta1.acos() + theta2.acos() - theta.acos() +} + +pub fn line_of_sight_spheroid( + mean_radius: f64, + radius_eq: f64, + radius_p: f64, + r1: DVec3, + r2: DVec3, +) -> f64 { + let eps = (1.0 - radius_p.powi(2) / radius_eq.powi(2)).sqrt(); + let scale = (1.0 - eps.powi(2)).sqrt(); + let r1 = DVec3::new(r1.x, r1.y, r1.z / scale); + let r2 = DVec3::new(r2.x, r2.y, r2.z / scale); + line_of_sight(mean_radius, r1, r2) +} + +pub trait LineOfSight: TrySpheroid + TryMeanRadius { + fn line_of_sight(&self, r1: DVec3, r2: DVec3) -> Result { + let mean_radius = self.try_mean_radius()?; + if let (Ok(r_eq), Ok(r_p)) = (self.try_equatorial_radius(), self.try_polar_radius()) { + return Ok(line_of_sight_spheroid(mean_radius, r_eq, r_p, r1, r2)); + } + Ok(line_of_sight(mean_radius, r1, r2)) + } +} + +impl LineOfSight for T {} + #[derive(Debug, Clone, Error, PartialEq)] pub enum ElevationMaskError { #[error("invalid azimuth range: {}..{}", .0.to_degrees(), .1.to_degrees())] @@ -112,6 +158,78 @@ pub fn visibility_dyn( ) } +pub fn visibility_los( + times: &[DynTime], + gs: &DynGroundLocation, + body: DynOrigin, + sc: &DynTrajectory, + ephem: &impl Ephemeris, + provider: Option<&P>, +) -> Vec> { + if times.len() < 2 { + return vec![]; + } + let start = *times.first().unwrap(); + let end = *times.last().unwrap(); + let times: Vec = times + .iter() + .map(|t| (*t - start).to_decimal_seconds()) + .collect(); + let root_finder = Brent::default(); + find_windows( + |t| { + let time = start + TimeDelta::from_decimal_seconds(t); + let epoch = time + .try_to_scale(Tdb, provider) + .unwrap() + .seconds_since_j2000(); + let origin_id = sc.origin().id(); + let target_id = body.id(); + let path = path_from_ids(origin_id.0, target_id.0); + let mut r_body = DVec3::ZERO; + for (origin, target) in path.into_iter().tuple_windows() { + let p: DVec3 = ephem.position(epoch, origin, target).unwrap().into(); + r_body += p; + } + let r_sc = sc.interpolate_at(time).position() - r_body; + let r_gs = DynGroundPropagator::with_dynamic(gs.clone(), provider.cloned()) + .propagate_dyn(time) + .unwrap() + .position() + - r_body; + body.line_of_sight(r_gs, r_sc).unwrap() + }, + start, + end, + ×, + root_finder, + ) +} + +pub fn visibility_combined< + P: DeltaUt1TaiProvider + Clone + Send + Sync, + E: Ephemeris + Send + Sync, +>( + times: &[DynTime], + gs: &DynGroundLocation, + mask: &ElevationMask, + bodies: &[DynOrigin], + sc: &DynTrajectory, + ephem: &E, + provider: Option<&P>, +) -> Vec> { + let w1 = visibility_dyn(times, gs, mask, sc, provider); + let wb: Vec>> = bodies + .par_iter() + .map(|&body| visibility_los(times, gs, body, sc, ephem, provider)) + .collect(); + let mut w = w1; + for w2 in wb { + w = intersect_windows(&w, &w2); + } + w +} + pub fn elevation< T: TimeScale + TryToScale + Clone, O: Origin + TrySpheroid + RotationalElements + Clone, @@ -171,15 +289,46 @@ pub fn visibility< #[cfg(test)] mod tests { use lox_bodies::Earth; + use lox_ephem::spk::parser::{parse_daf_spk, Spk}; use lox_math::assert_close; use lox_math::is_close::IsClose; use lox_time::time_scales::Tai; + use lox_time::ut1::DeltaUt1Tai; use lox_time::utc::Utc; use lox_time::Time; use std::iter::zip; + use std::path::PathBuf; + use std::sync::OnceLock; use super::*; + #[test] + fn test_line_of_sight() { + let r1 = DVec3::new(0.0, -4464.696, -5102.509); + let r2 = DVec3::new(0.0, 5740.323, 3189.068); + let r_sun = DVec3::new(122233179.0, -76150708.0, 33016374.0); + let r = Earth.equatorial_radius(); + + let los = line_of_sight(r, r1, r2); + let los_sun = line_of_sight(r, r1, r_sun); + + assert!(los < 0.0); + assert!(los_sun >= 0.0); + } + + #[test] + fn test_line_of_sight_trait() { + let r1 = DVec3::new(0.0, -4464.696, -5102.509); + let r2 = DVec3::new(0.0, 5740.323, 3189.068); + let r_sun = DVec3::new(122233179.0, -76150708.0, 33016374.0); + + let los = Earth.line_of_sight(r1, r2).unwrap(); + let los_sun = Earth.line_of_sight(r1, r_sun).unwrap(); + + assert!(los < 0.0); + assert!(los_sun >= 0.0); + } + #[test] fn test_elevation() { let gs = ground_station_trajectory(); @@ -233,6 +382,29 @@ mod tests { } } + #[test] + fn test_visibility_combined() { + let gs = location_dyn(); + let mask = ElevationMask::with_fixed_elevation(0.0); + let sc = spacecraft_trajectory_dyn(); + let times: Vec = sc.states().iter().map(|s| s.time()).collect(); + let expected = contacts_combined(); + let actual = visibility_combined( + ×, + &gs, + &mask, + &vec![DynOrigin::Moon], + &sc, + ephemeris(), + None::<&DeltaUt1Tai>, + ); + assert_eq!(actual.len(), expected.len()); + for (actual, expected) in zip(actual, expected) { + assert_close!(actual.start(), expected.start(), 0.0, 1e-4); + assert_close!(actual.end(), expected.end(), 0.0, 1e-4); + } + } + fn ground_station_trajectory() -> Trajectory { Trajectory::from_csv( include_str!("../../../data/trajectory_cebr.csv"), @@ -251,12 +423,27 @@ mod tests { .unwrap() } + fn spacecraft_trajectory_dyn() -> DynTrajectory { + Trajectory::from_csv_dyn( + include_str!("../../../data/trajectory_lunar.csv"), + DynOrigin::Earth, + DynFrame::Icrf, + ) + .unwrap() + } + fn location() -> GroundLocation { let longitude = -4.3676f64.to_radians(); let latitude = 40.4527f64.to_radians(); GroundLocation::new(longitude, latitude, 0.0, Earth) } + fn location_dyn() -> GroundLocation { + let longitude = -4.3676f64.to_radians(); + let latitude = 40.4527f64.to_radians(); + GroundLocation::with_dynamic(longitude, latitude, 0.0, DynOrigin::Earth).unwrap() + } + fn contacts() -> Vec> { let mut windows = vec![]; let mut reader = @@ -269,4 +456,28 @@ mod tests { } windows } + + fn contacts_combined() -> Vec> { + let mut windows = vec![]; + let mut reader = csv::Reader::from_reader( + include_str!("../../../data/contacts_combined.csv").as_bytes(), + ); + for result in reader.records() { + let record = result.unwrap(); + let start = record[0].parse::().unwrap().to_dyn_time(); + let end = record[1].parse::().unwrap().to_dyn_time(); + windows.push(Window::new(start, end)); + } + windows + } + + fn ephemeris() -> &'static Spk { + let contents = std::fs::read(data_dir().join("de440s.bsp")).unwrap(); + static EPHEMERIS: OnceLock = OnceLock::new(); + EPHEMERIS.get_or_init(|| parse_daf_spk(&contents).unwrap()) + } + + pub fn data_dir() -> PathBuf { + PathBuf::from(format!("{}/../../data", env!("CARGO_MANIFEST_DIR"))) + } } diff --git a/crates/lox-orbits/src/events.rs b/crates/lox-orbits/src/events.rs index 6374313a..14c357d2 100644 --- a/crates/lox-orbits/src/events.rs +++ b/crates/lox-orbits/src/events.rs @@ -132,6 +132,39 @@ impl Window { { self.end() - self.start() } + + fn contains(&self, other: &Self) -> bool + where + // FIXME: Manually implement `Ord` on `Time` + T: Ord, + { + self.start <= other.start && self.end >= other.end + } + + fn intersect(&self, other: &Self) -> Option + where + T: Clone + Ord, + { + if self.contains(other) { + return Some(other.clone()); + } + if other.contains(self) { + return Some(self.clone()); + } + if other.start < self.end && other.end > self.end { + return Some(Window { + start: other.start.clone(), + end: self.end.clone(), + }); + } + if self.start < other.end && self.end > other.end { + return Some(Window { + start: self.start.clone(), + end: other.end.clone(), + }); + } + None + } } pub fn find_windows f64 + Copy, T: TimeScale + Clone, R: FindBracketedRoot>( @@ -188,6 +221,21 @@ pub fn find_windows f64 + Copy, T: TimeScale + Clone, R: FindBrack } } +pub fn intersect_windows(w1: &[Window], w2: &[Window]) -> Vec> +where + T: TimeScale + Ord + Clone, +{ + let mut output = vec![]; + for w1 in w1 { + for w2 in w2 { + if let Some(w) = w1.intersect(w2) { + output.push(w) + } + } + } + output +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/lox-orbits/src/ground.rs b/crates/lox-orbits/src/ground.rs index 2b1805ab..f82a0e9b 100644 --- a/crates/lox-orbits/src/ground.rs +++ b/crates/lox-orbits/src/ground.rs @@ -195,7 +195,7 @@ pub enum GroundPropagatorError { pub struct GroundPropagator { location: GroundLocation, // FIXME: We should not take ownership of the provider here - provider: P, + provider: Option

, } pub type DynGroundPropagator

= GroundPropagator; @@ -204,13 +204,13 @@ impl GroundPropagator where B: Spheroid, { - pub fn new(location: GroundLocation, provider: P) -> Self { + pub fn new(location: GroundLocation, provider: Option

) -> Self { GroundPropagator { location, provider } } } impl DynGroundPropagator

{ - pub fn with_dynamic(location: DynGroundLocation, provider: P) -> Self { + pub fn with_dynamic(location: DynGroundLocation, provider: Option

) -> Self { GroundPropagator { location, provider } } @@ -224,7 +224,7 @@ impl DynGroundPropagator

{ ); let rot = s .reference_frame() - .try_rotation(DynFrame::Icrf, time, Some(&self.provider)) + .try_rotation(DynFrame::Icrf, time, self.provider.as_ref()) .map_err(|err| GroundPropagatorError::FrameTransformation(err.to_string()))?; let (r1, v1) = rot.rotate_state(s.position(), s.velocity()); Ok(State::new(time, r1, v1, self.location.body, DynFrame::Icrf)) @@ -260,7 +260,7 @@ where ); let rot = s .reference_frame() - .try_rotation(Icrf, time.clone(), Some(&self.provider)) + .try_rotation(Icrf, time.clone(), self.provider.as_ref()) .map_err(|err| GroundPropagatorError::FrameTransformation(err.to_string()))?; let (r1, v1) = rot.rotate_state(s.position(), s.velocity()); Ok(State::new(time, r1, v1, self.location.body.clone(), Icrf)) @@ -333,7 +333,7 @@ mod tests { let longitude = -4.3676f64.to_radians(); let latitude = 40.4527f64.to_radians(); let location = GroundLocation::new(longitude, latitude, 0.0, Earth); - let propagator = GroundPropagator::new(location, ()); + let propagator = GroundPropagator::new(location, None::<()>); let time = utc!(2022, 1, 31, 23).unwrap().to_time(); let expected = DVec3::new(-1765.9535510583582, 4524.585984442561, 4120.189198495323); let state = propagator.propagate(time).unwrap(); diff --git a/crates/lox-orbits/src/python.rs b/crates/lox-orbits/src/python.rs index ddb5a769..1086f740 100644 --- a/crates/lox-orbits/src/python.rs +++ b/crates/lox-orbits/src/python.rs @@ -31,7 +31,7 @@ use lox_time::python::time::PyTime; use lox_time::python::ut1::PyUt1Provider; use lox_time::time_scales::{DynTimeScale, Tai}; -use crate::analysis::{visibility_dyn, ElevationMask, ElevationMaskError}; +use crate::analysis::{visibility_combined, ElevationMask, ElevationMaskError}; use crate::elements::{DynKeplerian, Keplerian}; use crate::events::{Event, FindEventError, Window}; use crate::frames::iau::IauFrameTransformationError; @@ -630,7 +630,8 @@ impl From for PyErr { #[pymethods] impl PyGroundPropagator { #[new] - fn new(location: PyGroundLocation, provider: PyUt1Provider) -> Self { + #[pyo3(signature = (location, provider=None))] + fn new(location: PyGroundLocation, provider: Option) -> Self { PyGroundPropagator(DynGroundPropagator::with_dynamic(location.0, provider)) } @@ -783,12 +784,21 @@ impl PyEnsemble { } #[pyfunction] -#[pyo3(signature = (times, ground_stations, spacecraft, provider=None))] +#[pyo3(signature = ( + times, + ground_stations, + spacecraft, + ephemeris, + bodies=None, + provider=None, +))] pub fn visibility_all( py: Python<'_>, times: &Bound<'_, PyList>, ground_stations: HashMap, spacecraft: &Bound<'_, PyEnsemble>, + ephemeris: &Bound<'_, PySpk>, + bodies: Option>, provider: Option<&Bound<'_, PyUt1Provider>>, ) -> PyResult>>> { let times: Vec = times @@ -796,8 +806,14 @@ pub fn visibility_all( .into_iter() .map(|s| s.0) .collect(); + let bodies: Vec = bodies + .unwrap_or_default() + .into_iter() + .map(|b| b.0) + .collect(); let provider = provider.map(|p| &p.get().0); let spacecraft = &spacecraft.get().0; + let ephemeris = &ephemeris.get().0; Ok(py.allow_threads(|| { ground_stations.iter().fold( HashMap::with_capacity(ground_stations.len()), @@ -809,10 +825,12 @@ pub fn visibility_all( .fold(HashMap::new, |mut passes, (sc_name, sc)| { passes.insert( sc_name.clone(), - visibility_dyn(×, &gs.0, &mask.0, sc, provider) - .into_iter() - .map(PyWindow) - .collect(), + visibility_combined( + ×, &gs.0, &mask.0, &bodies, sc, ephemeris, provider, + ) + .into_iter() + .map(PyWindow) + .collect(), ); passes diff --git a/crates/lox-orbits/src/trajectories.rs b/crates/lox-orbits/src/trajectories.rs index da9948d8..be607170 100644 --- a/crates/lox-orbits/src/trajectories.rs +++ b/crates/lox-orbits/src/trajectories.rs @@ -4,6 +4,7 @@ use csv::Error; use glam::DVec3; use lox_ephem::Ephemeris; use lox_time::time_scales::{DynTimeScale, TimeScale}; +use lox_time::DynTime; use thiserror::Error; use lox_bodies::{DynOrigin, Origin}; @@ -291,6 +292,75 @@ where } } +impl DynTrajectory { + pub fn from_csv_dyn( + csv: &str, + origin: DynOrigin, + frame: DynFrame, + ) -> Result { + let mut reader = csv::Reader::from_reader(csv.as_bytes()); + let mut states = Vec::new(); + for result in reader.records() { + let record = result?; + if record.len() != 7 { + return Err(TrajectoryError::CsvError( + "invalid record length".to_string(), + )); + } + let time: DynTime = Utc::from_iso(record.get(0).unwrap()) + .map_err(|e| TrajectoryError::CsvError(e.to_string()))? + .to_dyn_time(); + let x = record + .get(1) + .unwrap() + .parse() + .map_err(|e: ParseFloatError| { + TrajectoryError::CsvError(format!("invalid x coordinate: {}", e)) + })?; + let y = record + .get(2) + .unwrap() + .parse() + .map_err(|e: ParseFloatError| { + TrajectoryError::CsvError(format!("invalid y coordinate: {}", e)) + })?; + let z = record + .get(3) + .unwrap() + .parse() + .map_err(|e: ParseFloatError| { + TrajectoryError::CsvError(format!("invalid z coordinate: {}", e)) + })?; + let position = DVec3::new(x, y, z); + let vx = record + .get(4) + .unwrap() + .parse() + .map_err(|e: ParseFloatError| { + TrajectoryError::CsvError(format!("invalid x velocity: {}", e)) + })?; + let vy = record + .get(5) + .unwrap() + .parse() + .map_err(|e: ParseFloatError| { + TrajectoryError::CsvError(format!("invalid y velocity: {}", e)) + })?; + let vz = record + .get(6) + .unwrap() + .parse() + .map_err(|e: ParseFloatError| { + TrajectoryError::CsvError(format!("invalid z velocity: {}", e)) + })?; + let velocity = DVec3::new(vx, vy, vz); + let state = State::new(time, position, velocity, origin, frame); + states.push(state); + } + Trajectory::new(&states) + } +} + #[derive(Error, Debug)] pub enum TrajectoryTransformationError { #[error(transparent)] diff --git a/crates/lox-space/tests/conftest.py b/crates/lox-space/tests/conftest.py index 49b9cd02..c9816111 100644 --- a/crates/lox-space/tests/conftest.py +++ b/crates/lox-space/tests/conftest.py @@ -5,6 +5,8 @@ # file, you can obtain one at https://mozilla.org/MPL/2.0/. import pathlib +from pathlib import Path + import numpy as np import pytest @@ -28,7 +30,7 @@ def oneweb(): trajectories = [] for i in range(0, len(lines), 3): - tle = lines[i : i + 3] + tle = lines[i: i + 3] name = tle[0].strip() trajectory = lox.SGP4("".join(tle)).propagate(times) trajectories.append((name, trajectory)) @@ -52,3 +54,11 @@ def estrack(): ), lox.ElevationMask.fixed(0)) for name, lat, lon in stations } + + +@pytest.fixture(scope="session") +def ephemeris(): + spk = ( + Path(__file__).parent.joinpath("..", "..", "..", "data", "de440s.bsp").resolve() + ) + return lox.SPK(str(spk)) diff --git a/crates/lox-space/tests/test_benchmarks.py b/crates/lox-space/tests/test_benchmarks.py index 91a16a4f..5644e966 100644 --- a/crates/lox-space/tests/test_benchmarks.py +++ b/crates/lox-space/tests/test_benchmarks.py @@ -8,19 +8,21 @@ import lox_space as lox + @pytest.fixture(scope="session") def times(oneweb): t0 = next(iter(oneweb.values())).states()[0].time() return [t0 + t for t in lox.TimeDelta.range(0, 86400, 3000)] + @pytest.fixture(scope="session") def ensemble(oneweb): return lox.Ensemble(oneweb) - + @pytest.mark.benchmark() -def test_visibility_benchmark(estrack, ensemble, oneweb, provider, times): - passes = lox.visibility_all(times, estrack, ensemble, provider) +def test_visibility_benchmark(estrack, ensemble, oneweb, provider, times, ephemeris): + passes = lox.visibility_all(times, estrack, ensemble, ephemeris, provider=provider) assert len(passes) == len(estrack) for sc_passes in passes.values(): assert len(sc_passes) == len(oneweb) diff --git a/crates/lox-space/tests/test_states.py b/crates/lox-space/tests/test_states.py index d154fb91..12d36db0 100644 --- a/crates/lox-space/tests/test_states.py +++ b/crates/lox-space/tests/test_states.py @@ -8,15 +8,6 @@ import numpy as np import numpy.testing as npt import pytest -from pathlib import Path - - -@pytest.fixture -def ephemeris(): - spk = ( - Path(__file__).parent.joinpath("..", "..", "..", "data", "de440s.bsp").resolve() - ) - return lox.SPK(str(spk)) def test_state_to_ground_location(): diff --git a/data/contacts_combined.csv b/data/contacts_combined.csv new file mode 100755 index 00000000..e9678089 --- /dev/null +++ b/data/contacts_combined.csv @@ -0,0 +1,34 @@ +start, stop +2022-01-31T23:00:00.000, 2022-02-01T09:53:09.179 +2022-02-01T22:07:30.004, 2022-02-02T08:01:01.073 +2022-02-02T19:14:33.959, 2022-02-03T07:41:18.287 +2022-02-03T19:43:40.357, 2022-02-04T08:13:16.482 +2022-02-04T19:54:36.888, 2022-02-05T08:29:09.723 +2022-02-05T20:01:30.035, 2022-02-06T08:40:07.651 +2022-02-06T20:06:59.797, 2022-02-07T08:49:13.674 +2022-02-07T20:12:18.951, 2022-02-08T08:58:01.581 +2022-02-08T20:18:28.969, 2022-02-09T09:07:56.871 +2022-02-09T20:27:03.897, 2022-02-10T09:21:24.056 +2022-02-10T20:42:11.150, 2022-02-11T09:46:15.901 +2022-02-11T21:30:56.258, 2022-02-12T08:28:49.958 +2022-02-12T18:33:09.086, 2022-02-13T07:01:07.043 +2022-02-13T19:03:55.989, 2022-02-14T07:34:09.428 +2022-02-14T19:14:41.759, 2022-02-15T07:50:02.948 +2022-02-15T19:20:55.946, 2022-02-16T08:00:42.402 +2022-02-16T19:25:00.435, 2022-02-17T08:09:04.759 +2022-02-17T19:26:17.855, 2022-02-18T08:15:04.740 +2022-02-18T20:00:38.232, 2022-02-19T08:40:57.088 +2022-02-19T21:04:05.874, 2022-02-19T21:21:25.723 +2022-02-19T22:37:13.993, 2022-02-20T09:06:30.165 +2022-02-20T22:09:05.827, 2022-02-21T00:22:31.483 +2022-02-21T01:30:34.313, 2022-02-21T09:32:45.859 +2022-02-21T23:16:54.751, 2022-02-22T03:22:42.524 +2022-02-22T04:26:01.160, 2022-02-22T10:01:01.198 +2022-02-23T00:28:18.839, 2022-02-23T06:21:17.454 +2022-02-23T07:24:07.266, 2022-02-23T10:32:58.244 +2022-02-24T01:42:48.668, 2022-02-24T09:18:49.553 +2022-02-24T10:24:59.359, 2022-02-24T11:10:50.348 +2022-02-25T02:57:50.325, 2022-02-25T11:57:10.048 +2022-02-26T04:08:44.715, 2022-02-26T12:53:52.017 +2022-02-27T05:10:38.470, 2022-02-27T14:00:37.531 +2022-02-28T06:01:05.667, 2022-02-28T15:14:18.557