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.010
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   105217 use v5.18;
  1         14  
31 1     1   7 use warnings;
  1         2  
  1         84  
32            
33             package Weather::GHCN::App::Extremes;
34            
35             our $VERSION = 'v0.0.010';
36            
37 1     1   6 use feature 'signatures';
  1         3  
  1         167  
38 1     1   7 no warnings 'experimental::signatures';
  1         1  
  1         47  
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   5 use base 'Exporter';
  1         2  
  1         160  
58            
59             our @EXPORT = ( 'run' );
60            
61             ########################################################################
62             # Libraries
63             ########################################################################
64 1     1   1006 use English qw( -no_match_vars ) ;
  1         3554  
  1         5  
65 1     1   1090 use Getopt::Long qw( GetOptionsFromArray );
  1         12716  
  1         6  
66 1     1   680 use Pod::Usage;
  1         54350  
  1         129  
67 1     1   503 use Const::Fast;
  1         2468  
  1         7  
68 1     1   597 use Hash::Wrap {-lvalue => 1, -defined => 1, -as => '_wrap_hash'};
  1         3974  
  1         7  
69            
70 1     1   1993 use ControlBreak;
  1         16178  
  1         56  
71 1     1   11 use List::Util qw(max min sum);
  1         3  
  1         86  
72 1     1   482 use Set::IntSpan::Fast;
  1         6096  
  1         45  
73            
74             # modules for Windows only
75 1     1   580 use if $OSNAME eq 'MSWin32', 'Win32::Clipboard';
  1         15  
  1         19  
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 20992 sub run ($progname, $argv_aref) {
  3         7  
  3         5  
  3         5  
135            
136 3         16 $Opt = get_options($argv_aref);
137            
138 3         15 my @files = $argv_aref->@*;
139            
140 3 50 66     65 my $limit = $Opt->limit //
141             ( $Opt->cold ? $DEFAULT_COLD_LIMIT
142             : $DEFAULT_HOT_LIMIT
143             );
144            
145 3   66     1025 my $ndays = $Opt->ndays // $DEFAULT_NDAYS;
146            
147 3 100       567 my $cmp_op = $Opt->cold ? '<=' : '>=';
148            
149 3         74 my $years_set = Set::IntSpan::Fast->new;
150            
151             ## no critic [RequireBriefOpen]
152 3         34 my ( $output, $new_fh, $old_fh );
153 3 0 33     55 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         482 @files = $argv_aref->@*;
160 3 50       13 @files = ($DASH) unless @files;
161            
162 3         8 foreach my $file (@files) {
163 3         7 my $fh;
164 3 50       10 if ($file eq $DASH) {
165 0         0 $fh = *STDIN;
166             } else {
167 3 50       156 open $fh, q(<), $file or die;
168             }
169            
170 3         385 @ExtremeWaves = ();
171 3         12 %Location = ();
172            
173             # controlling on bool is_extreme and alpha stnid, minor to major
174 3         50 my $cb = ControlBreak->new( '+XT', 'STNID', 'EOF' );
175            
176 3         579 read_data( $fh, $cb, $limit );
177             }
178            
179 3         11 my $years_href;
180 3 100       92 if ( $Opt->peryear ) {
181 1         30 $years_href = report_extremes_per_year($limit, $ndays, $cmp_op);
182             } else {
183 2         642 $years_href = report_extremes_daycounts($limit, $ndays, $cmp_op)
184             }
185            
186             # generate lines for each year that was missing
187 3 50       86 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     608 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         93 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         7  
  3         5  
  3         6  
  3         6  
227            
228 3         10 my $extremes_begins;
229             my @extreme_days;
230 3         0 my $lineno;
231            
232 3         243 while ( my $data = <$fh> ) {
233 14076         451175 chomp $data;
234 14076 50       27552 next if $data eq $EMPTY;
235 14076 50       31418 last if $data =~ m{ \A Notes: }xms;
236            
237 14076         166814 my ($year,$month,$day,$decade,$s_decade,$s_year,$s_qtr,$tmax,$tmin,$tavg,$qflags,$stnid,$loc ) = split $TAB, $data;
238            
239 14076         31715 $lineno++;
240 14076 100       27485 if ($lineno == 1) {
241 3 50 33     47 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         12 next;
244             }
245            
246 14073 100       51079 last unless $year =~ m{ \A \d{4} \Z }xms;
247            
248 14070         57843 my $ymd = sprintf '%04d-%02d-%02d', $year, $month, $day;
249            
250 14070 100       272291 my $value = $Opt->cold ? $tmin : $tmax;
251            
252 14070 100 66     184323 next if not defined $value or $value eq $EMPTY;
253            
254 13827         26394 $Location{$stnid} = $loc;
255            
256 13827 100       216332 my $is_extreme = $Opt->cold
257             ? $value <= $limit
258             : $value >= $limit
259             ;
260            
261             my $on_break = sub {
262 13827 100   13827   858667 if ( $is_extreme ) {
    100          
263 1185 100       2568 $extremes_begins = $ymd if $cb->break('XT');
264 1185         16443 push @extreme_days, [$ymd, $value, $stnid, $loc];
265             } elsif ($cb->break('XT')) {
266 545         7830 push @ExtremeWaves, [$extremes_begins, [@extreme_days], $stnid, $loc ];
267 545         963 $extremes_begins = undef;
268 545         934 @extreme_days = ();
269             }
270 13827 100       159803 if ($cb->break('STNID')) {
271 3         46 $extremes_begins = undef;
272 3         8 @extreme_days = ();
273             }
274 13827         184396 };
275            
276 13827         42878 $cb->test_and_do($is_extreme, $stnid, eof, $on_break);
277             }
278            
279 3         166 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         6  
  2         5  
  2         3  
  2         5  
297 2         4 my %years;
298            
299 2         15 my $daycount_col_head = sprintf '%d-day waves %s %dC', $ndays, $cmp_op, $limit;
300 2         161 say join $TAB, 'StnId', 'Location', 'Year', 'YMD', $daycount_col_head, 'Avg C', 'Max C';
301            
302 2         12 foreach my $xw_aref (@ExtremeWaves) {
303 338         1127 my ($xw_begin, $xdays_aref, $stnid, $loc) = $xw_aref->@*;
304            
305 338   33     601 $stnid //= $EMPTY;
306 338   33     538 $loc //= $EMPTY;
307            
308 338         494 my $count = scalar $xdays_aref->@*;
309            
310 338 100       1058 next if $count < $ndays;
311            
312 58         117 my $year = substr $xw_begin, 0, 4; ## no critic [ProhibitMagicNumbers]
313            
314 58         141 my @temps = map { $_->[1] } $xdays_aref->@*;
  264         684  
315 58         176 my $sum = sum(@temps);
316 58 100       1279 my $extreme = $Opt->cold ? min(@temps) : max(@temps);
317 58         976 my $avg = sprintf '%0.1f', $sum / $count;
318             say join $TAB,
319 58         976 $stnid, $Location{$stnid}, $year, $xw_begin, $count, $avg, $extreme;
320 58         462 $years{$stnid}{$year}++;
321             }
322            
323 2         11 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 34 sub report_extremes_per_year ($limit, $ndays, $cmp_op) {
  1         4  
  1         2  
  1         4  
  1         2  
339 1 50       22 my $type = $Opt->cold ? 'Coldwaves' : 'Heatwaves';
340 1         28 my $title = sprintf '%d-day waves %s %dC', $ndays, $cmp_op, $limit;
341 1         102 say join $TAB, 'StnId', 'Location', 'Year', $title;
342            
343 1         29 my %years;
344            
345 1         6 foreach my $xw_aref (@ExtremeWaves) {
346 207         592 my ($xw_begin, $xdays_aref, $stnid, $loc) = $xw_aref->@*;
347 207   33     339 $stnid //= $EMPTY;
348 207   33     347 $loc //= $EMPTY;
349 207         1266 my ($year) = split $DASH, $xw_begin;
350 207         392 my $count = scalar $xdays_aref->@*;
351 207 100       516 next if $count < $ndays;
352 18         53 $years{$stnid}{$year}++;
353             }
354            
355 1         15 foreach my $stnid ( sort keys %years ) {
356 2         15 foreach my $yr ( sort keys $years{$stnid}->%* ) {
357 12         157 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 7 sub get_options ($argv_aref) {
  3         6  
  3         4  
383            
384 3         21 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         7 my %opt;
395            
396             # create a list of option key names by stripping the various adornments
397 3         12 my @keys = map { (split m{ [!+=:|] }xms)[0] } grep { !ref } @options;
  24         86  
  24         53  
398             # initialize all possible options to undef
399 3         27 @opt{ @keys } = ( undef ) x @keys;
400            
401 3 50       27 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         2450 my $Opt = _wrap_hash \%opt;
417            
418 3 50       150 pod2usage(1) if $Opt->usage;
419 3 50       641 pod2usage(-verbose => 2) if $Opt->help;
420            
421 3         490 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