File Coverage

blib/lib/Weather/Meteo.pm
Criterion Covered Total %
statement 93 120 77.5
branch 33 56 58.9
condition 17 39 43.5
subroutine 12 13 92.3
pod 3 3 100.0
total 158 231 68.4


line stmt bran cond sub pod time code
1             package Weather::Meteo;
2              
3 6     6   1653518 use strict;
  6         14  
  6         278  
4 6     6   37 use warnings;
  6         13  
  6         464  
5              
6 6     6   41 use Carp;
  6         11  
  6         517  
7 6     6   4370 use CHI;
  6         625581  
  6         345  
8 6     6   58 use JSON::MaybeXS;
  6         11  
  6         506  
9 6     6   5618 use LWP::UserAgent;
  6         402566  
  6         321  
10 6     6   65 use Scalar::Util;
  6         24  
  6         417  
11 6     6   42 use Time::HiRes;
  6         17  
  6         71  
12 6     6   358 use URI;
  6         16  
  6         184  
13              
14 6     6   31 use constant FIRST_YEAR => 1940;
  6         12  
  6         11306  
15              
16             =head1 NAME
17              
18             Weather::Meteo - Interface to L for historical weather data
19              
20             =head1 VERSION
21              
22             Version 0.12
23              
24             =cut
25              
26             our $VERSION = '0.12';
27              
28             =head1 SYNOPSIS
29              
30             The C module provides an interface to the Open-Meteo API for retrieving historical weather data from 1940.
31             It allows users to fetch weather information by specifying latitude, longitude, and a date.
32             The module supports object-oriented usage and allows customization of the HTTP user agent.
33              
34             use Weather::Meteo;
35              
36             my $meteo = Weather::Meteo->new();
37             my $weather = $meteo->weather({ latitude => 0.1, longitude => 0.2, date => '2022-12-25' });
38              
39             =over 4
40              
41             =item * Caching
42              
43             Identical requests are cached (using L or a user-supplied caching object),
44             reducing the number of HTTP requests to the API and speeding up repeated queries.
45              
46             This module leverages L for caching geocoding responses.
47             When a geocode request is made,
48             a cache key is constructed from the request.
49             If a cached response exists,
50             it is returned immediately,
51             avoiding unnecessary API calls.
52              
53             =item * Rate-Limiting
54              
55             A minimum interval between successive API calls can be enforced to ensure that the API is not overwhelmed and to comply with any request throttling requirements.
56              
57             Rate-limiting is implemented using L.
58             A minimum interval between API
59             calls can be specified via the C parameter in the constructor.
60             Before making an API call,
61             the module checks how much time has elapsed since the
62             last request and,
63             if necessary,
64             sleeps for the remaining time.
65              
66             =back
67              
68             =head1 METHODS
69              
70             =head2 new
71              
72             my $meteo = Weather::Meteo->new();
73             my $ua = LWP::UserAgent->new();
74             $ua->env_proxy(1);
75             $meteo = Weather::Meteo->new(ua => $ua);
76              
77             my $weather = $meteo->weather({ latitude => 51.34, longitude => 1.42, date => '2022-12-25' });
78             my @snowfall = @{$weather->{'hourly'}->{'snowfall'}};
79              
80             print 'Number of cms of snow: ', $snowfall[1], "\n";
81              
82             Creates a new instance. Acceptable options include:
83              
84             =over 4
85              
86             =item * C
87              
88             A caching object.
89             If not provided,
90             an in-memory cache is created with a default expiration of one hour.
91              
92             =item * C
93              
94             The API host endpoint.
95             Defaults to L.
96              
97             =item * C
98              
99             Minimum number of seconds to wait between API requests.
100             Defaults to C<0> (no delay).
101             Use this option to enforce rate-limiting.
102              
103             =item * C
104              
105             An object to use for HTTP requests.
106             If not provided, a default user agent is created.
107              
108             =back
109              
110             =cut
111              
112             sub new {
113 9     9 1 1322946 my $class = shift;
114 9 50       56 my %args = (ref($_[0]) eq 'HASH') ? %{$_[0]} : @_;
  0         0  
115              
116 9 100       51 if(!defined($class)) {
    100          
117             # Weather::Meteo::new() used rather than Weather::Meteo->new()
118 1         22 $class = __PACKAGE__;
119             } elsif(ref($class)) {
120             # clone the given object
121 1         24 return bless { %{$class}, %args }, ref($class);
  1         11  
122             }
123              
124 8         21 my $ua = $args{ua};
125 8 100       29 if(!defined($ua)) {
126 7         76 $ua = LWP::UserAgent->new(agent => __PACKAGE__ . "/$VERSION");
127 7         17683 $ua->default_header(accept_encoding => 'gzip,deflate');
128             }
129 8   50     512 my $host = $args{host} || 'archive-api.open-meteo.com';
130              
131             # Set up caching (default to an in-memory cache if none provided)
132 8   66     75 my $cache = $args{cache} || CHI->new(
133             driver => 'Memory',
134             global => 1,
135             expires_in => '1 day',
136             );
137              
138             # Set up rate-limiting: minimum interval between requests (in seconds)
139 8   100     512835 my $min_interval = $args{min_interval} || 0; # default: no delay
140              
141 8         176 return bless {
142             min_interval => $min_interval,
143             last_request => 0, # Initialize last_request timestamp
144             %args,
145             cache => $cache,
146             host => $host,
147             ua => $ua
148             }, $class;
149             }
150              
151             =head2 weather
152              
153             use Geo::Location::Point;
154              
155             my $ramsgate = Geo::Location::Point->new({ latitude => 51.34, longitude => 1.42 });
156             # Print snowfall at 1AM on Christmas morning in Ramsgate
157             $weather = $meteo->weather($ramsgate, '2022-12-25');
158             @snowfall = @{$weather->{'hourly'}->{'snowfall'}};
159              
160             print 'Number of cms of snow: ', $snowfall[1], "\n";
161              
162             use DateTime;
163             my $dt = DateTime->new(year => 2024, month => 2, day => 1);
164             $weather = $meteo->weather({ location => $ramsgate, date => $dt });
165              
166             The date argument can be an ISO-8601 formatted date,
167             or an object that understands the strftime method.
168              
169             Takes an optional argument, tz, containing the time zone.
170             If not given, the module tries to work it out from the given location,
171             for that to work set TIMEZONEDB_KEY to be your API key from L.
172             If all else fails, the module falls back to Europe/London.
173              
174             =cut
175              
176             sub weather
177             {
178 8     8 1 5788 my $self = shift;
179 8         31 my %param;
180              
181 8 100 33     63 if(ref($_[0]) eq 'HASH') {
    50 33        
    50          
    50          
182 7         22 %param = %{$_[0]};
  7         45  
183             } elsif((scalar(@_) == 2) && Scalar::Util::blessed($_[0]) && ($_[0]->can('latitude'))) {
184             # Two arguments - a location object and a date
185 0         0 my $location = $_[0];
186 0         0 $param{latitude} = $location->latitude();
187 0         0 $param{longitude} = $location->longitude();
188 0         0 $param{'date'} = $_[1];
189 0 0 0     0 if($_[0]->can('tz') && $ENV{'TIMEZONEDB_KEY'}) {
190 0         0 $param{'tz'} = $_[0]->tz();
191             }
192             } elsif(ref($_[0])) {
193 0         0 Carp::croak('Usage: weather(latitude => $latitude, longitude => $longitude, date => "YYYY-MM-DD" [ , tz = $tz ])');
194 0         0 return;
195             } elsif((@_ % 2) == 0) {
196 1         3 %param = @_;
197             }
198              
199 8         53 my $latitude = $param{latitude};
200 8         18 my $longitude = $param{longitude};
201 8         20 my $location = $param{'location'};
202 8         18 my $date = $param{'date'};
203 8   50     49 my $tz = $param{'tz'} || 'Europe/London';
204              
205 8 0 66     46 if((!defined($latitude)) && defined($location) &&
      33        
      33        
206             Scalar::Util::blessed($location) && $location->can('latitude')) {
207 0         0 $latitude = $location->latitude();
208 0         0 $longitude = $location->longitude();
209             }
210 8 50 66     76 if((!defined($latitude)) || (!defined($longitude)) || (!defined($date))) {
      33        
211 2         61 Carp::croak('Usage: weather(latitude => $latitude, longitude => $longitude, date => "YYYY-MM-DD")');
212 0         0 return;
213             }
214              
215             # Handle numbers starting with a decimal point
216 6 50       61 if($latitude =~ /^\./) {
217 0         0 $latitude = "0$latitude";
218             }
219 6 50       36 if($latitude =~ /^\-\.(\d+)$/) {
220 0         0 $latitude = "-0.$1";
221             }
222 6 50       29 if($longitude =~ /^\./) {
223 0         0 $longitude = "0$longitude";
224             }
225 6 50       29 if($longitude =~ /^\-\.(\d+)$/) {
226 0         0 $longitude = "-0.$1";
227             }
228              
229 6 50 33     90 if(($latitude !~ /^-?\d+(\.\d+)?$/) || ($longitude !~ /^-?\d+(\.\d+)?$/)) {
230 0         0 Carp::croak(__PACKAGE__, ": Invalid latitude/longitude format ($latitude, $longitude)");
231             }
232              
233 6 50 33     48 if(Scalar::Util::blessed($date) && $date->can('strftime')) {
    100          
234 0         0 $date = $date->strftime('%F');
235             } elsif($date =~ /^(\d{4})-/) {
236 5 100       41 return if($1 < FIRST_YEAR);
237             } else {
238 1         8 Carp::carp("'$date' is not a valid date");
239 1         513 return;
240             }
241              
242 4 50       22 unless($date =~ /^\d{4}-\d{2}-\d{2}$/) {
243 0         0 croak('Invalid date format. Expected YYYY-MM-DD');
244             }
245              
246 4         56 my $uri = URI->new("https://$self->{host}/v1/archive");
247 4         25793 my %query_parameters = (
248             'latitude' => $latitude,
249             'longitude' => $longitude,
250             'start_date' => $date,
251             'end_date' => $date,
252             'hourly' => 'temperature_2m,rain,snowfall,weathercode',
253             'daily' => 'weathercode,temperature_2m_max,temperature_2m_min,rain_sum,snowfall_sum,precipitation_hours,windspeed_10m_max,windgusts_10m_max',
254             'timezone' => $tz,
255             # https://stackoverflow.com/questions/16086962/how-to-get-a-time-zone-from-a-location-using-latitude-and-longitude-coordinates
256             'windspeed_unit' => 'mph',
257             'precipitation_unit' => 'inch'
258             );
259              
260 4         37 $uri->query_form(%query_parameters);
261 4         1860 my $url = $uri->as_string();
262              
263 4         56 $url =~ s/%2C/,/g;
264              
265             # Create a cache key based on the location, date and time zone (might want to use a stronger hash function if needed)
266 4         33 my $cache_key = "weather:$latitude:$longitude:$date:$tz";
267 4 50       51 if(my $cached = $self->{cache}->get($cache_key)) {
268 0         0 return $cached;
269             }
270              
271             # Enforce rate-limiting: ensure at least min_interval seconds between requests
272 4         480 my $now = time();
273 4         12 my $elapsed = $now - $self->{last_request};
274 4 100       23 if($elapsed < $self->{min_interval}) {
275 1         1002434 Time::HiRes::sleep($self->{min_interval} - $elapsed);
276             }
277              
278 4         43 my $res = $self->{ua}->get($url);
279              
280             # Update last_request timestamp
281 4         512 $self->{last_request} = time();
282              
283 4 50       19 if($res->is_error()) {
284 0         0 Carp::carp(ref($self), ": $url API returned error: ", $res->status_line());
285 0         0 return;
286             }
287             # $res->content_type('text/plain'); # May be needed to decode correctly
288              
289 4         45 my $rc;
290 4         10 eval { $rc = JSON::MaybeXS->new()->utf8()->decode($res->decoded_content()) };
  4         39  
291 4 100       1346 if($@) {
292 1         21 Carp::carp("Failed to parse JSON response: $@");
293 1         280 return;
294             }
295              
296 3 50       14 if($rc) {
297 3 50       13 if($rc->{'error'}) {
298             # TODO: print error code
299 0         0 return;
300             }
301 3 50       15 if(defined($rc->{'hourly'})) {
302             # Cache the result before returning it
303 3         36 $self->{'cache'}->set($cache_key, $rc);
304              
305 3         1539 return $rc; # No support for list context, yet
306             }
307             }
308              
309             # my @results = @{ $data || [] };
310             # wantarray ? @results : $results[0];
311             }
312              
313             =head2 ua
314              
315             Accessor method to get and set UserAgent object used internally. You
316             can call I for example, to get the proxy information from
317             environment variables:
318              
319             $meteo->ua()->env_proxy(1);
320              
321             You can also set your own User-Agent object:
322              
323             use LWP::UserAgent::Throttled;
324              
325             my $ua = LWP::UserAgent::Throttled->new();
326             $ua->throttle('open-meteo.com' => 1);
327             $meteo->ua($ua);
328              
329             =cut
330              
331             sub ua {
332 0     0 1   my $self = shift;
333              
334 0 0         if (@_) {
335 0           $self->{ua} = shift;
336             }
337             return $self->{ua}
338 0           }
339              
340             =head1 AUTHOR
341              
342             Nigel Horne, C<< >>
343              
344             This library is free software; you can redistribute it and/or modify
345             it under the same terms as Perl itself.
346              
347             Lots of thanks to the folks at L.
348              
349             =head1 BUGS
350              
351             This module is provided as-is without any warranty.
352              
353             Please report any bugs or feature requests to C,
354             or through the web interface at
355             L.
356             I will be notified, and then you'll
357             automatically be notified of progress on your bug as I make changes.
358              
359             =head1 SEE ALSO
360              
361             Open Meteo API: L
362              
363             =head1 SUPPORT
364              
365             You can find documentation for this module with the perldoc command.
366              
367             perldoc Weather::Meteo
368              
369             You can also look for information at:
370              
371             =over 4
372              
373             =item * MetaCPAN
374              
375             L
376              
377             =item * RT: CPAN's request tracker
378              
379             L
380              
381             =item * CPANTS
382              
383             L
384              
385             =item * CPAN Testers' Matrix
386              
387             L
388              
389             =item * CPAN Testers Dependencies
390              
391             L
392              
393             =back
394              
395             =head1 LICENSE AND COPYRIGHT
396              
397             Copyright 2023-2025 Nigel Horne.
398              
399             This program is released under the following licence: GPL2
400              
401             =cut
402              
403             1;