| File: | blib/lib/Geo/AnomalyDetector.pm |
| Coverage: | 93.0% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Geo::AnomalyDetector; | |||||
| 2 | ||||||
| 3 | 1 1 1 | 46389 1 3 | use strict; | |||
| 4 | 1 1 1 | 1 0 10 | use warnings; | |||
| 5 | ||||||
| 6 | # TODO: GIS::Distance | |||||
| 7 | ||||||
| 8 | 1 1 1 | 1 5 8 | use Params::Get 0.13; | |||
| 9 | 1 1 1 | 141 2781 30 | use Math::Trig; | |||
| 10 | 1 1 1 | 127 7634 12 | use Return::Set 0.03; | |||
| 11 | 1 1 1 | 2 2 6 | use Params::Validate::Strict 0.12; | |||
| 12 | 1 1 1 | 115 1855 1 | use Statistics::Basic qw(mean stddev); | |||
| 13 | # use Geo::Inverse; | |||||
| 14 | ||||||
| 15 - 37 | =head1 NAME
Geo::AnomalyDetector - Detect anomalies in geospatial coordinate datasets
=head1 SYNOPSIS
This module analyzes latitude and longitude data points to identify anomalies based on their distance from the mean location.
use Geo::AnomalyDetector;
my $detector = Geo::AnomalyDetector->new(threshold => 3);
my $coords = [ [37.7749, -122.4194], [40.7128, -74.0060], [35.6895, 139.6917] ];
my $anomalies = $detector->detect_anomalies($coords);
print 'Anomalies: ', join ', ', map { ($_->[0], $_->[1]) } @{$anomalies};
Each co-ordinate can be either a two element array of [latitude, longitude] or an object that has
C<latitude> and C<longitude> methods.
=head1 VERSION
0.03
=cut | |||||
| 38 | ||||||
| 39 | our $VERSION = '0.03'; | |||||
| 40 | ||||||
| 41 - 68 | =head1 SUBROUTINES/METHODS =head2 new Creates a Geo::AnomalyDetector object. It takes two optional parameters: =over 4 =item * C<threshold> A number to hint at what consitutues an anomaly. The larger the number, the larger the distance to be called an anomaly, there will be fewer matches. The default is 3. =item * C<units> The unit to be used internally for measurement. Can be C<M> or C<K>. The default is C<K>. This should have no effect on the determination of outliers. =back =cut | |||||
| 69 | ||||||
| 70 | sub new { | |||||
| 71 | 5 | 41613 | my ($class, %args) = @_; | |||
| 72 | ||||||
| 73 | return bless { | |||||
| 74 | threshold => $args{threshold} || 3, | |||||
| 75 | 5 | 14 | unit => $args{units} || $args{unit} || 'K', # unit was the only name read before 0.03 | |||
| 76 | }, $class; | |||||
| 77 | } | |||||
| 78 | ||||||
| 79 - 118 | =head2 detect_anomalies
Identify outlier geographic coordinates based on their distance from the average location of a dataset.
Takes an array reference of coordinate pairs.
Each coordinate can be an array reference [lat, lon] or an object with latitude() and longitude() methods.
It returns a reference to an array of coordinates considered anomalous based on their distance from the mean.
=head3 API SPECIFICATION
=head4 INPUT
{
'coordinates' => { type => 'arrayref' } # Each element can be either a coordinate pair or an object
}
=head4 OUTPUT
{
'type' => 'arrayref', # A list of coordinates
'schema' => {
'type' => 'arrayref',
'min' => 2, # Each coordinate is two numbers
'max' => 2,
'schema' => {
# FIXME: specify that the latitude (the first number) is between -90 and 90
'type' => 'number', 'min' => -180.0, 'max' => 180.0 }
}
}
=over 4
=item * Argument error: croak
=item * No matches found: []
=back
=cut | |||||
| 119 | ||||||
| 120 | sub detect_anomalies | |||||
| 121 | { | |||||
| 122 | 2 | 33 | my $self = shift; | |||
| 123 | 2 | 3 | my $params = Params::Validate::Strict::validate_strict({ | |||
| 124 | args => Params::Get::get_params('coordinates', @_), | |||||
| 125 | schema => { | |||||
| 126 | 'coordinates' => { | |||||
| 127 | 'type' => 'arrayref', | |||||
| 128 | } | |||||
| 129 | } | |||||
| 130 | }); | |||||
| 131 | # my $params = Params::Get::get_params('coordinates', @_); | |||||
| 132 | ||||||
| 133 | 2 | 122 | my $coordinates = $params->{'coordinates'}; | |||
| 134 | ||||||
| 135 | 2 | 0 | my @distances; | |||
| 136 | 2 8 2 | 2 20 2 | my $mean_lat = mean(map { (ref($_) eq 'ARRAY') ? $_->[0] : $_->latitude() } @{$coordinates}); | |||
| 137 | 2 8 2 | 75 10 0 | my $mean_lon = mean(map { (ref($_) eq 'ARRAY') ? $_->[1] : $_->longitude() } @{$coordinates}); | |||
| 138 | ||||||
| 139 | # my $inverse = Geo::Inverse->new(); | |||||
| 140 | ||||||
| 141 | 2 | 34 | die if(!defined($mean_lat) || !defined($mean_lon)); | |||
| 142 | ||||||
| 143 | 2 2 | 2 1 | foreach my $coord (@{$coordinates}) { | |||
| 144 | 8 6 | 6 3 | my ($lat, $lon) = (ref($coord) eq 'ARRAY') ? @{$coord} : ($coord->latitude(), $coord->longitude()); | |||
| 145 | 8 | 12 | die if(!defined($lat) || !defined($lon)); | |||
| 146 | # my $distance = distance($lat, $lon, $mean_lat, $mean_lon, 'K'); | |||||
| 147 | ||||||
| 148 | # Thanks to Robbie Hatley for working out the arguments to Math::Trig | |||||
| 149 | 8 | 1 | my $t1 = $lon * (pi/180); | |||
| 150 | 8 | 9 | my $p1 = (pi/2) - ($lat * (pi/180)); | |||
| 151 | 8 | 4 | my $t2 = $mean_lon * (pi/180); | |||
| 152 | 8 | 50 | my $p2 = (pi/2) - ($mean_lat * (pi/180)); | |||
| 153 | 8 | 34 | my $rho = ($self->{'unit'} eq 'M') ? 3959.0 : 6371.0; # radius of Earth in miles/Km | |||
| 154 | 8 | 5 | my $distance = Math::Trig::great_circle_distance($t1, $p1, $t2, $p2, $rho); | |||
| 155 | 8 | 89 | push @distances, $distance; | |||
| 156 | } | |||||
| 157 | ||||||
| 158 | 2 | 2 | my $mean_dist = mean(@distances); | |||
| 159 | 2 | 32 | my $std_dist = stddev(@distances); | |||
| 160 | ||||||
| 161 | 2 | 104 | my @anomalies; | |||
| 162 | 2 | 4 | for my $i (0 .. $#distances) { | |||
| 163 | 8 | 104 | if (abs($distances[$i] - $mean_dist) > ($self->{threshold} * $std_dist)) { | |||
| 164 | 2 | 9 | push @anomalies, $coordinates->[$i]; | |||
| 165 | } | |||||
| 166 | } | |||||
| 167 | ||||||
| 168 | 2 | 4 | return Return::Set::set_return(\@anomalies, { | |||
| 169 | 'type' => 'arrayref', # A list of coordinates | |||||
| 170 | 'schema' => { | |||||
| 171 | 'type' => 'arrayref', | |||||
| 172 | 'min' => 2, # Each coordinate is two numbers | |||||
| 173 | 'max' => 2, | |||||
| 174 | 'schema' => { | |||||
| 175 | # FIXME: specify that the latitude (the first number) is between -90 and 90 | |||||
| 176 | 'type' => 'number', 'min' => -180.0, 'max' => 180.0 } | |||||
| 177 | } | |||||
| 178 | } | |||||
| 179 | ); | |||||
| 180 | } | |||||
| 181 | ||||||
| 182 | # Now use Math::Trig. I tried Geo::Inverse, but that always throws messages about undefined variables | |||||
| 183 | ||||||
| 184 | # =head2 distance | |||||
| 185 | # | |||||
| 186 | # Calculate the distance between two geographical points using latitude and longitude. | |||||
| 187 | # Supports distance in kilometres (K), nautical miles (N), or miles. | |||||
| 188 | # | |||||
| 189 | # From L<http://www.geodatasource.com/developers/perl> | |||||
| 190 | # FIXME: use Math::Trig | |||||
| 191 | # | |||||
| 192 | # =cut | |||||
| 193 | # | |||||
| 194 | # sub distance { | |||||
| 195 | # my ($lat1, $lon1, $lat2, $lon2, $unit) = @_; | |||||
| 196 | # my $theta = $lon1 - $lon2; | |||||
| 197 | # my $dist = sin(_deg2rad($lat1)) * sin(_deg2rad($lat2)) + cos(_deg2rad($lat1)) * cos(_deg2rad($lat2)) * cos(_deg2rad($theta)); | |||||
| 198 | # $dist = _acos($dist); | |||||
| 199 | # $dist = _rad2deg($dist); | |||||
| 200 | # $dist = $dist * 60 * 1.1515; | |||||
| 201 | # if ($unit eq 'K') { | |||||
| 202 | # $dist = $dist * 1.609344; # number of kilometres in a mile | |||||
| 203 | # } elsif ($unit eq 'N') { | |||||
| 204 | # $dist = $dist * 0.8684; | |||||
| 205 | # } | |||||
| 206 | # return ($dist); | |||||
| 207 | # } | |||||
| 208 | # | |||||
| 209 | # my $pi = atan2(1,1) * 4; | |||||
| 210 | # | |||||
| 211 | # #:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: | |||||
| 212 | # #::: This function get the arccos function using arctan function ::: | |||||
| 213 | # #:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: | |||||
| 214 | # sub _acos { | |||||
| 215 | # my ($rad) = @_; | |||||
| 216 | # my $ret = atan2(sqrt(1 - $rad**2), $rad); | |||||
| 217 | # return $ret; | |||||
| 218 | # } | |||||
| 219 | # | |||||
| 220 | # #:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: | |||||
| 221 | # #::: This function converts decimal degrees to radians ::: | |||||
| 222 | # #:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: | |||||
| 223 | # sub _deg2rad { | |||||
| 224 | # my ($deg) = @_; | |||||
| 225 | # return ($deg * $pi / 180); | |||||
| 226 | # } | |||||
| 227 | # | |||||
| 228 | # #:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: | |||||
| 229 | # #::: This function converts radians to decimal degrees ::: | |||||
| 230 | # #:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: | |||||
| 231 | # sub _rad2deg { | |||||
| 232 | # my ($rad) = @_; | |||||
| 233 | # return ($rad * 180 / $pi); | |||||
| 234 | # } | |||||
| 235 | ||||||
| 236 - 296 | =head1 AUTHOR Nigel Horne, C<< <njh at nigelhorne.com> >> =head1 BUGS =head1 SEE ALSO =over 4 =item * Test coverage report: L<https://nigelhorne.github.io/Geo-AnomalyDetector/coverage/> =item * L<Geo::Location::Point> =item * L<Math::Trig> =back =head1 SUPPORT This module is provided as-is without any warranty. Please report any bugs or feature requests to C<bug-geo-anomalydetector at rt.cpan.org>, or through the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Geo-AnomalyDetector>. I will be notified, and then you'll automatically be notified of progress on your bug as I make changes. You can find documentation for this module with the perldoc command. perldoc Geo::AnomalyDetector You can also look for information at: =over 4 =item * MetaCPAN L<https://metacpan.org/dist/Geo-AnomalyDetector> =item * RT: CPAN's request tracker L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Geo-AnomalyDetector> =item * CPAN Testers' Matrix L<http://matrix.cpantesters.org/?dist=Geo-AnomalyDetector> =item * CPAN Testers Dependencies L<http://deps.cpantesters.org/?module=Geo::AnomalyDetector> =back =head1 LICENSE AND COPYRIGHT Copyright 2026 Nigel Horne. This program is released under the following licence: GPL2 =cut | |||||
| 297 | ||||||
| 298 | 1; | |||||
| 299 | ||||||