1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
/*
* ANISE Toolkit
* Copyright (C) 2021-onward Christopher Rabotin <christopher.rabotin@gmail.com> et al. (cf. AUTHORS.md)
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* Documentation: https://nyxspace.com/
*/
use super::utils::mean_anomaly_to_true_anomaly_rad;
use super::PhysicsResult;
use crate::{
astro::utils::true_anomaly_to_mean_anomaly_rad,
errors::{
HyperbolicTrueAnomalySnafu, InfiniteValueSnafu, MathError, ParabolicEccentricitySnafu,
ParabolicSemiParamSnafu, PhysicsError, RadiusSnafu, VelocitySnafu,
},
math::{
angles::{between_0_360, between_pm_180},
cartesian::CartesianState,
rotation::DCM,
Matrix3, Vector3, Vector6,
},
prelude::{uuid_from_epoch, Frame},
};
#[cfg(feature = "analysis")]
use crate::ephemerides::ephemeris::LocalFrame;
use core::f64::consts::{PI, TAU};
use core::fmt;
use hifitime::{Duration, Epoch, TimeUnits, Unit};
use log::{info, warn};
use snafu::ensure;
#[cfg(feature = "python")]
use pyo3::prelude::*;
#[cfg(feature = "python")]
use pyo3::types::PyType;
/// If an orbit has an eccentricity below the following value, it is considered circular.
pub const ECC_EPSILON: f64 = 1e-11;
/// A helper type alias, but no assumptions are made on the underlying validity of the frame.
pub type Orbit = CartesianState;
impl Orbit {
/// Attempts to create a new Orbit around the provided Celestial or Geoid frame from the Keplerian orbital elements.
///
/// **Units:** km, none, degrees, degrees, degrees, degrees
///
/// WARNING: This function will return an error if the singularities in the conversion are encountered.
/// NOTE: The state is defined in Cartesian coordinates as they are non-singular. This causes rounding
/// errors when creating a state from its Keplerian orbital elements (cf. the state tests).
/// One should expect these errors to be on the order of 1e-12.
#[allow(clippy::too_many_arguments)]
#[allow(clippy::neg_multiply)]
pub fn try_keplerian(
sma_km: f64,
ecc: f64,
inc_deg: f64,
raan_deg: f64,
aop_deg: f64,
ta_deg: f64,
epoch: Epoch,
frame: Frame,
) -> PhysicsResult<Self> {
let mu_km3_s2 = frame.mu_km3_s2()?;
if mu_km3_s2.abs() < f64::EPSILON {
warn!("GM is near zero ({mu_km3_s2} km^3/s^2): expect rounding errors!",);
}
// Algorithm from GMAT's StateConversionUtil::KeplerianToCartesian
let ecc = if ecc < 0.0 {
warn!("eccentricity cannot be negative: sign of eccentricity changed");
ecc * -1.0
} else {
ecc
};
let sma = if ecc > 1.0 && sma_km > 0.0 {
warn!("eccentricity > 1 (hyperbolic) BUT SMA > 0 (elliptical): sign of SMA changed");
sma_km * -1.0
} else if ecc < 1.0 && sma_km < 0.0 {
warn!("eccentricity < 1 (elliptical) BUT SMA < 0 (hyperbolic): sign of SMA changed");
sma_km * -1.0
} else {
sma_km
};
if (sma * (1.0 - ecc)).abs() < 1e-3 {
// GMAT errors below one meter. Let's warn for below that, but not panic, might be useful for landing scenarios?
warn!("radius of periapsis is less than one meter");
}
ensure!(
(1.0 - ecc).abs() >= ECC_EPSILON,
ParabolicEccentricitySnafu { limit: ECC_EPSILON }
);
if ecc > 1.0 {
let ta_deg = between_0_360(ta_deg);
ensure!(
ta_deg <= (PI - (1.0 / ecc).acos()).to_degrees(),
HyperbolicTrueAnomalySnafu { ta_deg }
);
}
ensure!(
(1.0 + ecc * ta_deg.to_radians().cos()).is_finite(),
InfiniteValueSnafu {
action: "computing radius of orbit"
}
);
// Done with all the warnings and errors supported by GMAT
// The conversion algorithm itself comes from GMAT's StateConversionUtil::ComputeKeplToCart
// NOTE: GMAT supports mean anomaly instead of true anomaly, but only for backward compatibility reasons
// so it isn't supported here.
let inc_rad = inc_deg.to_radians();
let raan_rad = raan_deg.to_radians();
let aop_rad = aop_deg.to_radians();
let ta_rad = ta_deg.to_radians();
let p_km = sma * (1.0 - ecc.powi(2));
ensure!(p_km.abs() >= f64::EPSILON, ParabolicSemiParamSnafu { p_km });
// NOTE: At this point GMAT computes 1+ecc**2 and checks whether it's very small.
// It then reports that the radius may be too large. We've effectively already done
// this check above (and panicked if needed), so it isn't repeated here.
let radius = p_km / (1.0 + ecc * ta_rad.cos());
let (sin_aop_ta, cos_aop_ta) = (aop_rad + ta_rad).sin_cos();
let (sin_inc, cos_inc) = inc_rad.sin_cos();
let (sin_raan, cos_raan) = raan_rad.sin_cos();
let (sin_aop, cos_aop) = aop_rad.sin_cos();
let x = radius * (cos_aop_ta * cos_raan - cos_inc * sin_aop_ta * sin_raan);
let y = radius * (cos_aop_ta * sin_raan + cos_inc * sin_aop_ta * cos_raan);
let z = radius * sin_aop_ta * sin_inc;
let sqrt_gm_p = (mu_km3_s2 / p_km).sqrt();
let cos_ta_ecc = ta_rad.cos() + ecc;
let sin_ta = ta_rad.sin();
let vx = sqrt_gm_p * cos_ta_ecc * (-sin_aop * cos_raan - cos_inc * sin_raan * cos_aop)
- sqrt_gm_p * sin_ta * (cos_aop * cos_raan - cos_inc * sin_raan * sin_aop);
let vy = sqrt_gm_p * cos_ta_ecc * (-sin_aop * sin_raan + cos_inc * cos_raan * cos_aop)
- sqrt_gm_p * sin_ta * (cos_aop * sin_raan + cos_inc * cos_raan * sin_aop);
let vz = sqrt_gm_p * (cos_ta_ecc * sin_inc * cos_aop - sin_ta * sin_inc * sin_aop);
Ok(Self {
radius_km: Vector3::new(x, y, z),
velocity_km_s: Vector3::new(vx, vy, vz),
epoch,
frame,
})
}
/// Attempts to create a new Orbit from the provided radii of apoapsis and periapsis, in kilometers
#[allow(clippy::too_many_arguments)]
pub fn try_keplerian_apsis_radii(
r_a_km: f64,
r_p_km: f64,
inc_deg: f64,
raan_deg: f64,
aop_deg: f64,
ta_deg: f64,
epoch: Epoch,
frame: Frame,
) -> PhysicsResult<Self> {
ensure!(
r_a_km > f64::EPSILON,
RadiusSnafu {
action: "radius of apoapsis is negative"
}
);
ensure!(
r_p_km > f64::EPSILON,
RadiusSnafu {
action: "radius of periapsis is negative"
}
);
// The two checks above ensure that sma > 0
let sma = (r_a_km + r_p_km) / 2.0;
let ecc = r_a_km / sma - 1.0;
Self::try_keplerian(sma, ecc, inc_deg, raan_deg, aop_deg, ta_deg, epoch, frame)
}
/// Attempts to create a new Orbit around the provided frame from the borrowed state vector
///
/// The state vector **must** be sma, ecc, inc, raan, aop, ta. This function is a shortcut to `cartesian`
/// and as such it has the same unit requirements.
pub fn try_keplerian_vec(state: &Vector6, epoch: Epoch, frame: Frame) -> PhysicsResult<Self> {
Self::try_keplerian(
state[0], state[1], state[2], state[3], state[4], state[5], epoch, frame,
)
}
/// Creates (without error checking) a new Orbit around the provided Celestial or Geoid frame from the Keplerian orbital elements.
///
/// **Units:** km, none, degrees, degrees, degrees, degrees
///
/// WARNING: This function will panic if the singularities in the conversion are expected.
/// NOTE: The state is defined in Cartesian coordinates as they are non-singular. This causes rounding
/// errors when creating a state from its Keplerian orbital elements (cf. the state tests).
/// One should expect these errors to be on the order of 1e-12.
#[allow(clippy::too_many_arguments)]
pub fn keplerian(
sma_km: f64,
ecc: f64,
inc_deg: f64,
raan_deg: f64,
aop_deg: f64,
ta_deg: f64,
epoch: Epoch,
frame: Frame,
) -> Self {
Self::try_keplerian(
sma_km, ecc, inc_deg, raan_deg, aop_deg, ta_deg, epoch, frame,
)
.unwrap()
}
/// Creates a new Orbit from the provided radii of apoapsis and periapsis, in kilometers
#[allow(clippy::too_many_arguments)]
pub fn keplerian_apsis_radii(
r_a_km: f64,
r_p_km: f64,
inc_deg: f64,
raan_deg: f64,
aop_deg: f64,
ta_deg: f64,
epoch: Epoch,
frame: Frame,
) -> Self {
Self::try_keplerian_apsis_radii(
r_a_km, r_p_km, inc_deg, raan_deg, aop_deg, ta_deg, epoch, frame,
)
.unwrap()
}
/// Initializes a new orbit from the Keplerian orbital elements using the mean anomaly instead of the true anomaly.
///
/// # Implementation notes
/// This function starts by converting the mean anomaly to true anomaly, and then it initializes the orbit
/// using the keplerian(..) method.
/// The conversion is from GMAT's MeanToTrueAnomaly function, transliterated originally by Claude and GPT4 with human adjustments.
#[allow(clippy::too_many_arguments)]
pub fn try_keplerian_mean_anomaly(
sma_km: f64,
ecc: f64,
inc_deg: f64,
raan_deg: f64,
aop_deg: f64,
ma_deg: f64,
epoch: Epoch,
frame: Frame,
) -> PhysicsResult<Self> {
// Start by computing the true anomaly
let ta_rad = mean_anomaly_to_true_anomaly_rad(ma_deg.to_radians(), ecc)?;
Self::try_keplerian(
sma_km,
ecc,
inc_deg,
raan_deg,
aop_deg,
ta_rad.to_degrees(),
epoch,
frame,
)
}
/// Creates a new Orbit around the provided frame from the borrowed state vector
///
/// The state vector **must** be sma, ecc, inc, raan, aop, ta. This function is a shortcut to `cartesian`
/// and as such it has the same unit requirements.
pub fn keplerian_vec(state: &Vector6, epoch: Epoch, frame: Frame) -> Self {
Self::try_keplerian_vec(state, epoch, frame).unwrap()
}
/// Returns this state as a Keplerian Vector6 in [km, none, degrees, degrees, degrees, degrees]
///
/// Note that the time is **not** returned in the vector.
pub fn to_keplerian_vec(self) -> PhysicsResult<Vector6> {
Ok(Vector6::new(
self.sma_km()?,
self.ecc()?,
self.inc_deg()?,
self.raan_deg()?,
self.aop_deg()?,
self.ta_deg()?,
))
}
/// Returns the orbital momentum vector
pub fn hvec(&self) -> PhysicsResult<Vector3> {
ensure!(
self.rmag_km() > f64::EPSILON,
RadiusSnafu {
action: "cannot compute orbital momentum vector with zero radius"
}
);
ensure!(
self.vmag_km_s() > f64::EPSILON,
VelocitySnafu {
action: "cannot compute orbital momentum vector with zero velocity"
}
);
Ok(self.radius_km.cross(&self.velocity_km_s))
}
/// Returns the orbital momentum unit vector
pub fn h_hat(&self) -> PhysicsResult<Vector3> {
let hvec = self.hvec()?;
Ok(hvec / hvec.norm())
}
/// Returns the eccentricity vector (no unit)
pub fn evec(&self) -> Result<Vector3, PhysicsError> {
let r = self.radius_km;
ensure!(
self.rmag_km() > f64::EPSILON,
RadiusSnafu {
action: "cannot compute eccentricity vector with zero radial state"
}
);
let v = self.velocity_km_s;
Ok(
((v.norm().powi(2) - self.frame.mu_km3_s2()? / r.norm()) * r - (r.dot(&v)) * v)
/ self.frame.mu_km3_s2()?,
)
}
}
#[allow(clippy::too_many_arguments)]
#[cfg_attr(feature = "python", pymethods)]
impl Orbit {
/// Builds the rotation matrix that rotates from the topocentric frame (SEZ) into the body fixed frame of this state.
///
/// # Frame warnings
/// + If the state is NOT in a body fixed frame (i.e. ITRF93), then this computation is INVALID.
/// + (Usually) no time derivative can be computed: the orbit is expected to be a body fixed frame where the `at_epoch` function will fail. Exceptions for Moon body fixed frames.
///
/// # UNUSED Arguments
/// + `from`: ID of this new frame. Only used to set the "from" frame of the DCM. -- No longer used since 0.5.3
///
/// # Source
/// From the GMAT MathSpec, page 30 section 2.6.9 and from `Calculate_RFT` in `TopocentricAxes.cpp`, this returns the
/// rotation matrix from the topocentric frame (SEZ) to body fixed frame.
/// In the GMAT MathSpec notation, R_{IF} is the DCM from body fixed to inertial. Similarly, R{FT} is from topocentric
/// to body fixed.
///
/// :rtype: DCM
pub fn dcm_from_topocentric_to_body_fixed(&self) -> PhysicsResult<DCM> {
let rot_mat_dt = if let Ok(pre) = self.at_epoch(self.epoch - Unit::Second * 1) {
if let Ok(post) = self.at_epoch(self.epoch + Unit::Second * 1) {
let dcm_pre = pre.dcm3x3_from_topocentric_to_body_fixed()?;
let dcm_post = post.dcm3x3_from_topocentric_to_body_fixed()?;
Some(0.5 * dcm_post.rot_mat - 0.5 * dcm_pre.rot_mat)
} else {
None
}
} else {
None
};
Ok(DCM {
rot_mat: self.dcm3x3_from_topocentric_to_body_fixed()?.rot_mat,
rot_mat_dt,
from: uuid_from_epoch(self.frame.orientation_id, self.epoch),
to: self.frame.orientation_id,
})
}
/// Builds the rotation matrix that rotates from the topocentric frame (SEZ) into the body fixed frame of this state.
///
/// # Frame warning
/// If the state is NOT in a body fixed frame (i.e. ITRF93), then this computation is INVALID.
///
/// # Source
/// From the GMAT MathSpec, page 30 section 2.6.9 and from `Calculate_RFT` in `TopocentricAxes.cpp`, this returns the
/// rotation matrix from the topocentric frame (SEZ) to body fixed frame.
/// In the GMAT MathSpec notation, R_{IF} is the DCM from body fixed to inertial. Similarly, R{FT} is from topocentric
/// to body fixed.
///
/// :rtype: DCM
pub fn dcm3x3_from_topocentric_to_body_fixed(&self) -> PhysicsResult<DCM> {
if (self.radius_km.x.powi(2) + self.radius_km.y.powi(2)).sqrt() < 1e-3 {
warn!("SEZ frame ill-defined when close to the poles");
}
let phi = self.latitude_deg()?.to_radians();
let lambda = self.longitude_deg().to_radians();
let z_hat = Vector3::new(
phi.cos() * lambda.cos(),
phi.cos() * lambda.sin(),
phi.sin(),
);
// y_hat MUST be renormalized otherwise it's about 0.76 and therefore the rotation looses the norms conservation property.
let mut y_hat = Vector3::new(0.0, 0.0, 1.0).cross(&z_hat);
y_hat /= y_hat.norm();
let x_hat = y_hat.cross(&z_hat);
let rot_mat = Matrix3::new(
x_hat[0], y_hat[0], z_hat[0], x_hat[1], y_hat[1], z_hat[1], x_hat[2], y_hat[2],
z_hat[2],
);
Ok(DCM {
rot_mat,
rot_mat_dt: None,
from: uuid_from_epoch(self.frame.orientation_id, self.epoch),
to: self.frame.orientation_id,
})
}
/// Builds the rotation matrix that rotates from this state's inertial frame to this state's RIC frame
///
/// # Frame warning
/// If the state is NOT in an inertial frame, then this computation is INVALID.
///
/// # Algorithm
/// 1. Compute the state data one millisecond before and one millisecond assuming two body dynamics
/// 2. Compute the DCM for this state, and the pre and post states
/// 3. Build the c vector as the normalized orbital momentum vector
/// 4. Build the i vector as the cross product of \hat{r} and c
/// 5. Build the RIC DCM as a 3x3 of the columns [\hat{r}, \hat{i}, \hat{c}], for the post, post, and current states
/// 6. Compute the difference between the DCMs of the pre and post states, to build the DCM time derivative
/// 7. Return the DCM structure with a 6x6 state DCM.
///
/// # Note on the time derivative
/// If the pre or post states cannot be computed, then the time derivative of the DCM will _not_ be set.
/// Further note that most astrodynamics tools do *not* account for the time derivative in the RIC frame.
///
/// :rtype: DCM
pub fn dcm_from_ric_to_inertial(&self) -> PhysicsResult<DCM> {
let rot_mat_dt = if let Ok(pre) = self.at_epoch(self.epoch - Unit::Millisecond * 1) {
if let Ok(post) = self.at_epoch(self.epoch + Unit::Millisecond * 1) {
let dcm_pre = pre.dcm3x3_from_ric_to_inertial()?;
let dcm_post = post.dcm3x3_from_ric_to_inertial()?;
Some(500.0 * (dcm_post.rot_mat - dcm_pre.rot_mat))
} else {
None
}
} else {
None
};
Ok(DCM {
rot_mat: self.dcm3x3_from_ric_to_inertial()?.rot_mat,
rot_mat_dt,
from: uuid_from_epoch(self.frame.orientation_id, self.epoch),
to: self.frame.orientation_id,
})
}
/// Builds the rotation matrix that rotates from this state's inertial frame to this state's RIC frame
///
/// # Frame warning
/// If the state is NOT in an inertial frame, then this computation is INVALID.
///
/// # Algorithm
/// 1. Build the c vector as the normalized orbital momentum vector
/// 2. Build the i vector as the cross product of \hat{r} and c
/// 3. Build the RIC DCM as a 3x3 of the columns [\hat{r}, \hat{i}, \hat{c}]
/// 4. Return the DCM structure **without** accounting for the transport theorem.
///
/// :rtype: DCM
pub fn dcm3x3_from_ric_to_inertial(&self) -> PhysicsResult<DCM> {
let r_hat = self.r_hat();
let c_hat = self.hvec()? / self.hmag()?;
let i_hat = c_hat.cross(&r_hat);
let rot_mat = Matrix3::from_columns(&[r_hat, i_hat, c_hat]);
Ok(DCM {
rot_mat,
rot_mat_dt: None,
from: uuid_from_epoch(self.frame.orientation_id, self.epoch),
to: self.frame.orientation_id,
})
}
/// Builds the rotation matrix that rotates from this state's inertial frame to this state's RCN frame (radial, cross, normal)
///
/// # Frame warning
/// If the stattion is NOT in an inertial frame, then this computation is INVALID.
///
/// # Algorithm
/// 1. Compute \hat{r}, \hat{h}, the unit vectors of the radius and orbital momentum.
/// 2. Compute the cross product of these
/// 3. Build the DCM with these unit vectors
/// 4. Return the DCM structure
///
/// :rtype: DCM
pub fn dcm3x3_from_rcn_to_inertial(&self) -> PhysicsResult<DCM> {
let r = self.r_hat();
let n = self.hvec()? / self.hmag()?;
let c = n.cross(&r);
let rot_mat =
Matrix3::new(r[0], r[1], r[2], c[0], c[1], c[2], n[0], n[1], n[2]).transpose();
Ok(DCM {
rot_mat,
rot_mat_dt: None,
from: uuid_from_epoch(self.frame.orientation_id, self.epoch),
to: self.frame.orientation_id,
})
}
/// Builds the rotation matrix that rotates from this state's inertial frame to this state's RCN frame (radial, cross, normal)
///
/// # Frame warning
/// If the stattion is NOT in an inertial frame, then this computation is INVALID.
///
/// # Algorithm
/// 1. Compute \hat{r}, \hat{h}, the unit vectors of the radius and orbital momentum.
/// 2. Compute the cross product of these
/// 3. Build the DCM with these unit vectors
/// 4. Return the DCM structure with a 6x6 DCM with the time derivative of the VNC frame set.
///
/// # Note on the time derivative
/// If the pre or post states cannot be computed, then the time derivative of the DCM will _not_ be set.
/// Further note that most astrodynamics tools do *not* account for the time derivative in the RIC frame.
///
/// :rtype: DCM
pub fn dcm_from_rcn_to_inertial(&self) -> PhysicsResult<DCM> {
let rot_mat_dt = if let Ok(pre) = self.at_epoch(self.epoch - Unit::Millisecond * 1) {
if let Ok(post) = self.at_epoch(self.epoch + Unit::Millisecond * 1) {
let dcm_pre = pre.dcm3x3_from_rcn_to_inertial()?;
let dcm_post = post.dcm3x3_from_rcn_to_inertial()?;
Some(500.0 * (dcm_post.rot_mat - dcm_pre.rot_mat))
} else {
None
}
} else {
None
};
Ok(DCM {
rot_mat: self.dcm3x3_from_rcn_to_inertial()?.rot_mat,
rot_mat_dt,
from: uuid_from_epoch(self.frame.orientation_id, self.epoch),
to: self.frame.orientation_id,
})
}
/// Builds the rotation matrix that rotates from this state's inertial frame to this state's VNC frame (velocity, normal, cross)
///
/// # Frame warning
/// If the stattion is NOT in an inertial frame, then this computation is INVALID.
///
/// # Algorithm
/// 1. Compute \hat{v}, \hat{h}, the unit vectors of the radius and orbital momentum.
/// 2. Compute the cross product of these
/// 3. Build the DCM with these unit vectors
/// 4. Return the DCM structure.
///
/// :rtype: DCM
pub fn dcm3x3_from_vnc_to_inertial(&self) -> PhysicsResult<DCM> {
let v = self.velocity_km_s / self.vmag_km_s();
let n = self.hvec()? / self.hmag()?;
let c = v.cross(&n);
let rot_mat =
Matrix3::new(v[0], v[1], v[2], n[0], n[1], n[2], c[0], c[1], c[2]).transpose();
Ok(DCM {
rot_mat,
rot_mat_dt: None,
from: uuid_from_epoch(self.frame.orientation_id, self.epoch),
to: self.frame.orientation_id,
})
}
/// Builds the rotation matrix that rotates from this state's inertial frame to this state's VNC frame (velocity, normal, cross)
///
/// # Frame warning
/// If the stattion is NOT in an inertial frame, then this computation is INVALID.
///
/// # Algorithm
/// 1. Compute \hat{v}, \hat{h}, the unit vectors of the radius and orbital momentum.
/// 2. Compute the cross product of these
/// 3. Build the DCM with these unit vectors
/// 4. Compute the difference between the DCMs of the pre and post states (+/- 1 ms), to build the DCM time derivative
/// 4. Return the DCM structure with a 6x6 DCM with the time derivative of the VNC frame set.
///
/// # Note on the time derivative
/// If the pre or post states cannot be computed, then the time derivative of the DCM will _not_ be set.
/// Further note that most astrodynamics tools do *not* account for the time derivative in the RIC frame.
///
/// :rtype: DCM
pub fn dcm_from_vnc_to_inertial(&self) -> PhysicsResult<DCM> {
let rot_mat_dt = if let Ok(pre) = self.at_epoch(self.epoch - Unit::Millisecond * 1) {
if let Ok(post) = self.at_epoch(self.epoch + Unit::Millisecond * 1) {
let dcm_pre = pre.dcm3x3_from_vnc_to_inertial()?;
let dcm_post = post.dcm3x3_from_vnc_to_inertial()?;
Some(500.0 * (dcm_post.rot_mat - dcm_pre.rot_mat))
} else {
None
}
} else {
None
};
Ok(DCM {
rot_mat: self.dcm3x3_from_vnc_to_inertial()?.rot_mat,
rot_mat_dt,
from: uuid_from_epoch(self.frame.orientation_id, self.epoch),
to: self.frame.orientation_id,
})
}
/// Returns the DCM to rotate this orbit from the provided local frame to the inertial frame.
///
/// :type local_frame: LocalFrame
/// :rtype: DCM
#[cfg(feature = "analysis")]
pub fn dcm_to_inertial(&self, local_frame: LocalFrame) -> PhysicsResult<DCM> {
match local_frame {
LocalFrame::Inertial => Ok(DCM::identity(
self.frame.orientation_id,
self.frame.orientation_id,
)),
LocalFrame::RIC => self.dcm_from_ric_to_inertial(),
LocalFrame::RCN => self.dcm_from_rcn_to_inertial(),
LocalFrame::VNC => self.dcm_from_vnc_to_inertial(),
}
}
/// Creates a new Orbit around the provided Celestial or Geoid frame from the Keplerian orbital elements.
///
/// **Units:** km, none, degrees, degrees, degrees, degrees
///
/// NOTE: The state is defined in Cartesian coordinates as they are non-singular. This causes rounding
/// errors when creating a state from its Keplerian orbital elements (cf. the state tests).
/// One should expect these errors to be on the order of 1e-12.
///
/// :type sma_km: float
/// :type ecc: float
/// :type inc_deg: float
/// :type raan_deg: float
/// :type aop_deg: float
/// :type ta_deg: float
/// :type epoch: Epoch
/// :type frame: Frame
/// :rtype: Orbit
#[cfg(feature = "python")]
#[classmethod]
pub fn from_keplerian(
_cls: &Bound<'_, PyType>,
sma_km: f64,
ecc: f64,
inc_deg: f64,
raan_deg: f64,
aop_deg: f64,
ta_deg: f64,
epoch: Epoch,
frame: Frame,
) -> PhysicsResult<Self> {
Self::try_keplerian(
sma_km, ecc, inc_deg, raan_deg, aop_deg, ta_deg, epoch, frame,
)
}
/// Attempts to create a new Orbit from the provided radii of apoapsis and periapsis, in kilometers
///
/// :type r_a_km: float
/// :type r_p_km: float
/// :type inc_deg: float
/// :type raan_deg: float
/// :type aop_deg: float
/// :type ta_deg: float
/// :type epoch: Epoch
/// :type frame: Frame
/// :rtype: Orbit
#[cfg(feature = "python")]
#[classmethod]
pub fn from_keplerian_apsis_radii(
_cls: &Bound<'_, PyType>,
r_a_km: f64,
r_p_km: f64,
inc_deg: f64,
raan_deg: f64,
aop_deg: f64,
ta_deg: f64,
epoch: Epoch,
frame: Frame,
) -> PhysicsResult<Self> {
Self::try_keplerian_apsis_radii(
r_a_km, r_p_km, inc_deg, raan_deg, aop_deg, ta_deg, epoch, frame,
)
}
/// Initializes a new orbit from the Keplerian orbital elements using the mean anomaly instead of the true anomaly.
///
/// # Implementation notes
/// This function starts by converting the mean anomaly to true anomaly, and then it initializes the orbit
/// using the keplerian(..) method.
/// The conversion is from GMAT's MeanToTrueAnomaly function, transliterated originally by Claude and GPT4 with human adjustments.
///
/// :type sma_km: float
/// :type ecc: float
/// :type inc_deg: float
/// :type raan_deg: float
/// :type aop_deg: float
/// :type ma_deg: float
/// :type epoch: Epoch
/// :type frame: Frame
/// :rtype: Orbit
#[cfg(feature = "python")]
#[classmethod]
pub fn from_keplerian_mean_anomaly(
_cls: &Bound<'_, PyType>,
sma_km: f64,
ecc: f64,
inc_deg: f64,
raan_deg: f64,
aop_deg: f64,
ma_deg: f64,
epoch: Epoch,
frame: Frame,
) -> PhysicsResult<Self> {
Self::try_keplerian_mean_anomaly(
sma_km, ecc, inc_deg, raan_deg, aop_deg, ma_deg, epoch, frame,
)
}
/// Returns the orbital momentum value on the X axis
///
/// :rtype: float
pub fn hx(&self) -> PhysicsResult<f64> {
Ok(self.hvec()?[0])
}
/// Returns the orbital momentum value on the Y axis
///
/// :rtype: float
pub fn hy(&self) -> PhysicsResult<f64> {
Ok(self.hvec()?[1])
}
/// Returns the orbital momentum value on the Z axis
///
/// :rtype: float
pub fn hz(&self) -> PhysicsResult<f64> {
Ok(self.hvec()?[2])
}
/// Returns the norm of the orbital momentum
///
/// :rtype: float
pub fn hmag(&self) -> PhysicsResult<f64> {
Ok(self.hvec()?.norm())
}
/// Returns the specific mechanical energy in km^2/s^2
///
/// :rtype: float
pub fn energy_km2_s2(&self) -> PhysicsResult<f64> {
ensure!(
self.rmag_km() > f64::EPSILON,
RadiusSnafu {
action: "cannot compute energy with zero radial state"
}
);
Ok(self.vmag_km_s().powi(2) / 2.0 - self.frame.mu_km3_s2()? / self.rmag_km())
}
/// Returns the semi-major axis in km
///
/// :rtype: float
pub fn sma_km(&self) -> PhysicsResult<f64> {
// Division by zero prevented in energy_km2_s2
Ok(-self.frame.mu_km3_s2()? / (2.0 * self.energy_km2_s2()?))
}
/// Mutates this orbit to change the SMA
///
/// :type new_sma_km: float
/// :rtype: None
pub fn set_sma_km(&mut self, new_sma_km: f64) -> PhysicsResult<()> {
let me = Self::try_keplerian(
new_sma_km,
self.ecc()?,
self.inc_deg()?,
self.raan_deg()?,
self.aop_deg()?,
self.ta_deg()?,
self.epoch,
self.frame,
)?;
*self = me;
Ok(())
}
/// Returns a copy of the state with a new SMA
///
/// :type new_sma_km: float
/// :rtype: Orbit
pub fn with_sma_km(&self, new_sma_km: f64) -> PhysicsResult<Self> {
let mut me = *self;
me.set_sma_km(new_sma_km)?;
Ok(me)
}
/// Returns a copy of the state with a provided SMA added to the current one
///
/// :type delta_sma_km: float
/// :rtype: Orbit
pub fn add_sma_km(&self, delta_sma_km: f64) -> PhysicsResult<Self> {
let mut me = *self;
me.set_sma_km(me.sma_km()? + delta_sma_km)?;
Ok(me)
}
/// Returns the period
///
/// :rtype: Duration
pub fn period(&self) -> PhysicsResult<Duration> {
Ok(TAU
* (self.sma_km()?.powi(3) / self.frame.mu_km3_s2()?)
.sqrt()
.seconds())
}
/// Returns the mean motion in degrees per seconds
///
/// :rtype: float
pub fn mean_motion_deg_s(&self) -> PhysicsResult<f64> {
Ok((self.frame.mu_km3_s2()? / self.sma_km()?.abs().powi(3)).sqrt())
}
/// Returns the eccentricity (no unit)
///
/// :rtype: float
pub fn ecc(&self) -> PhysicsResult<f64> {
Ok(self.evec()?.norm())
}
/// Mutates this orbit to change the ECC
///
/// :type new_ecc: float
/// :rtype: None
pub fn set_ecc(&mut self, new_ecc: f64) -> PhysicsResult<()> {
let me = Self::try_keplerian(
self.sma_km()?,
new_ecc,
self.inc_deg()?,
self.raan_deg()?,
self.aop_deg()?,
self.ta_deg()?,
self.epoch,
self.frame,
)?;
*self = me;
Ok(())
}
/// Returns a copy of the state with a new ECC
///
/// :type new_ecc: float
/// :rtype: Orbit
pub fn with_ecc(&self, new_ecc: f64) -> PhysicsResult<Self> {
let mut me = *self;
me.set_ecc(new_ecc)?;
Ok(me)
}
/// Returns a copy of the state with a provided ECC added to the current one
///
/// :type delta_ecc: float
/// :rtype: Orbit
pub fn add_ecc(&self, delta_ecc: f64) -> PhysicsResult<Self> {
let mut me = *self;
me.set_ecc(me.ecc()? + delta_ecc)?;
Ok(me)
}
/// Returns the inclination in degrees
///
/// :rtype: float
pub fn inc_deg(&self) -> PhysicsResult<f64> {
Ok((self.hvec()?[2] / self.hmag()?).acos().to_degrees())
}
/// Mutates this orbit to change the INC
///
/// :type new_inc_deg: float
/// :rtype: None
pub fn set_inc_deg(&mut self, new_inc_deg: f64) -> PhysicsResult<()> {
let me = Self::try_keplerian(
self.sma_km()?,
self.ecc()?,
new_inc_deg,
self.raan_deg()?,
self.aop_deg()?,
self.ta_deg()?,
self.epoch,
self.frame,
)?;
*self = me;
Ok(())
}
/// Returns a copy of the state with a new INC
///
/// :type new_inc_deg: float
/// :rtype: Orbit
pub fn with_inc_deg(&self, new_inc_deg: f64) -> PhysicsResult<Self> {
let mut me = *self;
me.set_inc_deg(new_inc_deg)?;
Ok(me)
}
/// Returns a copy of the state with a provided INC added to the current one
///
/// :type delta_inc_deg: float
/// :rtype: Orbit
pub fn add_inc_deg(&self, delta_inc_deg: f64) -> PhysicsResult<Self> {
let mut me = *self;
me.set_inc_deg(me.inc_deg()? + delta_inc_deg)?;
Ok(me)
}
/// Returns the argument of periapsis in degrees
///
/// :rtype: float
pub fn aop_deg(&self) -> PhysicsResult<f64> {
let n = Vector3::new(0.0, 0.0, 1.0).cross(&self.hvec()?);
let cos_aop = n.dot(&self.evec()?) / (n.norm() * self.ecc()?);
let aop = cos_aop.acos();
if aop.is_nan() {
if cos_aop > 1.0 {
Ok(180.0)
} else {
Ok(0.0)
}
} else if self.evec()?[2] < 0.0 {
Ok((TAU - aop).to_degrees())
} else {
Ok(aop.to_degrees())
}
}
/// Mutates this orbit to change the AOP
///
/// :type new_aop_deg: float
/// :rtype: None
pub fn set_aop_deg(&mut self, new_aop_deg: f64) -> PhysicsResult<()> {
let me = Self::try_keplerian(
self.sma_km()?,
self.ecc()?,
self.inc_deg()?,
self.raan_deg()?,
new_aop_deg,
self.ta_deg()?,
self.epoch,
self.frame,
)?;
*self = me;
Ok(())
}
/// Returns a copy of the state with a new AOP
///
/// :type new_aop_deg: float
/// :rtype: Orbit
pub fn with_aop_deg(&self, new_aop_deg: f64) -> PhysicsResult<Self> {
let mut me = *self;
me.set_aop_deg(new_aop_deg)?;
Ok(me)
}
/// Returns a copy of the state with a provided AOP added to the current one
///
/// :type delta_aop_deg: float
/// :rtype: Orbit
pub fn add_aop_deg(&self, delta_aop_deg: f64) -> PhysicsResult<Self> {
let mut me = *self;
me.set_aop_deg(me.aop_deg()? + delta_aop_deg)?;
Ok(me)
}
/// Returns the right ascension of the ascending node in degrees
///
/// :rtype: float
pub fn raan_deg(&self) -> PhysicsResult<f64> {
let n = Vector3::new(0.0, 0.0, 1.0).cross(&self.hvec()?);
let cos_raan = n[0] / n.norm();
let raan = cos_raan.acos();
if raan.is_nan() {
if cos_raan > 1.0 {
Ok(180.0)
} else {
Ok(0.0)
}
} else if n[1] < 0.0 {
Ok((TAU - raan).to_degrees())
} else {
Ok(raan.to_degrees())
}
}
/// Mutates this orbit to change the RAAN
///
/// :type new_raan_deg: float
/// :rtype: None
pub fn set_raan_deg(&mut self, new_raan_deg: f64) -> PhysicsResult<()> {
let me = Self::try_keplerian(
self.sma_km()?,
self.ecc()?,
self.inc_deg()?,
new_raan_deg,
self.aop_deg()?,
self.ta_deg()?,
self.epoch,
self.frame,
)?;
*self = me;
Ok(())
}
/// Returns a copy of the state with a new RAAN
///
/// :type new_raan_deg: float
/// :rtype: Orbit
pub fn with_raan_deg(&self, new_raan_deg: f64) -> PhysicsResult<Self> {
let mut me = *self;
me.set_raan_deg(new_raan_deg)?;
Ok(me)
}
/// Returns a copy of the state with a provided RAAN added to the current one
///
/// :type delta_raan_deg: float
/// :rtype: Orbit
pub fn add_raan_deg(&self, delta_raan_deg: f64) -> PhysicsResult<Self> {
let mut me = *self;
me.set_raan_deg(me.raan_deg()? + delta_raan_deg)?;
Ok(me)
}
/// Returns the true anomaly in degrees between 0 and 360.0
///
/// NOTE: This function will emit a warning stating that the TA should be avoided if in a very near circular orbit
/// Code from <https://github.com/ChristopherRabotin/GMAT/blob/80bde040e12946a61dae90d9fc3538f16df34190/src/gmatutil/util/StateConversionUtil.cpp#L6835>
///
/// LIMITATION: For an orbit whose true anomaly is (very nearly) 0.0 or 180.0, this function may return either 0.0 or 180.0 with a very small time increment.
/// This is due to the precision of the cosine calculation: if the arccosine calculation is out of bounds, the sign of the cosine of the true anomaly is used
/// to determine whether the true anomaly should be 0.0 or 180.0. **In other words**, there is an ambiguity in the computation in the true anomaly exactly at 180.0 and 0.0.
///
/// :rtype: float
pub fn ta_deg(&self) -> PhysicsResult<f64> {
if self.ecc()? < ECC_EPSILON {
warn!(
"true anomaly ill-defined for circular orbit (e = {})",
self.ecc()?
);
}
let cos_nu = self.evec()?.dot(&self.radius_km) / (self.ecc()? * self.rmag_km());
// If we're close the valid bounds, let's just do a sign check and return the true anomaly
let ta = cos_nu.acos();
if ta.is_nan() {
if cos_nu > 1.0 {
Ok(180.0)
} else {
Ok(0.0)
}
} else if self.radius_km.dot(&self.velocity_km_s) < 0.0 {
Ok((TAU - ta).to_degrees())
} else {
Ok(ta.to_degrees())
}
}
/// Mutates this orbit to change the TA
///
/// :type new_ta_deg: float
/// :rtype: None
pub fn set_ta_deg(&mut self, new_ta_deg: f64) -> PhysicsResult<()> {
let me = Self::try_keplerian(
self.sma_km()?,
self.ecc()?,
self.inc_deg()?,
self.raan_deg()?,
self.aop_deg()?,
new_ta_deg,
self.epoch,
self.frame,
)?;
*self = me;
Ok(())
}
/// Returns the time derivative of the true anomaly computed as the 360.0 degrees divided by the orbital period (in seconds).
///
/// :rtype: float
pub fn ta_dot_deg_s(&self) -> PhysicsResult<f64> {
Ok(360.0 / self.period()?.to_seconds())
}
/// Returns a copy of the state with a new TA
///
/// :type new_ta_deg: float
/// :rtype: Orbit
pub fn with_ta_deg(&self, new_ta_deg: f64) -> PhysicsResult<Self> {
let mut me = *self;
me.set_ta_deg(new_ta_deg)?;
Ok(me)
}
/// Returns a copy of the state with a provided TA added to the current one
///
/// :type delta_ta_deg: float
/// :rtype: Orbit
pub fn add_ta_deg(&self, delta_ta_deg: f64) -> PhysicsResult<Self> {
let mut me = *self;
me.set_ta_deg(me.ta_deg()? + delta_ta_deg)?;
Ok(me)
}
/// Returns a copy of this state with the provided apoasis and periapsis
///
/// :type new_ra_km: float
/// :type new_rp_km: float
/// :rtype: Orbit
pub fn with_apoapsis_periapsis_km(
&self,
new_ra_km: f64,
new_rp_km: f64,
) -> PhysicsResult<Self> {
Self::try_keplerian_apsis_radii(
new_ra_km,
new_rp_km,
self.inc_deg()?,
self.raan_deg()?,
self.aop_deg()?,
self.ta_deg()?,
self.epoch,
self.frame,
)
}
/// Returns a copy of this state with the provided apoasis and periapsis added to the current values
///
/// :type delta_ra_km: float
/// :type delta_rp_km: float
/// :rtype: Orbit
pub fn add_apoapsis_periapsis_km(
&self,
delta_ra_km: f64,
delta_rp_km: f64,
) -> PhysicsResult<Self> {
Self::try_keplerian_apsis_radii(
self.apoapsis_km()? + delta_ra_km,
self.periapsis_km()? + delta_rp_km,
self.inc_deg()?,
self.raan_deg()?,
self.aop_deg()?,
self.ta_deg()?,
self.epoch,
self.frame,
)
}
/// Returns the true longitude in degrees
///
/// :rtype: float
pub fn tlong_deg(&self) -> PhysicsResult<f64> {
// Angles already in degrees
Ok(between_0_360(
self.aop_deg()? + self.raan_deg()? + self.ta_deg()?,
))
}
/// Returns the argument of latitude in degrees
///
/// NOTE: If the orbit is near circular, the AoL will be computed from the true longitude
/// instead of relying on the ill-defined true anomaly.
///
/// :rtype: float
pub fn aol_deg(&self) -> PhysicsResult<f64> {
Ok(between_0_360(if self.ecc()? < ECC_EPSILON {
self.tlong_deg()? - self.raan_deg()?
} else {
self.aop_deg()? + self.ta_deg()?
}))
}
/// Returns the radius of periapsis (or perigee around Earth), in kilometers.
///
/// :rtype: float
pub fn periapsis_km(&self) -> PhysicsResult<f64> {
Ok(self.sma_km()? * (1.0 - self.ecc()?))
}
/// Returns the radius of apoapsis (or apogee around Earth), in kilometers.
///
/// :rtype: float
pub fn apoapsis_km(&self) -> PhysicsResult<f64> {
Ok(self.sma_km()? * (1.0 + self.ecc()?))
}
/// Returns the eccentric anomaly in degrees
///
/// This is a conversion from GMAT's StateConversionUtil::TrueToEccentricAnomaly
///
/// :rtype: float
pub fn ea_deg(&self) -> PhysicsResult<f64> {
let (sin_ta, cos_ta) = self.ta_deg()?.to_radians().sin_cos();
let ecc_cos_ta = self.ecc()? * cos_ta;
let sin_ea = ((1.0 - self.ecc()?.powi(2)).sqrt() * sin_ta) / (1.0 + ecc_cos_ta);
let cos_ea = (self.ecc()? + cos_ta) / (1.0 + ecc_cos_ta);
// The atan2 function is a bit confusing: https://doc.rust-lang.org/std/primitive.f64.html#method.atan2 .
Ok(sin_ea.atan2(cos_ea).to_degrees())
}
/// Returns the flight path angle in degrees
///
/// :rtype: float
pub fn fpa_deg(&self) -> PhysicsResult<f64> {
let nu = self.ta_deg()?.to_radians();
let ecc = self.ecc()?;
let denom = (1.0 + 2.0 * ecc * nu.cos() + ecc.powi(2)).sqrt();
let sin_fpa = ecc * nu.sin() / denom;
let cos_fpa = 1.0 + ecc * nu.cos() / denom;
Ok(sin_fpa.atan2(cos_fpa).to_degrees())
}
/// Returns the mean anomaly in degrees
///
/// This is a conversion from GMAT's StateConversionUtil::TrueToMeanAnomaly
///
/// :rtype: float
pub fn ma_deg(&self) -> PhysicsResult<f64> {
if self.ecc()?.abs() < ECC_EPSILON {
Err(PhysicsError::ParabolicEccentricity { limit: ECC_EPSILON })
} else if self.ecc()? < 1.0 {
Ok(between_0_360(
(self.ea_deg()?.to_radians() - self.ecc()? * self.ea_deg()?.to_radians().sin())
.to_degrees(),
))
} else {
// From GMAT's TrueToHyperbolicAnomaly
Ok(
((self.ta_deg()?.to_radians().sin() * (self.ecc()?.powi(2) - 1.0)).sqrt()
/ (1.0 + self.ecc()? * self.ta_deg()?.to_radians().cos()))
.asinh()
.to_degrees(),
)
}
}
/// Returns the semi parameter (or semilatus rectum)
///
/// :rtype: float
pub fn semi_parameter_km(&self) -> PhysicsResult<f64> {
Ok(self.sma_km()? * (1.0 - self.ecc()?.powi(2)))
}
/// Returns whether this state satisfies the requirement to compute the Mean Brouwer Short orbital
/// element set.
///
/// This is a conversion from GMAT's StateConversionUtil::CartesianToBrouwerMeanShort.
/// The details are at the log level `info`.
/// NOTE: Mean Brouwer Short are only defined around Earth. However, `nyx` does *not* check the
/// main celestial body around which the state is defined (GMAT does perform this verification).
///
/// :rtype: bool
pub fn is_brouwer_short_valid(&self) -> PhysicsResult<bool> {
if self.inc_deg()? > 180.0 {
info!("Brouwer Mean Short only applicable for inclinations less than 180.0");
Ok(false)
} else if self.ecc()? >= 1.0 || self.ecc()? < 0.0 {
info!("Brouwer Mean Short only applicable for elliptical orbits");
Ok(false)
} else if self.periapsis_km()? < 3000.0 {
// NOTE: GMAT emits a warning if the periagee is less than the Earth radius, but we do not do that here.
info!("Brouwer Mean Short only applicable for if perigee is greater than 3000 km");
Ok(false)
} else {
Ok(true)
}
}
/// Returns the right ascension of this orbit in degrees
///
/// :rtype: float
pub fn right_ascension_deg(&self) -> f64 {
between_0_360((self.radius_km.y.atan2(self.radius_km.x)).to_degrees())
}
/// Returns the declination of this orbit in degrees
///
/// :rtype: float
pub fn declination_deg(&self) -> f64 {
between_pm_180((self.radius_km.z / self.rmag_km()).asin().to_degrees())
}
/// Returns the semi minor axis in km, includes code for a hyperbolic orbit
///
/// :rtype: float
pub fn semi_minor_axis_km(&self) -> PhysicsResult<f64> {
if self.ecc()? <= 1.0 {
Ok(self.sma_km()? * (1.0 - self.ecc()?.powi(2)).sqrt())
} else {
Ok(self.hmag()?.powi(2)
/ (self.frame.mu_km3_s2()? * (self.ecc()?.powi(2) - 1.0).sqrt()))
}
}
/// Returns the velocity declination of this orbit in degrees
///
/// :rtype: float
pub fn velocity_declination_deg(&self) -> f64 {
between_pm_180(
(self.velocity_km_s.z / self.vmag_km_s())
.asin()
.to_degrees(),
)
}
/// Returns the $C_3$ of this orbit in km^2/s^2
///
/// :rtype: float
pub fn c3_km2_s2(&self) -> PhysicsResult<f64> {
Ok(-self.frame.mu_km3_s2()? / self.sma_km()?)
}
/// Returns the Longitude of the Ascending Node (LTAN), or an error of equatorial orbits
///
/// :rtype: float
pub fn ltan_deg(&self) -> PhysicsResult<f64> {
let n = Vector3::new(0.0, 0.0, 1.0).cross(&self.hvec()?);
if n.norm_squared() < f64::EPSILON {
return Err(PhysicsError::AppliedMath {
source: MathError::DomainError {
value: n.norm_squared(),
msg: "orbit is equatorial",
},
});
}
let mut ltan = n.y.atan2(n.x);
if ltan < 0.0 {
ltan += TAU;
}
Ok(ltan.to_degrees())
}
/// Returns the radius of periapse in kilometers for the provided turn angle of this hyperbolic orbit.
/// Returns an error if the orbit is not hyperbolic.
///
/// :type turn_angle_degrees: float
/// :rtype: float
pub fn vinf_periapsis_km(&self, turn_angle_degrees: f64) -> PhysicsResult<f64> {
let ecc = self.ecc()?;
if ecc <= 1.0 {
Err(PhysicsError::NotHyperbolic {
ecc: self.ecc().unwrap(),
})
} else {
let cos_rho = (0.5 * (PI - turn_angle_degrees.to_radians())).cos();
Ok((1.0 / cos_rho - 1.0) * self.frame.mu_km3_s2()? / self.vmag_km_s().powi(2))
}
}
/// Returns the turn angle in degrees for the provided radius of periapse passage of this hyperbolic orbit
/// Returns an error if the orbit is not hyperbolic.
///
/// :type periapsis_km: float
/// :rtype: float
pub fn vinf_turn_angle_deg(&self, periapsis_km: f64) -> PhysicsResult<f64> {
let ecc = self.ecc()?;
if ecc <= 1.0 {
Err(PhysicsError::NotHyperbolic {
ecc: self.ecc().unwrap(),
})
} else {
let rho = (1.0
/ (1.0 + self.vmag_km_s().powi(2) * (periapsis_km / self.frame.mu_km3_s2()?)))
.acos();
Ok(between_0_360((PI - 2.0 * rho).to_degrees()))
}
}
/// Returns the hyperbolic anomaly in degrees between 0 and 360.0
/// Returns an error if the orbit is not hyperbolic.
///
/// :rtype: float
pub fn hyperbolic_anomaly_deg(&self) -> PhysicsResult<f64> {
let ecc = self.ecc()?;
if ecc <= 1.0 {
Err(PhysicsError::NotHyperbolic {
ecc: self.ecc().unwrap(),
})
} else {
let (sin_ta, cos_ta) = self.ta_deg()?.to_radians().sin_cos();
let sinh_h = (sin_ta * (ecc.powi(2) - 1.0).sqrt()) / (1.0 + ecc * cos_ta);
Ok(between_0_360(sinh_h.asinh().to_degrees()))
}
}
/// Adjusts the true anomaly of this orbit using the mean anomaly.
///
/// # Astrodynamics note
/// This is not a true propagation of the orbit. This is akin to a two body propagation ONLY without any other force models applied.
/// Use Nyx for high fidelity propagation.
///
/// :type new_epoch: Epoch
/// :rtype: Orbit
pub fn at_epoch(&self, new_epoch: Epoch) -> PhysicsResult<Self> {
let m0_rad = self.ma_deg()?.to_radians();
let mt_rad = m0_rad
+ (self.frame.mu_km3_s2()? / self.sma_km()?.powi(3)).sqrt()
* (new_epoch - self.epoch).to_seconds();
Self::try_keplerian_mean_anomaly(
self.sma_km()?,
self.ecc()?,
self.inc_deg()?,
self.raan_deg()?,
self.aop_deg()?,
mt_rad.to_degrees(),
new_epoch,
self.frame,
)
}
/// Calculates the duration to reach a specific radius in the orbit.
///
/// This function computes the time it will take for the orbiting body to reach
/// the given `radius_km` from its current position. The calculation assumes
/// two-body dynamics and considers the direction of motion.
///
/// # Assumptions & Limitations
///
/// - Assumes pure Keplerian motion.
/// - For elliptical orbits, if the radius is reachable at two points (ascending and descending parts
/// of the orbit), this function calculates the time to reach the radius corresponding to the
/// true anomaly in `[0, PI]` (typically the ascending part or up to apoapsis if starting past periapsis).
/// - For circular orbits, if the radius is within the apoapse and periapse, then a duration of zero is returned.
/// - For hyperbolic/parabolic orbits, the true anomaly at radius is also computed in `[0, PI]`. If this
/// point is in the past, the function returns an error, as it doesn't look for solutions on the
/// departing leg if `nu > PI` would be required (unless current TA is already > PI and target radius is further along).
/// The current implementation strictly uses the `acos` result, so `nu_rad_at_radius` is always `0 <= nu <= PI`.
/// This means it finds the time to reach the radius on the path from periapsis up to the point where true anomaly is PI.
///
/// :type radius_km: float
/// :rtype: Duration
pub fn duration_to_radius(&self, radius_km: f64) -> PhysicsResult<Duration> {
// Pre-condition check for radius_km
ensure!(
radius_km.is_sign_positive(),
RadiusSnafu {
action: "target radius must be positive"
}
);
let ecc = self.ecc()?;
// Reachability checks
let rp_km = self.periapsis_km()?;
if ecc < 1.0 {
// Elliptical
let ra_km = self.apoapsis_km()?;
ensure!(
radius_km >= rp_km && radius_km <= ra_km,
RadiusSnafu {
action: "radius not within apoapse and periapse"
}
);
if ecc < ECC_EPSILON {
// If within ra and rp, but circular, we'll assert zero duration to reach.
return Ok(Duration::from_seconds(0.0));
}
} else {
// Hyperbolic
ensure!(
radius_km >= rp_km,
RadiusSnafu {
action: "radius below periapsis for hyperbolic orbit"
}
);
}
// Retrieve semi-latus rectum
let p_km = self.semi_parameter_km()?;
// Calculate cos_nu_val
let cos_nu_val = (p_km / radius_km - 1.0) / ecc;
// Validate and clamp cos_nu_val
ensure!(
(-1.0 - 1e-9..1.0 + 1e-9).contains(&cos_nu_val),
RadiusSnafu {
action: "cannot compute true anomaly at desired radius: cos(nu) out of bounds"
}
);
let cos_nu_rad_at_radius = cos_nu_val.clamp(-1.0, 1.0);
// Calculate true anomaly at radius
let nu_rad_at_radius = cos_nu_rad_at_radius.acos();
// Calculate mean anomaly at target radius
let m_rad_at_radius = true_anomaly_to_mean_anomaly_rad(nu_rad_at_radius, ecc)
.map_err(|e| PhysicsError::AppliedMath { source: e })?;
// Get current mean anomaly
let m_current_rad = self.ma_deg()?.to_radians();
// Calculate mean motion n_rad_s
let n_rad_s = self.mean_motion_deg_s()?;
if !n_rad_s.is_finite() || n_rad_s <= 0.0 {
return Err(PhysicsError::AppliedMath {
source: MathError::DomainError {
value: n_rad_s,
msg: "mean motion computation failed",
},
});
}
// Step 13: Calculate time from periapsis to target radius and current position
let t_from_p_to_radius_s = m_rad_at_radius / n_rad_s;
let t_from_p_to_current_s = m_current_rad / n_rad_s;
// Step 14: Calculate initial delta time
let mut delta_t_s = t_from_p_to_radius_s - t_from_p_to_current_s;
// Step 15: Adjust delta_t_s
if delta_t_s < -1e-9 {
if ecc < 1.0 {
// Elliptical: target radius (on the 0->PI true anomaly arc) will be reached in the next orbit.
delta_t_s += self.period()?.to_seconds();
} else {
// Hyperbolic/Parabolic: nu_rad_at_radius is on the [0, PI] arc.
// This specific point (on the 0->PI arc) is in the past.
return RadiusSnafu {
action: "Radius (on [0,PI] TA arc) in past for hyperbolic orbit",
}
.fail();
}
} else if delta_t_s < 0.0 {
// If delta_t_s is negative but very close to zero (-1e-9 < delta_t_s < 0.0)
delta_t_s = 0.0;
}
// Step 16: Return Ok(Duration::from_seconds(delta_t_s))
Ok(Duration::from_seconds(delta_t_s))
}
/// Returns a Cartesian state representing the RIC difference between self and other, in position and velocity (with transport theorem).
/// Refer to dcm_from_ric_to_inertial for details on the RIC frame.
///
/// # Algorithm
/// 1. Compute the difference between `other` and `self`
/// 2. Compute the RIC DCM of `self`
/// 3. Rotate the difference into the RIC frame of `self`
/// 4. Strip the astrodynamical information from the frame, enabling only computations from `CartesianState`
///
/// :type other: Orbit
/// :rtype: Orbit
pub fn ric_difference(&self, other: &Self) -> PhysicsResult<Self> {
let mut rslt = (self.dcm_from_ric_to_inertial()?.transpose() * (*other - *self)?)?;
rslt.frame.strip();
Ok(rslt)
}
/// Returns a Cartesian state representing the VNC difference between self and other, in position and velocity (with transport theorem).
/// Refer to dcm_from_vnc_to_inertial for details on the VNC frame.
///
/// # Algorithm
/// 1. Compute the difference between `other` and `self`
/// 2. Compute the VNC DCM of `self`
/// 3. Rotate the difference into the VNC frame of `self`
/// 4. Strip the astrodynamical information from the frame, enabling only computations from `CartesianState`
///
/// :type other: Orbit
/// :rtype: Orbit
pub fn vnc_difference(&self, other: &Self) -> PhysicsResult<Self> {
let mut rslt = (self.dcm_from_vnc_to_inertial()?.transpose() * (*other - *self)?)?;
rslt.frame.strip();
Ok(rslt)
}
}
#[allow(clippy::format_in_format_args)]
impl fmt::LowerHex for Orbit {
/// Prints the Keplerian orbital elements in floating point with units if frame is celestial,
/// If frame is geodetic, prints the range, altitude, latitude, and longitude with respect to the planetocentric frame
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let decimals = f.precision().unwrap_or(6);
write!(
f,
"[{:x}] {}\tsma = {} km\tecc = {}\tinc = {} deg\traan = {} deg\taop = {} deg\tta = {} deg",
self.frame,
self.epoch,
format!("{:.*}", decimals, self.sma_km().unwrap_or(f64::NAN)),
format!("{:.*}", decimals, self.ecc().unwrap_or(f64::NAN)),
format!("{:.*}", decimals, self.inc_deg().unwrap_or(f64::NAN)),
format!("{:.*}", decimals, self.raan_deg().unwrap_or(f64::NAN)),
format!("{:.*}", decimals, self.aop_deg().unwrap_or(f64::NAN)),
format!("{:.*}", decimals, self.ta_deg().unwrap_or(f64::NAN)),
)
}
}
#[allow(clippy::format_in_format_args)]
impl fmt::UpperHex for Orbit {
/// Prints the prints the range, altitude, latitude, and longitude with respect to the planetocentric frame in floating point with units if frame is celestial,
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let decimals = f.precision().unwrap_or(3);
write!(
f,
"[{:x}] {}\trange = {} km\talt. = {} km\tlatitude = {} deg\tlongitude = {} deg",
self.frame,
self.epoch,
format!("{:.*}", decimals, self.rmag_km()),
format!("{:.*}", decimals, self.height_km().unwrap_or(f64::NAN)),
format!("{:.*}", decimals, self.latitude_deg().unwrap_or(f64::NAN)),
format!("{:.*}", decimals, self.longitude_deg()),
)
}
}