TER1 (Statement): 100.00%
TER2 (Branch): 85.71%
TER3 (LCSAJ): 100.0% (2/2)
Approximate LCSAJ segments: 15
● Covered — this LCSAJ path was executed during testing.
● Not covered — this LCSAJ path was never executed. These are the paths to focus on.
Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:
start → end → jump
Uncovered paths show [NOT COVERED] in the tooltip.
1: package Geo::AnomalyDetector; 2: 3: use strict; 4: use warnings; 5: 6: # TODO: GIS::Distance 7: 8: use Params::Get 0.13; 9: use Math::Trig; 10: use Return::Set 0.03; 11: use Params::Validate::Strict 0.12; 12: use Statistics::Basic qw(mean stddev); 13: # use Geo::Inverse; 14: 15: =head1 NAME 16: 17: Geo::AnomalyDetector - Detect anomalies in geospatial coordinate datasets 18: 19: =head1 SYNOPSIS 20: 21: This module analyzes latitude and longitude data points to identify anomalies based on their distance from the mean location. 22: 23: use Geo::AnomalyDetector; 24: 25: my $detector = Geo::AnomalyDetector->new(threshold => 3); 26: my $coords = [ [37.7749, -122.4194], [40.7128, -74.0060], [35.6895, 139.6917] ]; 27: my $anomalies = $detector->detect_anomalies($coords); 28: print 'Anomalies: ', join ', ', map { ($_->[0], $_->[1]) } @{$anomalies}; 29: 30: Each co-ordinate can be either a two element array of [latitude, longitude] or an object that has 31: C<latitude> and C<longitude> methods. 32: 33: =head1 VERSION 34: 35: 0.03 36: 37: =cut 38: 39: our $VERSION = '0.03'; 40: 41: =head1 SUBROUTINES/METHODS 42: 43: =head2 new 44: 45: Creates a Geo::AnomalyDetector object. 46: 47: It takes two optional parameters: 48: 49: =over 4 50: 51: =item * C<threshold> 52: 53: A number to hint at what consitutues an anomaly. 54: The larger the number, 55: the larger the distance to be called an anomaly, 56: there will be fewer matches. 57: The default is 3. 58: 59: =item * C<units> 60: 61: The unit to be used internally for measurement. 62: Can be C<M> or C<K>. 63: The default is C<K>. 64: This should have no effect on the determination of outliers. 65: 66: =back 67: 68: =cut 69: 70: sub new { 71: my ($class, %args) = @_; 72: 73: return bless {Mutants (Total: 2, Killed: 2, Survived: 0)
74: threshold => $args{threshold} || 3, 75: unit => $args{units} || $args{unit} || 'K', # unit was the only name read before 0.03 76: }, $class; 77: } 78: 79: =head2 detect_anomalies 80: 81: Identify outlier geographic coordinates based on their distance from the average location of a dataset. 82: 83: Takes an array reference of coordinate pairs. 84: Each coordinate can be an array reference [lat, lon] or an object with latitude() and longitude() methods. 85: 86: It returns a reference to an array of coordinates considered anomalous based on their distance from the mean. 87: 88: =head3 API SPECIFICATION 89: 90: =head4 INPUT 91: 92: { 93: 'coordinates' => { type => 'arrayref' } # Each element can be either a coordinate pair or an object 94: } 95: 96: =head4 OUTPUT 97: 98: { 99: 'type' => 'arrayref', # A list of coordinates 100: 'schema' => { 101: 'type' => 'arrayref', 102: 'min' => 2, # Each coordinate is two numbers 103: 'max' => 2, 104: 'schema' => { 105: # FIXME: specify that the latitude (the first number) is between -90 and 90 106: 'type' => 'number', 'min' => -180.0, 'max' => 180.0 } 107: } 108: } 109: 110: =over 4 111: 112: =item * Argument error: croak 113: 114: =item * No matches found: [] 115: 116: =back 117: 118: =cut 119: 120: sub detect_anomalies 121: { ●122 → 143 → 158 122: my $self = shift; 123: 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: my $coordinates = $params->{'coordinates'}; 134: 135: my @distances; 136: my $mean_lat = mean(map { (ref($_) eq 'ARRAY') ? $_->[0] : $_->latitude() } @{$coordinates}); 137: my $mean_lon = mean(map { (ref($_) eq 'ARRAY') ? $_->[1] : $_->longitude() } @{$coordinates}); 138: 139: # my $inverse = Geo::Inverse->new(); 140: 141: die if(!defined($mean_lat) || !defined($mean_lon)); 142: 143: foreach my $coord (@{$coordinates}) { 144: my ($lat, $lon) = (ref($coord) eq 'ARRAY') ? @{$coord} : ($coord->latitude(), $coord->longitude()); 145: 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: my $t1 = $lon * (pi/180); 150: my $p1 = (pi/2) - ($lat * (pi/180)); 151: my $t2 = $mean_lon * (pi/180); 152: my $p2 = (pi/2) - ($mean_lat * (pi/180)); 153: my $rho = ($self->{'unit'} eq 'M') ? 3959.0 : 6371.0; # radius of Earth in miles/Km 154: my $distance = Math::Trig::great_circle_distance($t1, $p1, $t2, $p2, $rho); 155: push @distances, $distance; 156: } 157: ●158 → 162 → 168 158: my $mean_dist = mean(@distances); 159: my $std_dist = stddev(@distances); 160: 161: my @anomalies; 162: for my $i (0 .. $#distances) { 163: if (abs($distances[$i] - $mean_dist) > ($self->{threshold} * $std_dist)) {
Mutants (Total: 4, Killed: 4, Survived: 0)
164: push @anomalies, $coordinates->[$i]; 165: } 166: } 167: 168: return Return::Set::set_return(\@anomalies, {
Mutants (Total: 2, Killed: 2, Survived: 0)
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: =head1 AUTHOR 237: 238: Nigel Horne, C<< <njh at nigelhorne.com> >> 239: 240: =head1 BUGS 241: 242: =head1 SEE ALSO 243: 244: =over 4 245: 246: =item * Test coverage report: L<https://nigelhorne.github.io/Geo-AnomalyDetector/coverage/> 247: 248: =item * L<Geo::Location::Point> 249: 250: =item * L<Math::Trig> 251: 252: =back 253: 254: =head1 SUPPORT 255: 256: This module is provided as-is without any warranty. 257: 258: Please report any bugs or feature requests to C<bug-geo-anomalydetector at rt.cpan.org>, 259: or through the web interface at 260: L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Geo-AnomalyDetector>. 261: I will be notified, and then you'll 262: automatically be notified of progress on your bug as I make changes. 263: 264: You can find documentation for this module with the perldoc command. 265: 266: perldoc Geo::AnomalyDetector 267: 268: You can also look for information at: 269: 270: =over 4 271: 272: =item * MetaCPAN 273: 274: L<https://metacpan.org/dist/Geo-AnomalyDetector> 275: 276: =item * RT: CPAN's request tracker 277: 278: L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Geo-AnomalyDetector> 279: 280: =item * CPAN Testers' Matrix 281: 282: L<http://matrix.cpantesters.org/?dist=Geo-AnomalyDetector> 283: 284: =item * CPAN Testers Dependencies 285: 286: L<http://deps.cpantesters.org/?module=Geo::AnomalyDetector> 287: 288: =back 289: 290: =head1 LICENSE AND COPYRIGHT 291: 292: Copyright 2026 Nigel Horne. 293: 294: This program is released under the following licence: GPL2 295: 296: =cut 297: 298: 1; 299: 300: __END__