File Coverage

blib/lib/TimeZone/Solar.pm
Criterion Covered Total %
statement 220 230 95.6
branch 82 90 91.1
condition 11 18 61.1
subroutine 43 46 93.4
pod 22 25 88.0
total 378 409 92.4


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