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   919815 use strict;
  6         54  
  6         156  
13 6     6   24 use warnings;
  6         8  
  6         275  
14             ## use critic (Modules::RequireExplicitPackage)
15              
16             package TimeZone::Solar;
17             $TimeZone::Solar::VERSION = '0.2.2';
18 6     6   608 use utf8;
  6         21  
  6         37  
19 6     6   2584 use autodie;
  6         71183  
  6         23  
20             use overload
21 6         33 '""' => "as_string",
22 6     6   38422 'eq' => "eq_string";
  6         3285  
23 6     6   519 use Carp qw(croak);
  6         10  
  6         241  
24 6     6   31 use Readonly;
  6         8  
  6         299  
25 6     6   2405 use DateTime::TimeZone v0.80.0;
  6         755555  
  6         306  
26 6     6   52 use Try::Tiny;
  6         13  
  6         564  
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   39 use Carp qw(croak);
  6         11  
  6         5174  
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   610 return ( sort keys %constants );
66             }
67              
68             # get the value of a constant by name
69             sub get
70             {
71 25332     25332   33044 my $name = shift;
72              
73             # require valid name parameter
74 25332 100       51303 if ( not exists $constants{$name} ) {
75 1         19 croak( __PACKAGE__ . ": non-existent constant requested: $name" );
76             }
77 25331         150511 return $constants{$name};
78             }
79             }
80             $TimeZone::Solar::Constant::VERSION = '0.2.2';
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   64584 my $name = shift;
96 25318         29794 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   3430 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     4753 ? "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         2425 my $class_check = 0;
115             try {
116 2328     2328   246518 $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         9944 };
127 2328 50       30251 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         3271 my $classpath = $class;
133 2328         8526 $classpath =~ s/::/\//gx;
134 2328         3406 $classpath .= ".pm";
135             ## no critic ( Variables::RequireLocalizedPunctuationVars) # this must be global to work
136 2328         5583 $INC{$classpath} = 1;
137 2328         3469 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   48 Readonly::Scalar my $TZSOLAR_CLASS_PREFIX => "DateTime::TimeZone::Solar::";
146              
147             # hour-based timezones from -12 to +12
148 6         204 foreach my $tz_dir (qw( East West )) {
149 12         28 foreach my $tz_int ( 0 .. 12 ) {
150 156         460 my $short_name = sprintf( "%s%02d", $tz_dir, $tz_int );
151 156         258 my $long_name = "Solar/" . $short_name;
152 156         196 my $class_name = $TZSOLAR_CLASS_PREFIX . $short_name;
153 156         281 _tz_subclass($class_name);
154 156         576 $DateTime::TimeZone::Catalog::LINKS{$short_name} = $long_name;
155             }
156             }
157              
158             # longitude-based time zones from -180 to +180
159 6         12 foreach my $tz_dir (qw( E W )) {
160 12         31 foreach my $tz_int ( 0 .. 180 ) {
161 2172         6360 my $short_name = sprintf( "Lon%03d%s", $tz_int, $tz_dir );
162 2172         3460 my $long_name = "Solar/" . $short_name;
163 2172         2824 my $class_name = $TZSOLAR_CLASS_PREFIX . $short_name;
164 2172         3806 _tz_subclass($class_name);
165 2172         9337 $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   8965 my $class = shift;
177 457 100       902 my $classname = ref $class ? ref $class : $class;
178 457 100       896 if ( not defined $classname ) {
179 2         39 croak("incompatible class: invalid method call on undefined value");
180             }
181 455 100       888 if ( not $class->isa(__PACKAGE__) ) {
182 3         31 croak( "incompatible class: invalid method call for '$classname': not in " . __PACKAGE__ . " hierarchy" );
183             }
184 452         768 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 91061 my ( $class, $type ) = @_;
194 4551 100       8932 if ( $type eq "DateTime::TimeZone" ) {
195 56         93 return 1;
196             }
197 4495         17033 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 3060 my $class = shift;
205 4         12 _class_guard($class);
206              
207             {
208             ## no critic (TestingAndDebugging::ProhibitNoStrict)
209 6     6   52 no strict 'refs';
  6         11  
  6         32442  
  1         1  
210 1 50       2 if ( defined ${ $class . "::VERSION" } ) {
  1         4  
211 1         1 return ${ $class . "::VERSION" };
  1         25  
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   144 my $param_ref = shift;
221              
222             # safety check on latitude
223 115 100       634 if ( not $param_ref->{latitude} =~ /^[-+]?\d+(\.\d+)?$/x ) {
224 1         11 croak( __PACKAGE__ . "::_tz_params_latitude: latitude '" . $param_ref->{latitude} . "' is not numeric" );
225             }
226 114 100       291 if ( abs( $param_ref->{latitude} ) > _const("MAX_LATITUDE_FP") + _const("PRECISION_FP") ) {
227 2         26 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       676 if ( abs( $param_ref->{latitude} ) >= _const("LIMIT_LATITUDE") - _const("PRECISION_FP") ) {
232 56   66     408 my $use_lon_tz = ( exists $param_ref->{use_lon_tz} and $param_ref->{use_lon_tz} );
233 56 100       113 $param_ref->{short_name} = $use_lon_tz ? "Lon000E" : "East00";
234 56         100 $param_ref->{name} = "Solar/" . $param_ref->{short_name};
235 56         78 $param_ref->{offset_min} = 0;
236 56         86 $param_ref->{offset} = _offset_min2str(0);
237 56         112 return $param_ref;
238             }
239 56         328 return;
240             }
241              
242             # formatting functions
243             sub _tz_prefix
244             {
245 1659     1659   2552 my ( $use_lon_tz, $sign ) = @_;
246 1659 100       3896 return $use_lon_tz ? "Lon" : ( $sign > 0 ? "East" : "West" );
    100          
247             }
248              
249             sub _tz_suffix
250             {
251 1659     1659   2432 my ( $use_lon_tz, $sign ) = @_;
252 1659 100       6816 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   4001 my %params = @_;
259 1722 100       3298 if ( not exists $params{longitude} ) {
260 1         9 croak __PACKAGE__ . "::_tz_params: longitude parameter missing";
261             }
262              
263             # if latitude is provided, use UTC within 10° latitude of poles
264 1721 100       3009 if ( exists $params{latitude} ) {
265              
266             # check latitude data and special case for polar regions
267 115         212 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       308 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       8638 if ( not $params{longitude} =~ /^[-+]?\d+(\.\d+)?$/x ) {
280 1         10 croak( __PACKAGE__ . "::_tz_params: longitude '" . $params{longitude} . "' is not numeric" );
281             }
282 1661 100       3361 if ( abs( $params{longitude} ) > _const("MAX_LONGITUDE_FP") + _const("PRECISION_FP") ) {
283 2         30 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     12893 my $use_lon_tz = ( exists $params{use_lon_tz} and $params{use_lon_tz} );
289 1659 100       2953 my $tz_degree_width = $use_lon_tz ? 1 : 15; # 1 for longitude-based tz, 15 for hour-based tz
290 1659 100       2422 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     2535 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         304 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         95 $params{short_name} = $tz_name;
301 45         95 $params{name} = "Solar/" . $tz_name;
302 45         68 $params{offset_min} = 720;
303 45         96 $params{offset} = _offset_min2str(720);
304 45         121 return \%params;
305             }
306              
307             # handle special case of half-wide tz at negativ< side of solar date line (180° longitude)
308 1614 100       10771 if ( $params{longitude} <= -_const("MAX_LONGITUDE_INT") + $tz_degree_width / 2.0 + _const("PRECISION_FP") ) {
309 14         89 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         35 $params{short_name} = $tz_name;
314 14         31 $params{name} = "Solar/" . $tz_name;
315 14         22 $params{offset_min} = -720;
316 14         26 $params{offset} = _offset_min2str(-720);
317 14         53 return \%params;
318             }
319              
320             # handle other times zones
321 1600         9992 my $tz_int = int( abs( $params{longitude} ) / $tz_degree_width + 0.5 + _const("PRECISION_FP") );
322 1600 100       9456 my $sign = ( $params{longitude} > -$tz_degree_width / 2.0 + _const("PRECISION_FP") ) ? 1 : -1;
323 1600         9497 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         2933 my $offset = $sign * $tz_int * ( _const("MINUTES_PER_DEGREE_LON") * $tz_degree_width );
328 1600         8658 $params{short_name} = $tz_name;
329 1600         3268 $params{name} = "Solar/" . $tz_name;
330 1600         2210 $params{offset_min} = $offset;
331 1600         2440 $params{offset} = _offset_min2str($offset);
332 1600         3503 return \%params;
333             }
334              
335             # get timezone instance
336             sub _tz_instance
337             {
338 1719     1719   4352 my $hashref = shift;
339              
340             # consistency checks
341 1719 100       2988 if ( not defined $hashref ) {
342 1         8 croak __PACKAGE__ . "::_tz_instance: object not found in parameters";
343             }
344 1718 100       3597 if ( ref $hashref ne "HASH" ) {
345 1         10 croak __PACKAGE__ . "::_tz_instance: received non-hash " . ( ref $hashref ) . " for object";
346             }
347 1717 100       2728 if ( not exists $hashref->{short_name} ) {
348 1         8 croak __PACKAGE__ . "::_tz_instance: short_name attribute missing";
349             }
350 1716 100       2770 if ( $hashref->{short_name} !~ _const("TZSOLAR_ZONE_RE") ) {
351             croak __PACKAGE__
352             . "::_tz_instance: short_name attrbute "
353             . $hashref->{short_name}
354 1         23 . " is not a valid Solar timezone";
355             }
356              
357             # look up class instance, return it if found
358 1715         16409 my $class = _const("TZSOLAR_CLASS_PREFIX") . $hashref->{short_name};
359 1715 100       11777 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         1558 foreach my $key (qw(longitude latitude)) {
363 1810 100       3080 if ( exists $hashref->{$key} ) {
364 1016         13866 $_INSTANCES{$class}->{$key} = $hashref->{$key};
365             } else {
366 794         1514 delete $_INSTANCES{$class}->{$key};
367             }
368             }
369 905         1758 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       5439 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         1324 my $obj = bless $hashref, $class;
380 810         1430 $_INSTANCES{$class} = $obj;
381              
382             # return the new object
383 810         1122 return $obj;
384             }
385              
386             # instantiate a new TimeZone::Solar object
387             sub new
388             {
389 1723     1723 1 499448 my ( $in_class, %args ) = @_;
390 1723   33     6774 my $class = ref($in_class) || $in_class;
391              
392             # safety check
393 1723 100       3362 if ( not $class->isa(__PACKAGE__) ) {
394 1         10 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         3587 my $tzsolar_class_prefix = _const("TZSOLAR_CLASS_PREFIX");
399 1722         9889 my $tzsolar_zone_re = _const("TZSOLAR_ZONE_RE");
400 1722 100       21871 if ( $in_class =~ qr( $tzsolar_class_prefix ( $tzsolar_zone_re ))x ) {
401 839         2078 my $in_tz = $1;
402 839 100       2774 if ( substr( $in_tz, 0, 4 ) eq "East" ) {
    100          
    50          
403 44         91 my $tz_int = int substr( $in_tz, 4, 2 );
404 44         83 $args{longitude} = $tz_int * 15;
405 44         67 $args{use_lon_tz} = 0;
406             } elsif ( substr( $in_tz, 0, 4 ) eq "West" ) {
407 42         89 my $tz_int = int substr( $in_tz, 4, 2 );
408 42         79 $args{longitude} = -$tz_int * 15;
409 42         62 $args{use_lon_tz} = 0;
410             } elsif ( substr( $in_tz, 0, 3 ) eq "Lon" ) {
411 753         1550 my $tz_int = int substr( $in_tz, 3, 3 );
412 753 100       1275 my $sign = ( substr( $in_tz, 6, 1 ) eq "E" ? 1 : -1 );
413 753         1284 $args{longitude} = $sign * $tz_int;
414 753         1056 $args{use_lon_tz} = 1;
415             } else {
416 0         0 croak __PACKAGE__ . "->new() received unrecognized class name $in_class";
417             }
418 839         1054 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         5257 my $tz_params = _tz_params(%args);
424 1715         2911 my $self = _tz_instance($tz_params);
425 1715         7274 return $self;
426             }
427              
428             #
429             # accessor methods
430             #
431              
432             # longitude: read-only accessor
433             sub longitude
434             {
435 125     125 1 3974 my $self = shift;
436 125         628 return $self->{longitude};
437             }
438              
439             # latitude read-only accessor
440             sub latitude
441             {
442 112     112 1 127 my $self = shift;
443 112 50       206 return if not exists $self->{latitude};
444 112         181 return $self->{latitude};
445             }
446              
447             # name: read accessor
448             sub name
449             {
450 1598     1598 1 2324 my $self = shift;
451 1598         8493 return $self->{name};
452             }
453              
454             # short_name: read accessor
455             sub short_name
456             {
457 987     987 1 195223 my $self = shift;
458 987         15843 return $self->{short_name};
459             }
460              
461             # long_name: read accessor
462 750     750 1 1360 sub long_name { my $self = shift; return $self->name(); }
  750         2277  
463              
464             # offset read accessor
465             sub offset
466             {
467 890     890 1 1382 my $self = shift;
468 890         4606 return $self->{offset};
469             }
470              
471             # offset_min read accessor
472             sub offset_min
473             {
474 834     834 1 1337 my $self = shift;
475 834         4038 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   2086 my $offset_min = shift;
486 1715 100       2901 my $sign = $offset_min >= 0 ? "+" : "-";
487 1715         2803 my $hours = int( abs($offset_min) / 60 );
488 1715         2496 my $minutes = abs($offset_min) % 60;
489 1715         5189 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 134 my $self = shift;
496 112         225 return $self->{offset};
497             }
498              
499             # convert offset minutes to seconds
500             sub offset_sec
501             {
502 56     56 1 63 my $self = shift;
503 56         123 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 1124 sub is_floating { return 0; }
513 76     76 1 7104 sub is_olson { return 0; }
514 13     13 1 55 sub category { return "Solar"; }
515 48 100   48 1 13916 sub is_utc { my $self = shift; return $self->{offset_min} == 0 ? 1 : 0; }
  48         129  
516 26     26 1 87 sub is_dst_for_datetime { return 0; }
517 24     24 1 125 sub offset_for_datetime { my $self = shift; return $self->offset_sec(); }
  24         39  
518 32     32 1 188 sub offset_for_local_datetime { my $self = shift; return $self->offset_sec(); }
  32         48  
519 13     13 1 22 sub short_name_for_datetime { my $self = shift; return $self->short_name(); }
  13         46  
520              
521             # instance method to respond to DateTime::TimeZone as it expects its timezone subclasses to
522             sub instance
523             {
524 451     451 1 346318 my ( $class, %args ) = @_;
525 451         1100 _class_guard($class);
526 451         643 delete $args{is_olson}; # never accept the is_olson attribute since it isn't true for solar timezones
527 451         1017 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 941 my $self = shift;
535 56         87 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 116416 my ( $self, $arg ) = @_;
543 396 100 66     1310 if ( ref $arg and $arg->isa(__PACKAGE__) ) {
544 388         2212 return $self->name eq $arg->name;
545             }
546 8         18 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.2
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             =over
863              
864             =item LongitudeTZ project on Github
865              
866             https://github.com/ikluft/LongitudeTZ
867              
868             =item Perl source
869              
870             https://github.com/ikluft/LongitudeTZ/tree/main/src/perl
871              
872             =item "Time zone and daylight saving time data" at Internet Assigned Numbers Authority (IANA)
873              
874             https://data.iana.org/time-zones/tz-link.html
875              
876             =back
877              
878             =head1 BUGS AND LIMITATIONS
879              
880             Please report bugs via GitHub at L<https://github.com/ikluft/LongitudeTZ/issues>
881              
882             Patches and enhancements may be submitted via a pull request at L<https://github.com/ikluft/LongitudeTZ/pulls>
883              
884             =head1 AUTHOR
885              
886             Ian Kluft <ian.kluft+github@gmail.com>
887              
888             =head1 COPYRIGHT AND LICENSE
889              
890             This software is copyright (c) 2022 by Ian Kluft.
891              
892             This is free software; you can redistribute it and/or modify it under
893             the same terms as the Perl 5 programming language system itself.
894              
895             =cut