File Coverage

blib/lib/Geo/PostalCode.pm
Criterion Covered Total %
statement 7 9 77.7
branch n/a
condition n/a
subroutine 3 3 100.0
pod n/a
total 10 12 83.3


line stmt bran cond sub pod time code
1             package Geo::PostalCode;
2              
3 5     5   98224 use strict;
  5         11  
  5         184  
4 5     5   29 use vars qw($VERSION);
  5         9  
  5         280  
5 5     5   2943 use DB_File;
  0            
  0            
6             use POSIX;
7              
8             $VERSION = '0.07';
9              
10             use constant PI => 3.14159265;
11             use constant LAT_DEGREES => 180;
12             use constant LON_DEGREES => 360;
13              
14             # Earth radius in various units
15             our %_UNITS = (
16             mi => 3956,
17             km => 6376.5,
18             );
19             # Aliases
20             $_UNITS{$_} = $_UNITS{mi}
21             foreach (qw(mile miles));
22             $_UNITS{$_} = $_UNITS{km}
23             foreach (qw(kilometer kilometers));
24              
25             sub _max
26             {
27             my $max = shift;
28             foreach my $v (@_)
29             {
30             $max = $v
31             if ($v > $max);
32             }
33             $max;
34             }
35              
36             sub new {
37             my ($class, %options) = @_;
38             my $db_dir = $options{db_dir};
39             my $btree;
40             if ($options{cachesize}) {
41             if ($options{cachesize} eq 'max') {
42             $options{cachesize} = _max(map { -s "$db_dir/$_.db"} qw(postalcode city latlon));
43             }
44             $btree = new DB_File::BTREEINFO;
45             $btree->{cachesize} = $options{cachesize};
46             } else {
47             $btree = $DB_BTREE;
48             }
49              
50             my (%postalcode, %city, %latlon);
51              
52             tie %postalcode, 'DB_File', "$db_dir/postalcode.db", O_RDONLY, 0666, $btree
53             or die "Couldn't open postalcode.db in $db_dir: $!\n";
54             tie %city, 'DB_File', "$db_dir/city.db", O_RDONLY, 0666, $btree
55             or die "Couldn't open city.db in $db_dir: $!\n";
56             tie %latlon, 'DB_File', "$db_dir/latlon.db", O_RDONLY, 0666, $btree
57             or die "Couldn't open latlon.db in $db_dir: $!\n";
58             my $self = {postalcode => \%postalcode, city => \%city, latlon => \%latlon};
59              
60             if ($options{units} && $_UNITS{lc $options{units}})
61             {
62             $self->{_earth_radius}=$_UNITS{lc $options{units}}
63             }
64             elsif ($options{earth_radius})
65             {
66             $self->{_earth_radius} = $options{earth_radius};
67             }
68             else
69             {
70             $self->{_earth_radius} = $_UNITS{mi};
71             }
72            
73             bless $self, $class;
74             }
75              
76             sub lookup_postal_code {
77             my ($self, %options) = @_;
78             my $v = $self->{postalcode}->{$options{postal_code}};
79             return unless $v;
80             my ($lat, $lon, $city, $state) = split(/,/,$v);
81             return {lat => $lat, lon => $lon, city => $city, state => $state};
82             }
83              
84             sub lookup_city_state {
85             my ($self, %options) = @_;
86             my $city_state = uc(join("", $options{state}, $options{city}));
87             my $v = $self->{city}->{$city_state};
88             return unless $v;
89             my ($postal_code_str, $lat, $lon) = split(/\|/,$v);
90             my @postal_codes = ($postal_code_str =~ m!(.{5})!g);
91             return {lat => $lat, lon => $lon, postal_codes => \@postal_codes};
92             }
93              
94             sub calculate_distance {
95             my ($self, %options) = @_;
96             my ($a, $b) = @{$options{postal_codes}};
97             my $ra = $self->lookup_postal_code(postal_code => $a);
98             my $rb = $self->lookup_postal_code(postal_code => $b);
99             return unless $ra && $rb;
100             return _calculate_distance($ra->{lat}, $ra->{lon}, $rb->{lat}, $rb->{lon}, $self->{_earth_radius});
101             }
102              
103             # in miles
104             # in miles
105             sub _calculate_distance {
106             my ($lat_1, $lon_1, $lat_2, $lon_2, $rho) = @_;
107              
108             # Convert all the degrees to radians
109             $lat_1 *= PI/180;
110             $lon_1 *= PI/180;
111             $lat_2 *= PI/180;
112             $lon_2 *= PI/180;
113              
114             # Find the deltas
115             my $delta_lat = $lat_2 - $lat_1;
116             my $delta_lon = $lon_2 - $lon_1;
117              
118             # Find the Great Circle distance
119             my $temp = sin($delta_lat/2.0)**2 + cos($lat_1) * cos($lat_2) * sin($delta_lon/2.0)**2;
120              
121             return $rho * 2 * atan2(sqrt($temp),sqrt(1-$temp));
122             }
123              
124             sub nearby_postal_codes {
125             my $self = shift;
126             [ map { $_->{postal_code} } @{$self->query_postal_codes(@_)}];
127             }
128              
129             sub query_postal_codes {
130             my ($self, %options) = @_;
131             my $pcdb = $self->{postalcode};
132             my $lldb = $self->{latlon};
133             my ($lat, $lon, $distance, $order_by) = @options{qw(lat lon distance order_by)};
134             my %select = map {$_ => 1} @{$options{select}};
135              
136             my $distance_degrees = _min($distance/(PI * $self->{_earth_radius} / LAT_DEGREES),
137             LAT_DEGREES);
138             my $min_lat = floor($lat - $distance_degrees);
139             my $max_lat = floor($lat + $distance_degrees);
140             my @postal_codes;
141             for my $x ($min_lat .. $max_lat) {
142             my $lon_rtw; # Latitude wrapped 'round-the-world, so correct longitude
143              
144             # Fix absurdly large latitudes.
145             while ($x > LAT_DEGREES)
146             {
147             $x -= LAT_DEGREES;
148             }
149              
150             # If we wrapped around a pole, fix up the latitude and set a flag
151             # to fix the longitude when we get there.
152             if ($x > (LAT_DEGREES/2))
153             {
154             $x = -$x + LAT_DEGREES;
155             $lon_rtw = 1;
156             }
157             elsif ($x < -(LAT_DEGREES/2))
158             {
159             $x = -$x - LAT_DEGREES;
160             $lon_rtw = 1;
161             }
162             else
163             {
164             $lon_rtw = 0;
165             }
166              
167             # Calculate the number of degrees longitude we need to scan
168             my($lon_distance_degrees);
169             if ($x==90) # Special case for north pole
170             {
171             $lon_distance_degrees=LON_DEGREES/2;
172             }
173             else
174             {
175             $lon_distance_degrees = _min($distance / _min($self->_lon_miles($x),$self->_lon_miles($x+1)),
176             LON_DEGREES/2);
177             }
178              
179             # If the latitude wrapped 'round-the-world and the longitude
180             # search diameter extends around the entire world, the search
181             # areas for one latitude and its round-the-world counterpart will
182             # overlap. Correct this by shrinking the search area of the
183             # wrapped latitude.
184             # Yes, this is confusing.
185             if ($lon_rtw && $lon_distance_degrees > (LON_DEGREES/4))
186             {
187             $lon_distance_degrees = LON_DEGREES/2 - $lon_distance_degrees;
188             }
189             my $min_lon = floor($lon - $lon_distance_degrees);
190             my $max_lon = floor($lon + $lon_distance_degrees);
191              
192             # Special-case hack:
193             # Shrink whole-world searches, to prevent overlap.
194             if (($max_lon-$min_lon) == LON_DEGREES)
195             {
196             $max_lon--;
197             }
198              
199             for my $y ($min_lon .. $max_lon) {
200             # Correct longitude for latitude that wrapped 'round-the-world.
201             if ($lon_rtw)
202             {
203             $y += 180;
204             }
205              
206             # Correct longitudes that wrap around boundaries
207             while ($y > (LON_DEGREES/2))
208             {
209             $y -= LON_DEGREES;
210             }
211             while ($y < -(LON_DEGREES/2))
212             {
213             $y += LON_DEGREES;
214             }
215              
216             next unless _calculate_distance($lat, $lon,
217             _test_near($lat, $x),
218             _test_near($lon, $y),
219             $self->{_earth_radius}) <= $distance;
220             my $postal_code_str = $lldb->{"$x-$y"};
221             next unless $postal_code_str;
222             my @cell_zips = ($postal_code_str =~ m!(.{5})!g);
223             if (_calculate_distance($lat, $lon,
224             _test_far($lat, $x),
225             _test_far($lon, $y),
226             $self->{_earth_radius}) <= $distance) {
227             # include all of cell
228             for (@cell_zips) {
229             my %h = (postal_code => $_);
230             if ($select{distance}||$select{lat}||$select{lon}||$select{city}||$select{state})
231             {
232             my($rlat,$rlon,undef)=split(/,/,$pcdb->{$_},3);
233             my $r;
234             for my $field (keys %select) {
235             if ($field eq 'distance') {
236             $h{distance} = _calculate_distance($lat,$lon,$rlat,$rlon,$self->{_earth_radius});
237             } elsif ($field eq 'postal_code') {
238             ; # Do Nothing.
239             } elsif ($field eq 'lat') {
240             $h{lat} = $rlat;
241             } elsif ($field eq 'lon') {
242             $h{lon} = $rlon;
243             } else {
244             $r = $self->lookup_postal_code(postal_code => $_)
245             unless $r;
246             $h{$field} = $r->{$field};
247             }
248             }
249             }
250             push @postal_codes, \%h;
251             }
252             } else {
253             # include only postal code with distance
254             for (@cell_zips) {
255             # Can we guarantee this will never be undef?...
256             my($rlat,$rlon,undef)=split(/,/,$pcdb->{$_},3);
257             my $r;
258             my $d = _calculate_distance($lat, $lon, $rlat, $rlon,$self->{_earth_radius});
259             if ($d <= $distance) {
260             my %h = (postal_code => $_);
261             for my $field (keys %select) {
262             if ($field eq 'distance') {
263             $h{distance} = $d;
264             } elsif ($field eq 'postal_code') {
265             ; # Do Nothing.
266             } elsif ($field eq 'lat') {
267             $h{lat} = $rlat;
268             } elsif ($field eq 'lon') {
269             $h{lon} = $rlon;
270             }
271             else {
272             $r = $self->lookup_postal_code(postal_code => $_)
273             unless $r;
274             $h{$field} = $r->{$field};
275             }
276             }
277             push @postal_codes, \%h;
278             }
279             }
280             }
281             }
282             }
283             if ($order_by) {
284             if ($order_by eq 'city' || $order_by eq 'state') {
285             @postal_codes = sort { $a->{$order_by} cmp $b->{$order_by} } @postal_codes;
286             } else {
287             @postal_codes = sort { $a->{$order_by} <=> $b->{$order_by} } @postal_codes;
288             }
289             }
290             return \@postal_codes;
291             }
292              
293             sub _test_near {
294             my ($center, $cell) = @_;
295             if (floor($center) == $cell) {
296             return $center;
297             } elsif ($cell < $center and (_sign($cell)==_sign($center) or $center < (LON_DEGREES/4))) {
298             return $cell + 1;
299             } else {
300             return $cell;
301             }
302             }
303              
304             sub _sign
305             {
306             return $_[0]==0?0:($_[0]/abs($_[0]));
307             }
308              
309             sub _test_far {
310             my ($center, $cell) = @_;
311             if (floor($center) == $cell) {
312             if ($center - $cell < 0.5) {
313             return $cell + 1;
314             } else {
315             return $cell;
316             }
317             } elsif ($cell < $center) {
318             return $cell;
319             } else {
320             return $cell + 1;
321             }
322             }
323              
324             sub _lon_miles
325             {
326             my $self = shift;
327             my($lat)=@_;
328              
329             # Formula from:
330             # http://www.malaysiagis.com/related_technologies/mapping/basics1b.cfm
331             my $r = cos($lat*PI/180)*(2*PI*$self->{_earth_radius}/LON_DEGREES);
332             $r;
333             }
334              
335             sub _min
336             {
337             return $_[0]<$_[1]?$_[0]:$_[1];
338             }
339              
340             1;
341             __END__