File Coverage

lib/Weather/GHCN/App/Extremes.pm
Criterion Covered Total %
statement 162 176 92.0
branch 40 58 68.9
condition 14 33 42.4
subroutine 20 20 100.0
pod 5 5 100.0
total 241 292 82.5


line stmt bran cond sub pod time code
1             # Weather::GHCN::Extremes.pm - analyze of extremes from Weather::GHCN::Fetch.pm output
2            
3             ## no critic (Documentation::RequirePodAtEnd)
4            
5             =head1 NAME
6            
7             Weather::GHCN::App::Extremes - Report temperature extremes from Weather::GHCN::Fetch output
8              
9             =head1 VERSION
10              
11             version v0.0.011
12            
13             =head1 SYNOPSIS
14            
15             use Weather::GHCN::App::Extremes;
16            
17             Weather::GHCN::App::Extremes->run( \@ARGV );
18            
19             See ghcn_extremes -help for details.
20            
21             =cut
22            
23             ########################################################################
24             # Pragmas
25             ########################################################################
26            
27             # these are needed because perlcritic fails to detect that Object::Pad handles these things
28             ## no critic [ValuesAndExpressions::ProhibitVersionStrings]
29            
30 1     1   106680 use v5.18;
  1         15  
31 1     1   6 use warnings;
  1         2  
  1         85  
32            
33             package Weather::GHCN::App::Extremes;
34            
35             our $VERSION = 'v0.0.011';
36            
37 1     1   6 use feature 'signatures';
  1         6  
  1         168  
38 1     1   7 no warnings 'experimental::signatures';
  1         2  
  1         48  
39            
40             ########################################################################
41             # perlcritic rules
42             ########################################################################
43            
44             ## no critic [Subroutines::ProhibitSubroutinePrototypes]
45             ## no critic [ErrorHandling::RequireCarping]
46             ## no critic [Modules::ProhibitAutomaticExportation]
47            
48             # due to use of postfix dereferencing, we have to disable these warnings
49             ## no critic [References::ProhibitDoubleSigils]
50            
51             ########################################################################
52             # Export
53             ########################################################################
54            
55             require Exporter;
56            
57 1     1   6 use base 'Exporter';
  1         1  
  1         167  
58            
59             our @EXPORT = ( 'run' );
60            
61             ########################################################################
62             # Libraries
63             ########################################################################
64 1     1   528 use English qw( -no_match_vars ) ;
  1         3787  
  1         6  
65 1     1   1183 use Getopt::Long qw( GetOptionsFromArray );
  1         12800  
  1         6  
66 1     1   654 use Pod::Usage;
  1         54169  
  1         127  
67 1     1   525 use Const::Fast;
  1         2497  
  1         17  
68 1     1   613 use Hash::Wrap {-lvalue => 1, -defined => 1, -as => '_wrap_hash'};
  1         3934  
  1         8  
69            
70 1     1   1901 use ControlBreak;
  1         15353  
  1         47  
71 1     1   8 use List::Util qw(max min sum);
  1         3  
  1         78  
72 1     1   446 use Set::IntSpan::Fast;
  1         6307  
  1         44  
73            
74             # modules for Windows only
75 1     1   616 use if $OSNAME eq 'MSWin32', 'Win32::Clipboard';
  1         13  
  1         7  
76            
77             ########################################################################
78             # Global delarations
79             ########################################################################
80            
81             # is it ok to use Win32::Clipboard?
82             our $USE_WINCLIP = $OSNAME eq 'MSWin32';
83            
84             my $Opt;
85            
86             my @ExtremeWaves;
87             my %Location;
88            
89             ########################################################################
90             # Constants
91             ########################################################################
92            
93             const my $EMPTY => q(); # empty string
94             const my $SPACE => q( ); # space character
95             const my $TAB => qq(\t); # tab character
96             const my $DASH => q(-); # dash character
97             const my $TRUE => 1; # perl's usual TRUE
98             const my $FALSE => not $TRUE; # a dual-var consisting of '' and 0
99            
100             const my $DEFAULT_HOT_LIMIT => 30;
101             const my $DEFAULT_COLD_LIMIT => -20;
102             const my $DEFAULT_NDAYS => 5;
103            
104             ########################################################################
105             # Script Mainline
106             ########################################################################
107            
108             __PACKAGE__->run( \@ARGV ) unless caller;
109            
110             #-----------------------------------------------------------------------
111             =head1 SUBROUTINES
112            
113             =head2 run ( \@ARGV )
114            
115             Invoke this subroutine, passing in a reference to @ARGV, in order to
116             perform an analysis of the heat or cold waves in the input data.
117            
118             Input is from stdin, or from the files listed in @ARGV. Data should
119             contain tab-separated output if the format generated by:
120            
121             ghcn_fetch -report detail
122            
123             The following columns are expected:
124            
125             Year, Month, Day, Decade, S_Decade, S_Year, S_Qtr,
126             TMAX, TMIN, Tavg, Qflags, StationId, Location
127            
128             Any other columns are ignored.
129            
130             See ghnc_extremes.pl -help for details.
131            
132             =cut
133            
134 3     3 1 19497 sub run ($progname, $argv_aref) {
  3         7  
  3         9  
  3         5  
135            
136 3         10 $Opt = get_options($argv_aref);
137            
138 3         14 my @files = $argv_aref->@*;
139            
140 3 50 66     58 my $limit = $Opt->limit //
141             ( $Opt->cold ? $DEFAULT_COLD_LIMIT
142             : $DEFAULT_HOT_LIMIT
143             );
144            
145 3   66     1030 my $ndays = $Opt->ndays // $DEFAULT_NDAYS;
146            
147 3 100       534 my $cmp_op = $Opt->cold ? '<=' : '>=';
148            
149 3         61 my $years_set = Set::IntSpan::Fast->new;
150            
151             ## no critic [RequireBriefOpen]
152 3         29 my ( $output, $new_fh, $old_fh );
153 3 0 33     71 if ( $Opt->outclip and $USE_WINCLIP ) {
154 0 0       0 open $new_fh, '>', \$output
155             or die 'Unable to open buffer for write';
156 0         0 $old_fh = select $new_fh; ## no critic (ProhibitOneArgSelect)
157             }
158            
159 3         492 @files = $argv_aref->@*;
160 3 50       10 @files = ($DASH) unless @files;
161            
162 3         9 foreach my $file (@files) {
163 3         6 my $fh;
164 3 50       10 if ($file eq $DASH) {
165 0         0 $fh = *STDIN;
166             } else {
167 3 50       145 open $fh, q(<), $file or die;
168             }
169            
170 3         394 @ExtremeWaves = ();
171 3         14 %Location = ();
172            
173             # controlling on bool is_extreme and alpha stnid, minor to major
174 3         78 my $cb = ControlBreak->new( '+XT', 'STNID', 'EOF' );
175            
176 3         535 read_data( $fh, $cb, $limit );
177             }
178            
179 3         13 my $years_href;
180 3 100       93 if ( $Opt->peryear ) {
181 1         21 $years_href = report_extremes_per_year($limit, $ndays, $cmp_op);
182             } else {
183 2         726 $years_href = report_extremes_daycounts($limit, $ndays, $cmp_op)
184             }
185            
186             # generate lines for each year that was missing
187 3 50       88 if ($Opt->nogaps) {
188 0         0 foreach my $stnid (keys $years_href->%*) {
189 0         0 my @years = sort keys $years_href->{$stnid}->%*;
190            
191 0         0 my $s = Set::IntSpan::Fast->new( min(@years) .. max(@years) );
192 0         0 my $t = Set::IntSpan::Fast->new( @years );
193 0         0 my $gaps = $s->diff($t);
194 0         0 my $iter = $gaps->iterate_runs();
195            
196 0         0 while ( my ( $from, $to ) = $iter->() ) {
197 0         0 foreach my $yr ($from .. $to) {
198 0         0 say join $TAB, $stnid, $Location{$stnid}, $yr;
199             }
200             }
201             }
202             }
203            
204             WRAP_UP:
205             # send output to the Windows clipboard
206 3 0 33     599 if ( $Opt->outclip and $USE_WINCLIP ) {
207 0         0 Win32::Clipboard->new()->Set( $output );
208 0         0 select $old_fh; ## no critic [ProhibitOneArgSelect]
209             }
210            
211 3         112 return;
212             }
213            
214             ########################################################################
215             # Script-specific Subroutines
216             ########################################################################
217            
218             =head2 read_data ( $fh, $cb, $limit )
219            
220             Read weather data from the filehandle and collect extreme waves
221             according to $limit and $Opt->cold (true for cold waves, false for
222             heat waves).
223            
224             =cut
225            
226 3     3 1 7 sub read_data ( $fh, $cb, $limit ) {
  3         5  
  3         4  
  3         6  
  3         5  
227            
228 3         10 my $extremes_begins;
229             my @extreme_days;
230 3         0 my $lineno;
231            
232 3         239 while ( my $data = <$fh> ) {
233 14076         443044 chomp $data;
234 14076 50       28184 next if $data eq $EMPTY;
235 14076 50       30303 last if $data =~ m{ \A Notes: }xms;
236            
237 14076         164463 my ($year,$month,$day,$decade,$s_decade,$s_year,$s_qtr,$tmax,$tmin,$tavg,$qflags,$stnid,$loc ) = split $TAB, $data;
238            
239 14076         32916 $lineno++;
240 14076 100       27217 if ($lineno == 1) {
241 3 50 33     33 die '*E* invalid input data: ' . $data
      33        
242             unless $year eq 'Year' and $tmax =~ m{ \A TMAX }xms and $tmin =~ m{ \A TMIN }xms;
243 3         14 next;
244             }
245            
246 14073 100       48712 last unless $year =~ m{ \A \d{4} \Z }xms;
247            
248 14070         54704 my $ymd = sprintf '%04d-%02d-%02d', $year, $month, $day;
249            
250 14070 100       269989 my $value = $Opt->cold ? $tmin : $tmax;
251            
252 14070 100 66     183241 next if not defined $value or $value eq $EMPTY;
253            
254 13827         26133 $Location{$stnid} = $loc;
255            
256 13827 100       218429 my $is_extreme = $Opt->cold
257             ? $value <= $limit
258             : $value >= $limit
259             ;
260            
261             my $on_break = sub {
262 13827 100   13827   852401 if ( $is_extreme ) {
    100          
263 1185 100       2428 $extremes_begins = $ymd if $cb->break('XT');
264 1185         16467 push @extreme_days, [$ymd, $value, $stnid, $loc];
265             } elsif ($cb->break('XT')) {
266 545         7943 push @ExtremeWaves, [$extremes_begins, [@extreme_days], $stnid, $loc ];
267 545         913 $extremes_begins = undef;
268 545         931 @extreme_days = ();
269             }
270 13827 100       159833 if ($cb->break('STNID')) {
271 3         40 $extremes_begins = undef;
272 3         9 @extreme_days = ();
273             }
274 13827         183534 };
275            
276 13827         42239 $cb->test_and_do($is_extreme, $stnid, eof, $on_break);
277             }
278            
279 3         169 return;
280             }
281            
282             =head2 report_extremes_daycounts ($limit, $ndays, $cmp_op)
283            
284             Analyzes the input data lookin for $ndays consecutive days when
285             the temperature is beyond $limit. By default, heatwaves are examined.
286             If the option -cold is given, then cold waves are examined.
287            
288             Returns a reference to a hash keyed on year, and which contains a
289             tab_separated line of text that includes the station id, location,
290             year, ymd the wave began, the number of days the wave lasted,
291             the average temperature during the wave, and the most extreme (hot
292             or cold) temperature during the wave.
293            
294             =cut
295            
296 2     2 1 4 sub report_extremes_daycounts ($limit, $ndays, $cmp_op) {
  2         5  
  2         6  
  2         6  
  2         3  
297 2         5 my %years;
298            
299 2         15 my $daycount_col_head = sprintf '%d-day waves %s %dC', $ndays, $cmp_op, $limit;
300 2         179 say join $TAB, 'StnId', 'Location', 'Year', 'YMD', $daycount_col_head, 'Avg C', 'Max C';
301            
302 2         13 foreach my $xw_aref (@ExtremeWaves) {
303 338         1316 my ($xw_begin, $xdays_aref, $stnid, $loc) = $xw_aref->@*;
304            
305 338   33     565 $stnid //= $EMPTY;
306 338   33     532 $loc //= $EMPTY;
307            
308 338         539 my $count = scalar $xdays_aref->@*;
309            
310 338 100       739 next if $count < $ndays;
311            
312 58         114 my $year = substr $xw_begin, 0, 4; ## no critic [ProhibitMagicNumbers]
313            
314 58         156 my @temps = map { $_->[1] } $xdays_aref->@*;
  264         731  
315 58         166 my $sum = sum(@temps);
316 58 100       1249 my $extreme = $Opt->cold ? min(@temps) : max(@temps);
317 58         1003 my $avg = sprintf '%0.1f', $sum / $count;
318             say join $TAB,
319 58         940 $stnid, $Location{$stnid}, $year, $xw_begin, $count, $avg, $extreme;
320 58         405 $years{$stnid}{$year}++;
321             }
322            
323 2         20 return \%years;
324             }
325            
326             =head2 report_extremes_per_year ($limit, $ndays, $cmp_op)
327            
328             Analyzes the input data lookin for $ndays consecutive days when
329             the temperature is beyond $limit. By default, heatwaves are examined.
330             If the option -cold is given, then cold waves are examined.
331            
332             Returns a reference to a hash keyed on year, and which contains a
333             tab_separated line of text that includes the station id, location,
334             year, and a count of the number of waves detected during that year.
335            
336             =cut
337            
338 1     1 1 32 sub report_extremes_per_year ($limit, $ndays, $cmp_op) {
  1         3  
  1         2  
  1         2  
  1         2  
339 1 50       20 my $type = $Opt->cold ? 'Coldwaves' : 'Heatwaves';
340 1         32 my $title = sprintf '%d-day waves %s %dC', $ndays, $cmp_op, $limit;
341 1         87 say join $TAB, 'StnId', 'Location', 'Year', $title;
342            
343 1         31 my %years;
344            
345 1         5 foreach my $xw_aref (@ExtremeWaves) {
346 207         586 my ($xw_begin, $xdays_aref, $stnid, $loc) = $xw_aref->@*;
347 207   33     338 $stnid //= $EMPTY;
348 207   33     330 $loc //= $EMPTY;
349 207         1199 my ($year) = split $DASH, $xw_begin;
350 207         396 my $count = scalar $xdays_aref->@*;
351 207 100       564 next if $count < $ndays;
352 18         57 $years{$stnid}{$year}++;
353             }
354            
355 1         9 foreach my $stnid ( sort keys %years ) {
356 2         12 foreach my $yr ( sort keys $years{$stnid}->%* ) {
357 12         154 say join $TAB, $stnid, $Location{$stnid}, $yr, $years{$stnid}{$yr};
358             }
359             }
360            
361 1         6 return \%years;
362             }
363            
364             ########################################################################
365             # Script-standard Subroutines
366             ########################################################################
367            
368             =head2 get_options ( \@ARGV )
369            
370             B encapsulates everything we need to process command line
371             options, or to set options when invoking this script from a test script.
372            
373             Normally it's called by passing a reference to @ARGV; from a test script
374             you'd set up a local array variable to specify the options.
375            
376             By convention, you should set up a file-scoped lexical variable named
377             $Opt and set it in the mainline using the return value from this function.
378             Then all options can be accessed used $Opt->option notation.
379            
380             =cut
381            
382 3     3 1 5 sub get_options ($argv_aref) {
  3         6  
  3         5  
383            
384 3         20 my @options = (
385             'limit=i', # lower bound of extremes daily temperature
386             'ndays=i', # number of consecutive days needed to be a extremes
387             'peryear', # report number of heatwaves per year
388             'cold', # report coldwaves instead of heatwaves
389             'nogaps', # generate a line for missing years (for charting)
390             'outclip', # output data to the Windows clipboard
391             'help','usage|?', # help
392             );
393            
394 3         6 my %opt;
395            
396             # create a list of option key names by stripping the various adornments
397 3         8 my @keys = map { (split m{ [!+=:|] }xms)[0] } grep { !ref } @options;
  24         71  
  24         44  
398             # initialize all possible options to undef
399 3         27 @opt{ @keys } = ( undef ) x @keys;
400            
401 3 50       19 GetOptionsFromArray($argv_aref, \%opt, @options)
402             or pod2usage(2);
403            
404             # Make %opt into an object and name it the same as what we usually
405             # call the global options object. Note that this doesn't set the
406             # global -- the script will have to do that using the return value
407             # from this function. But, what this does is allow us to call
408             # $Opt->help and other option within this function using the same
409             # syntax as what we use in the script. This is handy if you need
410             # to rename option '-foo' to '-bar' because you can do a find/replace
411             # on '$Opt->foo' and you'll get any instances of it here as well as
412             # in the script.
413            
414             ## no critic [Capitalization]
415             ## no critic [ProhibitReusedNames]
416 3         2645 my $Opt = _wrap_hash \%opt;
417            
418 3 50       113 pod2usage(1) if $Opt->usage;
419 3 50       595 pod2usage(-verbose => 2) if $Opt->help;
420            
421 3         488 return $Opt;
422             }
423            
424             1; # needed in case we import this as a module (e.g. for testing)
425            
426             =head1 AUTHOR
427            
428             Gary Puckering (jgpuckering@rogers.com)
429            
430             =head1 LICENSE AND COPYRIGHT
431            
432             Copyright 2022, Gary Puckering
433            
434             =cut