File Coverage

blib/lib/NOAA/Aurora.pm
Criterion Covered Total %
statement 136 136 100.0
branch 52 54 96.3
condition 36 41 87.8
subroutine 19 19 100.0
pod 6 6 100.0
total 249 256 97.2


line stmt bran cond sub pod time code
1             package NOAA::Aurora;
2              
3 3     3   697238 use 5.006;
  3         12  
4 3     3   24 use strict;
  3         13  
  3         135  
5 3     3   18 use warnings;
  3         6  
  3         202  
6              
7 3     3   363 use parent 'Weather::API::Base';
  3         309  
  3         25  
8 3     3   77215 use Weather::API::Base qw(:all);
  3         5  
  3         19554  
9              
10             =head1 NAME
11              
12             NOAA::Aurora - Simple client for NOAA's Aurora Forecast Service
13              
14             =cut
15              
16             our $VERSION = '0.1';
17              
18             =head1 SYNOPSIS
19              
20             use NOAA::Aurora;
21              
22             # Constructor can use RFC 3339 or ISO instead of timestamps for timeseries
23             my $aurora = NOAA::Aurora->new(date_format => 'rfc');
24              
25             # Save the latest probability map to an image file
26             $aurora->get_image(hemisphere => 'north', output => 'aurora_north.jpg');
27              
28             # Get aurora probability for a given location
29             my $probability = $aurora->get_probability(lat => 51.2, lon => -1.8);
30              
31             # Get 3-day forecast as a timeseries
32             my $forecast = $aurora->get_forecast();
33              
34             # Get 27-day outlook as a timeseries
35             my $outlook = $aurora->get_outlook();
36              
37             =head1 DESCRIPTION
38              
39             NOAA::Aurora provides basic access to the L
40             Aurora Forecast API. This service provides real-time aurora forecasts based on solar activity and geomagnetic conditions.
41              
42             The module fetches aurora probability data, latest aurora images, and the 3-day aurora forecast.
43              
44             Responses are cached (by default for 120 sec).
45              
46             =head1 CONSTRUCTOR
47              
48             =head2 C
49              
50             my $aurora = NOAA::Aurora->new(
51             cache => $cache_secs?,
52             swpc => $swpc_services_subdomain,
53             date_format => $unix_rfc_or_iso,
54             timeout => $timeout_sec?,
55             agent => $user_agent_string?,
56             ua => $lwp_ua?,
57             );
58            
59             Optional parameters:
60              
61             =over 4
62              
63             =item * C : Will cache results for the specified seconds. Default: C<120>.
64              
65             =item * C : Space Weather Prediction Center subdomain. Default: C.
66              
67             =item * C : Format for functions that return dates/timestamps.
68             Can be C (unix timestamp), C (for I) or C (for I).
69             Default: C.
70              
71             =item * C : Timeout for requests in secs. Default: C<30>.
72              
73             =item * C : Customize the user agent string.
74              
75             =item * C : Pass your own L to customise further.
76              
77             =back
78              
79             Note that the module base is L, so some additional parameters
80             are inherited - see the base module for details.
81              
82             =head1 METHODS
83              
84             =head2 C
85              
86             my $image_data = $aurora->get_image(
87             hemisphere => $hem,
88             output => $filename?
89             );
90              
91             Returns the latest aurora oval image for the specified hemisphere in jpg data.
92             Optionally will save it to $filename.
93             Function caches the results (see constructor).
94              
95             Optional parameters:
96              
97             =over 4
98              
99             =item * C : C or C (accepts abbreviations). Default: C.
100              
101             =item * C : If specified will save to specified jpg file.
102              
103             =back
104              
105             =head2 C
106              
107             my $probability = $aurora->get_probability(
108             lat => $lat,
109             lon => $lon,
110             hash => $return_hash?
111             );
112              
113             Fetches the aurora probability at a specific latitude and longitude if specified,
114             otherwise will return all the globe. Can return the original NOAA JSON string, or
115             decode it into a Perl hash of hashes:
116              
117             {
118             $longitude1 => {$latitude1 => $prob},
119             ...
120             }
121              
122             Probability given as an integer percentage value (0-100). Granularity is 1 degree.
123             In Perl hash mode, 0 probability locations will be ommitted from the response.
124              
125             The function caches the results (see constructor), so subsequent calls will not
126             require downloading and decoding.
127              
128             Optional parameters:
129              
130             =over 4
131              
132             =item * C : If true, will return Perl hash instead of JSON.
133              
134             =back
135              
136             =head2 C
137              
138             my $forecast = $aurora->get_forecast(
139             format => $output?
140             );
141              
142             Retrieves NOAA's 3-day space forecast (preferred over the geomagnetic forecast due
143             to more frequent / twice daily update) and by default returns an arrayref of hashes:
144              
145             [{time => $timestamp, kp => $kp_value},...]
146              
147             The timestamp will be at the start of the 3h time range NOAA returns.
148              
149             Optional parameters:
150              
151             =over 4
152              
153             =item * C : If C<'text'> is specified as the format, raw text output will be returned
154             instead of an array with the timeseries.
155              
156             =back
157              
158             =head2 C
159              
160             my $outlook = $aurora->get_outlook(
161             format => $output?
162             );
163              
164             Retrieves NOAA's 27-day outlook with the forecasted daily values for the 10.7cm Solar
165             radio flux, the Planetary A Index and the largest Kp index. By default returns an
166             arrayref of hashes:
167              
168             [
169             {
170             time => $timestamp,
171             flux => $flux_value,
172             ap => $a_index,
173             kp => $max_kp_value
174             }, ...
175             ]
176              
177             =over 4
178              
179             =item * C : If C<'text'> is specified as the format, raw text output will be returned
180             instead of an array with the timeseries.
181              
182             =back
183              
184             =head1 UTILITY FUNCTIONS
185              
186             =head2 C
187              
188             my $g_index = kp_to_g($kp_index);
189              
190             Pass the Kp index and get the G-Index (Geomagnetic storm from G1 to G5) or 0 if
191             the Kp is not indicative of a Geomagnetic Storm. Fractional kp is rounded half up
192             (e.g. kp >= 4.5 -> G1).
193              
194             =cut
195              
196             sub new {
197 8     8 1 529677 my ($class, %args) = @_;
198              
199 8         76 my $self = $class->SUPER::new(%args);
200              
201 8         620 $self->{data} = {};
202 8   100     60 $self->{cache} = $args{cache} // 120;
203 8   100     54 $self->{swpc} = $args{swpc} || 'services.swpc.noaa.gov';
204 8         43 $self->{swpc} =~ s#^http(?:s)?://##;
205             # Time delimiter for rfc/iso
206 8   100     46 my $time_f = lc($args{date_format} || '');
207 8 100       31 $self->{td} = ' ' if $time_f eq 'rfc';
208 8 100       25 $self->{td} = 'T' if $time_f eq 'iso';
209              
210 8         74 return $self;
211             }
212              
213             sub get_image {
214 8     8 1 9332 my $self = shift;
215 8         25 my %args = @_;
216 8   100     77 $args{hem} ||= $args{hemisphere} || '';
      100        
217              
218 8 100       32 my $h = $args{hem} =~ /^s/i ? 'south' : 'north';
219 8   66     31 my $data = $self->_get_cache($h) || $self->_set_cache(
220             $h,
221             $self->_get_output(
222             $self->_get_ua(
223             "$self->{swpc}/images/animations/ovation/$h/latest.jpg"
224             )
225             )
226             );
227              
228 8 100       70 if ($args{output}) {
229 1 50       178 open(my $fh, '>:raw', $args{output}) or die $!;
230 1         19 print $fh $data;
231 1         202 close($fh);
232             }
233              
234 8         38 return $data;
235             }
236              
237             sub get_probability {
238 8     8 1 19637 my $self = shift;
239 8         36 my %args = @_;
240              
241 8         27 my ($json, $hash) = $self->_get_probabilities;
242              
243 8 100 66     54 if (defined $args{lat} && defined $args{lon}) {
244 6         83 $args{$_} = sprintf("%.0f", $args{$_}) for qw/lat lon/;
245 6         36 Weather::API::Base::_verify_lat_lon(\%args);
246 4   100     68 return $hash->{$args{lon}}->{$args{lat}} || 0;
247             }
248              
249 2 100       14 return $args{hash} ? $hash : $json;
250             }
251              
252             sub _get_text_source {
253 8     8   16 my $self = shift;
254 8         20 my $source = shift;
255 8         24 my %args = @_;
256 8         21 my $url = "$self->{swpc}/text/$source.txt";
257 8         55 my $resp = $self->_get_ua($url);
258 8         3687 my $content = $resp->decoded_content;
259              
260 8 100 100     1394 return $content if $args{format} && $args{format} eq 'text';
261              
262 6 100       40 return $source eq '3-day-forecast'
263             ? $self->_parse_geo($content)
264             : $self->_parse_outlook($content);
265             }
266              
267             sub get_forecast {
268 4     4 1 10769 my $self = shift;
269 4         20 return $self->_get_text_source('3-day-forecast', @_);
270             }
271              
272             sub get_outlook {
273 4     4 1 18762 my $self = shift;
274 4         17 return $self->_get_text_source('27-day-outlook', @_);
275             }
276              
277             sub kp_to_g {
278 7     7 1 16069 my $kp = shift;
279 7 100 66     74 return 0 if !$kp || $kp < 4.5;
280 6 100       20 return 'G1' if $kp < 5.5;
281 5 100       15 return 'G2' if $kp < 6.5;
282 4 100       18 return 'G3' if $kp < 7.5;
283 3 100       16 return 'G4' if $kp < 9;
284 1         5 return 'G5';
285             }
286              
287             sub _get_probabilities {
288 8     8   16 my $self = shift;
289 8         29 my $json = $self->_get_cache('json');
290 8         24 my $hash = $self->_get_cache('hash');
291 8 100 66     48 return ($json, $hash) if $json && $hash;
292 2         8 return $self->_refresh_probability;
293             }
294              
295             sub _refresh_probability {
296 2     2   4 my $self = shift;
297 2         38 my $resp = $self->_get_ua("$self->{swpc}/json/ovation_aurora_latest.json");
298 2         611103 my $json = $resp->decoded_content;
299 2         4387 my %raw = $self->_get_output($resp, 1);
300 2         90867 $self->_set_cache('json', $json);
301 2         4 my %hash;
302 2         5 foreach (@{$raw{coordinates}}) {
  2         10  
303 65162 100       131545 $hash{$_->[0]}->{$_->[1]} = $_->[2] if $_->[2];
304             }
305 2         18 $self->_set_cache('hash', \%hash);
306 2         7557 return ($json, \%hash);
307             }
308              
309             sub _get_cache {
310 24     24   36 my $self = shift;
311 24         40 my $key = shift;
312              
313             return
314             unless $self->{cache} && $self->{data}->{$key}
315 24 100 100     214 && (time() - $self->{data}->{$key}->{ts} <= $self->{cache});
      66        
316              
317 14         309 return $self->{data}->{$key}->{data};
318             }
319              
320             sub _set_cache {
321 10     10   7940 my $self = shift;
322 10         24 my $key = shift;
323 10         19 my $data = shift;
324              
325 10         43 $self->{data}->{$key}->{ts} = time();
326 10         30 $self->{data}->{$key}->{data} = $data;
327              
328 10         38 return $data;
329             }
330              
331             # Parse from last day to first, passing ref_month being the last month processed
332             sub _parse_mon_day {
333 6     6   23 my ($date, $ref_year, $ref_mon) = @_;
334 6         19 my ($mon, $day) = split /\s+/, $date;
335 6         25 $mon = mon_to_num($mon);
336              
337 6 100 100     200 $ref_year-- if $ref_mon && $mon > $ref_mon;
338 6         26 $date = sprintf("%d-%02d-%02d", $ref_year, $mon, $day);
339              
340 6 100       30 return wantarray ? ($date, $mon) : $date;
341             }
342              
343             sub _parse_geo {
344 3     3   9 my ($self, $data) = @_;
345 3         44 my @lines = split /\n/, $data;
346 3         18 my $g = qr/(?:\(G\d\)\s+)?/;
347            
348             # Find year in the "NOAA Kp index breakdown" or "breakdown" line
349 3         6 my $year;
350 3         25 while (defined(my $line = shift @lines)) {
351 26 100       108 if ($line =~ /Kp index breakdown\s+.*(\d{4})/i) {
352 2         10 $year = $1;
353 2         6 last;
354             }
355             }
356              
357 3 100       14 return [] unless $year;
358              
359             # Date headers
360 2         4 my @dates;
361 2         8 while (defined(my $line = shift @lines)) {
362 4 100       18 if ($line =~ /^\s*([A-Za-z]{3}\s+\d+)\s+([A-Za-z]{3}\s+\d+)\s+([A-Za-z]{3}\s+\d+)/) {
363 2         39 my ($dt3, $ref_mon) = _parse_mon_day($3, $year);
364 2         8 @dates = map {scalar _parse_mon_day($_, $year, $ref_mon)} ($1, $2);
  4         9  
365 2         6 push @dates, $dt3;
366 2         5 last;
367             }
368             }
369              
370 2 50       7 return [] unless @dates;
371              
372 2         4 my %kp_data;
373 2   100     13 my $td = $self->{td} || ' ';
374 2         6 foreach my $line (@lines) {
375 81 100       709 if ($line =~ /^\s*(\d{2})-\d{2}UT\s+([\d.]+)\s+$g([\d.]+)\s+$g([\d.]+)/) {
376 16         77 my ($t, @kp) = ($1, $2, $3, $4);
377 16         34 my @times = map {"$_$td$t:00:00Z"} @dates;
  48         117  
378 16 100       50 @times = map {datetime_to_ts($_)} @times unless $self->{td};
  24         879  
379 16         524 $kp_data{$times[$_]} = $kp[$_] for 0..2;
380             }
381             }
382              
383 2         33 my @result = map {{time => $_, kp => $kp_data{$_}}} sort keys %kp_data;
  48         137  
384 2         49 return \@result;
385             }
386              
387             sub _parse_outlook {
388 3     3   11 my ($self, $data) = @_;
389 3         35 my @lines = split /\n/, $data;
390 3         7 my @result;
391              
392 3   100     18 my $td = $self->{td} || ' ';
393 3         9 foreach my $line (@lines) {
394 79 100       362 if ($line =~ /^\s*(\d{4})\s+([A-Z][a-z]{2})\s+(\d{2})\s+(\d+)\s+(\d+)\s+(\d+)/) {
395 54         237 my ($year, $mon_str, $day, $flux, $ap, $kp) = ($1, $2, $3, $4, $5, $6);
396 54         128 my $mnum = mon_to_num($mon_str);
397            
398 54         1539 my $dt = sprintf("$year-%02d-%02d${td}00:00:00Z", $mnum, $day);
399 54 100       173 my $ts = $self->{td} ? $dt : datetime_to_ts($dt);
400            
401 54         1585 push @result, {
402             time => $ts,
403             flux => $flux,
404             ap => $ap,
405             kp => $kp,
406             };
407             }
408             }
409 3         40 return \@result;
410             }
411              
412             =head1 AUTHOR
413              
414             Dimitrios Kechagias, C<< >>
415              
416             =head1 BUGS
417              
418             Please report any bugs or feature requests either on L (preferred), or on RT (via the email
419             C or L).
420              
421             =head1 GIT
422              
423             L
424              
425             =head1 LICENSE AND COPYRIGHT
426              
427             This software is copyright (c) 2025 by Dimitrios Kechagias.
428              
429             This is free software; you can redistribute it and/or modify it under
430             the same terms as the Perl 5 programming language system itself.
431              
432             =cut
433              
434             1;