File Coverage

blib/lib/Astro/Coord/ECI/TLE/Iridium.pm
Criterion Covered Total %
statement 351 388 90.4
branch 77 130 59.2
condition 11 50 22.0
subroutine 47 52 90.3
pod 10 10 100.0
total 496 630 78.7


line stmt bran cond sub pod time code
1             =head1 NAME
2              
3             Astro::Coord::ECI::TLE::Iridium - Compute behavior of Iridium satellites
4              
5             =head1 SYNOPSIS
6              
7             The following is a semi-brief script to calculate Iridium flares. You
8             will need to substitute your own location where indicated.
9              
10             use Astro::SpaceTrack;
11             use Astro::Coord::ECI;
12             use Astro::Coord::ECI::TLE;
13             use Astro::Coord::ECI::Utils qw{deg2rad rad2deg};
14              
15             # 1600 Pennsylvania Avenue, Washington DC, USA
16             my $your_north_latitude_in_degrees = 38.898748;
17             my $your_east_longitude_in_degrees = -77.037684;
18             my $your_height_above_sea_level_in_meters = 16.68;
19            
20             # Create object representing the observers' location.
21             # Note that the input to geodetic() is latitude north
22             # and longitude west, in RADIANS, and height above sea
23             # level in KILOMETERS.
24            
25             my $loc = Astro::Coord::ECI->geodetic (
26             deg2rad ($your_north_latitude_in_degrees),
27             deg2rad ($your_east_longitude_in_degrees),
28             $your_height_above_sea_level_in_meters/1000);
29            
30             # Get all the Iridium data from CelesTrak; it is direct-
31             # fetched, so no password is needed.
32            
33             my $st = Astro::SpaceTrack->new (direct => 1);
34             my $data = $st->celestrak ('iridium');
35             $data->is_success or die $data->status_line;
36            
37             # Parse the fetched data, yielding Iridium objects.
38            
39             my @sats = Astro::Coord::ECI::TLE->parse ($data->content);
40            
41             # We want flares for the next 2 days. In order to try to
42             # duplicate http://www.heavens-above.com/ as closely as
43             # possible, we throw away daytime flares dimmer than -6,
44             # and nighttime flares dimmer than -1. We also calculate
45             # flares for spares, and assume night is any time the Sun
46             # is below the horizon.
47            
48             my $start = time ();
49             my $finish = $start + 2 * 86400;
50             my @flares;
51             my %mag_limit = (am => -1, day => -6, pm => -1);
52             foreach my $irid (@sats) {
53             $irid->can_flare (1) or next;
54             $irid->set (twilight => 0);
55             foreach my $flare ($irid->flare ($loc, $start, $finish)) {
56             $flare->{magnitude} <= $mag_limit{$flare->{type}}
57             and push @flares, $flare;
58             }
59             }
60             print <
61             Date/Time Satellite Elevation Azimuth Magnitude
62             eod
63             foreach my $flare (sort {$a->{time} <=> $b->{time}} @flares) {
64            
65             # If we wanted to make use of the Iridium object that
66             # produced the flare (e.g. to get apparant equatorial
67             # coordinates) we would need to set the time first.
68             ## $flare->{body}->universal ($flare->{time});
69            
70             # The returned angles are in radians, so we need to
71             # convert back to degrees.
72            
73             printf "%s %-15s %9.1f %9.1f %5.1f\n",
74             scalar localtime $flare->{time},
75             $flare->{body}->get ('name'),
76             rad2deg ($flare->{elevation}),
77             rad2deg ($flare->{azimuth}),
78             $flare->{magnitude};
79             }
80              
81             =head1 NOTICE -- IRIDIUM SUPPORT
82              
83             As of early 2017 the flaring Iridium satellites are being taken out of
84             service, to be replaced by non-flaring Iridium Next satellites. Once the
85             flaring Iridium satellites are out of service, I plan to deprecate and
86             remove all functionality relating to the acquisition of Iridium status.
87              
88             I have not yet decided on the disposition of
89             L. My
90             thought at the moment is that it will be moved to its own package rather
91             than deleted, because the flare functionality may be of historical
92             interest. By the same reasoning, the TLE parsing logic in
93             L will remain able to
94             rebless satellites as Iridium, but the canned status will be 'tumbling'.
95              
96             =head1 DESCRIPTION
97              
98             This class is a subclass of
99             L, representing
100             original-design Iridium satellites. This class will probably B work
101             for the Iridium Next satellites, which are being launched starting early
102             2017.
103              
104             The C<< Astro::Coord::ECI::TLE->parse() >> method makes use of
105             built-in data to determine which satellites to rebless into this class,
106             based on the object's NORAD SATCAT ID. This internal data can be
107             modified using the Astro::Coord::ECI::TLE->status method to correct
108             errors or for historical research. It is also possible to get an Iridium
109             object by calling $tle->rebless (iridium => {status => $status})
110             directly.
111              
112             What this subclass adds is the ability to generate information on
113             Iridium flares (or glints, as they are also called). Members of this
114             class are considered capable of generating flares based on their status,
115             as follows:
116              
117             0 => in service
118             1 => spare (may or may not flare)
119             2 => failed - no predictable flares.
120              
121             CelesTrak-style statuses ('+', 'S', and '-' respectively) are accepted
122             on input. See L method
123             iridium_status for a way to get current Iridium constellation status.
124              
125             =head2 Methods
126              
127             This class adds the following public methods:
128              
129             =over
130              
131             =cut
132              
133             package Astro::Coord::ECI::TLE::Iridium;
134              
135 3     3   532371 use strict;
  3         7  
  3         93  
136 3     3   10 use warnings;
  3         7  
  3         139  
137              
138 3     3   13 use base qw{ Astro::Coord::ECI::TLE };
  3         6  
  3         1684  
139              
140             our $VERSION = '0.133';
141              
142 3     3   99537 use Astro::Coord::ECI::Utils 0.091 qw{:all};
  3         81  
  3         1018  
143 3     3   18 use Carp;
  3         5  
  3         165  
144 3     3   33 use POSIX qw{floor strftime}; # For debugging
  3         3  
  3         29  
145              
146 3     3   201 use constant ATTRIBUTE_KEY => '_sub_TLE_Iridium';
  3         4  
  3         193  
147 3     3   15 use constant DEFAULT_MAX_MIRROR_ANGLE => deg2rad (10);
  3         5  
  3         24  
148 3     3   164 use constant MMAAREA => 1.88 * .86; # Area of MMA, in square meters.
  3         3  
  3         167  
149 3     3   15 use constant MMAPHI => deg2rad (-130); # The MMAs are at an angle of
  3         6  
  3         11  
150             # 40 degrees to the axis, so
151             # we need to lay them back 130
152             # degrees (90 + 40) to make
153             # them "flat".
154 3     3   216 use constant TWOPIOVER3 => TWOPI / 3; # 120 degrees, in radians.
  3         6  
  3         137  
155              
156 3     3   12 use constant BODY_STATUS_IS_OPERATIONAL => 0;
  3         5  
  3         162  
157 3     3   12 use constant BODY_STATUS_IS_SPARE => 1;
  3         3  
  3         112  
158 3     3   11 use constant BODY_STATUS_IS_TUMBLING => 2;
  3         4  
  3         95  
159 3     3   19 use constant BODY_STATUS_IS_DECAYED => 3;
  3         3  
  3         2343  
160              
161             my %mutator = (
162             algorithm => sub {
163             my ( $self, $name, $value ) = @_;
164             my $method = "_flare_$value";
165             croak "Error - Unknown flare algorithm name $value"
166             unless $self->can ($method);
167             $self->{&ATTRIBUTE_KEY}{$name} = $value;
168             $self->{&ATTRIBUTE_KEY}{_algorithm_method} = $method;
169             },
170             status => \&_set_operational_status,
171             );
172             foreach my $key (qw{am day extinction max_mirror_angle pm status zone}) {
173             $mutator{$key} = sub {$_[0]->{&ATTRIBUTE_KEY}{$_[1]} = $_[2]};
174             }
175              
176             my %accessor = ();
177             foreach my $key (keys %mutator) {
178             $accessor{$key} ||= sub {$_[0]->{&ATTRIBUTE_KEY}{$_[1]}}
179             }
180              
181             my %static = ( # static values
182             algorithm => 'fixed',
183             am => 1,
184             day => 1,
185             extinction => 1,
186             intrinsic_magnitude => 7.0,
187             max_mirror_angle => DEFAULT_MAX_MIRROR_ANGLE,
188             pm => 1,
189             status => '',
190             zone => undef, # Offset from GMT, in seconds
191             );
192              
193             my %statatr = ( # for convenience of get() and put().
194             &ATTRIBUTE_KEY => \%static,
195             );
196              
197             __PACKAGE__->alias (iridium => __PACKAGE__);
198              
199             # Pre-compute the transform vectors for each of the three Main
200             # Mission Antennae, so that we do not have to repeatedly compute
201             # the sin and cos of the relevant angles. The transform we are
202             # doing is a rotation of theta radians about the Z axis (theta
203             # being Main Mission Antenna index * 2 * PI / 3) to face the
204             # MMA in the X direction, followed by a rotation of phi radians
205             # about the Y axis (phi being -130 degrees) to lay the MMA back
206             # into the X-Y plane.
207              
208             # Although we are using vector math for the actual operation, the
209             # transform vectors are derived using matrix math, with the
210             # resultant matrix being decomposed into the row vectors we need.
211             # The actual computation is to premultiply the theta transform
212             # matrix by the phi transform matrix:
213              
214             # +- -+ +- -+
215             # | cos(phi) 0 sin(phi) | | cos(theta) -sin(theta) 0 |
216             # | 0 1 0 | X | sin(theta) cos(theta) 0 |
217             # | -sin(phi) 0 cos(phi) | | 0 0 1 |
218             # +- -+ +- -+
219              
220             # +- -+
221             # | cos(theta) * cos(phi) -sin(theta) * cos(phi) sin(phi) |
222             # = | sin(theta) cos(theta) 0 |
223             # | -cos(theta) * sin(phi) sin(theta) * sin(phi) cos(phi) |
224             # +- -+
225              
226             my @transform_vector;
227             { # Begin local symbol block.
228             my $cosphi = cos (MMAPHI);
229             my $sinphi = sin (MMAPHI);
230              
231             foreach my $mma (0 .. 2) {
232             my $theta = $mma * TWOPIOVER3;
233             my $costheta = $theta ? cos ($theta) : 1;
234             my $sintheta = $theta ? sin ($theta) : 0;
235              
236             push @transform_vector,
237             [ [$costheta * $cosphi, - $sintheta * $cosphi, $sinphi],
238             [$sintheta, $costheta, 0],
239             [- $costheta * $sinphi, $sintheta * $sinphi, $cosphi],
240             ];
241             }
242             } # End local symbol block.
243              
244             # We also pre-compute the inverse transforms, to facilitate the
245             # recovery of the virtual image of the illuminating body.
246              
247             my @inverse_transform_vector =
248             map {scalar _invert_matrix_list (@$_)} @transform_vector;
249              
250             =item $tle->after_reblessing (\%attribs);
251              
252             This method supports reblessing into a subclass, with the argument
253             representing attributes that the subclass may wish to set. It is called
254             by rebless() and should not be called by the user.
255              
256             At this level of the inheritance hierarchy, it sets the status of the
257             object from the {status} key of the given hash. If this key is absent,
258             the object is assumed capable of generating flares.
259              
260             =cut
261              
262             {
263             # This seems to me to be a bit of a crock, but I can think of no
264             # other way to prevent the intrinsic_magnitude from being clobbered
265             # as not relevant to the class.
266             my %retain = map { $_ => 1 }
267             qw{ intrinsic_magnitude }, keys %mutator;
268              
269             sub after_reblessing {
270 8     8 1 7263 my ($self, $attrs) = @_;
271 8 100       23 if (defined $attrs) {
272 7         46 $attrs = {%$attrs};
273             } else {
274 1         3 $attrs = {};
275             }
276 8 50       35 HASH_REF eq ref $attrs or croak <<'EOD';
277             Error - The argument of after_reblessing(), if any, must be a hash
278             reference.
279             EOD
280 8         38 foreach my $key (keys %static) {
281 72 100       223 $attrs->{$key} = $static{$key} unless defined $attrs->{$key};
282             }
283 8         33 foreach my $key (keys %$attrs) {
284 107 100       268 delete $attrs->{$key} unless $retain{$key};
285             }
286 8         50 $self->set (%$attrs);
287 8         35 return;
288             }
289             }
290              
291             # see Astro::Coord::ECI->attribute ();
292              
293             sub attribute {
294 0     0 1 0 my ($self, $name) = @_;
295 0 0       0 return exists $accessor{$name} ?
296             __PACKAGE__ :
297             $self->SUPER::attribute ($name);
298             }
299              
300             =item $tle->before_reblessing ()
301              
302             This method supports reblessing into a subclass. It is intended to do
303             any cleanup the old class needs before reblessing into the new class. It
304             is called by rebless(), and should not be called by the user.
305              
306             At this level of the inheritance hierarchy, it removes the status
307             attribute.
308              
309             =cut
310              
311             sub before_reblessing {
312 5     5 1 283 my ($self) = @_;
313 5         34 delete $self->{&ATTRIBUTE_KEY};
314 5         12 return;
315             }
316              
317             =item $tle->can_flare ($spare);
318              
319             This method returns true (in the Perl sense) if the object is capable
320             of producing flares, and false otherwise. If the optional $spare
321             argument is true, spares are considered capable of flaring, otherwise
322             not. If C<$spare> is C<'all'>, then all objects are considered capable
323             of flaring.
324              
325             =cut
326              
327             sub can_flare {
328 8     8 1 1264 my ( $self, $spare ) = @_;
329 8 100 100     43 defined $spare
330             and 'all' eq $spare
331             and return 1;
332 7         21 my $status = $self->get ('status');
333 7   66     56 return !$status || $spare && $status == $self->BODY_STATUS_IS_SPARE;
334             }
335              
336             =item @flares = $tle->flare ($sta, $start, $end);
337              
338             This method returns the list of flares produced by the given Iridium
339             satellite at the given station between the given start time and the
340             given end time. This list may be empty. If called in scalar context you
341             get the number of flares.
342              
343             All arguments are optional, with the defaults being
344              
345             $station = the 'station' attribute
346             $start = time()
347             $end = $start + 1 day
348              
349             Each flare is represented by a reference to an anonymous hash, with
350             elements as follows:
351              
352             angle => Mirror angle, radians
353             appulse => information about the position of the Sun
354             angle => distance from Sun to flare, radians
355             body => reference to the Sun object
356             area => Projected MMA area, square radians
357             azimuth => Azimuth of flare, radians
358             body => Reference to object producing flare
359             center => information about the center of the flare
360             body => location of the center of the flare
361             magnitude => estimated magnitude at the center
362             elevation => Elevation of flare, radians
363             magnitude => Estimated magnitude
364             mma => Flaring mma (0, 1, or 2)
365             range => Range to flare, kilometers
366             specular => True if specular reflection
367             station => reference to the observer's location
368             status => ''
369             type => Type of flare (see notes)
370             time => Time of flare
371             virtual_image => Location of virtual image
372              
373             Note that:
374              
375             * The time of the object passed in the {body} element is not
376             necessarily set to the time of the flare.
377              
378             * The {center}{body} element contains an Astro::Coord::ECI object
379             set to the location of the center of the flare at the given time.
380             The center is defined as the intersection of the plane of the
381             observer's horizon with the line from the virtual image of the
382             illuminating body through the flaring satellite.
383              
384             * The {mma} element indicates which Main Mission Antenna generated
385             the flare. The antennae are numbered clockwise (looking down on the
386             vehicle) from the front, so 0, 1, and 2 correspond to Heavens Above's
387             'Front', 'Right', and 'Left' respectively.
388              
389             * The {specular} element is actually the limb darkening factor if
390             applicable. Otherwise, it is 1 if the reflection is specular, and 0 if
391             not.
392              
393             * The {status} key is reserved for an explanation of why there is no
394             flare. When the hash is generated by the flare() method, this key will
395             always be false (in the Perl sense).
396              
397             * The {type} element contains 'day' if the flare occurs between the
398             beginning of twilight in the morning and the end of twilight in the
399             evening, 'am' if the flare is after midnight but not during the day,
400             and 'pm' if the flare is before midnight but not during the day.
401              
402             * The {virtual_image} element is an Astro::Coord::ECI object
403             representing the location of the virtual image of the illuminator
404             at the time of the flare.
405              
406             Why does this software produce different results than
407             L?
408              
409             The short answer is "I don't know, because I don't know how Heavens
410             Above gets their answers."
411              
412             In a little more detail, there appear to be several things going on:
413              
414             First, there appears to be no standard reference for how to calculate
415             the magnitude of a flare. This module calculates specular reflections
416             as though the sky were opaque, and the flaring Main Mission Antenna
417             were a window through to the virtual image of the Sun. Limb darkening
418             is taken into account, as is atmospheric extinction. Non-specular
419             flares are calculated by a fairly arbitrary equation whose coefficients
420             were fitted to visual flare magnitude estimates collected by Ron Lee
421             and made available on the Web by Randy John as part of his skysat
422             web site at C (missing; see
423             L). Atmospheric extinction
424             is also taken into account for the non-specular flares.
425              
426             Atmospheric extinction is calculated according to the article by Daniel
427             W. Green in the July 1992 issue of "International Comet Quarterly", and
428             available at L. Because
429             Heavens Above does not display flares dimmer than a certain magnitude
430             (-6 for day flares, and apparently 0 for night flares), it may not
431             display a flare that this code predicts. I have no information how
432             Heavens Above calculates magnitudes, but I find that this class gives
433             estimates about a magnitude brighter than Heavens Above at the dim end
434             of the scale.
435              
436             Second, I suspect that the positions and velocities calculated by
437             Astro::Coord::ECI::TLE differ slightly from those used by Heavens Above.
438             I do not know this, because I do not know what positions Heavens Above
439             uses, but there are slight differences among the results of all the
440             orbital propagation models I have looked at. All I can say about the
441             accuracy of Astro::Coord::ECI::TLE is that it duplicates the test data
442             given in "Spacetrack Report Number Three". But small differences are
443             important -- 0.1 degree at the satellite can make the difference between
444             seeing and not seeing a flare, and smaller differences can affect the
445             magnitude predictions, especially if they make the difference between
446             predicting a specular or non-specular flare. Occasionally I find that I
447             get very different results than Heavens Above, even when using orbital
448             data published on that web site.
449              
450             Third, Heavens Above issues predictions on satellites that my source
451             says are spares. I have skipped the spares by default because I do not
452             know that their attitudes are maintained to the requisite precision,
453             though perhaps they would be, to demonstrate that the spares are
454             functional. This software currently uses the Iridium status from
455             CelesTrak (C) (no
456             longer extant), since
457             it represents one-stop shopping, and Dr. Kelso has expressed the intent
458             to check with Iridium Satellite LLC monthly for status. Mike McCants'
459             "Status of Iridium Payloads" at
460             C (no longer
461             maintained) noted that flares may be unreliable for spares, so can_flare
462             () returns false for them. If this is not what you want, call can_flare
463             with a true value (e.g. C).
464              
465             Fourth, the Heavens Above definition of 'daytime' differs from mine.
466             Heavens Above does not document what their definition is, at least
467             not that I have found. My definition of daytime includes twilight,
468             which by default means the center of the Sun is less than 6 degrees
469             below the horizon. I know that, using that definition, this software
470             classifies some flares as daytime flares which Heavens Above classifies
471             as nighttime flares. It appears to me that Heavens Above considers it
472             night whenever the Sun is below the horizon.
473              
474             Fifth, the orbital elements used to make the prediction can differ.
475             I have occasionally seen Heavens Above using elements a day old,
476             versus the ones available from Space Track, and seen this difference
477             make a difference of six or eight seconds in the time of the flare.
478              
479             Sixth, this method takes no account of the decrease in magnitude that
480             would result from the Sun being extremely close to the horizon as seen
481             from the flaring satellite. I do not know whether Heavens Above does
482             this or not, but I have seen an instance where this code predicted a
483             flare but Heavens Above did not, where the observed flare was much
484             dimmer than this code predicted, and reddened. Subsequent calculations
485             put the Sun 0.1 degrees above the horizon as seen from the satellite.
486              
487             B that the algorithm used to calculate flares does not work at
488             latitudes beyond 85 degrees north or south, nor does it work for any
489             location that is not fixed to the Earth's surface. This may be fixed in
490             a future release. The chances of it being fixed in a future release will
491             be enhanced if someone claims to actually need it. This someone will be
492             invited to help test the new code.
493              
494             B that as of version 0.002_01 of this class, the 'backdate'
495             attribute determines whether a set of orbital elements can be used for
496             computations of flares before the epoch of the elements. If 'backdate'
497             is false and the start time passed to flare() is earlier than the epoch,
498             the start time is silently moved forward to the epoch. The initial
499             version of this functionality raised an exception if this adjustment
500             placed the start time after the end time, but as of version 0.003_01 of
501             this class, you simply get no flares if this happens.
502              
503             =cut
504              
505 3     3   24 use constant DTFMT => '%d-%b-%Y %H:%M:%S (GMT)';
  3         4  
  3         174  
506 3     3   11 use constant DAY_TOLERANCE => deg2rad (2); # Distance Sun moves in 8 minutes.
  3         4  
  3         16  
507 3     3   128 use constant AM_START_LIMIT => 86400 - 480; # 8 minutes before midnight.
  3         5  
  3         165  
508 3     3   14 use constant AM_END_LIMIT => 43200 + 480; # 8 minutes after noon.
  3         5  
  3         142  
509 3     3   11 use constant PM_START_LIMIT => 43200 - 480; # 8 minutes before noon.
  3         5  
  3         121  
510 3     3   11 use constant PM_END_LIMIT => 480; # 8 minutes after midnight.
  3         3  
  3         6756  
511              
512             sub flare {
513 2     2 1 109 my ( $self, @args ) = __default_station( @_ );
514 2         79 my $method = $self->{&ATTRIBUTE_KEY}{_algorithm_method};
515 2         8 return $self->$method (@args);
516             }
517              
518             # Called as $self->$method()
519             sub _flare_fixed { ## no critic (ProhibitUnusedPrivateSubroutines)
520 2     2   4 my $self = shift;
521 2         2 my $station = shift;
522             {
523 2         35 local $@;
  2         4  
524 2 50       7 __instance( $station, 'Astro::Coord::ECI' ) or croak <
525             Error - The station must be a subclass of Astro::Coord::ECI.
526             eod
527             }
528 2   33     39 my $start = shift || time ();
529 2   33     4 my $end = shift || $start + SECSPERDAY;
530 2 50       5 $end >= $start or croak <
531             Error - End time must be after start time.
532             eod
533              
534 2         11 $start = $self->max_effective_date($start);
535 2 50       28 $start > $end and return;
536              
537 2         3 my @flares;
538 2         4 my $illum = $self->get ('illum');
539 2         12 my $horizon = $self->get ('horizon');
540 2         31 my $twilight = $self->get ('twilight');
541 2         25 my $sun = $self->get( 'sun' );
542              
543 2         66 my %want = (
544             am => $self->get ('am'),
545             day => $self->get ('day'),
546             pm => $self->get ('pm'),
547             );
548 2   33     25 my $check_time = !($want{am} && $want{day} && $want{pm});
549 2         4 my $day_limit = $twilight - DAY_TOLERANCE;
550 2         3 my $night_limit = $twilight + DAY_TOLERANCE;
551 2         5 my $illum_tolerance = deg2rad (15);
552              
553             # We assume our observing location is fixed on the surface of the
554             # Earth, and take advantage of the fact that an Iridium orbit is
555             # very nearly polar. We use these to calculate the intervals
556             # between successive passes through the observer's latitude, since
557             # the satellite is visible then if ever.
558              
559             ### CAVEAT: The typical orbital inclination of an Iridium satellite
560             ### is about 85 degrees. So this algorithm only works between
561             ### latitudes 85 north and 85 south.
562              
563 2         15 my $satlat = ($self->universal ($start)->geodetic ())[0];
564 2         26 my $zdot = ($self->eci)[5];
565 2         35 my $stalat = ($station->geodetic ())[0];
566 2         42 my $period = $self->period;
567 2         28 my $angular_velocity = TWOPI / $period;
568 2 0       10 my ($time, $asc) = ($zdot > 0 ?
    50          
    50          
569             $satlat < $stalat ? (($stalat - $satlat) / $angular_velocity, 1) :
570             ((PI - $stalat - $satlat) / $angular_velocity, 0) :
571             $satlat < $stalat ?
572             ((PI + $stalat + $satlat) / $angular_velocity, 1) :
573             (($satlat - $stalat) / $angular_velocity, 0));
574 2         4 $time += $start;
575 2         6 my @deltas = (
576             (PIOVER2 + $stalat) * 2 / $angular_velocity,
577             (PIOVER2 - $stalat) * 2 / $angular_velocity,
578             );
579              
580             # At this point the time represents (approximately, because our
581             # calculated period is a little scant) a moment when the
582             # satellite crosses the observer's latitude.
583              
584             # Pick up a copy of our max mirror angle so we don't have to call
585             # get () repeatedly.
586              
587 2         5 my $max_mirror_angle = $self->get ('max_mirror_angle');
588              
589             # While this time is less than our end time ...
590              
591 2         6 while ($time < $end) {
592              
593             # Calculate location of satellite.
594              
595 66         190 $self->universal ($time);
596 66         12444 my ($satlat, $satlon, $satalt) = $self->geodetic;
597              
598             # Correct time to put satellite at same latitude as station.
599              
600 66 100       654 $time += ($asc ? $stalat - $satlat : $satlat - $stalat)
601             / $angular_velocity;
602 66         119 ($satlat, $satlon, $satalt) = $self->universal ($time)->geodetic;
603              
604             # Calculate whether satellite is above horizon.
605              
606 66         619 my ( undef, $elev, $rng ) = $station->azel_offset( $self, 0 );
607 66 100       3322 $elev > $horizon or next;
608              
609             # Check whether we are interested in this potential flare, based
610             # on whether it might be during the day, or am, or pm.
611              
612 4 50 0     43 $check_time and (eval {
613 0         0 my $sun_elev = ($station->azel ($sun->universal ($time)))[1];
614 0 0 0     0 ($want{day} && $sun_elev > $day_limit) and return 1;
615 0 0 0     0 (($want{am} || $want{pm}) && $sun_elev < $night_limit) or return 0;
      0        
616 0         0 my @local_time = $self->_time_in_zone( $time );
617 0         0 my $time_of_day = ($local_time[2] * 60 + $local_time[1]) * 60
618             + $local_time[0];
619 0 0 0     0 ($want{am} && ($time_of_day > AM_START_LIMIT ||
      0        
620             $time_of_day < AM_END_LIMIT)) and return 1;
621 0 0 0     0 ($want{pm} && ($time_of_day > PM_START_LIMIT ||
      0        
622             $time_of_day < PM_END_LIMIT)) and return 1;
623 0         0 0;
624             } || next);
625              
626             # Calculate whether the satellite is illuminated.
627              
628             ## my $lit = ($self->azel ($illum->universal ($time)))[1] >=
629 4 50       18 ($self->azel ($illum->universal ($time)))[1] >=
630             $self->dip () - $illum_tolerance or next;
631              
632             # For our screening to work we need to know the maximum angular
633             # distance we can travel in 30 seconds. This is the arc tangent
634             # of the velocity over the range
635              
636 4         157 my (undef, undef, undef, $xdot, $ydot, $zdot) = $self->eci ();
637 4         78 my $max_angle = atan2 (
638             sqrt ($xdot * $xdot + $ydot * $ydot + $zdot * $zdot), $rng)
639             * 30;
640 4         7 $max_angle += $max_mirror_angle; # Take into account near misses.
641              
642             # Iterate over a period of 16 minutes centered on our current
643             # time, calculating the location of the reflection of the sun
644             # versus the satellite, as seen by the observer.
645              
646 4         8 my @flare_potential = ([], [], []); # Flare-potential data by MMA.
647 4         9 foreach my $deltat (-8 .. 8) {
648 68         242 my $time = $deltat * 60 + $time;
649              
650             # See if the satellite is illuminated at this time.
651             # TODO This code may need some slop; specifically we might want to
652             # assume the Sun is higher than it actually is by $max_angle.
653              
654 68 100       212 ($self->universal ($time)->azel ($illum->universal ($time)))[1] >=
655             $self->dip () or next;
656              
657             # Transform the relevant coordinates into a coordinate system
658             # in which the axis of the satellite is along the Z axis (with
659             # the Earth in the negative Z direction) and the direction of
660             # motion (and hence one of the Main Mission Antennae) is along
661             # the X axis. The method returns Math::VectorReal objects
662             # corresponding to all inputs, including '$self'.
663              
664 64         2449 my ( $illum_vector, $station_vector ) =
665             $self->_flare_transform_coords_list (
666             $illum, $station->universal( $time ) );
667              
668             # Now we do a second iteration over the Main Mission Antennae,
669             # checking for the position of the Sun's reflection.
670              
671 64         151 foreach my $mma (0 .. 2) {
672              
673             # We clone the sun and the station, and then calculate the angle
674             # between the satellite and the reflection of the Sun, as seen by
675             # the observer. We skip to the next antenna if no reflection is
676             # generated.
677              
678 192         360 my $illum_vector = [@$illum_vector];
679 192         321 my $station_vector = [@$station_vector];
680             next unless defined (
681 192 100       314 my $angle = _flare_calculate_angle_list(
682             $mma, $illum_vector, $station_vector ) );
683              
684             # Save the angle, time, and cloned station for subsequent
685             # analysis.
686              
687 64         508 push @{$flare_potential[$mma]},
  64         213  
688             [$angle, $time, $illum_vector, $station_vector];
689              
690             # End of iterating over Main Mission Antennae.
691              
692             }
693              
694             # End of iterating over 16 minute period centered on current
695             # time.
696              
697             }
698              
699             # Now iterate over each MMA to calculate its flare, if any.
700              
701             MMA_LOOP:
702 4         11 foreach my $mma (0 .. 2) {
703              
704             # Find the best possibility for a flare. If none, or the angle is
705             # more than the max possible, ignore this antenna.
706              
707 12 100       43 next if @{$flare_potential[$mma]} < 2;
  12         86  
708 8         27 my @flare_approx;
709 8         12 do { # Begin local symbol block
710 8         10 my $inx = 0;
711 8         17 my $angle = $flare_potential[$mma][$inx][0];
712 8         11 foreach (1 .. @{$flare_potential[$mma]} - 1) {
  8         17  
713 56 100       108 next unless $flare_potential[$mma][$_][0] < $angle;
714 10         12 $inx = $_;
715 10         19 $angle = $flare_potential[$mma][$_][0];
716             }
717 8 100       23 next if $angle > $max_angle;
718              
719             # If the best potential is at the beginning or end of the list,
720             # calculate the entrance (or exit) of the flare so we have a
721             # starting point for out approximations. Note that we used to
722             # just abandon the calculation in these cases.
723              
724 6 50       17 if ($inx == 0) {
    100          
725 0         0 unshift @{$flare_potential[$mma]},
  0         0  
726             $self->_flare_entrance ($illum, $station, $mma,
727             $flare_potential[$mma][$inx][1] - 60,
728             $flare_potential[$mma][$inx][1]);
729 0         0 $inx++;
730 6         16 } elsif ($inx == @{$flare_potential[$mma]} - 1) {
731 2         2 push @{$flare_potential[$mma]},
  2         10  
732             $self->_flare_entrance ($illum, $station, $mma,
733             $flare_potential[$mma][$inx][1] + 60,
734             $flare_potential[$mma][$inx][1]);
735             }
736 6         23 @flare_approx = ($flare_potential[$mma][$inx - 1],
737             $flare_potential[$mma][$inx + 1]);
738             }; # End local symbol block;
739              
740             # Use successive approximation to find the time of minimum
741             # angle. We can not use a linear split-the-difference search,
742             # because the behavior is too far from linear. So we fudge by
743             # taking the second- and third-closest angles found, and working
744             # inward from them. We also use a weighted average of the two
745             # previously-used times to prevent converging so fast we jump
746             # over the point we are trying to find.
747              
748 6         20 while (abs ($flare_approx[1][1] - $flare_approx[0][1]) > .1) {
749              
750             # Calculate the next time to try as a weighted average of the
751             # previous two approximations, with the worse approximation
752             # having three times the weight of the better one. This prevents
753             # us from converging so fast we miss the true minimum. Yes, this
754             # is ad-hocery. I tried weighting the 'wrong' flare twice as
755             # much, but still missed the maximum sometimes. This was more
756             # obvious on daytime flares, where if you miss the peak the
757             # flare is probably not specular.
758              
759             ## my $time = ($flare_approx[1][1] * 2 + $flare_approx[0][1]) / 3;
760 148         235 my $time = ($flare_approx[1][1] * 3 + $flare_approx[0][1]) / 4;
761             #### my $time = ($flare_approx[1][1] * 6 + $flare_approx[0][1]) / 7;
762              
763             # Transform the relevant coordinates into a coordinate system
764             # in which the axis of the satellite is along the Z axis (with
765             # the Earth in the negative Z direction) and the direction of
766             # motion (and hence one of the Main Mission Antennae) is along
767             # the X axis.
768              
769 148         358 my ( $illum_vector, $station_vector ) =
770             $self->universal( $time )->
771             _flare_transform_coords_list(
772             $illum->universal( $time ),
773             $station->universal( $time ) );
774              
775             # Calculate the angle between the satellite and the reflection
776             # of the Sun, as seen by the observer.
777              
778 148         313 my $angle = _flare_calculate_angle_list(
779             $mma, $illum_vector, $station_vector );
780 148 50       1001 defined $angle or next MMA_LOOP;
781              
782             # Store the data in our approximation list, in order by angle.
783              
784 148         178 pop @flare_approx;
785 148         634 splice @flare_approx, $angle >= $flare_approx[0][0], 0,
786             [$angle, $time, $illum_vector, $station_vector];
787              
788             # End of successive approximation of time of minimum angle.
789              
790             }
791              
792             # Pull the (potential) flare data off the approximation list.
793              
794             my ($angle, $time, $illum_vector, $station_vector) =
795 6         13 @{$flare_approx[0]};
  6         15  
796              
797             # Skip it if the mirror angle is greater than the max.
798              
799 6 100       17 next if $angle > $max_mirror_angle;
800              
801             # All our approximations may have left us with a satellite which
802             # is not quite lit. This happened with Iridium 32 (OID 24945) on
803             # Feb 03 2007 at 07:45:19 PM. So we check for illumination one
804             # last time.
805             # TODO: this calculation should be performed not on the position
806             # of the Sun, but on the actual point on the Sun which is
807             # reflected to the observer. It looks now like it needs to be done
808             # in _flare_calculate_angle_list().
809              
810 4 50       12 ($self->universal ($time)->azel ($illum->universal ($time)))[1] >=
811             $self->dip () or next;
812              
813             # Calculate all the flare data.
814              
815 4         141 my $flare = $self->_flare_char_list ($station, $mma, $angle,
816             $time, $illum_vector, $station_vector);
817              
818             # Stash the data.
819              
820             push @flares, $flare
821 4 50 33     44 if !$flare->{status} && $want{$flare->{type}};
822              
823             # End of iteration over each MMA to calculate its flare.
824              
825             }
826              
827             # Compute the next approxiate crossing of the observer's
828             # latitude.
829              
830             } continue {
831 66         128 $time += $deltas[$asc];
832 66         134 $asc = 1 - $asc;
833             }
834              
835 2         42 return @flares;
836              
837             }
838              
839             # [$angle, $time, $illum_vector, $station_vector] =
840             # $self->_flare_entrance ($illum, $station, $mma, $start,
841             # $end);
842              
843             # Given that a flare is in progress at the end time and not at
844             # the start time, computes the start of the flare. Can be used
845             # for exit by reversing the times.
846              
847             sub _flare_entrance {
848 2     2   6 my ($self, $illum, $station, $mma, $start, $end) = @_;
849 2         4 my $output;
850             # my $time = find_first_true (
851             find_first_true (
852             $start, $end,
853             sub {
854 12     12   148 $self->universal ($_[0]);
855 12         2242 my ( $illum_vector, $station_vector ) =
856             $self->_flare_transform_coords_list(
857             $illum->universal( $_[0] ),
858             $station->universal( $_[0] ) );
859 12 100       26 if ( defined ( my $angle = _flare_calculate_angle_list(
860             $mma, $illum_vector, $station_vector ) ) ) {
861 6         45 $output = [$angle, $_[0], $illum_vector, $station_vector];
862 6         22 1;
863             } else {
864 6         14 0;
865             }
866 2         18 });
867 2   0     26 $output ||= do { # Can happen if end is entrance.
      33        
868             $self->universal ($end);
869             my ( $illum_vector, $station_vector ) =
870             $self->_flare_transform_coords_list(
871             $illum->universal( $end ),
872             $station->universal( $end) );
873             my $angle = _flare_calculate_angle_list(
874             $mma, $illum_vector, $station_vector );
875             defined $angle ?
876             [$angle, $end, $illum_vector, $station_vector] :
877             undef;
878             } || confess <
879             Programming error - No entrance found by _flare_entrance.
880             @{[join ' - ', grep {$_} map {$self->get ($_)} qw{name id}]}
881             \$mma = $mma
882             \$start = $start = @{[scalar localtime $start]}
883             \$end = $end = @{[scalar localtime $end]}
884             eod
885 2         6 return $output;
886             }
887              
888             # @vectors = $tle->_flare_transform_coords_list( $eci ....)
889             #
890             # This private method transforms the coordinates of the $tle
891             # object and all $eci objects passed in, so that the $tle
892             # object is at the origin of a coordinate system whose
893             # X axis is along the velocity vector of the $tle object, the
894             # Y axis is perpendicular to the plane of the orbit, and the
895             # Z axis is perpendicular to both of these, and therefore
896             # nominally "up and down", with the center of the Earth in the
897             # - Z direction. The objects are not modified, instead
898             # list references (containing the position vectors)
899             # corresponding to all arguments (except the invocant) are
900             # returned.
901              
902             sub _flare_transform_coords_list {
903 228     228   72570 my ( $self, @args ) = @_;
904 228         471 my @ref = $self->eci();
905 228         4631 my $X = vector_unitize( [@ref[3 .. 5]] );
906 228         5401 my $Y = vector_cross_product( vector_unitize( [ @ref[0 .. 2]] ), $X );
907 228         5907 my $Z = vector_cross_product( $X, $Y );
908 228         1567 my @coord = ($X, $Y, $Z);
909 228         292 my @rslt;
910 228         345 foreach my $loc (@args) {
911 456         3919 my @eci = $loc->eci();
912 456         27368 my @pos = map { $eci[$_] - $ref[$_] } 0 .. 2;
  1368         2107  
913             push @rslt, [
914 456         647 map { vector_dot_product( \@pos, $coord[$_] ) } 0 .. 2
  1368         12789  
915             ];
916             }
917 228         3689 return @rslt;
918             }
919              
920             # @original = $tle->_flare_transform_coords_inverse_list( @vectors )
921             #
922             # This private method is the inverse (almost) of
923             # _flare_transform_coords_list(). The weasel word is because it
924             # returns array references representing the ECI position vectors,
925             # rather than Astro::Coord::ECI objects.
926              
927             sub _flare_transform_coords_inverse_list {
928 8     8   15 my ( $self, @args ) = @_;
929 8         38 my @ref = $self->eci();
930 8         164 my $X = vector_unitize( [@ref[3 .. 5]] );
931 8         181 my $Y = vector_cross_product( vector_unitize( [ @ref[0 .. 2]] ), $X );
932 8         216 my $Z = vector_cross_product( $X, $Y );
933 8         60 my @coord = _invert_matrix_list($X, $Y, $Z);
934 8         12 my @rslt;
935 8         31 foreach my $loc (@args) {
936             push @rslt, [
937 8         20 map { vector_dot_product( $loc, $coord[$_] ) + $ref[$_] } 0 .. 2
  24         222  
938             ];
939             }
940 8         119 return @rslt;
941             }
942              
943             # $angle = _flare_calculate_angle_list($mma, $illum, $station)
944              
945             # This private subroutine calculates the angle between the
946             # satellite and the reflection of the Sun in the given Main
947             # Mission Antenna as seen from the observing station. $illum and
948             # $station are list reverences generated by
949             # _flare_transform_coords_list(). The satellite position is not
950             # needed because in this coordinate system it is [0, 0, 0]
951              
952             # A reflection can only occur if both the Sun and the observer
953             # are in front of the antenna (i.e. have positive Z coordinates
954             # after transforming everything so that the plane of the MMA is
955             # the X-Y axis). If there is no reflection, undef is returned.
956              
957             sub _flare_calculate_angle_list {
958 364     364   601 my ( $mma, $illum, $station ) = @_;
959              
960             # Rotate the objects so that the Main Mission Antenna of interest
961             # lies in the X-Y plane, facing in the +Z direction.
962              
963 364         450 my @eci;
964 364         568 foreach my $inx (0 .. 2) {
965 1092         10397 $eci[$inx] = vector_dot_product ($illum, $transform_vector[$mma][$inx])
966             }
967 364 100       5096 return unless $eci[2] > 0;
968 274         371 $eci[2] = - $eci[2];
969 274         478 $illum = [$eci[0], $eci[1], $eci[2]];
970 274         392 foreach my $inx (0 .. 2) {
971 822         7401 $eci[$inx] = vector_dot_product ($station, $transform_vector[$mma][$inx])
972             }
973 274 100       3636 return unless $eci[2] > 0;
974 222         352 $station = [$eci[0], $eci[1], $eci[2]];
975              
976             # Now calculate the angle between the illumination source and the
977             # observer as seen by the observer.
978              
979 222         495 return _list_angle( $station, $illum, [ 0, 0, 0 ] );
980             }
981              
982             # $hash_ref = $iridium->_flare_char_list (...)
983             #
984             # Calculate the characteristics of the flare of the given body.
985             # The arguments are as follows:
986             #
987             # $station => the object representing the observer.
988             # $mma => the flaring Main Mission Antenna (0 .. 2).
989             # $angle => the previously-calculated mirror angle.
990             # $time => the time of the flare.
991             # $illum_vector => the previously-calculated vector to the
992             # illuminating body (satellite = [0, 0, 0]).
993             # $station_vector => the previously-calculated vector to the
994             # observer (satellite = [0, 0, 0])
995              
996             sub _flare_char_list {
997              
998 8     8   20 my ($self, $station, $mma, $angle, $time, $illum_vector, $station_vector) = @_;
999              
1000             # Skip it if the flare is not above the horizon.
1001              
1002 8         18 my ($azim, $elev) = $station->azel ($self->universal ($time));
1003 8         554 my $horizon = $self->get ('horizon');
1004 8 50       119 if ($elev < $horizon) {
1005 0         0 return _make_status (sprintf (
1006             'Satellite %.2f degrees below horizon', rad2deg ($horizon - $elev)));
1007             }
1008              
1009             # Retrieve the illuminating body information.
1010              
1011 8         18 my $illum = $self->get ('illum');
1012 8         70 my $illum_radius = $illum->get ('diameter') / 2;
1013              
1014             # Retrieve missing station information.
1015              
1016 8         149 my $height = ($station->geodetic)[2];
1017              
1018             # And any odds and ends we might need.
1019              
1020 8         172 my $sun = $self->get( 'sun' );
1021 8         148 my $twilight = $self->get ('twilight');
1022 8         102 my $atm_extinct = $self->get ('extinction');
1023              
1024             # Calculate the range to the satellite, and to the reflection of
1025             # the Sun, from the observer.
1026              
1027 8         20 my $sat_range = vector_magnitude ($station_vector);
1028 8         107 my $illum_range = vector_magnitude ([
1029             $illum_vector->[0] - $station_vector->[0],
1030             $illum_vector->[1] - $station_vector->[1],
1031             $illum_vector->[2] - $station_vector->[2],
1032             ]);
1033              
1034             # Calculate the projected area of the MMA of interest, in square
1035             # radians.
1036              
1037 8         90 my $sat_area = abs ($station_vector->[2]) / $sat_range * MMAAREA
1038             / 1e6 / ($sat_range * $sat_range);
1039              
1040             # As a side effect, we calculate omega, the angle from the center
1041             # of the Sun to the edge, as seen by the observer.
1042              
1043 8         11 my $illum_omega = $illum_radius / $illum_range;
1044 8         15 my $illum_area = PI * $illum_omega * $illum_omega;
1045              
1046             # Calculate the magnitude of the illuminating body at the point
1047             # reflected by the Main Mission Antenna.
1048              
1049 8         24 my ($point_magnitude, $limb_darkening, $central_magnitude) =
1050             $illum->magnitude ($angle, $illum_omega);
1051              
1052             # Calculate the reduction in magnitude due to the fact that the
1053             # projected area of the main mission antenna is smaller than the
1054             # projected area of the Sun.
1055              
1056 8         341 my $area_correction =
1057             intensity_to_magnitude ($sat_area / $illum_area);
1058              
1059             # Calculate the dead-center flare magnitude as the central
1060             # magnitude of the Sun plus the delta caused by the fact that the
1061             # projected area of the main mission antenna is smaller than the
1062             # area of the sun.
1063              
1064 8         33 my $central_mag = $central_magnitude + $area_correction;
1065              
1066             # And for the test case, I got -8.0. Amazing.
1067              
1068             # Calculate the atmospheric extinction of the flare.
1069              
1070 8 50       23 my $extinction = $atm_extinct ?
1071             atmospheric_extinction ($elev, $height) : 0;
1072              
1073             # The following off-axis magnitude calculation is the result of
1074             # normalizing Ron Lee's magnitude data (made available by Randy
1075             # John in various forms at http://home.comcast.net/~skysat/ ) for
1076             # a projected Main Mission Antenna area of 1e-12 square radians
1077             # and zero atmospheric extinction. A logarithmic correlation was
1078             # suggested by Rob Matson (author of IRIDFLAR) at
1079             # http://www.satobs.org/seesat/Apr-1998/0175.html . I tried a
1080             # couple other possibilities, but ended up most satisfied (or
1081             # least unsatisfied) with a linear regression on
1082             # ln (8 - magnitude), with the 8 picked because it was the
1083             # maximum possible magnitude.
1084             # Maybe I could have done better with a larger arbitrary
1085             # constant, since the data sags a bit in the middle versus the
1086             # regression line, but there is a fair amount of scatter anyway.
1087              
1088             # All this means that the calculation is
1089             # mag = 8 - exp (-5.1306 * angle_in_radians + 2.4128) +
1090             # intensity_to_magnitude (area / 1e-12) +
1091             # atmospheric_refraction (elevation, height)
1092             # The R-squared for this is .563.
1093              
1094             # There are several possible sources of error:
1095              
1096             # * My mirror angle is defined slightly different than Randy
1097             # John's. Mine is the angle between the satellite and the
1098             # virtual image of the Sun as seen by the observer. His is the
1099             # angle between the actual reflection and a central specular
1100             # reflection, which I take to be 180 degrees minus the angle
1101             # between the Sun and the observer as seen from the satellite.
1102             # This makes my angle slightly smaller than his, the difference
1103             # being the angle between the observer and the satellite as
1104             # seen from the Sun, something on the order of 0.01 degrees or
1105             # less. This is probably not directly a source of error (since
1106             # I used my own calculation of mirror angle), but needs to be
1107             # taken into account when evaluating the other sources of
1108             # error.
1109              
1110             # * My calculated mirror angles are different than Randy John's
1111             # by an amount which is typically about a tenth of a degree,
1112             # but which can be on the order of a couple degrees. Since I
1113             # do not currently have any Wintel hardware, I can not tell
1114             # how much of this is difference in calculation and how much
1115             # is difference in orbital elements.
1116              
1117             # * The error between the visually estimated magnitudes and the
1118             # actual magnitudes is unknown. In theory, visual estimates are
1119             # fairly good given a number of nearby comparison stars of
1120             # magnitudes near the body to be estimated. How the ephemeral
1121             # nature of the flares affects the accuracy of this process is
1122             # not known to me. What happens with magnitudes brighter than
1123             # about -1, where there are no comparison objects available is
1124             # also unknown, as is the actual methodology of making the
1125             # estimates.
1126              
1127             # Note to me: the current estimate was done with
1128             # perl process.pl -quiet -specular -radians -constant 8
1129             # The previous was done with
1130             # perl process.pl -specular -radians, but the correlation was
1131             # done in a spreadsheet (normalized2.ods)
1132              
1133 3     3   20 use constant OFF_AXIS_FACTOR => -5.1306; # Was -3.9246
  3         12  
  3         178  
1134 3     3   12 use constant OFF_AXIS_CONST => 2.4128; # Was 2.60
  3         3  
  3         138  
1135 3     3   17 use constant OFF_AXIS_STD_AREA => 1e-12;
  3         5  
  3         113  
1136 3     3   9 use constant OFF_BASE_MAG => 8; # Was 10
  3         4  
  3         8081  
1137              
1138 8         86 my $off_axis_mag = OFF_BASE_MAG -
1139             exp ($angle * OFF_AXIS_FACTOR + OFF_AXIS_CONST) +
1140             intensity_to_magnitude ($sat_area / OFF_AXIS_STD_AREA) +
1141             $extinction;
1142 8 50       26 my $flare_mag = $limb_darkening > 0 ? do {
1143 0         0 my $specular_mag = $point_magnitude + $area_correction +
1144             $extinction;
1145 0         0 min ($specular_mag, $off_axis_mag) } : $off_axis_mag;
1146              
1147             # Compute the flare type (am, day, or pm)
1148              
1149 8         22 my $sun_elev = ($station->azel ($sun->universal ($time)))[1];
1150 8 50       1651 my $flare_type = $sun_elev >= $twilight ? 'day' :
    100          
1151             ( $self->_time_in_zone( $time ) )[2] > 12 ? 'pm' : 'am';
1152              
1153             # Compute the angle from the Sun to the flare.
1154              
1155 8         25 my $sun_angle = $station->angle ($sun, $self);
1156              
1157             # Wikipedia gives the following analytical expression for the
1158             # inversion of a 3 x 3 matrix at
1159             # http://en.wikipedia.org/wiki/Matrix_inversion
1160             # Given
1161             #
1162             # +- -+
1163             # | a b c |
1164             # A = | d e f |
1165             # | g h i |
1166             # + -+
1167             #
1168             # the inverse is
1169             #
1170             # +- -+
1171             # 1 | ei-fh ch-bi bf-ce |
1172             # A'= --- | fg-di ai-cg cd-af |
1173             # |A| | dh-eg bg-ah ae-bd |
1174             # +- -+
1175             #
1176             # where the determinant |A| = a(ei - fh) - b(di - fg) + c(dh - eg)
1177             # and the matrix is singuar if |A| == 0
1178             #
1179             # I can then undo the rotations by premultiplying the inverse
1180             # matrices in the reverse order, and add back the location of
1181             # the Iridium satellite to get the location of the virtual image
1182             # in ECI coordinates.
1183              
1184             # Compute the location of the virtual image of the illuminator,
1185             # in ECI coordinates:
1186              
1187 8         970 my ($virtual_image, $image_vector) = do {
1188              
1189             # Recover the position of the virtual image of the illuminator. We
1190             # calculated this before, but never saved it.
1191              
1192             my $image_vector = [
1193 8         33 map { vector_dot_product( $illum_vector,
  24         213  
1194             $transform_vector[$mma][$_] ) } 0 .. 2
1195             ];
1196 8         103 $image_vector->[2] = - $image_vector->[2];
1197              
1198             # Undo the rotations that placed the MMA of interest in the X-Y
1199             # plane.
1200              
1201             $image_vector = [
1202 8         12 map { vector_dot_product( $image_vector,
  24         219  
1203             $inverse_transform_vector[$mma][$_] ) } 0 .. 2
1204             ];
1205              
1206             # Transform from satellite-local coordinates to ECI coordinates.
1207              
1208 8         108 ( $image_vector ) = $self->_flare_transform_coords_inverse_list(
1209             $image_vector );
1210              
1211             # Manufacture an object representing the virtual image, and a vector
1212             # represnting same for use in calculating the flare sub-point.
1213              
1214 8         23 ( Astro::Coord::ECI->universal( $time )->eci( @{ $image_vector } ),
  8         600  
1215             $image_vector );
1216             };
1217              
1218             # Compute the distance to the flare center.
1219              
1220             # For the calculation, consider the Earth flat, and consider the
1221             # center of the flare at the given time to be the point where the
1222             # line from the virtual image of the Sun through the satellite
1223             # intersects the plane.
1224              
1225             # Per http://mathforum.org/library/drmath/view/55094.html
1226             # you can consider the plane defined as a point A (the observer)
1227             # and a normal vector N (toward the zenith), and the line
1228             # to be defined by point P (Iridium) and vector V (= Q - P, with
1229             # Q being the virtual image of the Sun). Then the intersection X
1230             # is given by
1231             #
1232             # (A - P) dot N
1233             # X = P + ------------- (Q - P)
1234             # (Q - P) dot N
1235             #
1236             # I have A (observer), P (Iridium), and Q (virtual image of Sun),
1237             # so all I need is N. This I can get by rotating a unit vector by
1238             # the longitude, then by the geodetic latitude. The distance is
1239             # | X - A |, and the direction is the azimuth of X from A, which
1240             # the azel() method will give me.
1241             #
1242              
1243 8         276 my $sub_vector = do {
1244 8         19 my $a = [($station->eci ())[0 .. 2]];
1245 8         290 my $p = [($self->eci ())[0 .. 2]];
1246 8         125 my $q = $image_vector;
1247 8         17 my @n = $station->geodetic ();
1248 8         155 $n[2] += 1;
1249 8         21 my $n = [(Astro::Coord::ECI->geodetic (@n)
1250             ->universal ($time)->eci ())[0 .. 2]];
1251 8         2423 $n = [$n->[0] - $a->[0], $n->[1] - $a->[1], $n->[2] - $a->[2]];
1252 8         20 my $q_p = [$q->[0] - $p->[0], $q->[1] - $p->[1], $q->[2] - $p->[2]];
1253 8         21 my $k = vector_dot_product ([$a->[0] - $p->[0], $a->[1] - $p->[1], $a->[2] - $p->[2]], $n) /
1254             vector_dot_product ($q_p, $n);
1255 8         216 [$q_p->[0] * $k + $p->[0], $q_p->[1] * $k + $p->[1], $q_p->[2] * $k + $p->[2]];
1256             };
1257 8         23 my $sub_point = Astro::Coord::ECI->new(
1258             station => $station )->universal( $time )->eci( @$sub_vector );
1259              
1260             # Stash the data.
1261              
1262 8         1141 my %rslt = (
1263             angle => $angle, # Mirror angle, radians
1264             appulse => { # Relationship of flare to Sun
1265             angle => $sun_angle, # Angle from flare to Sun
1266             body => $sun, # Reference to Sun
1267             },
1268             area => $sat_area, # Projected MMA area, square radians
1269             azimuth => $azim, # Azimuth of flare
1270             body => $self, # Reference to flaring body
1271             center => { # Information on flare center
1272             body => $sub_point, # Location of center
1273             magnitude => $central_mag, # Predicted magnitude at center
1274             },
1275             elevation => $elev, # Elevation of flare
1276             magnitude => $flare_mag, # Estimated magnitude
1277             mma => $mma, # Flaring mma (0, 1, or 2)
1278             range => $sat_range, # Range to satellite, kilometers
1279             specular => $limb_darkening, # True if specular
1280             station => $station, # Observer's location
1281             status => '', # True if below horizon or not illum
1282             time => $time, # Time of flare
1283             type => $flare_type, # Flare type ('am', 'day', 'pm')
1284             virtual_image => $virtual_image, # Virtual image of illum.
1285             );
1286              
1287 8 50       37 return wantarray ? %rslt : \%rslt;
1288             }
1289              
1290             # $ainv = _invert_matrix_list ($a)
1291              
1292             # This subroutine takes a reference to a list of three list
1293             # references, considers them as a matrix, and inverts that
1294             # matrix, returning a reference to the list of list references
1295             # that represents the inverted matrix. If called in list context,
1296             # it returns the list itself. You can also pass the three input
1297             # list references as a list.
1298              
1299             sub _invert_matrix_list {
1300 17     17   37 my @args = @_;
1301 17 50       36 confess <<'EOD' unless (grep { ARRAY_REF eq ref $_ } @args) == 3;
  51         98  
1302             Programming error -- _invert_matrix_list takes as its arguments three
1303             list references.
1304             EOD
1305 17         19 my ($a, $b, $c) = @{$args[0]};
  17         32  
1306 17         24 my ($d, $e, $f) = @{$args[1]};
  17         26  
1307 17         43 my ($g, $h, $i) = @{$args[2]};
  17         25  
1308 17         28 my $ei_fh = $e * $i - $f * $h;
1309 17         28 my $fg_di = $f * $g - $d * $i;
1310 17         25 my $dh_eg = $d * $h - $e * $g;
1311 17         25 my $A = $a * $ei_fh + $b * $fg_di + $c * $dh_eg;
1312 17 50       40 confess <
1313             Programming error -- You are trying to invert a singular matrix. This
1314             should not happen since our purpose is to undo a rotation.
1315             eod
1316 17         85 my @inv = (
1317             [$ei_fh / $A, ($c * $h - $b * $i) / $A, ($b * $f - $c * $e) / $A],
1318             [$fg_di / $A, ($a * $i - $c * $g) / $A, ($c * $d - $a * $f) / $A],
1319             [$dh_eg / $A, ($b * $g - $a * $h) / $A, ($a * $e - $b * $d) / $A],
1320             );
1321 17 100       49 return wantarray ? @inv : \@inv;
1322             }
1323              
1324             # $a = _list_angle ($A, $B, $C)
1325             #
1326             # This subroutine takes as input three list references, which are
1327             # assumed to define the coordinates of the vertices of a
1328             # triangle. The angle of the first vertex is computed (in
1329             # radians) by the law of cosines, and returned.
1330              
1331             sub _list_angle {
1332 222     222   291 my $A = shift;
1333 222         288 my $B = shift;
1334 222         270 my $C = shift;
1335              
1336 222         407 my $a = distsq ($B, $C);
1337 222         3953 my $b = distsq ($A, $C);
1338 222         3325 my $c = distsq ($A, $B);
1339              
1340 222         3437 return acos (($b + $c - $a) / sqrt (4 * $b * $c));
1341             }
1342              
1343             =item $value = $tle->get ($name);
1344              
1345             This method returns the value of the given attribute. Attributes other
1346             than 'status' are delegated to the parent.
1347              
1348             =cut
1349              
1350             sub get {
1351 1072     1072 1 147896 my $self = shift;
1352 1072         1412 my $name = shift;
1353              
1354 1072 100       2048 if (!$accessor{$name}) {
    50          
1355 1045         1990 return $self->SUPER::get ($name);
1356             } elsif (ref $self) {
1357 27         63 return $accessor{$name}->($self, $name);
1358             } else {
1359 0         0 return $accessor{$name}->(\%statatr, $name);
1360             }
1361             }
1362              
1363             # $status = _make_status ($message);
1364              
1365             # This subroutine returns a reference to a hash with key 'status'
1366             # containing the given message. In list context it returns the
1367             # hash itself.
1368              
1369             sub _make_status {
1370 8     8   21 my %stat = (status => @_);
1371 8 50       18 return wantarray ? %stat : \%stat;
1372             }
1373              
1374             =item $mag = $tle->magnitude( $station );
1375              
1376             This override of the superclass' method method returns the magnitude of
1377             the body as seen from the given station at the body's currently-set
1378             time. If no C<$station> is specified, the object's C<'station'>
1379             attribute is used. If that is not set, and exception is thrown.
1380              
1381             This method calls the superclass' C, and returns C
1382             if the superclass does. Otherwise it adds to the magnitude of the body
1383             itself the magnitude of any flare in progress, and returns the result.
1384              
1385             =cut
1386              
1387             sub magnitude {
1388 4     4 1 853 my ( $self, $sta ) = __default_station( @_ );
1389 4 50       99 defined( my $mag = $self->SUPER::magnitude( $sta ) )
1390             or return undef; ## no critic (ProhibitExplicitReturnUndef)
1391 4         1586 my $time = $self->universal();
1392 12         18 my @flare = grep { defined }
1393 4         34 map { $_->{magnitude} }
  12         20  
1394             $self->reflection( $sta, $time );
1395             @flare
1396 4 50       43 and $mag = add_magnitudes( $mag, @flare );
1397 4         48 return $mag;
1398             }
1399              
1400             =item @data = $tle->reflection ($station, $time)
1401              
1402             This method returns a list of references to hashes containing the same
1403             data as returned for a flare, calculated for the given observer and time
1404             for all Main Mission Antennae. If C<$time> is C, the current time
1405             setting of the invocant is used. If C<$station> is C the current
1406             C attribute is used. Note the following differences from the
1407             flare() hash:
1408              
1409             If the hash contains a 'status' key which is true (in the Perl sense),
1410             no reflection occurred, and the content of the key is a message saying
1411             why not. If the 'mma' key exists in addition to the 'status' key, the
1412             failure applies only to that MMA, and other MMAs may possibly generate a
1413             reflection. If the 'mma' key does not exist, then the satellite is
1414             either not illuminated or below the horizon for the given observer, and
1415             the @data list will contain only a single entry.
1416              
1417             Other than (maybe) 'mma', no other keys should be assumed to exist if
1418             the 'status' key is true.
1419              
1420             If called in scalar context, a reference to the \@data list is returned.
1421              
1422             B that prior to 0.061_01 the C<$time> argument defaulted to the
1423             current time. This behavior was undocumented, and therefore I felt free
1424             to change it.
1425              
1426             =cut
1427              
1428             sub reflection {
1429 4     4 1 9 my ( $self, $station, $time ) = __default_station( @_ );
1430 4         98 my $method = "_reflection_$self->{&ATTRIBUTE_KEY}{algorithm}";
1431 4         10 return $self->$method( $station, $time );
1432             }
1433              
1434             # Called as $self->$method, above
1435             sub _reflection_fixed { ## no critic (ProhibitUnusedPrivateSubroutines)
1436 4     4   10 my ( $self, $station, $time ) = @_;
1437 4 50       9 defined $time
1438             or $time = $self->universal();
1439 4         7 my $debug = $self->get ('debug');
1440 4         29 my $illum = $self->get ('illum')->universal ($time);
1441              
1442             # Calculate whether satellite is above horizon.
1443              
1444 4         62 my (undef, $elev) = $station->universal ($time)->
1445             azel_offset( $self->universal ($time), 0 );
1446 4 50       244 return scalar _make_status (
1447             sprintf ('Satellite %.2f degrees below horizon', rad2deg (-$elev)))
1448             unless $elev >= 0;
1449              
1450             # Calculate whether the satellite is illuminated.
1451              
1452 4         11 my $lit = ($self->azel ($illum->universal ($time)))[1] - $self->dip ();
1453 4 50       113 return scalar _make_status (
1454             sprintf ('Satellite fails to be illuminated by %.2f degrees',
1455             rad2deg (-$lit)))
1456             unless $lit >= 0;
1457              
1458             # Transform the relevant coordinates into a coordinate system
1459             # in which the axis of the satellite is along the Z axis (with
1460             # the Earth in the negative Z direction) and the direction of
1461             # motion (and hence one of the Main Mission Antennae) is along
1462             # the X axis.
1463              
1464 4         12 my ( $illum_vector, $station_vector ) =
1465             $self->_flare_transform_coords_list( $illum, $station );
1466              
1467 4         6 my @rslt;
1468              
1469 4         9 foreach my $mma (0 .. 2) {
1470              
1471             # We calculate
1472             # the angle between the satellite and the reflection of the Sun,
1473             # as seen by the observer. We skip to the next antenna if no
1474             # reflection is generated.
1475              
1476 12         23 my $angle = _flare_calculate_angle_list(
1477             $mma, $illum_vector, $station_vector );
1478 12 50       37 warn <
1479 0 0       0 MMA $mma Angle: @{[defined $angle ? rad2deg ($angle) . ' degrees' :
1480             'undefined']}
1481             eod
1482 12 100       34 push @rslt, defined $angle ?
1483             scalar $self->_flare_char_list ($station, $mma, $angle, $time,
1484             $illum_vector, $station_vector) :
1485             scalar _make_status ('Geometry does not allow reflection',
1486             mma => $mma);
1487             }
1488              
1489 4 50       15 return wantarray ? @rslt : \@rslt;
1490             }
1491              
1492             =item $tle->set ($name => $value ...)
1493              
1494             This method sets the value of the given attribute (or attributes).
1495             Attributes other than 'status' are delegated to the parent.
1496              
1497             =cut
1498              
1499             sub set {
1500 14     14 1 1712 my ($self, @args) = @_;
1501 14         37 while (@args) {
1502 78         503 my $name = shift @args;
1503 78         148 my $value = shift @args;
1504 78 100       230 if (!$mutator{$name}) {
    50          
1505 13         54 $self->SUPER::set ($name, $value);
1506             } elsif (ref $self) {
1507 65         140 $mutator{$name}->($self, $name, $value);
1508             } else {
1509 0         0 $mutator{$name}->(\%statatr, $name, $value);
1510             }
1511             }
1512 14         214 return $self;
1513             }
1514              
1515             # For use of -am and -pm
1516             {
1517             my $date_time_available = eval {
1518             load_module( 'DateTime' );
1519             load_module( 'DateTime::TimeZone' );
1520             1;
1521             };
1522              
1523             sub _time_in_zone {
1524 4     4   9 my ( $self, $time ) = @_;
1525              
1526 4 50       10 defined( my $zone = $self->get( 'zone' ) )
1527             or return localtime $time;
1528              
1529 4 50       39 looks_like_number( $zone )
1530             and return gmtime( $zone * 3600 + $time );
1531              
1532 0 0       0 if ( $date_time_available ) {
1533              
1534 0         0 my $dt = DateTime->from_epoch(
1535             epoch => $time,
1536             time_zone => $zone,
1537             );
1538              
1539 0         0 my @localtime = map { $dt->$_() } qw{ second minute hour day
  0         0  
1540             month_0 year day_of_week_0 day_of_year_0 is_dst };
1541 0         0 $localtime[5] -= 1900;
1542 0         0 return @localtime;
1543              
1544             } else {
1545              
1546 0         0 local $ENV{TZ} = $zone;
1547 0         0 return localtime $time;
1548              
1549             }
1550             }
1551             }
1552              
1553             sub __parse_name {
1554 1     1   15 my ( $self, $name ) = @_;
1555 1 50 33     11 defined $name
1556             and $name =~ s/ \s* [[] ( \S ) []] \s* \z //smx
1557             and $self->_set_operational_status( status => $1 );
1558 1         7 return $self->SUPER::__parse_name( $name );
1559             }
1560              
1561             {
1562             my @encode_status;
1563             $encode_status[BODY_STATUS_IS_OPERATIONAL] = '+';
1564             $encode_status[BODY_STATUS_IS_SPARE] = 'S';
1565             $encode_status[BODY_STATUS_IS_TUMBLING] = '-';
1566             $encode_status[BODY_STATUS_IS_DECAYED] = 'D';
1567              
1568             sub __encode_operational_status {
1569             ## my ( $self, $name, $status ) = @_;
1570 0     0   0 my ( $self, undef, $status ) = @_; # Name unused
1571 0 0       0 defined $status
1572             or $status = $self->get( 'status' );
1573 0 0       0 defined $encode_status[ $status ]
1574             or return BODY_STATUS_IS_TUMBLING;
1575 0         0 return $encode_status[ $status ];
1576             }
1577             }
1578              
1579             {
1580              
1581             my %status_map = (
1582             BODY_STATUS_IS_OPERATIONAL() => BODY_STATUS_IS_OPERATIONAL,
1583             '' => BODY_STATUS_IS_OPERATIONAL,
1584             '+' => BODY_STATUS_IS_OPERATIONAL,
1585             BODY_STATUS_IS_SPARE() => BODY_STATUS_IS_SPARE,
1586             '?' => BODY_STATUS_IS_SPARE,
1587             'S' => BODY_STATUS_IS_SPARE,
1588             's' => BODY_STATUS_IS_SPARE,
1589             'D' => BODY_STATUS_IS_DECAYED,
1590             'd' => BODY_STATUS_IS_DECAYED,
1591             BODY_STATUS_IS_TUMBLING() => BODY_STATUS_IS_TUMBLING,
1592             BODY_STATUS_IS_DECAYED() => BODY_STATUS_IS_DECAYED,
1593             );
1594              
1595             sub __decode_operational_status {
1596 295     295   432218 my ( $value ) = @_;
1597 295 50       389 defined $value
1598             or return BODY_STATUS_IS_OPERATIONAL;;
1599 295 100       493 defined $status_map{$value}
1600             or return BODY_STATUS_IS_TUMBLING;
1601 199         247 return $status_map{$value};
1602             }
1603             }
1604              
1605             sub _set_operational_status {
1606 0     0     my ( $self, $name, $value ) = @_;
1607 0           $self->{&ATTRIBUTE_KEY}{$name} = __decode_operational_status( $value );
1608 0           return $self;
1609             }
1610              
1611             {
1612             my %json_map = (
1613             operational_status => \&__encode_operational_status,
1614             );
1615              
1616             sub TO_JSON {
1617 0     0 1   my ( $self ) = @_;
1618 0           return $self->__to_json(
1619             \%json_map,
1620             $self->SUPER::TO_JSON(),
1621             );
1622             }
1623             }
1624              
1625             # All Iridium Classic satellites.
1626             #
1627             # Generated by Astro::SpaceTrack
1628             # $ tools/all_iridium_classic -iridium -indent=0
1629             # on Mon Jun 29 12:08:42 2020 GMT
1630             #
1631             # The following static method is UNSUPPORTED, and exists solely for the
1632             # benefit of xt/author/iridium_status.t. It may be modified or revoked
1633             # at any time.
1634 0     0     sub __iridium_status_as_of { return( qw{ 7 16 11 29 5 2020 } ) }
1635              
1636             __PACKAGE__->status( clear => 'iridium' );
1637             __PACKAGE__->status( add => 24792, iridium => 'D', 'Iridium 8', 'Decayed 2017-11-24' );
1638             __PACKAGE__->status( add => 24793, iridium => '-', 'Iridium 7', 'Tumbling' );
1639             __PACKAGE__->status( add => 24794, iridium => 'D', 'Iridium 6', 'Decayed 2017-12-23' );
1640             __PACKAGE__->status( add => 24795, iridium => '-', 'Iridium 5', 'Tumbling' );
1641             __PACKAGE__->status( add => 24796, iridium => '-', 'Iridium 4', 'Tumbling' );
1642             __PACKAGE__->status( add => 24836, iridium => '-', 'Iridium 914', 'Tumbling' );
1643             __PACKAGE__->status( add => 24837, iridium => 'D', 'Iridium 12', 'Decayed 2018-09-02' );
1644             __PACKAGE__->status( add => 24838, iridium => 'D', 'Iridium 9', 'Decayed 2003-03-11' );
1645             __PACKAGE__->status( add => 24839, iridium => 'D', 'Iridium 10', 'Decayed 2018-10-06' );
1646             __PACKAGE__->status( add => 24840, iridium => 'D', 'Iridium 13', 'Decayed 2018-04-29' );
1647             __PACKAGE__->status( add => 24841, iridium => '-', 'Iridium 16', 'Tumbling' );
1648             __PACKAGE__->status( add => 24842, iridium => '-', 'Iridium 911', 'Tumbling' );
1649             __PACKAGE__->status( add => 24869, iridium => 'D', 'Iridium 15', 'Decayed 2018-10-14' );
1650             __PACKAGE__->status( add => 24870, iridium => '-', 'Iridium 17', 'Tumbling' );
1651             __PACKAGE__->status( add => 24871, iridium => '-', 'Iridium 920', 'Tumbling' );
1652             __PACKAGE__->status( add => 24872, iridium => 'D', 'Iridium 18', 'Decayed 2018-08-19' );
1653             __PACKAGE__->status( add => 24873, iridium => '-', 'Iridium 921', 'Tumbling' );
1654             __PACKAGE__->status( add => 24903, iridium => '-', 'Iridium 26', 'Tumbling' );
1655             __PACKAGE__->status( add => 24904, iridium => 'D', 'Iridium 25', 'Decayed 2018-05-14' );
1656             __PACKAGE__->status( add => 24905, iridium => 'D', 'Iridium 46', 'Decayed 2019-05-11' );
1657             __PACKAGE__->status( add => 24906, iridium => 'D', 'Iridium 23', 'Decayed 2018-03-28' );
1658             __PACKAGE__->status( add => 24907, iridium => '-', 'Iridium 22', 'Tumbling' );
1659             __PACKAGE__->status( add => 24925, iridium => '-', 'Dummy mass 1', 'Tumbling' );
1660             __PACKAGE__->status( add => 24926, iridium => '-', 'Dummy mass 2', 'Tumbling' );
1661             __PACKAGE__->status( add => 24944, iridium => '-', 'Iridium 29', 'Tumbling' );
1662             __PACKAGE__->status( add => 24945, iridium => 'D', 'Iridium 32', 'Decayed 2019-03-10' );
1663             __PACKAGE__->status( add => 24946, iridium => '-', 'Iridium 33', 'Tumbling' );
1664             __PACKAGE__->status( add => 24947, iridium => 'D', 'Iridium 27', 'Decayed 2002-02-01' );
1665             __PACKAGE__->status( add => 24948, iridium => '-', 'Iridium 28', 'Tumbling' );
1666             __PACKAGE__->status( add => 24949, iridium => 'D', 'Iridium 30', 'Decayed 2017-09-28' );
1667             __PACKAGE__->status( add => 24950, iridium => 'D', 'Iridium 31', 'Decayed 2018-12-20' );
1668             __PACKAGE__->status( add => 24965, iridium => 'D', 'Iridium 19', 'Decayed 2018-04-07' );
1669             __PACKAGE__->status( add => 24966, iridium => 'D', 'Iridium 35', 'Decayed 2018-12-26' );
1670             __PACKAGE__->status( add => 24967, iridium => '-', 'Iridium 36', 'Tumbling' );
1671             __PACKAGE__->status( add => 24968, iridium => 'D', 'Iridium 37', 'Decayed 2018-05-26' );
1672             __PACKAGE__->status( add => 24969, iridium => 'D', 'Iridium 34', 'Decayed 2018-01-08' );
1673             __PACKAGE__->status( add => 25039, iridium => 'D', 'Iridium 43', 'Decayed 2018-02-11' );
1674             __PACKAGE__->status( add => 25040, iridium => 'D', 'Iridium 41', 'Decayed 2018-07-28' );
1675             __PACKAGE__->status( add => 25041, iridium => 'D', 'Iridium 40', 'Decayed 2018-09-23' );
1676             __PACKAGE__->status( add => 25042, iridium => '-', 'Iridium 39', 'Tumbling' );
1677             __PACKAGE__->status( add => 25043, iridium => '-', 'Iridium 38', 'Tumbling' );
1678             __PACKAGE__->status( add => 25077, iridium => '-', 'Iridium 42', 'Tumbling' );
1679             __PACKAGE__->status( add => 25078, iridium => '-', 'Iridium 44', 'Tumbling' );
1680             __PACKAGE__->status( add => 25104, iridium => '-', 'Iridium 45', 'Tumbling' );
1681             __PACKAGE__->status( add => 25105, iridium => '-', 'Iridium 24', 'Tumbling' );
1682             __PACKAGE__->status( add => 25106, iridium => 'D', 'Iridium 47', 'Decayed 2018-09-01' );
1683             __PACKAGE__->status( add => 25107, iridium => 'D', 'Iridium 48', 'Decayed 2001-05-05' );
1684             __PACKAGE__->status( add => 25108, iridium => 'D', 'Iridium 49', 'Decayed 2018-02-13' );
1685             __PACKAGE__->status( add => 25169, iridium => 'D', 'Iridium 52', 'Decayed 2018-11-05' );
1686             __PACKAGE__->status( add => 25170, iridium => 'D', 'Iridium 56', 'Decayed 2018-10-11' );
1687             __PACKAGE__->status( add => 25171, iridium => 'D', 'Iridium 54', 'Decayed 2019-05-11' );
1688             __PACKAGE__->status( add => 25172, iridium => 'D', 'Iridium 50', 'Decayed 2018-09-23' );
1689             __PACKAGE__->status( add => 25173, iridium => 'D', 'Iridium 53', 'Decayed 2018-09-30' );
1690             __PACKAGE__->status( add => 25262, iridium => '-', 'Iridium 51', 'Tumbling' );
1691             __PACKAGE__->status( add => 25263, iridium => 'D', 'Iridium 61', 'Decayed 2019-07-23' );
1692             __PACKAGE__->status( add => 25272, iridium => 'D', 'Iridium 55', 'Decayed 2019-03-31' );
1693             __PACKAGE__->status( add => 25273, iridium => '-', 'Iridium 57', 'Tumbling' );
1694             __PACKAGE__->status( add => 25274, iridium => 'D', 'Iridium 58', 'Decayed 2019-04-07' );
1695             __PACKAGE__->status( add => 25275, iridium => 'D', 'Iridium 59', 'Decayed 2019-03-11' );
1696             __PACKAGE__->status( add => 25276, iridium => 'D', 'Iridium 60', 'Decayed 2019-03-17' );
1697             __PACKAGE__->status( add => 25285, iridium => 'D', 'Iridium 62', 'Decayed 2018-11-07' );
1698             __PACKAGE__->status( add => 25286, iridium => '-', 'Iridium 63', 'Tumbling' );
1699             __PACKAGE__->status( add => 25287, iridium => 'D', 'Iridium 64', 'Decayed 2019-04-01' );
1700             __PACKAGE__->status( add => 25288, iridium => 'D', 'Iridium 65', 'Decayed 2018-07-19' );
1701             __PACKAGE__->status( add => 25289, iridium => 'D', 'Iridium 66', 'Decayed 2018-08-23' );
1702             __PACKAGE__->status( add => 25290, iridium => 'D', 'Iridium 67', 'Decayed 2018-07-02' );
1703             __PACKAGE__->status( add => 25291, iridium => 'D', 'Iridium 68', 'Decayed 2018-06-06' );
1704             __PACKAGE__->status( add => 25319, iridium => '-', 'Iridium 69', 'Tumbling' );
1705             __PACKAGE__->status( add => 25320, iridium => '-', 'Iridium 71', 'Tumbling' );
1706             __PACKAGE__->status( add => 25342, iridium => 'D', 'Iridium 70', 'Decayed 2018-10-11' );
1707             __PACKAGE__->status( add => 25343, iridium => 'D', 'Iridium 72', 'Decayed 2018-05-14' );
1708             __PACKAGE__->status( add => 25344, iridium => '-', 'Iridium 73', 'Tumbling' );
1709             __PACKAGE__->status( add => 25345, iridium => 'D', 'Iridium 74', 'Decayed 2017-06-11' );
1710             __PACKAGE__->status( add => 25346, iridium => 'D', 'Iridium 75', 'Decayed 2018-07-10' );
1711             __PACKAGE__->status( add => 25431, iridium => 'D', 'Iridium 3', 'Decayed 2018-02-08' );
1712             __PACKAGE__->status( add => 25432, iridium => 'D', 'Iridium 76', 'Decayed 2018-08-28' );
1713             __PACKAGE__->status( add => 25467, iridium => '-', 'Iridium 82', 'Tumbling' );
1714             __PACKAGE__->status( add => 25468, iridium => 'D', 'Iridium 81', 'Decayed 2018-07-17' );
1715             __PACKAGE__->status( add => 25469, iridium => 'D', 'Iridium 80', 'Decayed 2018-08-12' );
1716             __PACKAGE__->status( add => 25470, iridium => 'D', 'Iridium 79', 'Decayed 2000-11-29' );
1717             __PACKAGE__->status( add => 25471, iridium => 'D', 'Iridium 77', 'Decayed 2017-09-22' );
1718             __PACKAGE__->status( add => 25527, iridium => '-', 'Iridium 2', 'Tumbling' );
1719             __PACKAGE__->status( add => 25528, iridium => 'D', 'Iridium 86', 'Decayed 2018-10-05' );
1720             __PACKAGE__->status( add => 25529, iridium => 'D', 'Iridium 85', 'Decayed 2000-12-30' );
1721             __PACKAGE__->status( add => 25530, iridium => 'D', 'Iridium 84', 'Decayed 2018-11-04' );
1722             __PACKAGE__->status( add => 25531, iridium => 'D', 'Iridium 83', 'Decayed 2018-11-05' );
1723             __PACKAGE__->status( add => 25577, iridium => 'D', 'Iridium 20', 'Decayed 2018-10-22' );
1724             __PACKAGE__->status( add => 25578, iridium => 'D', 'Iridium 11', 'Decayed 2018-10-22' );
1725             __PACKAGE__->status( add => 25777, iridium => 'D', 'Iridium 14', 'Decayed 2019-03-15' );
1726             __PACKAGE__->status( add => 25778, iridium => 'D', 'Iridium 21', 'Decayed 2018-05-24' );
1727             __PACKAGE__->status( add => 27372, iridium => 'D', 'Iridium 91', 'Decayed 2019-03-13' );
1728             __PACKAGE__->status( add => 27373, iridium => 'D', 'Iridium 90', 'Decayed 2019-01-23' );
1729             __PACKAGE__->status( add => 27374, iridium => 'D', 'Iridium 94', 'Decayed 2018-04-18' );
1730             __PACKAGE__->status( add => 27375, iridium => 'D', 'Iridium 95', 'Decayed 2019-03-25' );
1731             __PACKAGE__->status( add => 27376, iridium => '-', 'Iridium 96', 'SpaceTrack' );
1732             __PACKAGE__->status( add => 27450, iridium => 'D', 'Iridium 97', 'Decayed 2019-12-27' );
1733             __PACKAGE__->status( add => 27451, iridium => 'D', 'Iridium 98', 'Decayed 2018-08-24' );
1734              
1735             # Summary:
1736             # Operational [+]: 0
1737             # Spare [S]: 0
1738             # Tumbling [-]: 32
1739             # Decayed [D]: 65
1740              
1741             1;
1742              
1743             __END__