File Coverage

blib/lib/Weather/NHC/TropicalCyclone/ForecastAdvisory.pm
Criterion Covered Total %
statement 242 265 91.3
branch 41 76 53.9
condition 14 27 51.8
subroutine 10 10 100.0
pod 0 4 0.0
total 307 382 80.3


line stmt bran cond sub pod time code
1             package Weather::NHC::TropicalCyclone::ForecastAdvisory;
2              
3 1     1   1066 use strict;
  1         3  
  1         31  
4 1     1   6 use warnings;
  1         2  
  1         24  
5 1     1   576 use Date::Calc;
  1         6616  
  1         47  
6 1     1   855 use Getopt::Long;
  1         10903  
  1         5  
7              
8 1     1   703 use Object::Tiny qw/input_file input_text output_file as_atcf/;
  1         348  
  1         8  
9              
10             sub new {
11 5     5 0 5462 my ( $pkg, %self ) = @_;
12              
13             # input_file and input_text are mutually exclusive via the contructor
14 5 100 100     50 if ( not( exists $self{input_file} or exists $self{input_text} ) or ( exists $self{input_file} and exists $self{input_text} ) or not exists $self{output_file} ) {
      100        
      100        
      100        
15 3         24 die qq{Constructor requires specifying the 'input_file' xor 'input_text', and 'output_file' parameters.\n};
16             }
17 2         7 my $self = bless \%self, $pkg;
18 2         12 return $self;
19             }
20              
21             sub extract_and_save_atcf {
22 2     2 0 841 my $self = shift;
23 2         9 $self->extract_atcf;
24 2         38 return $self->save_atcf;
25             }
26              
27             sub save_atcf {
28 4     4 0 2561 my $self = shift;
29 4 50       105 open( my $fh, q{>}, $self->output_file ) || die qq{Failed to open output ATCF file} . $self->output_file . qq{ : $!.\n};
30 4         548 my $output_ref = $self->as_atcf;
31 4         103 print $fh join( qq{\n}, @$output_ref );
32 4         385 close $fh;
33 4         123 return $self->output_file;
34             }
35              
36             sub extract_atcf {
37 4     4 0 3117 my $self = shift;
38              
39 4         11 my @lines = ();
40              
41             ADVISORY_SOURCE:
42 4 100       108 if ( $self->input_file ) {
    50          
43 2 50       54 open( my $INPUT, q{<}, $self->input_file ) || die q{Failed to open forecast advisory file} . $self->input_file . qq{ for conversion to ATCF format: $!.\n};
44 2         207 @lines = (<$INPUT>);
45 2         29 close $INPUT;
46             }
47             elsif ( $self->input_text ) {
48 2         82 @lines = split /\n/, $self->input_text;
49             }
50              
51 4         55 my @output = (); # accumulate content for $output, do write at the very end
52              
53             # parse rss reports from Nat'l Hurricane Center Atlantic Marine Forecast
54             # Advisory. NHC Mail (Atlantic Marine)
55             # See:
56             # http://www.nhc.noaa.gov/signup.shtml and select the following list: Atlantic
57             # Marine (Forecast/Advisories and updates ONLY)
58              
59             # BASIN,CY,YYYYMMDDHH,TECHNUM,TECH,TAU,LatN/S,LonE/W,VMAX,MSLP,TY,RAD,WINDCODE,RAD1,RAD2,RAD3,RAD4,RADP,RRP,MRD,GUSTS,EYE,SUBREGION,MAXSEAS,INITIALS,DIR,SPEED,STORMNAME,DEPTH,SEAS,SEASCODE,SEAS1,SEAS2,SEAS3,SEAS4
60             #
61             # 1 1 1 1 1 1 1 1 1 1
62             # 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
63             #01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
64             #AL, 01, 2009052912, 03, OFCL, 0, 393N, 649W, 30, 1006, TD, 34, NEQ, 0, 0, 0, 0, 0, 0, 40, 40, 0, , 0, TBK, 65, 17, , , 12, NEQ, 60, 60, 0, 0
65 4         10 my $template = "_BASIN_, 01, 2009010100, , OFCL, 0, 000N, 000W, 30, 0, , 34, NEQ, 0, 0, 0, 0, 0, 0, 0, 40, 0, , 0, TBK, 65, 17, , , 12, NEQ, 60, 60, 0, 0";
66 4         46 my %month_lookup = ( dummy => '00', JAN => '01', FEB => '02', MAR => '03', APR => '04', MAY => '05', JUN => '06', JUL => '07', AUG => '08', SEP => '09', OCT => '10', NOV => '11', DEC => '12' );
67              
68             # These are the data we are extracting
69 4         9 my $pressure;
70 4         8 my $storm_class = "";
71 4         75 my $storm_name;
72             my $storm_basin;
73 4         0 my $storm_number;
74 4         0 my $adv_num;
75 4         0 my $storm_year;
76 4         0 my $nowcast_year;
77 4         0 my $forecast_year;
78 4         0 my $nowcast_month;
79 4         0 my $forecast_month;
80 4         0 my $nowcast_day;
81 4         0 my $forecast_day;
82 4         0 my $nowcast_hour;
83 4         0 my $forecast_hour;
84 4         0 my $date_time;
85 4         0 my $forecast_date_time;
86 4         0 my $nowcast_date_time;
87 4         0 my $nowcast_max_wind;
88 4         0 my $forecast_max_wind;
89 4         8 my $atcf_line = $template;
90 4         15 my $lat;
91             my $lon;
92 4         0 my $vmax;
93 4         0 my $gusts; # in kt
94 4         8 my $center_direction = 65;
95 4         6 my $center_speed = 17;
96              
97 4         13 my $body_ref = \@lines;
98 4         8 my $cnt = @{$body_ref};
  4         9  
99             #
100 4         7 my @match = ();
101              
102             # Get the NHC Number
103             # NWS TPC/NATIONAL HURRICANE CENTER MIAMI FL AL172005
104 4         8 @match = grep /(?:AL|EP|CP|WP|IO|SH|LS)\d{2}\d{4}$/, @{$body_ref};
  4         101  
105 4 50       37 if (@match) {
106 4 50       23 if ( $match[0] =~ /(AL|EP|CP|WP|IO|SH|LS)(\d{2})(\d{4})$/ ) {
107 4         12 $storm_basin = $1;
108 4         9 $storm_number = $2;
109 4         10 $storm_year = $3;
110             }
111 4 50 33     28 die qq{NO NHC NUMBER/YEAR\n} if not $storm_number or not $storm_year or not $storm_basin;
      33        
112             }
113 4         19 $atcf_line =~ s/_BASIN_/$storm_basin/;
114 4         29 my $storm_number_str = sprintf( "%02d", $storm_number );
115 4         15 substr( $atcf_line, 4, 2 ) = $storm_number_str;
116              
117             # Date format
118             # 1500Z THU SEP 02 2004
119             # July 18th TD 2. HAS CHANGED
120             # NOTE NOTE NOW! 1500 UTC TUE JUL 18 2006
121 4 50       14 if ( $storm_year > 2005 ) {
122 4         7 @match = grep /^\d{4} .+ \d{4}$/, @{$body_ref};
  4         94  
123             }
124             else {
125 0         0 @match = grep /^\d{4}Z .+ \d{4}$/, @{$body_ref};
  0         0  
126             }
127             #
128 4 50       23 if (@match) {
129 4         10 $date_time = $match[0];
130 4         10 chomp $date_time;
131 4         27 my @vals = split( ' ', $date_time );
132 4         13 $nowcast_hour = substr( $vals[0], 0, 2 );
133 4 50       12 if ( $storm_year > 2005 ) {
134 4         7 $nowcast_year = $vals[5];
135 4         10 $nowcast_month = $month_lookup{ $vals[3] };
136 4         6 $nowcast_day = $vals[4];
137             }
138             else {
139 0         0 $nowcast_year = $vals[4];
140 0         0 $nowcast_month = $month_lookup{ $vals[2] };
141 0         0 $nowcast_day = $vals[3];
142             }
143 4         11 $nowcast_date_time = $nowcast_year . $nowcast_month . $nowcast_day . $nowcast_hour;
144 4         17 substr( $atcf_line, 8, 10 ) = sprintf( "%10d", $nowcast_date_time );
145             }
146              
147             # advisory number does not appear in the ATCF format
148 4         10 @match = grep /FORECAST.+ADVISORY NUMBER/, @{$body_ref};
  4         63  
149              
150             # HURRICANE FRANCES ADVISORY NUMBER 40
151             # HURRICANE FRANCES FORECAST/ADVISORY NUMBER 37
152             # HURRICANE FRANCES FORECAST/ADVISORY NUMBER 37...CORRECTED
153             # HURRICANE FRANCES SPECIAL FORECAST/ADVISORY NUMBER 37
154 4 50       19 if (@match) {
155 4 50       30 if ( $match[0] =~ /^(.+)\s+FORECAST.+ADVISORY NUMBER\s+(\d{1,3})/ ) {
156 4         10 $storm_name = $1;
157 4         9 $adv_num = $2;
158 4         11 $storm_name =~ s/SPECIAL//;
159             }
160             }
161             else {
162 0         0 @match = grep /ADVISORY NUMBER/, @{$body_ref};
  0         0  
163 0 0       0 if ( $match[0] =~ /^(.+)\s+ADVISORY NUMBER\s+(\d{1,3})/ ) {
164 0         0 $storm_name = $1;
165 0         0 $adv_num = $2;
166             }
167             }
168 4         15 my @tmp = split( ' ', $storm_name );
169 4 50 0     13 if ( $tmp[0] eq 'HURRICANE' ) {
    0 0        
    0 0        
170 4         9 $storm_class = $tmp[0];
171 4         7 $storm_name = $tmp[1];
172             }
173             elsif ( $tmp[0] eq 'POTENTIAL' ) {
174 0         0 $storm_class = "$tmp[0] $tmp[1] $tmp[2]";
175 0         0 $storm_name = $tmp[3];
176             }
177             elsif ( $tmp[0] eq 'TROPICAL' or $tmp[0] eq 'SUBTROPICAL' or $tmp[0] eq 'REMNANTS' or $tmp[0] eq 'POST-TROPICAL' ) {
178              
179             # SUBTROPICAL is rare. see 2007 01
180 0         0 $storm_class = "$tmp[0] $tmp[1]";
181 0         0 $storm_name = $tmp[2];
182             }
183 4         14 substr( $atcf_line, 148, 10 ) = sprintf( "%10s", $storm_name );
184 4         15 my $adv_num_str = sprintf( "%02d", $adv_num );
185 4         11 my $adv_num_url_str = sprintf( "%03d", $adv_num );
186              
187             # HURRICANE CENTER LOCATED NEAR 23.4N 73.9W AT 02/1500Z
188             # or
189             #TROPICAL DEPRESSION DISSIPATING NEAR 29.0N 70.0W AT 24/2100Z
190             # or
191             # TROPICAL DEPRESSION CENTER LOCATED NEAR 11.2N 36.0W AT 25/0300Z
192 4         6 @match = grep /(CENTER LOCATED|DISSIPATING) NEAR/, @{$body_ref};
  4         107  
193 4         13 my $ns_hem = "N";
194 4         9 my $ew_hem = "W";
195 4 50       14 if (@match) {
196 4 50       26 if ( $match[0] =~ /CENTER LOCATED NEAR\s+(\d{1,3}\.\d{1,2})([N|S])\s+(\d{1,3}\.\d{1,2})([E|W])\s+AT/ ) {
197 4         23 $lat = $1;
198 4         7 $ns_hem = $2;
199 4         15 $lon = $3;
200 4         20 $ew_hem = $4;
201             }
202 4 50       14 if ( $match[0] =~ /DISSIPATING NEAR\s+(\d{1,3}\.\d{1,2})([N|S])\s+(\d{1,3}\.\d{1,2})([E|W])\s+AT/ ) {
203 0         0 $lat = $1;
204 0         0 $ns_hem = $2;
205 0         0 $lon = $3;
206 0         0 $ew_hem = $4;
207             }
208             }
209              
210 4         28 my $nowcast_lat = sprintf( "%4d$ns_hem", $lat * 10 );
211 4         14 my $nowcast_lon = sprintf( "%4d$ew_hem", $lon * 10 );
212 4         11 substr( $atcf_line, 34, 5 ) = sprintf( "%5s", $nowcast_lat );
213 4         10 substr( $atcf_line, 41, 5 ) = sprintf( "%5s", $nowcast_lon );
214              
215             #PRESENT MOVEMENT TOWARD THE NORTH-NORTHWEST OR 330 DEGREES AT 9 KT
216 4         7 @match = grep /^PRESENT MOVEMENT TOWARD THE/, @{$body_ref};
  4         47  
217 4 50       15 if (@match) {
218 4 50       37 if ( $match[0] =~ /PRESENT MOVEMENT TOWARD THE.+OR\s+(\d{1,3})\s+DEGREES AT\s+(\d{1,2})\s+KT/ ) {
219 4         10 $center_direction = $1;
220 4         8 $center_speed = $2;
221             }
222 4         14 substr( $atcf_line, 138, 4 ) = sprintf( "%4d", $center_direction );
223 4         11 substr( $atcf_line, 143, 4 ) = sprintf( "%4d", $center_speed );
224             }
225              
226 4         8 @match = grep /^ESTIMATED MINIMUM CENTRAL PRESSURE/, @{$body_ref};
  4         40  
227 4 50       12 if (@match) {
228 4 50       18 if ( $match[0] =~ /^ESTIMATED MINIMUM CENTRAL PRESSURE\s+(.+)\s+MB/ ) {
229 4         9 $pressure = $1;
230             }
231             }
232 4         14 substr( $atcf_line, 53, 4 ) = sprintf( "%4d", $pressure );
233              
234             #MAX SUSTAINED WINDS 25 KT WITH GUSTS TO 35 KT.
235             #MAX SUSTAINED WINDS 125 KT WITH GUSTS TO 155 KT.
236              
237 4         8 @match = grep /^MAX SUSTAINED WINDS/, @{$body_ref};
  4         44  
238              
239 4 50       17 if (@match) {
240 4 50       18 if ( $match[0] =~ /^MAX SUSTAINED WINDS\s+(\d{1,4}) KT WITH GUSTS TO\s+(\d{1,4})/ ) {
241 4         9 $vmax = $1;
242 4         10 $gusts = $2;
243             }
244             }
245              
246 4         13 substr( $atcf_line, 47, 4 ) = sprintf( "%4d", $vmax );
247 4         20 substr( $atcf_line, 113, 4 ) = sprintf( "%4d", $gusts );
248 4         16 my $forecast_atcf_filename = lc($storm_name) . "_advisory_" . $adv_num_str . ".fst";
249             #
250             # collect nowcast wind radii, if any
251 4         7 my $isotachs_found = 0;
252 4         8 my @isotachs;
253 4         6 for my $i ( 0 ... $#{$body_ref} ) {
  4         16  
254 76 100       114 if ( @{$body_ref}[$i] =~ /^MAX SUSTAINED WINDS/ ) {
  76         168  
255              
256             #64 KT....... 45NE 30SE 20SW 30NW.
257             #50 KT.......120NE 75SE 60SW 75NW.
258             #34 KT.......175NE 120SE 120SW 120NW.
259 4         9 $i++;
260 4         8 while (1) {
261 16 100       25 if ( @{$body_ref}[$i] =~ /^(\d{1,2}) KT\.{7}\s{0,}(\d{1,3})[N|S][E|W]\s+(\d{1,3})[N|S][E|W]\s+(\d{1,3})[N|S][E|W]\s+(\d{1,3})[N|S][E|W]/ ) {
  16         66  
262 12         22 $isotachs_found++;
263 12         42 my @wind_radii = ( $1, $2, $3, $4, $5 );
264 12         48 push @isotachs, @wind_radii;
265             }
266             else {
267 4         22 last;
268             }
269 12         21 $i++;
270             }
271 4         19 for ( my $j = $isotachs_found; $j > 0; $j-- ) {
272 12         36 for ( my $k = 0; $k < 4; $k++ ) {
273 48         83 my $starting_pos = 72 + ( $k * 6 );
274 48         75 my $list_pos = 1 + $k + ( 5 * ( $j - 1 ) );
275              
276             # fill in wind radii
277 48         123 substr( $atcf_line, $starting_pos, 5 ) = sprintf( "%5d", $isotachs[$list_pos] );
278              
279             # fill in isotach
280 48         136 substr( $atcf_line, 63, 3 ) = sprintf( "%3d", $isotachs[ 5 * ( $j - 1 ) ] );
281             }
282 12         30 push @output, $atcf_line;
283             }
284 4 50       21 unless ($isotachs_found) {
285 0         0 push @output, $atcf_line;
286             }
287 4         9 last;
288             }
289             }
290              
291             # FORECAST tracks and points
292             # FORECAST VALID 10/0000Z 21.5N 84.5W FORECASTs have a bizzare date format:
293             # dd/hhmm BUT near end of the month say 31/0000 the forecast dates switch to
294             # 01/0000, 01/12000 so we need to check for this and increment the month.
295 4         8 my $forecast_period = 0;
296 4         8 $forecast_year = $nowcast_year;
297 4         7 $forecast_month = $nowcast_month;
298 4         30 $forecast_day = $nowcast_day;
299 4         7 $forecast_hour = $nowcast_hour;
300 4         8 my $i = 0;
301 4         5 while ( $i < $#{$body_ref} ) {
  188         547  
302 184 100       266 if ( @{$body_ref}[$i] =~ /^(FORECAST|OUTLOOK) VALID/ ) {
  184         451  
303 32         65 my $atcf_line = $template;
304 32         108 $atcf_line =~ s/_BASIN_/$storm_basin/;
305              
306             # jgf20160105: fill in the storm number
307 32         66 substr( $atcf_line, 4, 2 ) = $storm_number_str;
308              
309             # fill in the nowcast time
310 32         83 substr( $atcf_line, 8, 10 ) = sprintf( "%10d", $nowcast_date_time );
311              
312             # fill in the storm name
313 32         68 substr( $atcf_line, 148, 10 ) = sprintf( "%10s", $storm_name );
314 32         49 my $line = @{$body_ref}[$i];
  32         65  
315 32         62 chomp $line;
316              
317             # if the storm will dissipate, there is no more data to process
318 32 50       80 if ( $line =~ /DISSIPATED/ ) {
319 0         0 last;
320             }
321 32 50       135 if ( $line =~ /^(FORECAST|OUTLOOK) VALID\s+(\d{2})\/(\d{4})Z/ ) {
322 32         74 $forecast_day = $2;
323 32         67 $forecast_hour = substr( $3, 0, 2 );
324             }
325 32 50       144 if ( $line =~ /Z\s+(\d{1,2}\.\d{1,2})([N|S])\s+(\d{1,2}\.\d{1,2})([E|W])/ ) {
326 32         65 $lat = $1;
327 32         51 $ns_hem = $2;
328 32         56 $lon = $3;
329 32         55 $ew_hem = $4;
330             }
331 32         121 my $forecast_lat = sprintf( "%4d$ns_hem", $lat * 10 );
332 32         89 my $forecast_lon = sprintf( "%4d$ew_hem", $lon * 10 );
333 32         77 substr( $atcf_line, 34, 5 ) = sprintf( "%5s", $forecast_lat );
334 32         75 substr( $atcf_line, 41, 5 ) = sprintf( "%5s", $forecast_lon );
335              
336             # Get the next line
337             # MAX WIND 30 KT...GUSTS 40 KT.
338 32         50 $i++;
339 32         47 $line = @{$body_ref}[$i];
  32         65  
340 32         57 chomp $line;
341 32 50       149 if ( $line =~ /^MAX WIND\s+(\d{1,4}) KT\.\.\.GUSTS\s+(\d{1,4}) KT\./ ) {
342 32         62 $vmax = $1;
343 32         50 $gusts = $2;
344             }
345 32         87 substr( $atcf_line, 47, 4 ) = sprintf( "%4d", $vmax );
346 32         70 substr( $atcf_line, 113, 4 ) = sprintf( "%4d", $gusts );
347 32         90 $forecast_date_time = sprintf( "%04d%02d%02d%02d", $forecast_year, $forecast_month, $forecast_day, $forecast_hour );
348              
349             # check to see if we have crossed into the next month
350 32 50       73 if ( $forecast_date_time < $nowcast_date_time ) {
351 0         0 $forecast_month++;
352 0 0       0 if ( $forecast_month > 12 ) {
353 0         0 $forecast_month = 1;
354             }
355             }
356              
357             # Determine the time in hours (forecast period) between the current
358             # forecast and the nowcast time
359 32         150 ( my $ddays, my $dhrs, my $dsec ) = Date::Calc::Delta_DHMS( $nowcast_year, $nowcast_month, $nowcast_day, $nowcast_hour, 0, 0, $forecast_year, $forecast_month, $forecast_day, $forecast_hour, 0, 0 );
360 32         134 my $forecast_period = $ddays * 24 + $dhrs;
361 32         73 substr( $atcf_line, 29, 4 ) = sprintf( "%4d", $forecast_period );
362              
363             # Get the next line and parse the isotachs
364 32         50 $i++;
365 32         63 ( $i, my $output_ref ) = _parseIotachs( $body_ref, $i, $atcf_line );
366 32         109 push @output, @$output_ref;
367             }
368 184         272 $i++;
369             }
370              
371             # save in instance's internal field
372 4         18 $self->{as_atcf} = \@output;
373              
374             # return array reference for convenience
375 4         139 return $self->as_atcf;
376             }
377              
378             sub _parseIotachs {
379 32     32   65 my ( $body_ref, $i, $atcf_line ) = @_;
380 32         46 my $isotachs_found = 0;
381 32         65 my @isotachs = ();
382 32         50 my @output = ();
383 32         63 while (1) {
384              
385             #64 KT... 45NE 30SE 20SW 30NW.
386 96 100       141 if ( @{$body_ref}[$i] =~ /^(\d{1,2}) KT\.{3}\s{0,}(\d{1,3})[N|S][E|W]\s+(\d{1,3})[N|S][E|W]\s+(\d{1,3})[N|S][E|W]\s+(\d{1,3})[N|S][E|W]/ ) {
  96         368  
387 64         99 $isotachs_found++;
388 64         209 my @wind_radii = ( $1, $2, $3, $4, $5 );
389 64         226 push @isotachs, @wind_radii;
390             }
391             else {
392 32         55 last;
393             }
394 64         107 $i++;
395             }
396 32         76 for ( my $j = $isotachs_found; $j > 0; $j-- ) {
397 64         129 for ( my $k = 0; $k < 4; $k++ ) {
398 256         437 my $starting_pos = 72 + ( $k * 6 );
399 256         480 my $list_pos = 1 + $k + ( 5 * ( $j - 1 ) );
400              
401             # fill in wind radii
402 256         581 substr( $atcf_line, $starting_pos, 5 ) = sprintf( "%5d", $isotachs[$list_pos] );
403              
404             # fill in isotach
405 256         699 substr( $atcf_line, 63, 3 ) = sprintf( "%3d", $isotachs[ 5 * ( $j - 1 ) ] );
406             }
407 64         159 push @output, $atcf_line;
408             }
409 32 100       84 unless ($isotachs_found) {
410 8         20 push @output, $atcf_line;
411             }
412 32         108 return ( $i, \@output );
413             }
414              
415             1;
416              
417             __END__