File Coverage

blib/lib/Geo/Converter/dms2dd.pm
Criterion Covered Total %
statement 92 97 94.8
branch 52 58 89.6
condition 16 24 66.6
subroutine 12 12 100.0
pod 0 1 0.0
total 172 192 89.5


line stmt bran cond sub pod time code
1             # package to convert degrees minutes seconds values to decimal degrees
2             # also does some simple validation of decimal degree values as a side effect
3             package Geo::Converter::dms2dd;
4            
5 1     1   143604 use strict;
  1         2  
  1         43  
6 1     1   6 use warnings;
  1         10  
  1         87  
7 1     1   24 use 5.010;
  1         4  
8            
9             our $VERSION = '0.06';
10            
11 1     1   8 use Carp;
  1         2  
  1         95  
12            
13 1     1   831 use Readonly;
  1         5591  
  1         76  
14 1     1   761 use Regexp::Common qw/number/;
  1         6747  
  1         4  
15 1     1   4546 use English qw { -no_match_vars };
  1         2  
  1         10  
16            
17             require Exporter;
18 1     1   514 use base qw(Exporter);
  1         2  
  1         1981  
19             our @EXPORT_OK = qw( dms2dd );
20            
21             #############################################################
22             ## some stuff to handle values in degrees
23            
24             # some regexes
25             Readonly my $RE_REAL => qr /$RE{num}{real}/xms;
26             Readonly my $RE_INT => qr /$RE{num}{int} /xms;
27             Readonly my $RE_HEMI => qr {
28             # the hemisphere if given as text
29             # handle full words, ignoring numbers and punctuation
30             # needs utf solution
31             \s*
32             [NESWnesw]
33             [a-zA-Z]*
34             \s*
35             }xms;
36            
37             # a few constants
38             Readonly my $MAX_VALID_DD => 360;
39             Readonly my $MIN_VALID_DD => -180;
40             Readonly my $MAX_VALID_LAT => 90;
41             Readonly my $MAX_VALID_LON => 180;
42            
43             Readonly my $INVALID_CHAR_CONTEXT => 3;
44            
45             # how many distinct numbers we can have in a DMS string?
46             Readonly my $MAX_DMS_NUM_COUNT => 3;
47            
48             my $err_msg_pfx = 'DMS2DD Value error: ';
49            
50             # convert degrees minutes seconds values into decimal degrees
51             # e.g.;
52             # S23°32'09.567" = -23.5359908333333
53             # 149°23'18.009"E = 149.388335833333
54             sub dms2dd {
55 30     30 0 245935 my $args = shift;
56            
57 30         76 my $value = $args->{value};
58 30 100       296 croak "Argument 'value' not supplied\n"
59             if !defined $value;
60            
61 29         48 my $first_char_invalid;
62 29 100       151 if (not $value =~ m/ \A [\s0-9NEWSnews+-] /xms) {
63 2         9 $first_char_invalid = substr $value, 0, $INVALID_CHAR_CONTEXT;
64             }
65            
66 29 100       336 croak $err_msg_pfx . "Invalid string at start of value: $value\n"
67             if defined $first_char_invalid;
68            
69 27         51 my @nums = eval {
70 27         102 _dms2dd_extract_nums ( { value => $value } );
71             };
72 27 100       395 croak $EVAL_ERROR if ($EVAL_ERROR);
73            
74 24         41 my $hemi = eval {
75 24         90 _dms2dd_extract_hemisphere (
76             { value => $value },
77             );
78             };
79 24 100       302 croak $EVAL_ERROR if $EVAL_ERROR;
80            
81 22         41 my $multiplier = 1;
82 22 100       78 if ($hemi =~ / ^\s* [SsWw-] /xms) {
83 11         19 $multiplier = -1;
84             }
85            
86             # now apply the defaults
87             # $deg is +ve, as hemispheres are handled separately
88 22   50     74 my $deg = abs ($nums[0] || 0);
89 22   100     62 my $min = $nums[1] || 0;
90 22   100     58 my $sec = $nums[2] || 0;
91            
92 22         100 my $dd = $multiplier
93             * ( $deg
94             + $min / 60
95             + $sec / 3600
96             );
97            
98 22         38 my $valid = eval {
99 22         190 _dms2dd_validate_dd_value ( {
100             %$args,
101             value => $dd,
102             hemisphere => $hemi,
103             } );
104             };
105 22 100       371 croak $EVAL_ERROR if $EVAL_ERROR;
106            
107             #my $res = join (q{ }, $value, $dd, $multiplier, $hemi, @nums) . "\n";
108            
109 19         74 return $dd;
110             }
111            
112             # are the numbers we extracted OK?
113             # must find three or fewer of which only the last can be decimal
114             sub _dms2dd_extract_nums {
115 27     27   46 my $args = shift;
116            
117 27         52 my $value = $args->{value};
118            
119 27         158 my @nums = $value =~ m/$RE_REAL/gxms;
120 27         607 my $deg = $nums[0];
121 27         50 my $min = $nums[1];
122 27         43 my $sec = $nums[2];
123            
124             # some verification
125 27         46 my $msg;
126            
127 27 50       107 if (! defined $deg) {
    100          
128 0         0 $msg = 'No numeric values in string';
129             }
130             elsif (scalar @nums > $MAX_DMS_NUM_COUNT) {
131 1         8 $msg = 'Too many numbers in string';
132             }
133            
134 27 100       184 if (defined $sec) {
135 22 100 33     88 if ($min !~ / \A $RE_INT \z/xms) {
    50          
136 1         13 $msg = 'Seconds value given, but minutes value is floating point';
137             }
138             elsif ($sec < 0 || $sec > 60) {
139 0         0 $msg = 'Seconds value is out of range';
140             }
141             }
142            
143 27 100       493 if (defined $min) {
144 25 100 33     103 if ($deg !~ / \A $RE_INT \z/xms) {
    50          
145 1         42 $msg = 'Minutes value given, but degrees value is floating point';
146             }
147             elsif ($min < 0 || $min > 60) {
148 0         0 $msg = 'Minutes value is out of range';
149             }
150             }
151            
152             # the valid degrees values depend on the hemisphere,
153             # so are trapped elsewhere
154            
155             #my $msg_pfx = 'DMS value error: ';
156 27         375 my $msg_suffix = qq{: '$value'\n};
157            
158 27 100       549 croak $err_msg_pfx . $msg . $msg_suffix
159             if $msg;
160            
161 24 50       158 return wantarray ? @nums : \@nums;
162             }
163            
164             sub _dms2dd_validate_dd_value {
165 22     22   41 my $args = shift;
166            
167 22         58 my $is_lat = $args->{is_lat};
168 22         37 my $is_lon = $args->{is_lon};
169            
170 22         43 my $dd = $args->{value};
171 22         42 my $hemi = $args->{hemisphere};
172            
173 22         35 my $msg_pfx = 'DMS2DD Coord error: ';
174 22         33 my $msg;
175            
176             # if we know the hemisphere then check it is in bounds,
177             # otherwise it must be in the interval [-180,360]
178 22 100 100     176 if ($is_lat // ($hemi =~ / ^[SsNn] /xms)) {
    100 100        
    50 33        
179 10 100       42 if ($is_lon) {
    100          
180 1         9 $msg = "Longitude specified, but latitude found: $dd\n"
181             }
182             elsif (abs ($dd) > $MAX_VALID_LAT) {
183 1         14 $msg = "Latitude out of bounds: $dd\n"
184             }
185             }
186             elsif ($is_lon // ($hemi =~ / [EeWw] /xms)) {
187 8 50       39 if ($is_lat) {
    100          
188 0         0 $msg = "Latitude specified, but longitude found\n"
189             }
190             elsif (abs ($dd) > $MAX_VALID_LON) {
191 1         9 $msg = "Longitude out of bounds: $dd\n"
192             }
193             }
194             elsif ($dd < $MIN_VALID_DD || $dd > $MAX_VALID_DD) {
195 0         0 $msg = "Coord out of bounds: $dd\n";
196             }
197 22 100       629 croak "$msg_pfx $msg" if $msg;
198            
199 19         56 return 1;
200             }
201            
202             sub _dms2dd_extract_hemisphere {
203 24     24   41 my $args = shift;
204            
205 24         48 my $value = $args->{value};
206            
207 24         73 my $hemi;
208             # can start with [NESWnesw-]
209 24 100       122 if ($value =~ m/ \A ( $RE_HEMI | [-] )/xms) {
210 14         192 $hemi = $1;
211             }
212             # cannot end with [-]
213 24 100       300 if ($value =~ m/ ( $RE_HEMI ) \z /xms) {
214 11         227 my $hemi_end = $1;
215            
216 11 100 66     287 croak "Cannot define hemisphere twice: $value\n"
217             if (defined $hemi && defined $hemi_end);
218            
219 9         17 $hemi = $hemi_end;
220             }
221 22 100       171 if (! defined $hemi) {
222 1         2 $hemi = q{};
223             }
224            
225 22         82 return $hemi;
226             }
227            
228            
229             1;
230            
231            
232             =pod
233            
234             =encoding ISO8859-1
235            
236             =head1 NAME
237            
238             Geo::Converter::dms2dd
239            
240             =head1 VERSION
241            
242             0.02
243            
244             =head1 SYNOPSIS
245            
246             use Geo::Converter::dms2dd qw { dms2dd };
247            
248             my $dms_value;
249             my $dd_value;
250            
251             $dms_value = q{S23°32'09.567"};
252             $dd_value = dms2dd ({value => $dms_value});
253             print $dms_value
254             # -23.5359908333333
255            
256             $dms_value = q{149°23'18.009"E};
257             $dd_value = dms2dd ({value => $dms_value});
258             print $dd_value
259             # 149.388335833333
260            
261             $dms_value = q{east 149°23'18.009};
262             $dd_value = dms2dd ({value => $dms_value});
263             print $dd_value
264             # 149.388335833333
265            
266            
267             # The following all croak with warnings:
268            
269             $dms_value = q{S23°32'09.567"};
270             $dd_value = dms2dd ({value => $dms_value, is_lon => 1});
271             # Coord error: Longitude specified, but latitude found
272            
273             $dms_value = q{149°23'18.009"E};
274             $dd_value = dms2dd ({value => $dms_value, is_lat => 1});
275             # Coord error: Latitude out of bounds: 149.388335833333
276            
277             $dms_value = q{149°23'18.009"25}; # extra number
278             $dd_value = dms2dd ({value => $dms_value});
279             # DMS value error: Too many numbers in string: '149°23'18.009"25'
280            
281            
282             =head1 DESCRIPTION
283            
284             Use this module to convert a coordinate value in degrees minutes seconds
285             to decimal degrees. It exports a single sub C which will
286             parse and convert a single value.
287            
288             A reasonable amount of location information is provided in
289             degrees/minutes/seconds (DMS) format, for example from Google Earth, GIS packages or
290             similar. For example, one might be given a location coordinate for just north east
291             of Dingo in Queensland, Australia. Four possible formats are:
292            
293             S23°32'09.567", E149°23'18.009"
294             23°32'09.567"S, 149°23'18.009"E
295             -23 32 9.567, +149 23 18.009
296             -23.535991, 149.388336
297            
298             The first three coordinates are in degrees/minutes/seconds while the fourth
299             is in decimal degrees. The fourth coordinate can be used in numeric
300             calculations, but the first three must first be converted to decimal degrees.
301            
302             The conversion process used in dms2dd is pretty generous in what it treats as DMS,
303             as there is a multitude of variations in punctuation and the like.
304             Up to three numeric values are extracted and any additional text is largely
305             ignored unless it could be interpreted as the hemisphere (see below).
306             It croaks if there are four or more numeric values.
307             If the hemisphere is known or the C or C arguments are specified then
308             values are validated (e.g. latitudes must be in the interval [-90, 90],
309             and longitudes with a hemisphere specified must be within [-180, 180]).
310             Otherwise values between [-180, 360] are accepted. If seconds are specified
311             and minutes have values after the radix (decimal point) then it croaks
312             (e.g. 35 26.5' 22"). Likewise, it croaks for cases like (35.2d 26').
313             It will also croak if you specify the hemisphere at the start and end of the
314             value, even if it is the same hemisphere.
315            
316             Note that this module only works on a single value.
317             Call it once each for latitude and longitude values to convert a full coordinate.
318            
319             =head1 AUTHOR
320            
321             Shawn Laffan S<(I)>.
322            
323             =head1 BUGS AND IRRITATIONS
324            
325             Hemispheres are very liberally interpreted. So long as the text component
326             starts with a valid character then it is used. This means that
327             (E 35 26') is treated the same as (Egregious 35 26').
328            
329             It also does not deal with non-English spellings of north, south, east or west.
330             Hemispheres need to satisfy qr/[NESWnesw+-]/. A solution could be to drop
331             in an appropriate regexp as an argument, or maybe there is an i18n
332             solution. Patches welcome.
333            
334             It could probably also give the parsed degrees, minutes and seconds rather
335             than convert them. They are pretty easy to calculate, though.
336            
337            
338             =head1 LICENSE
339            
340             This library is free software; you can redistribute it and/or modify
341             it under the same terms as Perl itself, either Perl version 5.8.9 or,
342             at your option, any later version of Perl 5 you may have available.
343            
344             =head1 See also
345            
346             L, although it requires the
347             degrees, minutes and seconds values to already be parsed from the string.
348            
349             =cut
350            
351