File Coverage

File:blib/lib/Geo/AnomalyDetector.pm
Coverage:93.0%

linestmtbrancondsubtimecode
1package 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
39our $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
70sub 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
120sub 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
2981;
299