File Coverage

blib/lib/Geo/PostalCode/NoDB.pm
Criterion Covered Total %
statement 173 187 92.5
branch 57 74 77.0
condition 10 30 33.3
subroutine 20 20 100.0
pod 6 6 100.0
total 266 317 83.9


line stmt bran cond sub pod time code
1             package Geo::PostalCode::NoDB;
2              
3 4     4   89876 use strict;
  4         11  
  4         165  
4 4     4   29 use vars qw($VERSION);
  4         9  
  4         191  
5 4     4   2970 use FileHandle;
  4         39458  
  4         31  
6 4     4   4665 use POSIX;
  4         30256  
  4         29  
7              
8             $VERSION = '0.01';
9              
10 4     4   14297 use constant PI => 3.14159265;
  4         9  
  4         316  
11 4     4   21 use constant LAT_DEGREES => 180;
  4         9  
  4         184  
12 4     4   20 use constant LON_DEGREES => 360;
  4         6  
  4         4895  
13              
14             # Earth radius in various units
15             our %_UNITS = (
16             mi => 3956,
17             km => 6376.5,
18             );
19              
20             # Aliases
21             $_UNITS{$_} = $_UNITS{mi} foreach (qw(mile miles));
22             $_UNITS{$_} = $_UNITS{km} foreach (qw(kilometer kilometers));
23              
24             sub new {
25 4     4 1 2925 my ( $class, %options ) = @_;
26              
27 4 50       49 my $zip = FileHandle->new( $options{csvfile}, "r" )
28             or die "Couldn't open 'data.csv': $!\n";
29              
30 4         686 my ( %postalcode, %city, %latlon );
31 0         0 my ( %zipcode, %cell, %lat, %lon );
32              
33             ## from installdb
34             # Skip header line
35 4         67816 <$zip>;
36 4         55 while (<$zip>) {
37 172764         216649 chomp;
38 172764         180006 my ( $zipcode, $lat, $lon, $city, $state );
39              
40             # strip enclosing quotes from fields
41 1209348         2451609 ( $zipcode, $city, $state, $lat, $lon ) =
42 172764         543612 map { substr( $_, 1, length($_) - 2 ) }
43             split(",");
44              
45             # the CSV format has mixed case cities
46 172764         478287 $city = uc($city);
47              
48 172764         701598 $zipcode{$zipcode} = "$lat,$lon,$city,$state";
49 172764         347997 $lat{$zipcode} = $lat;
50 172764         321859 $lon{$zipcode} = $lon;
51              
52 172764         405596 my $int_lat = floor($lat);
53 172764         318843 my $int_lon = floor($lon);
54              
55 172764         732867 $cell{"$int_lat-$int_lon"} .= $zipcode;
56 172764         839936 $city{"$state$city"} .= $zipcode;
57             }
58              
59 4         61647 foreach my $k ( keys %city ) {
60 121364         320537 my $v = $city{$k};
61 121364         500191 my @postal_codes = ( $v =~ m!(.{5})!g );
62 121364 50       292428 next unless @postal_codes;
63 121364         198986 my ( $tot_lat, $tot_lon, $count ) = ( 0, 0, 0, 0 );
64 121364         176619 for (@postal_codes) {
65 172764         483841 $tot_lat += $lat{$_};
66 172764         381767 $tot_lon += $lon{$_};
67 172764         309009 $count++;
68             }
69 121364         564865 my $avg_lat = sprintf( "%.5f", $tot_lat / $count );
70 121364         435791 my $avg_lon = sprintf( "%.5f", $tot_lon / $count );
71 121364         496366 $city{$k} = "$v|$avg_lat|$avg_lon";
72             }
73              
74 4         28489 my $self = { postalcode => \%zipcode, city => \%city, latlon => \%cell };
75              
76 4 100 66     96 if ( $options{units} && $_UNITS{ lc $options{units} } ) {
    100          
77 2         10 $self->{_earth_radius} = $_UNITS{ lc $options{units} };
78             }
79             elsif ( $options{earth_radius} ) {
80 1         4 $self->{_earth_radius} = $options{earth_radius};
81             }
82             else {
83 1         6 $self->{_earth_radius} = $_UNITS{mi};
84             }
85              
86 4         266531 bless $self, $class;
87             }
88              
89             sub lookup_postal_code {
90 2434     2434 1 5849 my ( $self, %options ) = @_;
91 2434         4281 my $v = $self->{postalcode}->{ $options{postal_code} };
92 2434 50       4761 return unless $v;
93 2434         8909 my ( $lat, $lon, $city, $state ) = split( /,/, $v );
94 2434         12841 return { lat => $lat, lon => $lon, city => $city, state => $state };
95             }
96              
97             sub lookup_city_state {
98 3     3 1 16741 my ( $self, %options ) = @_;
99 3         18 my $city_state = uc( join( "", $options{state}, $options{city} ) );
100 3         15 my $v = $self->{city}->{$city_state};
101 3 50       14 return unless $v;
102 3         18 my ( $postal_code_str, $lat, $lon ) = split( /\|/, $v );
103 3         138 my @postal_codes = ( $postal_code_str =~ m!(.{5})!g );
104 3         30 return { lat => $lat, lon => $lon, postal_codes => \@postal_codes };
105             }
106              
107             sub calculate_distance {
108 5     5 1 25958 my ( $self, %options ) = @_;
109 5         12 my ( $a, $b ) = @{ $options{postal_codes} };
  5         17  
110 5         21 my $ra = $self->lookup_postal_code( postal_code => $a );
111 5         16 my $rb = $self->lookup_postal_code( postal_code => $b );
112 5 50 33     45 return unless $ra && $rb;
113 5         27 return _calculate_distance( $ra->{lat}, $ra->{lon}, $rb->{lat}, $rb->{lon},
114             $self->{_earth_radius} );
115             }
116              
117             # in miles
118             # in miles
119             sub _calculate_distance {
120 16128     16128   30617 my ( $lat_1, $lon_1, $lat_2, $lon_2, $rho ) = @_;
121              
122             # Convert all the degrees to radians
123 16128         19614 $lat_1 *= PI / 180;
124 16128         19099 $lon_1 *= PI / 180;
125 16128         27167 $lat_2 *= PI / 180;
126 16128         26533 $lon_2 *= PI / 180;
127              
128             # Find the deltas
129 16128         20911 my $delta_lat = $lat_2 - $lat_1;
130 16128         19933 my $delta_lon = $lon_2 - $lon_1;
131              
132             # Find the Great Circle distance
133 16128         280135 my $temp =
134             sin( $delta_lat / 2.0 )**2 +
135             cos($lat_1) * cos($lat_2) * sin( $delta_lon / 2.0 )**2;
136              
137 16128         62528 return $rho * 2 * atan2( sqrt($temp), sqrt( 1 - $temp ) );
138             }
139              
140             sub nearby_postal_codes {
141 5     5 1 3112 my $self = shift;
142 5         12 [ map { $_->{postal_code} } @{ $self->query_postal_codes(@_) } ];
  2955         12266  
  5         28  
143             }
144              
145             sub query_postal_codes {
146 4     4   4164 use Data::Dumper;
  4         26387  
  4         6786  
147 9     9 1 17429 my ( $self, %options ) = @_;
148 9         26 my $pcdb = $self->{postalcode};
149 9         28 my $lldb = $self->{latlon};
150              
151 9         39 my ( $lat, $lon, $distance, $order_by ) =
152             @options{qw(lat lon distance order_by)};
153 9         20 my %select = map { $_ => 1 } @{ $options{select} };
  14         37  
  9         40  
154              
155 9         73 my $distance_degrees =
156             _min( $distance / ( PI * $self->{_earth_radius} / LAT_DEGREES ),
157             LAT_DEGREES );
158 9         137 my $min_lat = floor( $lat - $distance_degrees );
159 9         34 my $max_lat = floor( $lat + $distance_degrees );
160 9         19 my @postal_codes;
161 9         33 for my $x ( $min_lat .. $max_lat ) {
162 21         53 my $lon_rtw; # Latitude wrapped 'round-the-world, so correct longitude
163              
164             # Fix absurdly large latitudes.
165 21         133 while ( $x > LAT_DEGREES ) {
166 0         0 $x -= LAT_DEGREES;
167             }
168              
169             # If we wrapped around a pole, fix up the latitude and set a flag
170             # to fix the longitude when we get there.
171 21 50       141 if ( $x > ( LAT_DEGREES / 2 ) ) {
    50          
172 0         0 $x = -$x + LAT_DEGREES;
173 0         0 $lon_rtw = 1;
174             }
175             elsif ( $x < -( LAT_DEGREES / 2 ) ) {
176 0         0 $x = -$x - LAT_DEGREES;
177 0         0 $lon_rtw = 1;
178             }
179             else {
180 21         48 $lon_rtw = 0;
181             }
182              
183             # Calculate the number of degrees longitude we need to scan
184 21         43 my ($lon_distance_degrees);
185 21 50       86 if ( $x == 90 ) # Special case for north pole
186             {
187 0         0 $lon_distance_degrees = LON_DEGREES / 2;
188             }
189             else {
190 21         126 $lon_distance_degrees = _min(
191             $distance /
192             _min( $self->_lon_miles($x), $self->_lon_miles( $x + 1 ) ),
193             LON_DEGREES / 2
194             );
195             }
196              
197             # If the latitude wrapped 'round-the-world and the longitude
198             # search diameter extends around the entire world, the search
199             # areas for one latitude and its round-the-world counterpart will
200             # overlap. Correct this by shrinking the search area of the
201             # wrapped latitude.
202             # Yes, this is confusing.
203 21 50 33     128 if ( $lon_rtw && $lon_distance_degrees > ( LON_DEGREES / 4 ) ) {
204 0         0 $lon_distance_degrees = LON_DEGREES / 2 - $lon_distance_degrees;
205             }
206 21         122 my $min_lon = floor( $lon - $lon_distance_degrees );
207 21         89 my $max_lon = floor( $lon + $lon_distance_degrees );
208              
209             # Special-case hack:
210             # Shrink whole-world searches, to prevent overlap.
211 21 50       99 if ( ( $max_lon - $min_lon ) == LON_DEGREES ) {
212 0         0 $max_lon--;
213             }
214              
215 21         55 for my $y ( $min_lon .. $max_lon ) {
216              
217             # Correct longitude for latitude that wrapped 'round-the-world.
218 61 50       320 if ($lon_rtw) { $y += 180; }
  0         0  
219              
220             # Correct longitudes that wrap around boundaries
221 61         281 while ( $y > ( LON_DEGREES / 2 ) ) { $y -= LON_DEGREES; }
  0         0  
222 61         250 while ( $y < -( LON_DEGREES / 2 ) ) { $y += LON_DEGREES; }
  0         0  
223              
224             next
225 61 100       259 unless _calculate_distance(
226             $lat, $lon,
227             _test_near( $lat, $x ),
228             _test_near( $lon, $y ),
229             $self->{_earth_radius}
230             ) <= $distance;
231 53         18504 my $postal_code_str = $lldb->{"$x-$y"};
232 53 100       263 next unless $postal_code_str;
233 49         28578 my @cell_zips = ( $postal_code_str =~ m!(.{5})!g );
234 49 100       1074 if (
235             _calculate_distance(
236             $lat, $lon,
237             _test_far( $lat, $x ), _test_far( $lon, $y ),
238             $self->{_earth_radius}
239             ) <= $distance
240             )
241             {
242              
243             # include all of cell
244 2         8 for (@cell_zips) {
245 1040         2698 my %h = ( postal_code => $_ );
246 1040 0 33     2860 if ( $select{distance}
      33        
      0        
      0        
247             || $select{lat}
248             || $select{lon}
249             || $select{city}
250             || $select{state} )
251             {
252 1040         3417 my ( $rlat, $rlon, undef ) =
253             split( /,/, $pcdb->{$_}, 3 );
254 1040         1262 my $r;
255 1040         1993 for my $field ( keys %select ) {
256 5200 100       13703 if ( $field eq 'distance' ) {
    50          
    100          
    100          
257 1040         2264 $h{distance} =
258             _calculate_distance( $lat, $lon, $rlat, $rlon,
259             $self->{_earth_radius} );
260             }
261             elsif ( $field eq 'postal_code' ) {
262             ; # Do Nothing.
263             }
264             elsif ( $field eq 'lat' ) {
265 1040         3827 $h{lat} = $rlat;
266             }
267             elsif ( $field eq 'lon' ) {
268 1040         1790 $h{lon} = $rlon;
269             }
270             else {
271 2080 100       4870 $r =
272             $self->lookup_postal_code( postal_code => $_ )
273             unless $r;
274 2080         4248 $h{$field} = $r->{$field};
275             }
276             }
277             }
278 1040         3340 push @postal_codes, \%h;
279             }
280             }
281             else {
282              
283             # include only postal code with distance
284 47         157 for (@cell_zips) {
285              
286             # Can we guarantee this will never be undef?...
287 14973         73819 my ( $rlat, $rlon, undef ) = split( /,/, $pcdb->{$_}, 3 );
288 14973         20216 my $r;
289 14973         33303 my $d =
290             _calculate_distance( $lat, $lon, $rlat, $rlon,
291             $self->{_earth_radius} );
292 14973 100       43772 if ( $d <= $distance ) {
293 7830         26860 my %h = ( postal_code => $_ );
294 7830         17681 for my $field ( keys %select ) {
295 17391 100       49914 if ( $field eq 'distance' ) {
    50          
    100          
    100          
296 4875         9063 $h{distance} = $d;
297             }
298             elsif ( $field eq 'postal_code' ) {
299             ; # Do Nothing.
300             }
301             elsif ( $field eq 'lat' ) {
302 4875         13077 $h{lat} = $rlat;
303             }
304             elsif ( $field eq 'lon' ) {
305 4875         12866 $h{lon} = $rlon;
306             }
307             else {
308 2766 100       6460 $r =
309             $self->lookup_postal_code( postal_code => $_ )
310             unless $r;
311 2766         5987 $h{$field} = $r->{$field};
312             }
313             }
314 7830         27892 push @postal_codes, \%h;
315             }
316             }
317             }
318             }
319             }
320 9 100       59 if ($order_by) {
321 4 50 33     45 if ( $order_by eq 'city' || $order_by eq 'state' ) {
322 0         0 @postal_codes =
323 0         0 sort { $a->{$order_by} cmp $b->{$order_by} } @postal_codes;
324             }
325             else {
326 46098         72839 @postal_codes =
327 4         406 sort { $a->{$order_by} <=> $b->{$order_by} } @postal_codes;
328             }
329             }
330 9         151 return \@postal_codes;
331             }
332              
333             sub _test_near {
334 122     122   215 my ( $center, $cell ) = @_;
335 122 100 33     804 if ( floor($center) == $cell ) {
    100 66        
336 43         185 return $center;
337             }
338             elsif ( $cell < $center
339             and ( _sign($cell) == _sign($center) or $center < ( LON_DEGREES / 4 ) )
340             )
341             {
342 33         157 return $cell + 1;
343             }
344             else {
345 46         200 return $cell;
346             }
347             }
348              
349             sub _sign {
350 66 50   66   317 return $_[0] == 0 ? 0 : ( $_[0] / abs( $_[0] ) );
351             }
352              
353             sub _test_far {
354 98     98   249 my ( $center, $cell ) = @_;
355 98 100       514 if ( floor($center) == $cell ) {
    100          
356 40 100       2047 if ( $center - $cell < 0.5 ) {
357 12         122 return $cell + 1;
358             }
359             else {
360 28         145 return $cell;
361             }
362             }
363             elsif ( $cell < $center ) {
364 23         136 return $cell;
365             }
366             else {
367 35         162 return $cell + 1;
368             }
369             }
370              
371             sub _lon_miles {
372 42     42   82 my $self = shift;
373 42         85 my ($lat) = @_;
374              
375             # Formula from:
376             # http://www.malaysiagis.com/related_technologies/mapping/basics1b.cfm
377 42         168 my $r =
378             cos( $lat * PI / 180 ) *
379             ( 2 * PI * $self->{_earth_radius} / LON_DEGREES );
380 42         159 $r;
381             }
382              
383             sub _min {
384 51 100   51   259 return $_[0] < $_[1] ? $_[0] : $_[1];
385             }
386              
387             1;
388             __END__