File Coverage

blib/lib/TimeZone/Solar.pm
Criterion Covered Total %
statement 226 233 97.0
branch 80 86 93.0
condition 12 18 66.6
subroutine 47 50 94.0
pod 22 25 88.0
total 387 412 93.9


line stmt bran cond sub pod time code
1             # TimeZone::Solar
2             # ABSTRACT: local solar timezone lookup and utilities including DateTime compatibility
3             # part of Perl implementation of solar timezones library
4             #
5             # Copyright © 2020-2022 Ian Kluft. This program is free software; you can
6             # redistribute it and/or modify it under the terms of the GNU General Public
7             # License Version 3. See https://www.gnu.org/licenses/gpl-3.0-standalone.html
8              
9             # pragmas to silence some warnings from Perl::Critic
10             ## no critic (Modules::RequireExplicitPackage)
11             # This solves a catch-22 where parts of Perl::Critic want both package and use-strict to be first
12 6     6   1154998 use strict;
  6         68  
  6         194  
13 6     6   48 use warnings;
  6         10  
  6         348  
14             ## use critic (Modules::RequireExplicitPackage)
15              
16             package TimeZone::Solar;
17             $TimeZone::Solar::VERSION = '0.2.1';
18 6     6   668 use utf8;
  6         28  
  6         61  
19 6     6   3566 use autodie;
  6         91867  
  6         25  
20             use overload
21 6         47 '""' => "as_string",
22 6     6   46990 'eq' => "eq_string";
  6         4150  
23 6     6   654 use Carp qw(croak);
  6         14  
  6         331  
24 6     6   36 use Readonly;
  6         11  
  6         325  
25 6     6   2965 use DateTime::TimeZone qw(0.80);
  6         945612  
  6         231  
26 6     6   58 use Try::Tiny;
  6         55  
  6         696  
27             Readonly::Scalar my $debug_mode => ( exists $ENV{TZSOLAR_DEBUG} and $ENV{TZSOLAR_DEBUG} ) ? 1 : 0;
28              
29             # constants
30             ## no critic ( Modules::ProhibitMultiplePackages )
31             package TimeZone::Solar::Constant {
32 6     6   44 use Carp qw(croak);
  6         17  
  6         6247  
33             Readonly::Scalar my $TZSOLAR_CLASS_PREFIX => "DateTime::TimeZone::Solar::";
34             Readonly::Scalar my $TZSOLAR_LON_ZONE_RE => qr((Lon0[0-9][0-9][EW]) | (Lon1[0-7][0-9][EW]) | (Lon180[EW]))x;
35             Readonly::Scalar my $TZSOLAR_HOUR_ZONE_RE => qr((East|West)(0[0-9] | 1[0-2]))x;
36             Readonly::Scalar my $TZSOLAR_ZONE_RE => qr( $TZSOLAR_LON_ZONE_RE | $TZSOLAR_HOUR_ZONE_RE )x;
37             Readonly::Scalar my $PRECISION_DIGITS => 6; # max decimal digits of precision
38             Readonly::Scalar my $PRECISION_FP => ( 10**-$PRECISION_DIGITS ) / 2.0; # 1/2 width of floating point equality
39             Readonly::Scalar my $MAX_DEGREES => 360; # maximum degrees = 360
40             Readonly::Scalar my $MAX_LONGITUDE_INT => $MAX_DEGREES / 2; # min/max longitude in integer = 180
41             Readonly::Scalar my $MAX_LONGITUDE_FP => $MAX_DEGREES / 2.0; # min/max longitude in float = 180.0
42             Readonly::Scalar my $MAX_LATITUDE_FP => $MAX_DEGREES / 4.0; # min/max latitude in float = 90.0
43             Readonly::Scalar my $POLAR_UTC_AREA => 10; # latitude near poles to use UTC
44             Readonly::Scalar my $LIMIT_LATITUDE => $MAX_LATITUDE_FP - $POLAR_UTC_AREA; # max latitude for solar time zones
45             Readonly::Scalar my $MINUTES_PER_DEGREE_LON => 4; # minutes per degree longitude
46             Readonly::Hash my %constants => ( # allow tests to check constants
47             TZSOLAR_CLASS_PREFIX => $TZSOLAR_CLASS_PREFIX,
48             TZSOLAR_LON_ZONE_RE => $TZSOLAR_LON_ZONE_RE,
49             TZSOLAR_HOUR_ZONE_RE => $TZSOLAR_HOUR_ZONE_RE,
50             TZSOLAR_ZONE_RE => $TZSOLAR_ZONE_RE,
51             PRECISION_DIGITS => $PRECISION_DIGITS,
52             PRECISION_FP => $PRECISION_FP,
53             MAX_DEGREES => $MAX_DEGREES,
54             MAX_LONGITUDE_INT => $MAX_LONGITUDE_INT,
55             MAX_LONGITUDE_FP => $MAX_LONGITUDE_FP,
56             MAX_LATITUDE_FP => $MAX_LATITUDE_FP,
57             POLAR_UTC_AREA => $POLAR_UTC_AREA,
58             LIMIT_LATITUDE => $LIMIT_LATITUDE,
59             MINUTES_PER_DEGREE_LON => $MINUTES_PER_DEGREE_LON,
60             );
61              
62             # get list of constant names/keys
63             sub names
64             {
65 1     1   697 return ( sort keys %constants );
66             }
67              
68             # get the value of a constant by name
69             sub get
70             {
71 25332     25332   39841 my $name = shift;
72              
73             # require valid name parameter
74 25332 100       63399 if ( not exists $constants{$name} ) {
75 1         20 croak( __PACKAGE__ . ": non-existent constant requested: $name" );
76             }
77 25331         185246 return $constants{$name};
78             }
79             }
80             $TimeZone::Solar::Constant::VERSION = '0.2.1';
81             ## critic ( Modules::ProhibitMultiplePackages )
82              
83             # get list of constants
84             ## no critic ( Subroutines::ProhibitUnusedPrivateSubroutines )
85             sub _const_names
86             {
87 0     0   0 return TimeZone::Solar::Constant::names();
88             }
89             ## critic ( Subroutines::ProhibitUnusedPrivateSubroutines )
90              
91             # access constants
92             # throws exception if requested contant name doesn't exist
93             sub _const
94             {
95 25318     25318   79847 my $name = shift;
96 25318         36407 return TimeZone::Solar::Constant::get($name);
97             }
98              
99             # create timezone subclass
100             # this must be before the BEGIN block which uses it
101             sub _tz_subclass
102             {
103 2328     2328   4290 my ( $class, %opts ) = @_;
104              
105             # for test coverage: if $opts{test_break_eval} is set, break the eval below
106             # under normal circumstances, %opts parameters should be omitted
107             my $result_cmd = (
108             ( exists $opts{test_break_eval} and $opts{test_break_eval} )
109 2328 50 33     5940 ? "croak 'break due to test_break_eval'" # for testing we can force the eval to break
110             : "1" # normally the class definition returns 1
111             );
112              
113             ## no critic (BuiltinFunctions::ProhibitStringyEval)
114 2328         3151 my $class_check = 0;
115             try {
116 2328     2328   307933 $class_check =
117             eval "package $class {" . "\@"
118             . $class
119             . "::ISA = qw("
120             . __PACKAGE__ . ");" . "\$"
121             . $class
122             . "::VERSION = \$"
123             . __PACKAGE__
124             . "::VERSION;"
125             . "$result_cmd; " . "}";
126 2328         12141 };
127 2328 50       37639 if ( not $class_check ) {
128 0         0 croak __PACKAGE__ . "::_tz_subclass: unable to create class $class";
129             }
130              
131             # generate class file path for use in %INC so require() considers this class loaded
132 2328         4022 my $classpath = $class;
133 2328         10361 $classpath =~ s/::/\//gx;
134 2328         4250 $classpath .= ".pm";
135             ## no critic ( Variables::RequireLocalizedPunctuationVars) # this must be global to work
136 2328         8041 $INC{$classpath} = 1;
137 2328         4184 return;
138             }
139              
140             # create subclasses for DateTime::TimeZone::Solar::* time zones
141             # Set subclass @ISA to point here as its parent. Then the subclass inherits methods from this class.
142             # This modifies %DateTime::TimeZone::Catalog::LINKS the same way it allows DateTime::TimeZone::Alias to.
143             BEGIN {
144             # duplicate constant within BEGIN scope because it runs before constant assignments
145 6     6   67 Readonly::Scalar my $TZSOLAR_CLASS_PREFIX => "DateTime::TimeZone::Solar::";
146              
147             # hour-based timezones from -12 to +12
148 6         248 foreach my $tz_dir (qw( East West )) {
149 12         38 foreach my $tz_int ( 0 .. 12 ) {
150 156         561 my $short_name = sprintf( "%s%02d", $tz_dir, $tz_int );
151 156         330 my $long_name = "Solar/" . $short_name;
152 156         252 my $class_name = $TZSOLAR_CLASS_PREFIX . $short_name;
153 156         455 _tz_subclass($class_name);
154 156         686 $DateTime::TimeZone::Catalog::LINKS{$short_name} = $long_name;
155             }
156             }
157              
158             # longitude-based time zones from -180 to +180
159 6         14 foreach my $tz_dir (qw( E W )) {
160 12         48 foreach my $tz_int ( 0 .. 180 ) {
161 2172         7540 my $short_name = sprintf( "Lon%03d%s", $tz_int, $tz_dir );
162 2172         4114 my $long_name = "Solar/" . $short_name;
163 2172         3402 my $class_name = $TZSOLAR_CLASS_PREFIX . $short_name;
164 2172         4674 _tz_subclass($class_name);
165 2172         12238 $DateTime::TimeZone::Catalog::LINKS{$short_name} = $long_name;
166             }
167             }
168             }
169              
170             # file globals
171             my %_INSTANCES;
172              
173             # enforce class access
174             sub _class_guard
175             {
176 457     457   11323 my $class = shift;
177 457 100       1060 my $classname = ref $class ? ref $class : $class;
178 457 100       1045 if ( not defined $classname ) {
179 2         51 croak("incompatible class: invalid method call on undefined value");
180             }
181 455 100       1011 if ( not $class->isa(__PACKAGE__) ) {
182 3         37 croak( "incompatible class: invalid method call for '$classname': not in " . __PACKAGE__ . " hierarchy" );
183             }
184 452         989 return;
185             }
186              
187             # Override isa() method from UNIVERSAL to trick DateTime::TimeZone to accept our timezones as its subclasses.
188             # We don't inherit from DateTime::TimeZone as a base class because it's about Olson TZ db processing we don't need.
189             # But DateTime uses DateTime::TimeZone to look up time zones, and this makes solar timezones fit in.
190             ## no critic ( Subroutines::ProhibitBuiltinHomonyms )
191             sub isa
192             {
193 4551     4551 0 114829 my ( $class, $type ) = @_;
194 4551 100       10021 if ( $type eq "DateTime::TimeZone" ) {
195 56         132 return 1;
196             }
197 4495         20314 return $class->SUPER::isa($type);
198             }
199             ## critic ( Subroutines::ProhibitBuiltinHomonyms )
200              
201             # return TimeZone::Solar (or subclass) version number
202             sub version
203             {
204 4     4 1 3842 my $class = shift;
205 4         14 _class_guard($class);
206              
207             {
208             ## no critic (TestingAndDebugging::ProhibitNoStrict)
209 6     6   78 no strict 'refs';
  6         11  
  6         40180  
  1         2  
210 1 50       2 if ( defined ${ $class . "::VERSION" } ) {
  1         4  
211 1         2 return ${ $class . "::VERSION" };
  1         13  
212             }
213             }
214 0         0 return "00-dev";
215             }
216              
217             # check latitude data and initialize special case for polar regions - internal method called by _tz_params()
218             sub _tz_params_latitude
219             {
220 115     115   187 my $param_ref = shift;
221              
222             # safety check on latitude
223 115 100       753 if ( not $param_ref->{latitude} =~ /^[-+]?\d+(\.\d+)?$/x ) {
224 1         13 croak( __PACKAGE__ . "::_tz_params_latitude: latitude '" . $param_ref->{latitude} . "' is not numeric" );
225             }
226 114 100       379 if ( abs( $param_ref->{latitude} ) > _const("MAX_LATITUDE_FP") + _const("PRECISION_FP") ) {
227 2         37 croak __PACKAGE__ . "::_tz_params_latitude: latitude when provided must be in range -90..+90";
228             }
229              
230             # special case: use East00/Lon000E (equal to UTC) within 10° latitude of poles
231 112 100       890 if ( abs( $param_ref->{latitude} ) >= _const("LIMIT_LATITUDE") - _const("PRECISION_FP") ) {
232 56   66     536 my $use_lon_tz = ( exists $param_ref->{use_lon_tz} and $param_ref->{use_lon_tz} );
233 56 100       141 $param_ref->{short_name} = $use_lon_tz ? "Lon000E" : "East00";
234 56         136 $param_ref->{name} = "Solar/" . $param_ref->{short_name};
235 56         98 $param_ref->{offset_min} = 0;
236 56         101 $param_ref->{offset} = _offset_min2str(0);
237 56         144 return $param_ref;
238             }
239 56         425 return;
240             }
241              
242             # formatting functions
243             sub _tz_prefix
244             {
245 1659     1659   3430 my ( $use_lon_tz, $sign ) = @_;
246 1659 100       4674 return $use_lon_tz ? "Lon" : ( $sign > 0 ? "East" : "West" );
    100          
247             }
248              
249             sub _tz_suffix
250             {
251 1659     1659   3074 my ( $use_lon_tz, $sign ) = @_;
252 1659 100       8330 return $use_lon_tz ? ( $sign > 0 ? "E" : "W" ) : "";
    100          
253             }
254              
255             # get timezone parameters (name and minutes offset) - called by new()
256             sub _tz_params
257             {
258 1722     1722   4943 my %params = @_;
259 1722 100       4035 if ( not exists $params{longitude} ) {
260 1         12 croak __PACKAGE__ . "::_tz_params: longitude parameter missing";
261             }
262              
263             # if latitude is provided, use UTC within 10° latitude of poles
264 1721 100       3658 if ( exists $params{latitude} ) {
265              
266             # check latitude data and special case for polar regions
267 115         255 my $lat_params = _tz_params_latitude( \%params );
268              
269             # return if initialized, otherwise fall through to set time zone from longitude as usual
270 112 100       331 return $lat_params
271             if ref $lat_params eq "HASH";
272             }
273              
274             #
275             # set time zone from longitude
276             #
277              
278             # safety check on longitude
279 1662 100       10212 if ( not $params{longitude} =~ /^[-+]?\d+(\.\d+)?$/x ) {
280 1         14 croak( __PACKAGE__ . "::_tz_params: longitude '" . $params{longitude} . "' is not numeric" );
281             }
282 1661 100       4000 if ( abs( $params{longitude} ) > _const("MAX_LONGITUDE_FP") + _const("PRECISION_FP") ) {
283 2         84 croak __PACKAGE__ . "::_tz_params: longitude must be in the range -180 to +180";
284             }
285              
286             # set flag for longitude time zones: 0 = hourly 1-hour/15-degree zones, 1 = longitude 4-minute/1-degree zones
287             # defaults to hourly time zone ($use_lon_tz=0)
288 1659   100     16704 my $use_lon_tz = ( exists $params{use_lon_tz} and $params{use_lon_tz} );
289 1659 100       3629 my $tz_degree_width = $use_lon_tz ? 1 : 15; # 1 for longitude-based tz, 15 for hour-based tz
290 1659 100       3203 my $tz_digits = $use_lon_tz ? 3 : 2;
291              
292             # handle special case of half-wide tz at positive side of solar date line (180° longitude)
293 1659 100 100     3144 if ( $params{longitude} >= _const("MAX_LONGITUDE_INT") - $tz_degree_width / 2.0 - _const("PRECISION_FP")
294             or $params{longitude} <= -_const("MAX_LONGITUDE_INT") + _const("PRECISION_FP") )
295             {
296 45         408 my $tz_name = sprintf "%s%0*d%s",
297             _tz_prefix( $use_lon_tz, 1 ),
298             $tz_digits, _const("MAX_LONGITUDE_INT") / $tz_degree_width,
299             _tz_suffix( $use_lon_tz, 1 );
300 45         130 $params{short_name} = $tz_name;
301 45         134 $params{name} = "Solar/" . $tz_name;
302 45         82 $params{offset_min} = 720;
303 45         104 $params{offset} = _offset_min2str(720);
304 45         161 return \%params;
305             }
306              
307             # handle special case of half-wide tz at negativ< side of solar date line (180° longitude)
308 1614 100       13243 if ( $params{longitude} <= -_const("MAX_LONGITUDE_INT") + $tz_degree_width / 2.0 + _const("PRECISION_FP") ) {
309 14         116 my $tz_name = sprintf "%s%0*d%s",
310             _tz_prefix( $use_lon_tz, -1 ),
311             $tz_digits, _const("MAX_LONGITUDE_INT") / $tz_degree_width,
312             _tz_suffix( $use_lon_tz, -1 );
313 14         54 $params{short_name} = $tz_name;
314 14         44 $params{name} = "Solar/" . $tz_name;
315 14         45 $params{offset_min} = -720;
316 14         34 $params{offset} = _offset_min2str(-720);
317 14         48 return \%params;
318             }
319              
320             # handle other times zones
321 1600         12677 my $tz_int = int( abs( $params{longitude} ) / $tz_degree_width + 0.5 + _const("PRECISION_FP") );
322 1600 100       11384 my $sign = ( $params{longitude} > -$tz_degree_width / 2.0 + _const("PRECISION_FP") ) ? 1 : -1;
323 1600         11897 my $tz_name = sprintf "%s%0*d%s",
324             _tz_prefix( $use_lon_tz, $sign ),
325             $tz_digits, $tz_int,
326             _tz_suffix( $use_lon_tz, $sign );
327 1600         3578 my $offset = $sign * $tz_int * ( _const("MINUTES_PER_DEGREE_LON") * $tz_degree_width );
328 1600         11200 $params{short_name} = $tz_name;
329 1600         3946 $params{name} = "Solar/" . $tz_name;
330 1600         2886 $params{offset_min} = $offset;
331 1600         3025 $params{offset} = _offset_min2str($offset);
332 1600         4039 return \%params;
333             }
334              
335             # get timezone instance
336             sub _tz_instance
337             {
338 1719     1719   5862 my $hashref = shift;
339              
340             # consistency checks
341 1719 100       3577 if ( not defined $hashref ) {
342 1         11 croak __PACKAGE__ . "::_tz_instance: object not found in parameters";
343             }
344 1718 100       4172 if ( ref $hashref ne "HASH" ) {
345 1         16 croak __PACKAGE__ . "::_tz_instance: received non-hash " . ( ref $hashref ) . " for object";
346             }
347 1717 100       3669 if ( not exists $hashref->{short_name} ) {
348 1         11 croak __PACKAGE__ . "::_tz_instance: short_name attribute missing";
349             }
350 1716 100       3081 if ( $hashref->{short_name} !~ _const("TZSOLAR_ZONE_RE") ) {
351             croak __PACKAGE__
352             . "::_tz_instance: short_name attrbute "
353             . $hashref->{short_name}
354 1         49 . " is not a valid Solar timezone";
355             }
356              
357             # look up class instance, return it if found
358 1715         20124 my $class = _const("TZSOLAR_CLASS_PREFIX") . $hashref->{short_name};
359 1715 100       14525 if ( exists $_INSTANCES{$class} ) {
360              
361             # forward lat/lon parameters to the existing instance, mainly so tests can see where it came from
362 905         1903 foreach my $key (qw(longitude latitude)) {
363 1810 100       3797 if ( exists $hashref->{$key} ) {
364 1016         16699 $_INSTANCES{$class}->{$key} = $hashref->{$key};
365             } else {
366 794         1786 delete $_INSTANCES{$class}->{$key};
367             }
368             }
369 905         2086 return $_INSTANCES{$class};
370             }
371              
372             # make sure the new singleton object's class is a subclass of TimeZone::Solar
373             # this should have already been done by the BEGIN block for all solar timezone subclasses
374 810 50       5955 if ( not $class->isa(__PACKAGE__) ) {
375 0         0 _tz_subclass($class);
376             }
377              
378             # bless the new object into the timezone subclass and save the singleton instance
379 810         1693 my $obj = bless $hashref, $class;
380 810         1629 $_INSTANCES{$class} = $obj;
381              
382             # return the new object
383 810         1459 return $obj;
384             }
385              
386             # instantiate a new TimeZone::Solar object
387             sub new
388             {
389 1723     1723 1 607138 my ( $in_class, %args ) = @_;
390 1723   33     8036 my $class = ref($in_class) || $in_class;
391              
392             # safety check
393 1723 100       4221 if ( not $class->isa(__PACKAGE__) ) {
394 1         13 croak __PACKAGE__ . "->new() prohibited for unrelated class $class";
395             }
396              
397             # if we got here via DataTime::TimeZone::Solar::*->new(), override longitude/use_lon_tz parameters from class name
398 1722         4217 my $tzsolar_class_prefix = _const("TZSOLAR_CLASS_PREFIX");
399 1722         12247 my $tzsolar_zone_re = _const("TZSOLAR_ZONE_RE");
400 1722 100       26593 if ( $in_class =~ qr( $tzsolar_class_prefix ( $tzsolar_zone_re ))x ) {
401 839         2323 my $in_tz = $1;
402 839 100       3642 if ( substr( $in_tz, 0, 4 ) eq "East" ) {
    100          
    50          
403 44         114 my $tz_int = int substr( $in_tz, 4, 2 );
404 44         97 $args{longitude} = $tz_int * 15;
405 44         93 $args{use_lon_tz} = 0;
406             } elsif ( substr( $in_tz, 0, 4 ) eq "West" ) {
407 42         112 my $tz_int = int substr( $in_tz, 4, 2 );
408 42         97 $args{longitude} = -$tz_int * 15;
409 42         85 $args{use_lon_tz} = 0;
410             } elsif ( substr( $in_tz, 0, 3 ) eq "Lon" ) {
411 753         1812 my $tz_int = int substr( $in_tz, 3, 3 );
412 753 100       1590 my $sign = ( substr( $in_tz, 6, 1 ) eq "E" ? 1 : -1 );
413 753         1570 $args{longitude} = $sign * $tz_int;
414 753         1464 $args{use_lon_tz} = 1;
415             } else {
416 0         0 croak __PACKAGE__ . "->new() received unrecognized class name $in_class";
417             }
418 839         1379 delete $args{latitude};
419             }
420              
421             # use %args to look up a timezone singleton instance
422             # make a new one if it doesn't yet exist
423 1722         6359 my $tz_params = _tz_params(%args);
424 1715         3772 my $self = _tz_instance($tz_params);
425 1715         8838 return $self;
426             }
427              
428             #
429             # accessor methods
430             #
431              
432             # longitude: read-only accessor
433             sub longitude
434             {
435 125     125 1 4933 my $self = shift;
436 125         794 return $self->{longitude};
437             }
438              
439             # latitude read-only accessor
440             sub latitude
441             {
442 112     112 1 178 my $self = shift;
443 112 50       237 return if not exists $self->{latitude};
444 112         238 return $self->{latitude};
445             }
446              
447             # name: read accessor
448             sub name
449             {
450 1598     1598 1 2832 my $self = shift;
451 1598         10267 return $self->{name};
452             }
453              
454             # short_name: read accessor
455             sub short_name
456             {
457 987     987 1 235236 my $self = shift;
458 987         19257 return $self->{short_name};
459             }
460              
461             # long_name: read accessor
462 750     750 1 1670 sub long_name { my $self = shift; return $self->name(); }
  750         2875  
463              
464             # offset read accessor
465             sub offset
466             {
467 890     890 1 1683 my $self = shift;
468 890         5351 return $self->{offset};
469             }
470              
471             # offset_min read accessor
472             sub offset_min
473             {
474 834     834 1 1642 my $self = shift;
475 834         4899 return $self->{offset_min};
476             }
477              
478             #
479             # conversion functions
480             #
481              
482             # convert offset minutes to string
483             sub _offset_min2str
484             {
485 1715     1715   2474 my $offset_min = shift;
486 1715 100       3372 my $sign = $offset_min >= 0 ? "+" : "-";
487 1715         3376 my $hours = int( abs($offset_min) / 60 );
488 1715         2828 my $minutes = abs($offset_min) % 60;
489 1715         6410 return sprintf "%s%02d%s%02d", $sign, $hours, ":", $minutes;
490             }
491              
492             # offset minutes as string
493             sub offset_str
494             {
495 112     112 1 161 my $self = shift;
496 112         266 return $self->{offset};
497             }
498              
499             # convert offset minutes to seconds
500             sub offset_sec
501             {
502 56     56 1 77 my $self = shift;
503 56         160 return $self->{offset_min} * 60;
504             }
505              
506             #
507             # DateTime::TimeZone interface compatibility methods
508             # By definition, there is never a Daylight Savings change in the Solar time zones.
509             #
510 0     0 1 0 sub spans { return []; }
511 0     0 1 0 sub has_dst_changes { return 0; }
512 87     87 1 1538 sub is_floating { return 0; }
513 76     76 1 8830 sub is_olson { return 0; }
514 13     13 1 58 sub category { return "Solar"; }
515 48 100   48 1 20309 sub is_utc { my $self = shift; return $self->{offset_min} == 0 ? 1 : 0; }
  48         199  
516 26     26 1 119 sub is_dst_for_datetime { return 0; }
517 24     24 1 158 sub offset_for_datetime { my $self = shift; return $self->offset_sec(); }
  24         50  
518 32     32 1 242 sub offset_for_local_datetime { my $self = shift; return $self->offset_sec(); }
  32         66  
519 13     13 1 37 sub short_name_for_datetime { my $self = shift; return $self->short_name(); }
  13         50  
520              
521             # instance method to respond to DateTime::TimeZone as it expects its timezone subclasses to
522             sub instance
523             {
524 451     451 1 431841 my ( $class, %args ) = @_;
525 451         1285 _class_guard($class);
526 451         740 delete $args{is_olson}; # never accept the is_olson attribute since it isn't true for solar timezones
527 451         1633 return $class->new(%args);
528             }
529              
530             # convert to string for printing
531             # used to overload "" (to string) operator
532             sub as_string
533             {
534 56     56 0 1209 my $self = shift;
535 56         137 return $self->name() . " " . $self->offset();
536             }
537              
538             # equality comparison
539             # used to overload eq (string equality) operator
540             sub eq_string
541             {
542 396     396 0 143199 my ( $self, $arg ) = @_;
543 396 100 66     1539 if ( ref $arg and $arg->isa(__PACKAGE__) ) {
544 388         2683 return $self->name eq $arg->name;
545             }
546 8         23 return $self->name eq $arg;
547             }
548              
549             1;
550              
551             __END__
552              
553             =pod
554              
555             =encoding UTF-8
556              
557             =head1 NAME
558              
559             TimeZone::Solar - local solar timezone lookup and utilities including DateTime compatibility
560              
561             =head1 VERSION
562              
563             version 0.2.1
564              
565             =head1 SYNOPSIS
566              
567             Using TimeZone::Solar alone, with longitude only:
568              
569             use TimeZone::Solar;
570             use feature qw(say);
571              
572             # example without latitude - assumes between 80N and 80S latitude
573             my $solar_tz = TimeZone::Solar->new( longitude => -121.929 );
574             say join " ", ( $solar_tz->name, $solar_tz->short_name, $solar_tz->offset,
575             $solar_tz->offset_min );
576              
577             This outputs "Solar/West08 West08 -08:00 -480" using an hour-based time zone.
578              
579             Using TimeZone::Solar alone, with longitude and latitude:
580              
581             use TimeZone::Solar;
582             use feature qw(say);
583              
584             # example with latitude (location: SJC airport, San Jose, California)
585             my $solar_tz_lat = TimeZone::Solar->new( latitude => 37.363,
586             longitude => -121.929, use_lon_tz => 1 );
587             say $solar_tz_lat;
588              
589             This outputs "Solar/Lon122W -08:08" using a longitude-based time zone.
590              
591             Using TimeZone::Solar with DateTime:
592              
593             use DateTime;
594             use TimeZone::Solar;
595             use feature qw(say);
596              
597             # noon local solar time at 122W longitude, i.e. San Jose CA or Seattle WA
598             my $dt = DateTime->new( year => 2022, month => 6, hour => 1,
599             time_zone => "Solar/West08" );
600              
601             # convert to US Pacific Time (works for Standard or Daylight conversion)
602             $dt->set_time_zone( "US/Pacific" );
603             say $dt;
604              
605             This code prints "2022-06-01T13:00:00", which means noon was converted to
606             1PM, because Solar/West08 is equivalent to US Pacific Standard Time,
607             centered on 120W longitude. And Standard Time is 1 hour off from Daylight
608             Time, which changed noon Solar Time to 1PM Daylight Time.
609              
610             =head1 DESCRIPTION
611              
612             I<TimeZone::Solar> provides lookup and conversion utilities for Solar time zones, which are based on
613             the longitude of any location on Earth. See the next subsection below for more information.
614              
615             Through compatibility with L<DateTime::TimeZone>, I<TimeZone::Solar> allows the L<DateTime> module to
616             convert either direction between standard (Olson Database) timezones and Solar time zones.
617              
618             =head2 Overview of Solar time zones
619              
620             Solar time zones are based on the longitude of a location. Each time zone is defined around having
621             local solar noon, on average, the same as noon on the clock.
622              
623             Solar time zones are always in Standard Time. There are no Daylight Time changes, by definition. The main point
624             is to have a way to opt out of Daylight Saving Time by using solar time.
625              
626             The Solar time zones build upon existing standards.
627              
628             =over
629              
630             =item *
631             Lines of longitude are a well-established standard.
632              
633             =item *
634             Ships at sea use "nautical time" based on time zones 15 degrees of longitude wide.
635              
636             =item *
637             Time zones (without daylight saving offsets) are based on average solar noon at the Prime Meridian. Standard Time in
638             each time zone lines up with average solar noon on the meridian at the center of each time zone, at 15-degree of
639             longitude increments.
640              
641             =back
642              
643             15 degrees of longitude appears more than once above. That isn't a coincidence. It's derived from 360 degrees
644             of rotation in a day, divided by 24 hours in a day. The result is 15 degrees of longitude representing 1 hour
645             in Earth's rotation. That makes each time zone one hour wide. So Solar time zones use that too.
646              
647             The Solar Time Zones proposal is intended as a potential de-facto standard which people can use in their
648             local areas, providing for routine computational time conversion to and from local standard or daylight time.
649             In order for the proposal to become a de-facto standard, made in force by the number of people using it,
650             it starts with technical early adopters choosing to use it. At some point it would actually become an
651             official alternative via publication of an Internet RFC and adding the new time zones into the
652             Internet Assigned Numbers Authority (IANA) Time Zone Database files. The Time Zone Database feeds
653             the time zone conversions used by computers including servers, desktops, phones and embedded devices.
654              
655             There are normal variations of a matter of minutes between local solar noon and clock noon, depending on
656             the latitude and time of year. That variation is always the same number of minutes as local solar noon
657             differs from noon UTC at the same latitude on the Prime Meridian (0° longitude), due to seasonal effects
658             of the tilt in Earth's axis relative to our orbit around the Sun.
659              
660             The Solaer time zones also have another set of overlay time zones the width of 1 degree of longitude, which puts
661             them in 4-minute intervals of time. These are a hyper-local niche for potential use by outdoor events or activities
662             which must be scheduled around daylight. They can also be used by anyone who wants the middle of the scheduling day
663             to coincide closely with local solar noon.
664              
665             =head2 Definition of Solar time zones
666              
667             The Solar time zones definition includes the following rules.
668              
669             =over
670              
671             =item *
672             There are 24 hour-based Solar Time Zones, named West12, West11, West10, West09 through East12. East00 is equivalent to UTC. West00 is an alias for East00.
673              
674             =over
675              
676             =item *
677             Hour-based time zones are spaced in one-hour time increments, or 15 degrees of longitude.
678              
679             =item *
680             Each hour-based time zone is centered on a meridian at a multiple of 15 degrees. In positive and negative integers, these are 0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165 and 180.
681              
682             =item *
683             Each hour-based time zone spans the area ±7.5 degrees of longitude either side of its meridian.
684              
685             =back
686              
687             =item *
688             There are 360 longitude-based Solar Time Zones, named Lon180W for 180 degrees West through Lon180E for 180 degrees East. Lon000E is equivalent to UTC. Lon000W is an alias for Lon000E.
689              
690             =over
691              
692             =item *
693             Longitude-based time zones are spaced in 4-minute time increments, or 1 degree of longitude.
694              
695             =item *
696             Each longitude-based time zone is centered on the meridian of an integer degree of longitude.
697              
698             =item *
699             Each longitude-based time zone spans the area ±0.5 degrees of longitude either side of its meridian.
700              
701             =back
702              
703             =item *
704             In both hourly and longitude-based time zones, there is a limit to their usefulness at the poles. Beyond 80 degrees north or south, the definition uses UTC (East00 or Lon000E). This boundary is the only reason to include latitude in the computation of the time zone.
705              
706             =item *
707             When converting coordinates to a time zone, each time zone includes its boundary meridian at the lower end of its absolute value, which is in the direction toward 0 (UTC). The exception is at exactly ±180.0 degrees, which would be excluded from both sides by this rule. That case is arbitrarily set as +180 just to pick one.
708              
709             =item *
710             The category "Solar" is used for the longer names for these time zones. The names listed above are the short names. The full long name of each time zone is prefixed with "Solar/" such as "Solar/East00" or "Solar/Lon000E".
711              
712             =back
713              
714             =head1 FUNCTIONS AND METHODS
715              
716             =head2 Class methods
717              
718             =over
719              
720             =item $obj = TimeZone::Solar->new( longitude => $float, use_lon_tz => $bool, [latitude => $float] )
721              
722             Create a new instance of the time zone for the given longitude as a floating point number. The "use_lon_tz" parameter
723             is a boolean flag which if true selects longitude-based time zones, at a width of 1 degree of longitude. If false or
724             omitted, it selects hour-based time zones, at a width of 15 degrees of longitude.
725              
726             If a latitude parameter is provided, it only makes a difference if the latitude is within 10° of the poles,
727             at or beyond 80° North or South latitude. In the polar regions, it uses the equivalent of UTC, which is Solar/East00
728             for hour-based time zones or Solar/Lon000E for longitude-based time zones.
729              
730             I<TimeZone::Solar> uses a singleton pattern. So if a given solar time zone's class within the
731             I<DateTime::TimeZone::Solar::*> hierarchy already has an instance, that one will be returned.
732             A new instance is only returned the first time.
733              
734             =item $obj = DateTime::TimeZone::Solar::I<timezone>->new()
735              
736             This is the same class method as TimeZone::Solar->new() except that if called with a class in the
737             I<DateTime::TimeZone::Solar::*> hierarchy, it obtains the time zone parameters from the class name.
738             If an instance exists for that solar time zone class, then that instance is returned.
739             If not, a new one is instantiated and returned.
740              
741             =item $obj = DateTime::TimeZone::Solar::I<timezone>->instance()
742              
743             For compatibility with I<DateTime::TimeZone>, the instance() method returns the class' instance if it exists.
744             Otherwise it is created using the class name to fill in its parameters via the new() method.
745              
746             =item TimeZone::Solar->version()
747              
748             Return the version number of TimeZone::Solar, or for any subclass which inherits the method.
749              
750             When running code within a source-code development workspace, it returns "00-dev" to avoid warnings
751             about undefined values.
752             Release version numbers are assigned and added by the build system upon release,
753             and are not available when running directly from a source code repository.
754              
755             =back
756              
757             =head2 instance methods
758              
759             =over
760              
761             =item $obj->longitude()
762              
763             returns the longitude which was used to instantiate the time zone object.
764             This is mainly intended for testing. Once instantiated the time zone object serves all areas in its boundary.
765              
766             =item $obj->latitude()
767              
768             returns the latitude which was used to instantiate the time zone object, or undef if none was provided.
769             This is mainly intended for testing. Once instantiated the time zone object serves all areas in its boundary.
770              
771             =item $obj->name()
772              
773             returns a string with the long name, including the "Solar/" prefix, of the time zone.
774              
775             =item $obj->long_name()
776              
777             returns a string with the long name, including the "Solar/" prefix, of the time zone.
778             This is equivalent to $obj->name().
779              
780             =item $obj->short_name()
781              
782             returns a string with the short name, excluding the "Solar/" prefix, of the time zone.
783              
784             =item $obj->offset()
785              
786             returns a string with the time zone's offset from UTC in hour-minute format like +01:01 or -01:01 .
787             If seconds matter, it will include them in the format +01:01:01 or -01:01:01 .
788              
789             =item $obj->offset_str()
790              
791             returns a string with the time zone's offset from UTC in hour-minute format, equivalent to $obj->offset().
792              
793             =item $obj->offset_min()
794              
795             returns an integer with the number of minutes of the time zone's offest from UTC.
796              
797             =item $obj->offset_sec()
798              
799             returns an integer with the number of seconds of the time zone's offest from UTC.
800              
801             =back
802              
803             =head2 DateTime::TimeZone compatibility methods
804              
805             =over
806              
807             =item spans()
808              
809             always returns an empty list because there are never any Daylight Time transitions in solar time zones.
810              
811             =item has_dst_changes()
812              
813             always returns 0 (false) because there are never any Daylight Time transitions in solar time zones.
814              
815             =item is_floating()
816              
817             always returns 0 (false) because the solar time zones are not floating time zones.
818              
819             =item is_olson()
820              
821             always returns 0 (false) because the solar time zones are not in the Olson time zone database.
822             (Maybe some day.)
823              
824             =item category()
825              
826             always returns "Solar" for the time zone category.
827              
828             =item is_utc()
829              
830             Returns 1 (true) if the time zone is equivalent to UTC, meaning at 0 offset from UTC. This is only the case for
831             Solar/East00, Solar/West00 (which is an alias for Solar/East00), Solar/Lon000E and Solar/Lon000W (which is an alias
832             for Solar/Lon000E). Otherwise it returns 0 because the time zone is not UTC.
833              
834             =item is_dst_for_datetime()
835              
836             always returns 0 (false) because Daylight Saving Time never occurs in Solar time zones.
837              
838             =item offset_for_datetime()
839              
840             returns the time zone's offset from UTC in seconds. This is equivalent to $obj->offset_sec().
841              
842             =item offset_for_local_datetime()
843              
844             returns the time zone's offset from UTC in seconds. This is equivalent to $obj->offset_sec().
845              
846             =item short_name_for_datetime()
847              
848             returns the time zone's short name, without "Solar/". This is equivalent to $obj->short_name().
849              
850             =back
851              
852             I<TimeZone::Solar> also overloads the eq (string equality) and "" (convert to string) operators for
853             compatibility with I<DateTime::TimeZone>.
854              
855             =head1 LICENSE
856              
857             I<TimeZone::Solar> is Open Source software licensed under the GNU General Public License Version 3.
858             See L<https://www.gnu.org/licenses/gpl-3.0-standalone.html>.
859              
860             =head1 SEE ALSO
861              
862             LongitudeTZ on Github: https://github.com/ikluft/LongitudeTZ
863              
864             =head1 BUGS AND LIMITATIONS
865              
866             Please report bugs via GitHub at L<https://github.com/ikluft/LongitudeTZ/issues>
867              
868             Patches and enhancements may be submitted via a pull request at L<https://github.com/ikluft/LongitudeTZ/pulls>
869              
870             =head1 AUTHOR
871              
872             Ian Kluft <ian.kluft+github@gmail.com>
873              
874             =head1 COPYRIGHT AND LICENSE
875              
876             This software is copyright (c) 2022 by Ian Kluft.
877              
878             This is free software; you can redistribute it and/or modify it under
879             the same terms as the Perl 5 programming language system itself.
880              
881             =cut