-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathiod.py
1592 lines (1321 loc) · 63.7 KB
/
iod.py
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
#!/usr/bin/env python3
""" iod.py - Utilities for importing, validating, and operating on IOD/RDE/UK positional formatting formats """
import sys
if sys.version_info[0] != 3 or sys.version_info[1] < 6:
print("This script requires Python version 3.6")
sys.exit(1)
import os
import re
from datetime import datetime, timedelta
from math import asin, sin, cos, atan2
import logging
log = logging.getLogger(__name__)
from tle_util import launch_piece_letter_to_number, assumed_decimal_point
# REGEXP for valid angle string format with content
# Note that the columns containing the angle format are evaluated for validity with these REGEX
# Rules of the pattern match:
# 1. Force width of first field, column position of sign
# 2. Match only digits and spaces
# 3. No leading space, only trailing space
# 4. Trailing space must not have any other characters
""" Match IOD angle format, return Angle1 and Angle2 (with sign) groups
Regex101 description link - https://regex101.com/r/COxsWQ/6
"""
angle_content_IOD_re = re.compile(r"""
^ # Start at the beginning of the string
(?=[\d\s]{7}[+-]) # Lookahead for 7 decimal or spaces through to the sign
(\d+\s*?) # (Angle 1 Capture GROUP) Match 1+ digits followed by 0+ spaces
( # Start Angle 2 Capture GROUP
[+-] # Match sign
(?=[\d\s]{1,6}$) # Lookahead for 1+ digits followed by 0+ spaces, up to 6 digits total
(?!\d+\s+\d+$) # Negative lookahead - don't match 1+ digits followed by 1+ spaces followed by 1+ more digits
\d+\s*? # Match 1+ digits followed by 0+ spaces
) # End Angle 2 Capture GROUP
""", re.VERBOSE) # pylint: disable=anomalous-backslash-in-string
""" Match UK angle format, return Angle1 and Angle2 (with sign) groups
Regex101 description link - # https://regex101.com/r/COxsWQ/5
"""
angle_content_UK_re = re.compile(r"""
^ # Start at the beginning of the string
(?=[\d\s]{8}[+-]) # Lookahead for 8 decimal or spaces through to the sign
(\d+\s*?) # (Angle 1 Capture GROUP) Match 1+ digits followed by 0+ spaces
( # Start Angle 2 Capture GROUP
[+-] # Match sign
(?=[\d\s]{1,7}$) # Lookahead for 1+ digits followed by 0+ spaces, up to 7 digits total
(?!\d+\s+\d+$) # Negative lookahead - match 1+ digits followed by 1+ spaces followed by 1+ more digits
\d+\s*? # Match 1+ digits followed by 0+ spaces
) # End Angle 2 Capture GROUP
""", re.VERBOSE) # pylint: disable=anomalous-backslash-in-string
""" Match RDE angle format, return Angle1 and Angle2 (with sign) groups
Regex101 description link - https://regex101.com/r/COxsWQ/4
"""
angle_content_RDE_re = re.compile(r"""
^ # Start at the beginning of the string
(?=[\d\s]{6}[+-]) # Lookahead for 6 decimal or spaces through to the sign
(\d+\s*?) # (Angle 1 Capture GROUP) Match 1+ digits followed by 0+ spaces
( # Start Angle 2 Capture GROUP
[+-] # Match sign
(?=[\d\s]{1,6}$) # Lookahead for 1+ digits followed by 0+ spaces, up to 6 digits total
(?!\d+\s+\d+$) # Negative lookahead - match 1+ digits followed by 1+ spaces followed by 1+ more digits
\d+\s*? # Match 1+ digits followed by 0+ spaces
) # End Angle 2 Capture GROUP
""", re.VERBOSE) # pylint: disable=anomalous-backslash-in-string
def get_angle_content_IOD(AngleString):
""" Validates and returns pair of angles from IOD-formatted AngleString, False for each angle if not valid. """
match = angle_content_IOD_re.search(AngleString)
if (match):
return (match.group(1), match.group(2))
else:
return (False, False)
def get_angle_content_UK(AngleString):
""" Validates and returns pair of angles from UK-formatted AngleString, False for each angle if not valid. """
match = angle_content_UK_re.search(AngleString)
if (match):
return (match.group(1), match.group(2))
else:
return (False, False)
def get_angle_content_RDE(AngleString):
""" Validates and returns pair of angles from RDE-formatted AngleString, False for each angle if not valid. """
match = angle_content_RDE_re.search(AngleString)
if (match):
return (match.group(1), match.group(2))
else:
return (False, False)
def radec_from_azel(az, el, latgd, lst):
""" From David Vallado's astIOD.cpp
/*------------------------------------------------------------------------------
*
* procedure radec_azel
*
* this procedure converts right ascension declination values with
* azimuth, and elevation. notice the range is not defined because
* right ascension declination only allows a unit vector to be formed.
*
* author : david vallado 719-573-2600 22 jun 2002
*
* inputs description range / units
* rtasc - right ascension 0.0 to 2pi rad
* decl - declination -pi/2 to pi/2 rad
* lst - local sidedouble time -2pi to 2pi rad
* latgd - geodetic latitude -pi/2 to pi/2 rad
*
* outputs :
* az - azimuth 0.0 to 2pi rad
* el - elevation -pi/2 to pi/2 rad
*
* locals :
* lha - local hour angle -2pi to 2pi rad
* sinv - sine value
* cosv - cosine value
*
* coupling :
* arcsin - arc sine function
* atan2 - arc tangent function that resolves quadrant ambiguites
*
* references :
* vallado 2013, 265, alg 27
-----------------------------------------------------------------------------*/
http://www.convertalot.com/celestial_horizon_co-ordinates_calculator.html
"""
decl = asin(sin(el) * sin(latgd) + cos(el) * cos(latgd) * cos(az))
sinv = -(sin(az) * cos(el) * cos(latgd)) / (cos(latgd) * cos(decl))
cosv = (sin(el) - sin(latgd) * sin(decl)) / (cos(latgd) * cos(decl))
lha = atan2(sinv, cosv)
rtasc = lst - lha
return (rtasc, decl)
def azel_from_radec(rtasc, decl, latgd, lst):
lha = lst - rtasc
el = asin(sin(decl) * sin(latgd) + cos(decl) * cos(latgd) * cos(lha))
sinv = -sin(lha) * cos(decl) * cos(latgd) / (cos(el) * cos(latgd))
cosv = (sin(decl) - sin(el) * sin(latgd)) / (cos(el) * cos(latgd))
az = atan2(sinv, cosv)
return (az, el)
def launch_piece_number_to_letter(piece_num):
""" Converts UK/RDE format 2-digit launch piece to three-letter TLE standard, left-justified, space-padded.
The TLE standard is 24 upper case letter 3-letter code, omitting I (eye) and O (oh) from the alphabet,
with no representation for zero.
The 1st piece of a launch is denoted by 'A', and subsequent pieces 'B', 'C', 'D'... 'Z'.
The 25th (24*1 + 1) piece would be denoted by 'AA', and subsequent pieces 'AB', 'AC'... 'AZ', 'BA', BB', 'BC',... 'ZZ'.
The 601st (24*24 + 24 + 1) piece would be denoted by 'AAA', and subsequent pieces, 'AAB', 'AAC'... AZZ', 'BAA', 'BAB'... 'ZZZ'
This allows for a maximum of 24^3 + 24^2 + 24 pieces, or 14424 pieces for a single launch (ZZZ)
"""
# Zero is not a valid result, so we should never use the zero index
dictionary = '!ABCDEFGHJKLMNPQRSTUVWXYZ'
piece_letters = ''
# FIXME: Figure out what the right thing to do is if we're passed 0 (Assign to 1=A?)
x = int(piece_num)
if (x == 0):
x = 1
# 14424 = 24*24*24 + 24*24 + 24
# Just rail it high to preserve the format width
if x > 14424:
x = 14424
log.warning("Exceeded maximum value (14424) for launch piece number")
while x > 0:
x, idx = divmod(x, 24)
# With no zero value, zero remainder means we are at the maximum value in the alphabet
# Not the first of the next!
if (idx == 0):
idx = 24
x -= 1
piece_letters = dictionary[idx] + piece_letters
return "{:<s}".format(piece_letters)
def right_zero_pad(val, length=8):
""" Right-zero-pad short-form angle strings with zeros.
This reduces amount of error checking required, and makes decimal conversions more consistent
This will also make the IOD/UK/RDE angle lengths uniform (UK/RDE adds one more digit of precision on the right)
"""
val = val.rstrip()
zpadval = val + "0" * (length - len(val))
return zpadval
def angle_from_HHMMSSss(HHMMSSss):
""" Returns a decimal angle provided a string in 'right ascension HHMMSSss format'. Assumed positive value. """
HHMMSSss = right_zero_pad(HHMMSSss, 8)
try:
HH = int(HHMMSSss[0:2])
except ValueError:
HH = 0 # We should probably just declare it invalid...
try:
MM = int(HHMMSSss[2:4])
except ValueError:
MM = 0
try:
SS = int(HHMMSSss[4:6])
except ValueError:
SS = 0
try:
ss = int(HHMMSSss[6:8])
except ValueError:
ss = 0
HHMMSSss_angle = 15.0 * ((HH) + MM / 60.0 + SS / 3600.0 + ss * (0.01 / 3600.0))
return HHMMSSss_angle
def angle_from_HHMMmmmm(HHMMmmmm):
""" Returns a decimal angle provided a string in 'right ascension HHMMmmmm format'. Assumed positive value. """
HHMMmmmm = right_zero_pad(HHMMmmmm, 8)
try:
HH = int(HHMMmmmm[0:2])
except ValueError:
HH = 0 # We should probably just declare it invalid...
try:
MM = int(HHMMmmmm[2:4])
except ValueError:
MM = 0
try:
mmmm = int(HHMMmmmm[4:8])
except ValueError:
mmmm = 0
HHMMmmmm_angle = 15.0 * ((HH) + MM / 60.0 + mmmm / 600000.0)
return HHMMmmmm_angle
def angle_from_DDDMMSSs(DDDMMSSs):
""" Returns a decimal angle provided a string in right ascension DDDMMSSs format.
Input can be in the forms:
DDDMMSSs (unsigned right ascension)
+DDMMSSs (signed declination / elevation)
"""
DDDMMSSs = right_zero_pad(DDDMMSSs)
if (DDDMMSSs[0] in ("+", "-")):
SIGN = DDDMMSSs[0]
SIGN = int(SIGN + "1")
DDDMMSSs = "0" + DDDMMSSs[1:]
else:
SIGN = 1.0
try:
DDD = int(DDDMMSSs[0:3])
except ValueError:
DDD = 0 # We should probably just declare it invalid...
try:
MM = int(DDDMMSSs[3:5])
except ValueError:
MM = 0
try:
SS = int(DDDMMSSs[5:7])
except ValueError:
SS = 0
try:
s = int(DDDMMSSs[7])
except ValueError:
s = 0
DDDMMSSs_angle = SIGN * (DDD + MM / 60.0 + SS / 3600.0 + s * 0.1 / 3600.0)
return DDDMMSSs_angle
def angle_from_DDDMMmmm(DDDMMmmm):
""" Returns a decimal angle provided a string in right ascension DDDMMmmm format
Input can be in the forms:
DDDMMmmm (unsigned right ascension)
+DDMMmmm (signed declination / elevation)
"""
DDDMMmmm = right_zero_pad(DDDMMmmm)
if (DDDMMmmm[0] in ("+", "-")):
SIGN = DDDMMmmm[0]
SIGN = int(SIGN + "1")
DDDMMmmm = "0" + DDDMMmmm[1:]
else:
SIGN = 1.0
try:
DDD = int(DDDMMmmm[0:3])
except ValueError:
DDD = 0 # We should probably just declare it invalid...
try:
MM = int(DDDMMmmm[3:5])
except ValueError:
MM = 0
try:
mmm = int(DDDMMmmm[5:8])
except ValueError:
mmm = 0
DDDMMmmm_angle = SIGN * (DDD + MM / 60.0 + mmm * (0.001 / 60.0))
return DDDMMmmm_angle
def angle_from_DDDddddd(DDDddddd):
""" Returns a decimal angle provided a string in right ascension DDDddddd format.
Input can be in the forms:
DDDddddd (unsigned right ascension)
+DDddddd (signed declination / elevation)
"""
DDDddddd = right_zero_pad(DDDddddd)
if (DDDddddd[0] in ("+", "-")):
SIGN = DDDddddd[0]
SIGN = int(SIGN + "1")
DDDddddd = "0" + DDDddddd[1:]
else:
SIGN = 1.0
try:
DDD = int(DDDddddd[0:3])
except ValueError:
DDD = 0 # We should probably just declare it invalid...
try:
ddddd = int(DDDddddd[3:8])
except ValueError:
ddddd = 0
DDDddddd_angle = SIGN * (DDD + (ddddd * 0.00001))
return DDDddddd_angle
class Angle:
"""Observed Angle
# IOD - First four are OWTG system, minus one digit of precision
# 00000000001111
# 01234567890123
# Format 1: RA/DEC = HHMMSSs+DDMMSS MX (MX in seconds of arc)
# 2: RA/DEC = HHMMmmm+DDMMmm MX (MX in minutes of arc)
# 3: RA/DEC = HHMMmmm+DDdddd MX (MX in degrees of arc)
# 4: AZ/EL = DDDMMSS+DDMMSS MX (MX in seconds of arc)
# 5: AZ/EL = DDDMMmm+DDMMmm MX (MX in minutes of arc)
# 6: AZ/EL = DDDdddd+DDdddd MX (MX in degrees of arc)
# 7: RA/DEC = HHMMSSs+DDdddd MX (MX in degrees of arc)
# UK (RDE uses Code 1 with no sub-seconds)
# Column 0000000000111111
# 0123456789012345
# Code 1: RA/DEC = HHMMSSss+DDMMSSs
# 2: RA/DEC = HHMMmmmm+DDMMmmm
# 3: RA/DEC = HHMMmmmm+DDddddd
# 4: AZ/EL = DDDMMSSs DDMMSSs (elevation corrected for refraction)
# 5: AZ/EL = DDDMMmmm DDMMmmm (elevation corrected for refraction)
# 6: AZ/EL = DDDddddd DDddddd (elevation corrected for refraction)
# 7: AZ/EL = DDDMMSSs DDMMSSs (elevation not corrected for refraction)
# 8: AZ/EL = DDDMMmmm DDMMmmm (elevation not corrected for refraction)
# 9: AZ/EL = DDDddddd DDddddd (elevation not corrected for refraction)
# TODO: Figure out what to do about elevation / refraction
Given a packed Angle string of two angle pairs matching the format above,
create one of the following formats.
RA = HH.ddddd
DEC = DDD.ddddd
AZ = DDD.ddddd
EL = DD.ddddd
Where MX (Uncertainty) is not in degrees of arc, convert to degrees of arc
The IOD and UK angle codes (1-6) are functionaly equivalent,
noting the difference in precision.
IOD Format 7 is unique to it.
UK Formats 7-9 are identical in formatting to 4-6, noting the
considerations for atmospheric refraction.
RDE Format 1 is the same as IOD/UK format without the sub-seconds
"""
EpochDict = {
0: 0, # TODO: of Date (need to implement this "J-NOW" somewhere)
1: 1855,
2: 1875,
3: 1900,
4: 1950,
5: 2000,
6: 2050
}
def __init__(self, AngleFormatCode, EpochCode, AngleString, Uncertainty, UncertaintyString, RecordFormat="IOD"):
self.AngleFormatCode = AngleFormatCode
self.EpochCode = EpochCode
self.AngleString = AngleString
self.Uncertainty = Uncertainty
self.UncertaintyString = UncertaintyString
self.RecordFormat = RecordFormat
self.Epoch = None
self.Angle1 = None
self.Angle2 = None
self.RA = 0.0
self.DEC = 0.0
self.AZ = 0.0
self.EL = 0.0
self.ValidAngle = False
self.Epoch = self.EpochDict[self.EpochCode]
if (self.RecordFormat == "IOD"):
(self.Angle1, self.Angle2) = get_angle_content_IOD(self.AngleString)
elif (self.RecordFormat == "RDE"):
(self.Angle1, self.Angle2) = get_angle_content_RDE(self.AngleString)
elif (self.RecordFormat == "UK"):
(self.Angle1, self.Angle2) = get_angle_content_UK(self.AngleString)
else:
log.error(
"Did not find a valid Record Format ({}) specified for angle unpacking.".format(self.RecordFormat))
if (self.AngleFormatCode and self.Angle1 and self.Angle2):
# 1: IOD RA/DEC = HHMMSSs0+DDMMSS0 MX (MX in seconds of arc)
# 1: UK RA/DEC = HHMMSSss+DDMMSSs SSSs (seconds of arc)
# 1: RDE RA/DEC = HHMMSS00+DDMMSS0 SSS (seconds of arc)
# Note, the "0" in these formats, throughout, are the ones we padded onto the IOD/RDE formats
if (self.AngleFormatCode == 1):
self.RA = angle_from_HHMMSSss(self.Angle1)
self.DEC = angle_from_DDDMMSSs(self.Angle2)
if (self.RecordFormat == "IOD"):
# Convert uncertainty in seconds of arc to fractional degrees
self.Uncertainty *= (1 / 3600)
elif (self.RecordFormat == "RDE"):
self.Uncertainty *= (1 / 3600)
elif (self.RecordFormat == "UK"):
temp = right_zero_pad(self.UncertaintyString, length=4)
try:
self.Uncertainty = float(temp[0:3].lstrip() + "." + temp[3]) / 3600
except ValueError:
self.Uncertainty = 0
# IOD 2: RA/DEC = HHMMmmm0+DDMMmm0 MX (MX in minutes of arc)
# UK 2: RA/DEC = HHMMmmmm+DDMMmmm MMmm (minutes of arc)
elif (self.AngleFormatCode == 2):
self.RA = angle_from_HHMMmmmm(self.Angle1)
self.DEC = angle_from_DDDMMmmm(self.Angle2)
if (self.RecordFormat == "IOD"):
# Convert uncertainty in minutes of arc to fractional degrees
self.Uncertainty *= (1 / 60)
elif (self.RecordFormat == "UK"):
temp = right_zero_pad(self.UncertaintyString, length=4)
try:
self.Uncertainty = float(temp[0:2].lstrip() + "." + temp[2:4]) / 60
except ValueError:
self.Uncertainty = 0
# 3: IOD RA/DEC = HHMMmmm0+DDdddd0 MX (MX in degrees of arc, no need to convert)
# 3: UK RA/DEC = HHMMmmmm+DDddddd Dddd (degrees of arc)
elif (self.AngleFormatCode == 3):
self.RA = angle_from_HHMMmmmm(self.Angle1)
self.DEC = angle_from_DDDddddd(self.Angle2)
if (self.RecordFormat == "UK"):
temp = right_zero_pad(self.UncertaintyString, length=4)
try:
self.Uncertainty = float(temp[0].lstrip() + "." + temp[1:4])
except ValueError:
self.Uncertainty = 0
# 4: IOD AZ/EL = DDDMMSS0+DDMMSS0 MX (MX in seconds of arc)
# 4: UK AZ/EL = DDDMMSSs DDMMSSs SSSs (seconds of arc) (elevation corrected for refraction)
elif (self.AngleFormatCode == 4):
self.AZ = angle_from_DDDMMSSs(self.Angle1)
self.EL = angle_from_DDDMMSSs(self.Angle2)
if (self.RecordFormat == "IOD"):
# Convert uncertainty in seconds of arc to fractional degrees
self.Uncertainty *= (1 / 3600)
elif (self.RecordFormat == "UK"):
temp = right_zero_pad(self.UncertaintyString, length=4)
try:
self.Uncertainty = float(temp[0:3].lstrip() + "." + temp[3]) / 3600
except ValueError:
self.Uncertainty = 0
# 5: IOD AZ/EL = DDDMMmm0+DDMMmm0 MX (MX in minutes of arc)
# 5: UK AZ/EL = DDDMMmmm+DDMMmmm MMmm (minutes of arc) (elevation corrected for refraction)
elif (self.AngleFormatCode == 5):
self.AZ = angle_from_DDDMMmmm(self.Angle1)
self.EL = angle_from_DDDMMmmm(self.Angle2)
if (self.RecordFormat == "IOD"):
# Convert uncertainty in minutes of arc to fractional degrees
self.Uncertainty *= (1 / 60)
elif (self.RecordFormat == "UK"):
temp = right_zero_pad(self.UncertaintyString, length=4)
try:
self.Uncertainty = float(temp[0:2].lstrip() + "." + temp[2:4]) / 60
except ValueError:
self.Uncertainty = 0
# 6: IOD AZ/EL = DDDdddd0+DDdddd0 MX (MX in degrees of arc, no need to convert)
# 6: UK AZ/EL = DDDddddd+DDddddd Dddd (degrees of arc) (elevation corrected for refraction)
elif (self.AngleFormatCode == 6):
self.AZ = angle_from_DDDddddd(self.Angle1)
self.EL = angle_from_DDDddddd(self.Angle2)
if (self.RecordFormat == "UK"):
temp = right_zero_pad(self.UncertaintyString, length=4)
try:
self.Uncertainty = float(temp[0].lstrip() + "." + temp[1:4])
except ValueError:
self.Uncertainty = 0
# 7: RA/DECvv = HHMMSSs+DDdddd MX (MX in degrees of arc, no need to convert)
elif (self.AngleFormatCode == 7 and self.RecordFormat == "IOD"):
self.RA = angle_from_HHMMSSss(self.Angle1)
self.DEC = angle_from_DDDddddd(self.Angle2)
# TODO: Add in the remaining format codes for UK (7-9)
else:
log.error("Did not find an Angle case with AngleFormatCode '{}'".format(self.AngleFormatCode))
self.ValidAngle = False
if ((self.RA and self.DEC) or (self.AZ and self.EL)):
self.ValidAngle = True
else:
self.ValidAngle = False
# End of the Angle parsing cases
def DateTime_frompacked(DateTimeString, format_type="IOD"):
""" Unpack the datetime format from IOD strings
Of the string format:
00000000001111111111
01234567890123456789
WANT: YYYYMMDDHHMMSSssssss
IOD: YYYYMMDDHHMMSSsss
UK: YYMMDDHHMMSSssss
RDE: YYMMDDHHMMSS.ss
Also, deal with cases where users provide rounded-up/over-the-max values for HH:MM:SS fields.
"""
# Parse YEAR ourselves, as datetime works on a 2000-year boundary, whereas TLE dates work on a [19,20]57 year boundary
if (format_type in ["RDE", "UK"]):
YEAR = int(DateTimeString[0:2])
if (YEAR < 57): # Year of Sputnik launch, first cataloged object
# Make the input string of consistent between formats for the rest of the parsing
DateTimeString = '20' + DateTimeString
else:
DateTimeString = '19' + DateTimeString
# Zero pad the datestring out to microsecond precision
DateTimeString = right_zero_pad(DateTimeString, length=20)
# Deal with decimal point in RDE format
if (format_type == "RDE"):
if (DateTimeString[14] == "."):
SUBSEC = DateTimeString[15:].rstrip()
MICROSECOND = right_zero_pad(SUBSEC, 6)
DateTimeString = DateTimeString[:14] + MICROSECOND
else:
DateTimeString = DateTimeString[:14] + "000000"
YEAR = int(DateTimeString[0:4])
MONTH = int(DateTimeString[4:6])
DAY = int(DateTimeString[6:8])
HOUR = int(DateTimeString[8:10])
MINUTE = int(DateTimeString[10:12])
SECOND = int(DateTimeString[12:14])
MICROSECOND = int(DateTimeString[14:20])
# Handle inputs with more than the maximum than datetime.strptime() can handle
# This is fromt the users' software rounding up
carry_over_time = 0
if (SECOND > 59):
carry_over_time += 60
SECOND -= 60
if (MINUTE > 59):
carry_over_time += 60 * 60
MINUTE -= 60
if (HOUR > 23):
carry_over_time += 24 * 60 * 60
HOUR -= 24
DateTimeString = f"{YEAR:04d}{MONTH:02d}{DAY:02d}{HOUR:02d}{MINUTE:02d}{SECOND:02d}{MICROSECOND:06d}"
formatstring = '%Y%m%d%H%M%S%f'
DateTimeUnpacked = datetime.strptime(DateTimeString, formatstring)
DateTimeUnpacked = DateTimeUnpacked + timedelta(seconds=carry_over_time)
return DateTimeUnpacked
class IOD:
"""IOD/Visual Observation (includes UK and RDE encoded data)
NameCode, or NameString indicates packed data requiring further processing (to disentagle variable names)
# Array of variable names for IOD parsing.
# NameCode, or NameString indicates packed data requiring further processing (to disentagle variable names)
"""
# FIXME: There's some type of validation to be done making sure that ObjectNumber and InternationalDesignation are consistent with Celestrak SATCAT (or each other)
def __init__(self, line=None): # (Element #, start_col, end_col)
self.line = line
self.UserString = None # FIXME: A preMVP hack before we're parsing it fully.
self.ObjectNumber = None # (0, 0, 4) Object number (NORAD)
self.LaunchYear = None # (1, 6, 7) Launch year. Implied century.
self.InternationalDesignation = None # (2, 9, 14) International Designation (COSPAR)
self.Station = 9999 # (3, 16, 19) Four digit station number
self.StationStatusCode = None # (4, 21, 21) Station status code
self.StationStatus = None # Only in the IOD format
self.DateTimeString = None # (5, 23, 39) DateTime string YYYYMMDDHHMMSSsss
self.DateTime = None
self.TimeUncertainty = None # (6, 41, 42) Time Uncertainty, expressed as MX, Evaluated as M*10E(X-8).
self.TimeStandardCode = None # In the UK, RDE formats
self.AngleFormatCode = None # (7, 44, 44) Angle format code
self.EpochCode = None
self.Epoch = None # (8, 45, 45) Epoch code
self.RA = None
self.DEC = None
self.AZ = None
self.EL = None
self.ValidPosition = 1 # Every IOD starts life with the potential to be valid - assume valid until proven otherwise
self.PositionUncertainty = None # (10, 62, 63) Positional uncertainty, expressed as MX, Evaluated as M*10E(X-8).
self.OpticalCode = None # (11, 65, 65) Optical behavior code
self.VisualMagnitude = None # (12, 66, 69) Visual magnitude. Implied decimal point.
self.MagnitudeUncertainty = None # (13, 71, 72) Magnitude uncertainty. Implied decimal point.
self.FlashPeriod = None # (14, 74, 79) Flash period in seconds. Implied decimal point.
self.VisualMagnitude_high = None # In the UK, RDE formats
self.VisualMagnitude_low = None # In the UK, RDE formats
self.Remarks = None
self.message_id = None
self.IODType = None # IOD, UK or RDE
self.iod_string = line # duplicate to self.line, but DB calls it iod_string FIXME: ? (rename it above, and throughout iod.py)
self.obs_id = None # Created automatically on DB import
self.obsFingerPrint = None # Created automatically on DB import
self.import_timestamp = None # Created automatically on DB import
self.submitted = None # Created automatically on DB import
# TODO: The following functions are to-do
def calc_AZ_EL_from_RA_DEC(self):
# Create as a class function call, but put the actual workings as a module function
# so it can be accessed separately
pass
def calc_RA_DEC_from_AZ_EL(self):
# Create as a class function call, but put the actual workings as a module function
# so it can be accessed separately
pass
# end of class IOD
# Don't worry about the lint warnings on these: https://github.com/PyCQA/pylint/issues/1451
# Previous version for revert if necessary
# iod_format_re = re.compile ('^(\d{5}\s\d{2}\s\d{3}\D*)*\s{1,16}(\d{4}\s)(\D)\s(\d{8})(\d{0,9}$|\d{0,9}\s+\d{2}\s*\d*\s*\d*\s*[-+]*\d*\s*\d{0,2}\s[EFIRSXBHPADMNV]{0,1})') # pylint: disable=anomalous-backslash-in-string
# Simple format
# iod_format_re = re.compile ('^(\d{5}\s\d{2}\s\d{3}\D*)*\s{1,16}(\d{4}\s)(\D)\s(\d{8})(\d{0,9}$|\d{0,9}\s+\d\d.*)')
# This one catches all the good lines, and lets a ones with invalid VMAG/FLASH data through
# More description here: https://regex101.com/r/sevtPF/1
new_iod_format_re = re.compile(r"""
^ # Start at beginning of string
( # BEGIN NORAD/INTL DES GROUP
\d{5}\s
\d{2}\s
\d{3}
(?=[A-Z]+\s*)[\D\s]{3}(?<!\s\w)\s
|\s{16} # Or match empty from beginning for 16 characters
) # END NORAD/INTL DES GROUP
[0-9A-HJ-NP-Z]{4}\s # Station
[EGFPBTCO ]\s # Station status code
[\d+]{8} # YYYYMMDD
( # BEGIN optional remaining data GROUP
\d*\s*$| # End of the record, or
(?=.{9})\d*\s*?\s # Match the next 9 characters of the time string, digits than optional space
\d{2}\s # Time uncertainty
( # BEGIN ANGLE DATA BLOCK
[1-7] # Valid angle format codes
[\s0-6]\s # Valid time epoch codes
(?=[\d\s*]{7})\d+\s*? # First group of Angle data
[+-] # Sign
(?=[\d\s*]{6})\d+\s*?\s # Second group of Angle data
\d{2} # Position uncertainty
|\s{20} # 20 blank characters if no angle data
) # END ANGLE DATA GROUP
( # BEGIN Optical Behavior GROUP
\s[EFIRSXBHPADMNV] # Valid optical behavior code
( # BEGIN visual magnitude GROUP
[+-] # Visual Magnitude sign
(?=[\d\s*?]{3})\d+\s*?\s # Visual Magnitude
(?=[\d\s*?]{2})\d+\s*?\s # Visual Magnitude uncertainty
(\s+\d+$)? # Optional Flash period
)? # END optional visual magnitude GROUP
)? # END optional optical behavior GROUP
) # END optional remaining data GROUP
""", re.VERBOSE)
def format_test_iod(line=False):
match = new_iod_format_re.search(line)
if match:
return True
else:
return False
"""
Notes on the UK format:
This format is deficient for the following reasons:
- Uses international designator as the identifier (no NORAD number)
- Uses a 2 digit numeric code for launch piece number, as opposed to a 3-letter code in the TLE standard.
- Column 33, "Time Standard Code" appears to be meaningless, as 0 is listed as possible by the example, which lists 1-3 being valid.
- Doesn't require leading zeros (i.e., on positional accuracy numbers)
However, it is superior for the following reason:
- One more digit of precision in reported angles
"""
# Previous version for revert if necessary
# uk_format_re = re.compile ('^\d{17}(?=\d+[ ]*)[ \d]{10}(?=\d+[ ]*)[\d ]{5}[1-3][1-9](?=\d*[ ]*)[\d ]{8}[-+ ](?=\d+[ ]*)[\d ]{7}[ \d]{4}[0-6$]') # pylint: disable=anomalous-backslash-in-string
# Weaker UK format test which matches the beginning of a UK line but NOT the beginning of an RDE line
# uk_format_re = re.compile('^(?=\d+[ ]*?)[\d ]{24}(?=\d)[ \d]{3}') # pylint: disable=anomalous-backslash-in-string
# uk_format_re = re.compile('^(\d{17})') # pylint: disable=anomalous-backslash-in-string
# More description here: https://regex101.com/r/YUKMjI/1
new_uk_format_re = re.compile(r"""
^(\d{7}[0-9A-HJ-NP-Z]{4}\d{6}) # Intl Desig, Site Number, Date string
(?=\d[\d\s]{9}).{10} # Time of observation, must start with at least one digit, digits and spaces can follow
(?=[\d\s]{5}).{5} # Time accuracy, can be all blank
([1-3] # Time Standard code, 1-3 valid
[1-9]) # Angle Format Code, 1-9 valid
(?=\d[\d\s]{7}).{8} # AngleString1 - Must start with a digit, followed by 7 more digits or spaces
([-+\s] # Sign for ngleString2
(?=\d[\d\s]{6}).{7}) # AngleString2 - Must start with a digit, followed by 6 more digits or spaces
((?=[\d\s]{4}).{4}) # Position uncertainty, 4 digits or blanks
[0-6] # Epoch of start chart, 0-6 valid
(\s*?$| # End of minimum valid record, or continue matching
(?=[\s]{13}).{13} # Range and Range accuracy data (not used - accept any 13 characters)
((?=[\s\d+-]{3}).{3}) # Brightest Visual Magnitude 3 digits, spaces or sign
((INV|(?=[\s\d+-]{3}).{3})) # Faintest Visual Magnitude 3 digits, spaces or sign - or the phrase INV if the satellite becomes invisible
((?=[\d\s]{5}).{5}) # Flash period - 5 digits or spaces
([SIRFXE ]) # Remarks code, valid characters
)
""", re.VERBOSE)
# uk_format_re = re.compile('^\d{17}(?=\d+[ ]*)[ \d]{10}(?=\d+[ ]*)[\d ]{5}[1-3][1-9](?=\d*[ ]*)[\d ]{8}[-+ ](?=\d+[ ]*)[\d ]{7}[ \d]{4}[0-6$][\d ]{8}[\d ]{5}(?=[+-]?\d+\s*)[-+\d ]{3}(?=[+-]?\d+\s*)[-+\d ]{3}(?=[ ]*\d+[ ]*)[\d ]{5}[ SIRFXE]{1}') # pylint: disable=anomalous-backslash-in-string
def format_test_uk(line=False):
match = new_uk_format_re.search(line)
if match:
return True
else:
return False
# Previous version (currently in use due to https://github.com/consensys-space/trusat-orbit/issues/5) for revert if necessary
rde_format_re = re.compile(
'[0-9A-HJ-NP-Z]{4}\s\d{4}\s(?=\d.\d*[ ]*)[\d. ]{4}\d\s\d{3}[0-6]\n(^\d{2}\n(^\d{7}\s\d{6}.\d{2}\s\d{6}[-+ ]\d{6}(?=[- ]\d.\d)[- \d.]{4}(?=[- ]\d.\d)[- \d.]{4}.*\n){2,}){1,}999',
re.MULTILINE) # pylint: disable=anomalous-backslash-in-string
# The following regex creates stalling-conditions on bulk import ref: https://github.com/consensys-space/trusat-orbit/issues/5
# The following detects a complete RDE record block
# https://regex101.com/r/AINrwf/1
new_rde_format_re_verbose = re.compile(r"""
[0-9A-HJ-NP-Z]{4}\s # Observing site number (4 digits)
\d{4}\s # UTC Year and Month of Observation, in YYMM format
(?=\d.\d*?\s*?)[\d. ]{3} # Time Accuracy, in seconds. Format is T.t
[1-3] # Time Standard Code, 1-3 valid
[1]\s # Position Format Code, Russell always uses 1
\d{3} # Position Accuracy, SSS
[0-6]\n # Epoch of star chart used to determine position
(?: # Beginning of optional repeat data block for daily data
^\d{2}.*\n # 2 digit day of month, followed by optional space then newline
(?: # Beginning of optional repeat data block for observation lines
^\d{7}\s # International Designation - 7 digit string
\d{6}.\d{2}\s # UTC Time of Observation, in HHMMSS.ss
\d{6} # Observed Right Ascension or Azimuth, in HHMMSS format
[-+ ]\d{6} # Observed Declination or Elevation, in DDMMSS format (with sign)
(?=[- ]\d.\d)[- \d.]{4} # Brightest Visual Magnitude
(?=[- ]\d.\d)[- \d.]{4} # Faintest Visual magnitude
(?=[- ]\s?\d.*?\d*?)[- \d.]{3} # Flash period
[SIRFXE ].*\n # Optical behavior code
){1,} # Require one data line per day
){1,} # Require one day per record
999 # RDE record ends with 999
""", flags=re.MULTILINE | re.VERBOSE) # Need to OR flags to use multiple options
# With all extraneous white space removed, as to avoid the OR'ed flags in new_rde_format_re_verbose
new_rde_format_re_compact = re.compile(
r"[0-9A-HJ-NP-Z]{4}\s\d{4}\s(?=\d.\d*?\s*?)[\d. ]{3}[1-3][1]\s\d{3}[0-6]\n(?:^\d{2}.*\n(?:^\d{7}\s\d{6}.\d{2}\s\d{6}[-+ ]\d{6}(?=[- ]\d.\d)[- \d.]{4}(?=[- ]\d.\d)[- \d.]{4}(?=[- ]\s?\d.*?\d*?)[- \d.]{3}[SIRFXE ].*\n){1,}){1,}999",
re.MULTILINE)
# The following detects a valid data line within a RDE record block
# https://regex101.com/r/MDKDE6/2
new_rde_data_line_re = re.compile(r"""
^\d{7}\s # International Designation - 7 digit string
\d{6}.\d{2}\s # UTC Time of Observation, in HHMMSS.ss
\d{6} # Observed Right Ascension or Azimuth, in HHMMSS format
[-+ ]\d{6} # Observed Declination or Elevation, in DDMMSS format (with sign)
(?=[- ]\d.\d)[- \d.]{4} # Brightest Visual Magnitude
(?=[- ]\d.\d)[- \d.]{4} # Faintest Visual magnitude
(?=[- ]\s?\d.*?\d*?)[- \d.]{3} # Flash period
[SIRFXE ].*$ # Optical behavior code
""", re.VERBOSE)
rde_data_line_re = re.compile('\d{7}\s\d{6}.\d{2}\s\d{6}[-+ ]\d{6}(?=[- ]\d.\d)[- \d.]{4}(?=[- ]\d.\d)[- \d.]{4}')
def format_test_rde(block=False):
match = new_rde_format_re.search(block)
if match:
return True
else:
return False
# Parse enter blocks of text instead of just lines
# This is the typical use-case, as IOD entries usually come in clusters
def parse_iod_lines(block=False):
""" Parse a block of text containing IOD-formatted observation records, and return formatted records + count.
Format documented at: http://www.satobs.org/position/IODformat.html
"""
lines = block.split('\n')
IODentryList = []
iod_count = 0
iod_match_count = 0
iod_block_line = 0
earliest_time = datetime(1957, 10, 4, 19, 28, 34, 0)
import_time = datetime.utcnow()
for line in lines:
iod_block_line += 1
line = line.rstrip()
match = format_test_iod(line)
if (match):
iod_match_count += 1
log.debug("IOD match {} on line {}".format(iod_match_count, iod_block_line))
IOD_line = IOD(line)
IOD_line.IODType = "IOD"
line_length = len(line)
try:
IOD_line.ObjectNumber = int(line[0:5])
except ValueError:
IOD_line.ObjectNumber = 0
try:
LaunchYearTwoDigit = int(line[6:8])
if (LaunchYearTwoDigit < 57): # Year of Sputnik launch, first cataloged object
IOD_line.LaunchYear = 2000 + LaunchYearTwoDigit
else:
IOD_line.LaunchYear = 1900 + LaunchYearTwoDigit
IOD_line.InternationalDesignation = str(IOD_line.LaunchYear) + '-' + line[
9:15] # TODO: Figure out if we should strip() this or not
# If the observer wasn't sure, let the DB trigger look it up from SATCAT
if ("?" in IOD_line.InternationalDesignation):
IOD_line.InternationalDesignation = "?"
except ValueError:
IOD_line.InternationalDesignation = ""
try:
IOD_line.Station = line[16:20]
except ValueError: # No point in continuing if we don't have a station to report against
continue
IOD_line.StationStatusCode = line[
21] # Future TODO - Expand out the short and long description of the codes
if (line_length >= 41):
IOD_line.DateTimeString = line[23:40]
IOD_line.DateTime = DateTime_frompacked(IOD_line.DateTimeString)
else:
IOD_line.DateTimeString = line[23:]
IOD_line.DateTime = DateTime_frompacked(IOD_line.DateTimeString)
# Flag records from the future and pre history
if not (earliest_time < IOD_line.DateTime < import_time):
IOD_line.ValidPosition = -1
if (line_length >= 43):
try:
# Expressed as MX, where M = mantissa, and X = exponent input. Evaluated as M*10E(X-8).
IOD_line.TimeUncertainty = float(line[41]) * 10 ** (int(line[42]) - 8)
except ValueError:
IOD_line.TimeUncertainty = 0
if (line_length >= 45):
try:
IOD_line.AngleFormatCode = int(line[44])
except ValueError:
IOD_line.AngleFormatCode = -1
IOD_line.ValidPosition = -1 # Need format code to parse angle
try:
EpochCodeString = line[45]
if (EpochCodeString == " "):
IOD_line.EpochCode = 0
else:
IOD_line.EpochCode = int(line[45])
except ValueError:
IOD_line.EpochCode = -1
IOD_line.ValidPosition = -1 # If EpochCode as read as Zero (not blank), we won't trigger this error
else:
IOD_line.AngleFormatCode = -1
IOD_line.EpochCode = -1
IOD_line.ValidPosition = -1
# TODO: Figure out which of the missing data parts to actually warn users about
if (line_length > 61 and IOD_line.AngleFormatCode > 0):
AngleString = line[47:61]
try:
IOD_line.PositionUncertainty = float(line[62]) * 10 ** (int(line[63]) - 8)
except ValueError:
IOD_line.PositionUncertainty = None # Don't really need this (initialized state)
if ((IOD_line.AngleFormatCode >= 0) and (IOD_line.EpochCode >= 0) and AngleString):
try:
angle = Angle(
IOD_line.AngleFormatCode,
IOD_line.EpochCode,
AngleString,
IOD_line.PositionUncertainty,
"",
"IOD")
if (angle.ValidAngle):
IOD_line.AZ = angle.AZ
IOD_line.EL = angle.EL
IOD_line.RA = angle.RA
IOD_line.DEC = angle.DEC
IOD_line.Epoch = angle.Epoch
IOD_line.PositionUncertainty = angle.Uncertainty
else:
IOD_line.ValidPosition = -1
except:
log.error("Problem angle: '{}' - Offending line:".format(AngleString))
log.error(line)
IOD_line.ValidPosition = -1
elif ((IOD_line.AngleFormatCode >= 0) and (IOD_line.EpochCode >= 0)):
# Note - Not an error, because the standards provide for "reporting for duty"
# even if no observations are made.
log.warning(
"Valid Angle ({}) and Epoch ({}) codes, but no valid position data. Offending line:".format(
IOD_line.AngleFormatCode, IOD_line.EpochCode))
log.warning(line)
IOD_line.ValidPosition = -1
else:
IOD_line.ValidPosition = -1
else:
IOD_line.ValidPosition = -1
if (line_length >= 65):
IOD_line.OpticalCode = line[65]
if (line_length >= 70):
# Cols 67-70 Visual Magnitude
vmag = line[66:70]
vmag_sign = vmag[0]
vmag_whole = vmag[1:3]
vmag_frac = vmag[3]
try:
IOD_line.VisualMagnitude = float(vmag_sign + vmag_whole + '.' + vmag_frac)
except ValueError: