File Coverage

File:blib/lib/Geo/Coder/Free/Local.pm
Coverage:76.2%

linestmtbrancondsubpodtimecode
1package Geo::Coder::Free::Local;
2
3
18
18
18
167196
12
226
use strict;
4
18
18
18
28
9
334
use warnings;
5
18
18
18
202
5947
42
use autodie qw(:all);
6
7
18
18
18
43227
12
581
use Carp;
8
18
18
18
3479
100833
292
use Geo::Location::Point 0.19;  # 0.16 stores false values; 0.19 derives an accuracy from the fields matched
9
18
18
18
2652
19
283
use Geo::Coder::Free::MissCache;
10
18
18
18
2477
52
418
use Geo::Coder::Free::Provenance qw(GEOCODER);
11
18
18
18
2794
29
566
use Geo::Coder::Free::Utils qw(_abbreviate _normalize);
12
18
18
18
4062
484483
518
use Geo::StreetAddress::US;
13
18
18
18
4410
1046688
357
use Lingua::EN::AddressParse;
14
18
18
18
2888
55070
235
use Locale::CA;
15
18
18
18
2951
3116
229
use Locale::US;
16
18
18
18
4711
1033276
309
use Object::Configure;
17
18
18
18
44
15
273
use Params::Get;
18
18
18
18
35
15
307
use Readonly;
19
18
18
18
3702
175181
547
use Text::xSV::Slurp;
20# Imported functions are not methods: without this, $obj->croak() and the like
21# would resolve.
22
18
18
18
50
13
51
use namespace::clean;
23
24=encoding utf-8
25
26 - 34
=head1 NAME

Geo::Coder::Free::Local - Geocode using user-curated local data

=head1 VERSION

Version 0.51

=cut
35
36our $VERSION = '0.51';
37
38 - 85
=head1 SYNOPSIS

    use Geo::Coder::Free::Local;

    my $geocoder = Geo::Coder::Free::Local->new();
    my $location = $geocoder->geocode(location => 'Ramsgate, Kent, UK');
    printf "lat=%.6f lon=%.6f\n", $location->lat(), $location->long();

=head1 DESCRIPTION

Provides geocoding via a user-curated CSV dataset embedded in the module's
C<__DATA__> section.  Locations in the data were verified by GPS and by
inspecting geotagged photographs.  The data is read once at construction time
and indexed for fast lookup.

This is the highest-priority backend tried by C<Geo::Coder::Free>.

=head1 LIMITATIONS

=over 4

=item * The embedded C<__DATA__> dataset covers only a small set of hand-picked locations.
There is no mechanism for non-authors to contribute data without patching the module.

=item * Canadian and Australian address parsing is not yet implemented; those queries return C<undef>.

=item * C<_search> performs an O(n) linear scan through all rows.  The hash-based
index in C<new()> handles exact string matches; no index exists for partial field matches.

=item * C<our %alternatives> duplicates mappings in C<Geo::Coder::Free::__DATA__>.
Both should be consolidated into a shared external config file.

=item * C<$libpostal_is_installed> is a module-level (effectively global) flag.
Not thread-safe in a forking or threaded Perl deployment.

=item * The C<__DATA__> filehandle is a one-shot resource.  The first call to
C<new()> exhausts it with C<my @data = E<lt>DATAE<gt>>; every subsequent
C<new()> in the same process reads zero rows and builds an empty index.
Construct exactly one C<Local> object per process and share it.

=item * The hash-index key is C<lc(Geo::Location::Point-E<gt>new($row)-E<gt>as_string())>,
which B<includes the C<name> field>.  To get a direct index hit the caller must
supply the full string with the venue/place name as the leading component;
an address that omits the name falls through to the slower O(n) C<_search> path.

=back

=cut
86
87# Libpostal detection states - avoid magic numbers
88
18
18
18
7029
18
405
use constant LIBPOSTAL_UNKNOWN       => 0;
89
18
18
18
30
15
349
use constant LIBPOSTAL_INSTALLED     => 1;
90
18
18
18
94
65
47812
use constant LIBPOSTAL_NOT_INSTALLED => -1;
91
92# Module-level singleton flags - see LIMITATIONS re: thread safety.
93our $libpostal_is_installed = LIBPOSTAL_UNKNOWN;
94
95# Lazily initialised Locale singletons to avoid repeated object construction
96# (Locale::US->new() and Locale::CA->new() are called multiple times per geocode;
97# caching them reduces object churn on busy lookups).
98my $_locale_us;
99my $_locale_ca;
100
101# Confidence thresholds for _search results, aligned with OpenAddresses backend
102Readonly::Scalar my $CONF_EXACT  => 1.0;
103Readonly::Scalar my $CONF_HIGH   => 0.7;
104Readonly::Scalar my $CONF_MEDIUM => 0.5;
105
106# Hard-coded place-name aliases for ambiguous or database-inconsistent locations.
107# MAINTENANCE NOTE: the same mappings appear in Geo::Coder::Free::__DATA__.
108# Both should eventually be replaced by a shared external config file.
109our %alternatives = (
110        'ST LAWRENCE, THANET, KENT' => 'RAMSGATE, KENT',
111        'ST PETERS, THANET, KENT'   => 'ST PETERS, KENT',
112        'MINSTER, THANET, KENT'     => 'RAMSGATE, KENT',
113        'TYNE AND WEAR'             => 'BOROUGH OF NORTH TYNESIDE',
114);
115
116 - 160
=head1 METHODS

=head2 new

=head3 SYNOPSIS

    my $geocoder = Geo::Coder::Free::Local->new();
    my $geocoder = Geo::Coder::Free::Local->new(cache => $chi_cache);

=head3 DESCRIPTION

Constructor.  Reads the C<__DATA__> CSV block, builds a hash-based lookup
index, and derives the geographic centre of any city/state/country cluster
containing three or more data points.

B<One-shot filehandle>: the C<DATA> handle is consumed on the first call to
C<new()>.  Any subsequent call to C<new()> returns an object whose dataset and
index are both empty.  In code that constructs multiple C<Local> objects (e.g.
in tests) construct B<exactly one> instance and reuse it across all callers.

=head3 API SPECIFICATION

=head4 input

    # Input schema (Params::Validate::Strict)
    cache => { type => 'object', optional => 1, can => ['get', 'set'] }  # CHI-compatible cache object

=head4 output

    # Output schema (Return::Set)
    { type => 'object', isa => 'Geo::Coder::Free::Local' }

=head3 FORMAL SPECIFICATION

    LocalState ::= ⟨⟨ data  : seq Row;
                       index : Map[STRING → Row];
                       cache : Map[STRING → Point] ⟩⟩

    Init : Params → LocalState
    âˆ€ p : Params •
      let rows    == parse_csv(__DATA__) ∪ geographic_centres(__DATA__) •
      let idx_key == λ r • lc(Point(r).as_string()) •
      LocalState.index = { idx_key(r) ↦ r | r ∈ rows }

=cut
161
162sub new {
163
20
1
381680
        my $class = shift;
164
20
51
        my $params = Params::Get::get_params(undef, @_) // {};
165
166
20
261
        if (!defined($class)) {
167
1
1
                $class = __PACKAGE__;   # FIXME: only works when no arguments are given
168        } elsif (ref($class)) {
169
3
3
3
34
6
13
                return bless { %{$class}, %{$params} }, ref($class);
170        }
171
172
17
38
        $params = Object::Configure::configure($class, $params);
173
174
17
123077
        my @data = <DATA>;
175
176        my $self = bless {
177                data => xsv_slurp(
178                        shape    => 'aoh',
179                        text_csv => {
180                                allow_loose_quotes => 1,
181                                blank_is_undef     => 1,
182                                empty_is_undef     => 1,
183                                binary             => 1,
184                                escape_char        => '\\',
185                        },
186
1068
821
                        string => \join('', grep { !/^\s*(#|$)/ } @data),
187                ),
188
17
17
74
17151
                %{$params},
189        }, $class;
190
191        # Derive geographic centres from clusters of 3+ co-located data points,
192        # adding implicit town/city records to the dataset.
193
17
79
        my $towns = _find_geographic_centres($self->{'data'});
194
17
12
12
29
13
17
        push @{$self->{'data'}}, @{$towns} if $towns;
195
196        # Build a hash index on the stringified Geo::Location::Point representation
197        # so that exact-match lookups avoid the O(n) scan in _search.
198
17
17
11
21
        for my $row (@{$self->{'data'}}) {
199
1128
891
                my $key = lc(Geo::Location::Point->new($row)->as_string());
200
1128
78104
                $self->{'index'}{$key} = $row;
201        }
202
203        # Build an O(1) region existence index used by geocode() to skip the O(n)
204        # grep before _search when no rows for the requested state/country exist.
205        # Major: every (state|country) pair in the dataset is represented here.
206        # Minor: if geocode's target pair is absent, _search can never match.
207        # Conclusion: early return is safe - eliminates the O(n) grep per call.
208
17
17
14
21
        for my $row (@{$self->{'data'}}) {
209
1128
931
                my $rk = uc($row->{'state'} // '') . '|' . uc($row->{'country'} // '');
210
1128
602
                $self->{'region_index'}{$rk} = 1;
211        }
212
213
17
90
        return $self;
214}
215
216# What this backend counts as a miss, and where the whole process remembers
217# it.  The data is in __DATA__, so every instance has the same data and a miss
218# found by one is a miss for all of them.
219sub _miss_scope {
220
92
179
        return Geo::Coder::Free::MissCache->scope_for('Local');
221}
222
223sub _missed {
224
54
51
        my ($self, $lc) = @_;
225
226
54
58
        return Geo::Coder::Free::MissCache->missed($self->_miss_scope(), $lc);
227}
228
229sub _remember_miss {
230
38
44
        my ($self, $lc) = @_;
231
232
38
43
        Geo::Coder::Free::MissCache->remember($self->_miss_scope(), $lc);
233
38
99
        return;
234}
235
236 - 258
=head2 reads

    my $count = $geocoder->reads();

How many times this backend has searched its dataset since the object was
created.  A lookup answered from the index, from the result cache or from the
shared miss cache does not increase it, so C<geocode_many()> can take the count
before and after a value to tell a lookup that cost a search from one that was
answered from a cache.

=head3 API SPECIFICATION

=head4 input

    # Input schema (Params::Validate::Strict)
    # none

=head4 output

    # Output schema (Return::Set)
    { type => 'integer', min => 0 }   # 0 for a new object

=cut
259
260sub reads {
261
7
1
200
        my $self = shift;
262
263
7
20
        return $self->{'reads'} // 0;
264}
265
266 - 330
=head2 geocode

=head3 SYNOPSIS

    my $pt = $geocoder->geocode(location => '203 E Chatsworth Rd, Reisterstown, Baltimore, MD, US');
    print $pt->lat(), "\n";

    # All calling forms are accepted:
    $geocoder->geocode('203 E Chatsworth Rd, Reisterstown, MD, US');
    $geocoder->geocode({ location => '203 E Chatsworth Rd, Reisterstown, MD, US' });

=head3 API SPECIFICATION

=head4 input

    # Input schema (Params::Validate::Strict)
    location => { type => 'scalar' }  # address string; must contain at least two commas

=head4 output

    # Output schema (Return::Set)
    { type => 'object', isa => 'Geo::Location::Point', optional => 1 }

=head3 PROVENANCE

Every result carries C<geocoder>, C<database>, C<confidence> and C<accuracy>
(section 2.1 of F<BI-Phase6-Dependency-Specs.md>).  This backend reports
C<database> as C<Geo::Coder::Free::Local>, and C<accuracy> is what the matched
columns justify: a house number makes it an C<address>, a road a C<street>, a
name or city a C<locality>, a state a C<region> and a country a C<country>.  A
lookup answered from the index, which matches a whole row, is an exact match
(C<confidence> of 1.0) whose accuracy comes from the row's own fields - so a
derived geographic centre, which carries a city, state and country and nothing
narrower, is a C<locality>.

=head3 MESSAGES

    Usage: ...::geocode(...)   No location argument given.

=head3 FORMAL SPECIFICATION

    Geocode : STRING → Point?
    âˆ€ addr : STRING •
      let norm == lc(replace_usa(addr)) •
      (norm ∈ cache ⟹ result = cache[norm]) ∧
      (norm ∈ index ⟹ result = index[norm]) ∧
      (¬ result ⟹ result = parse_and_search(addr))

=head3 PSEUDOCODE

    normalise @_ into %params
    reject if location does not contain two or more commas (not a full address)
    check cache; return hit
    check hash index; return hit and cache it
    # index key includes the 'name' field; supply the venue/place name as the
    # leading address component to guarantee an index hit
    attempt country-specific parser (US, GB; skip CA/AU pending implementation)
    attempt Geo::StreetAddress::US for "..., USA" addresses
    attempt Geo::Address::Parser
    attempt Geo::libpostal (large memory footprint; loaded lazily)
    attempt 4-part regex decomposition (name/road/city/state/country)
    attempt %alternatives mapping
    return undef

=cut
331
332sub geocode {
333
124
1
65371
        my $self = shift;
334
335        # Handle being called as a function rather than a method
336
124
205
        if (!ref($self)) {
337
4
8
                if (scalar @_) {
338
1
3
                        return __PACKAGE__->new()->geocode(@_);
339                } elsif (!defined($self)) {
340
1
4
                        Carp::croak('Usage: ', __PACKAGE__, '::geocode(location => $location)');
341                } elsif ($self eq __PACKAGE__) {
342
1
5
                        Carp::croak("Usage: $self", '::geocode(location => $location)');
343                }
344
1
2
                return __PACKAGE__->new()->geocode($self);
345        } elsif (ref($self) eq 'HASH') {
346
1
2
                return __PACKAGE__->new()->geocode($self);
347        }
348
349
119
176
        my %params = _normalize_args(
350                'Usage: ' . __PACKAGE__ . '::geocode(location => $location)',
351                'location', @_
352        );
353
354
115
167
        my $location = $params{'location'}
355                or Carp::croak('Usage: geocode(location => $location)');
356
357        # Normalise the lookup key - "USA" is treated the same as "US"
358
111
135
        my $lc = lc($location);
359
111
285
        $lc =~ s/,\s*usa$/, us/i;
360
361
111
193
        return $self->{'cache'}{$lc}  if exists $self->{'cache'}{$lc};
362        return $self->_cache_and_return($lc, $self->_result_for($self->{'index'}{$lc}))
363
74
125
                if exists $self->{'index'}{$lc};
364
365        # A miss found by an earlier instance, or an earlier call through this
366        # one, means the same search would find nothing again: the data does not
367        # change while the process runs.  clear_miss_cache() is for when it has.
368
54
78
        return if $self->_missed($lc);
369
370        # This backend only handles full addresses (at least "road, city, country")
371
47
93
        return $self->_remember_miss($lc) if $location !~ /,.+,/;
372
373
32
29
        my $ap;
374
32
167
        if ($location =~ /USA?$/ || $location =~ /United States$/) {
375
3
16
                $ap = $self->{'ap'}{'us'} //= Lingua::EN::AddressParse->new(
376                        country => 'US', auto_clean => 1, force_case => 1, force_post_code => 0
377                );
378        } elsif ($location =~ /(England|Scotland|Wales|Northern Ireland|UK|GB)$/i) {
379
22
78
                $ap = $self->{'ap'}{'gb'} //= Lingua::EN::AddressParse->new(
380                        country => 'GB', auto_clean => 1, force_case => 1, force_post_code => 0
381                );
382        } elsif ($location =~ /Canada$/) {
383                # TODO: Canadian address parsing not yet implemented
384
1
2
                return $self->_remember_miss($lc);
385        } elsif ($location =~ /Australia$/) {
386                # TODO: Australian address parsing not yet implemented
387
1
1
                return $self->_remember_miss($lc);
388        }
389
390
30
5808353
        if ($ap) {
391
25
24
                my $l = $location;
392
25
200
                $l =~ s/(.+), (England|UK)$/$1, GB/i;
393
394
25
59
                if ($ap->parse($l) == 0) {
395
9
271411
                        my %c = $ap->components();
396
9
1489
                        my %addr = (location => $l);
397
398
9
18
                        if (my $street = $c{'street_name'}) {
399
9
15
                                if (my $type = $c{'street_type'}) {
400
9
24
                                        my $abbrev = _abbreviate($type);
401
9
47
                                        $street .= ' ' . ($abbrev || $type);
402                                        $street .= ' ' . $c{'street_direction_suffix'}
403
9
15
                                                if $c{'street_direction_suffix'};
404
9
9
                                        $street =~ s/^0+//;
405
9
15
                                        $addr{'road'} = $street;
406                                }
407                        }
408
409
9
12
                        if (length($c{'subcountry'}) == 2) {
410
1
2
                                $addr{'state'} = $c{'subcountry'};
411                        } else {
412
8
12
                                my $country_raw = $c{'country'} // '';
413
8
24
                                if ($country_raw =~ /Canada/i) {
414
0
0
                                        $addr{'country'} = 'CA';
415
0
0
                                        $addr{'state'}   = _to_two_letter_state('Canada', $c{'subcountry'});
416                                } elsif ($country_raw =~ /^(United States|USA|US)$/i) {
417
0
0
                                        $addr{'country'} = 'US';
418
0
0
                                        $addr{'state'}   = _to_two_letter_state('US', $c{'subcountry'});
419                                } elsif ($country_raw) {
420
8
10
                                        $addr{'country'} = $country_raw;
421
8
16
                                        $addr{'state'}   = $c{'subcountry'} if $c{'subcountry'};
422                                }
423                        }
424
9
12
                        $addr{'number'} = $c{'property_identifier'};
425
9
11
                        $addr{'city'}   = $c{'suburb'};
426
427
9
24
                        if (my $rc = $self->_search(\%addr, qw(number road city state country))) {
428
1
55
                                return $self->_cache_and_return($lc, $rc);
429                        }
430
8
15
                        if ($addr{'number'}) {
431
2
5
                                if (my $rc = $self->_search(\%addr, qw(road city state country))) {
432
0
0
                                        return $self->_cache_and_return($lc, $rc);
433                                }
434                        }
435
436                        # Abort early if no data at all matches this state/country -
437                        # saves traversing the full dataset for every remaining strategy.
438
8
12
                        if (!defined($addr{'country'})) {
439
1
6
                                $addr{'country'} = $l =~ /(United States|USA|US)$/i ? 'US'
440                                        : Carp::croak("TODO: extract country from $l");
441                        }
442                        # O(1) region check via index built in new() - replaces the former
443                        # O(n) grep across all rows.
444                        # Major: region_index maps every (state|country) pair in the dataset.
445                        # Minor: if target pair is absent, no _search call can match.
446                        # Conclusion: early return is equivalent and faster.
447
8
84
                        my $rk = uc($addr{'state'} // '') . '|' . uc($addr{'country'} // '');
448
8
29
                        return $self->_remember_miss($lc) unless $self->{'region_index'}{$rk};
449                }
450        }
451
452
27
80263
        if ($location =~ /^(.+?)[,\s]+(United States|USA|US)$/i) {
453
3
21
                my $l = $1;
454
3
5
                $l =~ tr/,/ /;
455
3
11
                $l =~ s/\s{2,}/ /g;
456
457                # Geo::StreetAddress::US is buggy (RT#122617) - skip county-style addresses
458
3
7
                if ($location !~ /\sCounty,/i) {
459
3
16
                        my $href = Geo::StreetAddress::US->parse_location($l)
460                                // Geo::StreetAddress::US->parse_address($l);
461
462
3
426
                        if ($href) {
463
3
5
                                if (my $state = $href->{'state'}) {
464
3
5
                                        $state = _to_two_letter_state('US', $state) if length($state) > 2;
465
3
6
                                        my $city   = uc($href->{'city'} // '');
466
3
5
                                        if (my $street = $href->{'street'}) {
467
3
6
                                                if ($href->{'type'}) {
468
1
2
                                                        $street .= ' ' . _abbreviate($href->{'type'});
469                                                }
470
3
13
                                                $street .= ' ' . $href->{'suffix'}  if $href->{'suffix'};
471
3
9
                                                $street  = $href->{'prefix'} . " $street" if $href->{'prefix'};
472                                                my %addr = (
473
3
11
                                                        number  => $href->{'number'},
474                                                        road    => $street,
475                                                        city    => $city,
476                                                        state   => $state,
477                                                        country => 'US',
478                                                );
479
3
4
                                                if ($href->{'number'}) {
480
0
0
                                                        if (my $rc = $self->_search(\%addr, qw(number road city state country))) {
481
0
0
                                                                $rc->{'country'} = 'US';
482
0
0
                                                                return $self->_cache_and_return($lc, $rc);
483                                                        }
484                                                }
485
3
8
                                                if (my $rc = $self->_search(\%addr, qw(road city state country))) {
486
1
73
                                                        $rc->{'country'} = 'US';
487
1
2
                                                        return $self->_cache_and_return($lc, $rc);
488                                                }
489                                                # G:S:US puts building name into street when number is absent
490
2
6
                                                if ($street && !$href->{'number'}) {
491
2
4
                                                        $addr{'name'} = delete $addr{'road'};
492
2
4
                                                        if (my $rc = $self->_search(\%addr, qw(name city state country))) {
493
0
0
                                                                $rc->{'country'} = 'US';
494
0
0
                                                                return $self->_cache_and_return($lc, $rc);
495                                                        }
496                                                }
497                                        }
498                                }
499                        }
500                }
501
502                # Fallback: try "name, street, town, state, US" split
503
2
9
                my @parts = split(/,\s*/, $location);
504
2
4
                if (scalar(@parts) == 5) {
505
1
2
                        my $state = _to_two_letter_state('US', $parts[3]);
506
1
2
                        if (length($state) == 2) {
507
1
2
                                my %addr = (city => $parts[2], state => $state, country => 'US');
508
1
2
                                if ($parts[0] !~ /^\d/) {
509
1
2
                                        $addr{'name'} = $parts[0];
510
1
2
                                        if ($parts[1] =~ /^(\d+)\s+(.+)/) {
511
1
2
                                                $addr{'number'} = $1;
512
1
3
                                                $addr{'road'}   = _normalize($2);
513
1
2
                                                if (my $rc = $self->_search(\%addr, qw(name number road city state country))) {
514
1
94
                                                        $rc->{'country'} = 'US';
515
1
2
                                                        return $self->_cache_and_return($lc, $rc);
516                                                }
517                                        } else {
518
0
0
                                                $addr{'road'} = _normalize($parts[1]);
519
0
0
                                                if (my $rc = $self->_search(\%addr, qw(name road city state country))) {
520
0
0
                                                        $rc->{'country'} = 'US';
521
0
0
                                                        return $self->_cache_and_return($lc, $rc);
522                                                }
523                                        }
524                                } else {
525
0
0
                                        $addr{'number'} = $parts[0];
526
0
0
                                        $addr{'road'}   = _normalize($parts[1]);
527
0
0
                                        if (my $rc = $self->_search(\%addr, qw(number road city state country))) {
528
0
0
                                                $rc->{'country'} = 'US';
529
0
0
                                                return $self->_cache_and_return($lc, $rc);
530                                        }
531                                }
532                        }
533                }
534        }
535
536        # Simple "Town, County, England" - no further sub-parsers will help
537
25
73
        if (($location =~ /[^,]+,[^,]+,.*England$/) && ($location !~ /[^,]+,[^,]+,[^,]+,.*England$/)) {
538
5
11
                return $self->_remember_miss($lc);
539        }
540
541        # Geo::Address::Parser - optional; load lazily, skip if not installed
542
20
103
        unless (Geo::Address::Parser->can('parse')) {
543
20
20
0
16
1267
0
                eval { require Geo::Address::Parser; Geo::Address::Parser->import() };
544        }
545
20
47
        if (Geo::Address::Parser->can('parse')) {
546
0
0
                my $addr_parser = Geo::Address::Parser->new(country => 'UK');
547
0
0
                if (my $fields = $addr_parser->parse($location)) {
548                        # Remove undef fields so _search only matches defined columns
549
0
0
0
0
0
0
                        delete $fields->{$_} for grep { !defined $fields->{$_} } keys %{$fields};
550
0
0
0
0
                        if (my $rc = $self->_search($fields, keys %{$fields})) {
551
0
0
                                $rc->{'country'} = 'UK';
552
0
0
                                return $self->_cache_and_return($lc, $rc);
553                        }
554                }
555        }
556
557        # Geo::libpostal - accurate but uses enormous RAM; initialise at most once
558
20
27
        if ($libpostal_is_installed == LIBPOSTAL_UNKNOWN) {
559
6
6
0
6
296
0
                if (eval { require Geo::libpostal; 1 }) {
560
0
0
                        Geo::libpostal->import();
561
0
0
                        $libpostal_is_installed = LIBPOSTAL_INSTALLED;
562                } else {
563
6
8
                        $libpostal_is_installed = LIBPOSTAL_NOT_INSTALLED;
564                }
565        }
566
567
20
27
        if ($libpostal_is_installed == LIBPOSTAL_INSTALLED) {
568
0
0
                my %addr = Geo::libpostal::parse_address($location);
569
0
0
                if (%addr) {
570                        # Normalise field names to match our data schema
571
0
0
                        $addr{'number'} = delete $addr{'house_number'} if $addr{'house_number'} && !$addr{'number'};
572
0
0
                        $addr{'name'}   = delete $addr{'house'}        if $addr{'house'}        && !$addr{'name'};
573
0
0
                        $addr{'location'} = $location;
574
575
0
0
                        if (my $street = $addr{'road'}) {
576
0
0
                                $addr{'road'} = _normalize($street);
577                        }
578
579                        # libpostal returns "england" as a state - map to country code
580
0
0
                        if (defined($addr{'state'}) && !defined($addr{'country'})
581                            && $addr{'state'} eq 'england') {
582
0
0
                                delete $addr{'state'};
583
0
0
                                $addr{'country'} = 'GB';
584                        }
585
586
0
0
                        if ($addr{'country'} && ($addr{'state'} || $addr{'state_district'})) {
587
0
0
                                if ($addr{'country'} =~ /Canada/i) {
588
0
0
                                        $addr{'country'} = 'Canada';
589                                        $addr{'state'}   = _to_two_letter_state('Canada', $addr{'state'})
590
0
0
                                                if $addr{'state'} && length($addr{'state'}) > 2;
591                                } elsif ($addr{'country'} =~ /^(United States|USA|US)$/i) {
592
0
0
                                        $addr{'country'} = 'US';
593                                        $addr{'state'}   = _to_two_letter_state('US', $addr{'state'})
594
0
0
                                                if $addr{'state'} && length($addr{'state'}) > 2;
595                                }
596
597
0
0
                                if ($addr{'state_district'}) {
598
0
0
                                        $addr{'state_district'} =~ s/^(.+)\s+COUNTY\s*$/$1/i;
599
0
0
                                        if (my $rc = $self->_search(\%addr, qw(number road city state_district state country))) {
600
0
0
                                                return $self->_cache_and_return($lc, $rc);
601                                        }
602                                }
603
0
0
                                if (my $rc = $self->_search(\%addr, qw(number road city state country))) {
604
0
0
                                        return $self->_cache_and_return($lc, $rc);
605                                }
606
0
0
                                if ($addr{'number'}) {
607
0
0
                                        if (my $rc = $self->_search(\%addr, qw(road city state country))) {
608
0
0
                                                return $self->_cache_and_return($lc, $rc);
609                                        }
610                                }
611                        }
612                }
613        }
614
615        # Last resort: decompose "road, city, state, country" with a regex
616
20
98
        if ($location =~ /^(.+?),\s*([\s\w]+),\s*([\s\w]+),\s*([\w\s]+)$/) {
617
15
66
                my %addr = (
618                        road    => $1,
619                        city    => $2,
620                        state   => $3,
621                        country => $4,
622                );
623
15
21
                $addr{'state'}   =~ s/\s+$//g;
624
15
18
                $addr{'country'} =~ s/\s+$//g;
625
626
15
38
                if ($addr{'road'} =~ /([\w\s]+),*\s+(.+)/) {
627
14
21
                        $addr{'name'} = $1;
628
14
18
                        $addr{'road'} = $2;
629                }
630
15
38
                if ($addr{'road'} =~ /^(\d+)\s+(.+)/) {
631
3
4
                        $addr{'number'} = $1;
632
3
4
                        $addr{'road'}   = $2;
633
3
6
                        if (my $rc = $self->_search(\%addr, qw(name number road city state country))) {
634
0
0
                                return $self->_cache_and_return($lc, $rc);
635                        }
636                } elsif (my $rc = $self->_search(\%addr, qw(name road city state country))) {
637
2
172
                        return $self->_cache_and_return($lc, $rc);
638                }
639
13
50
                if ($addr{'name'} && !defined($addr{'number'})) {
640
10
13
                        if (my $rc = $self->_search(\%addr, qw(name road city state country))) {
641
0
0
                                return $self->_cache_and_return($lc, $rc);
642                        }
643                }
644        }
645
646        # Try the alternatives table - curated mappings for inconsistent names
647
18
31
        $location = uc($location);
648
18
28
        for my $left (keys %alternatives) {
649
68
300
                next unless $location =~ $left;
650
7
27
                (my $mapped = $location) =~ s/$left/$alternatives{$left}/;
651
7
10
                $params{'location'} = $mapped;
652
7
25
                if (my $rc = $self->geocode(\%params)) {
653
1
3
                        return $self->_cache_and_return($lc, $rc);
654                }
655
6
26
                if ($mapped =~ /(.+), (England|UK)$/i) {
656
4
6
                        $params{'location'} = "$1, GB";
657
4
4
                        if (my $rc = $self->geocode(\%params)) {
658
3
9
                                return $self->_cache_and_return($lc, $rc);
659                        }
660                }
661
3
4
                $params{'location'} = $location;
662        }
663
664
14
29
        return $self->_remember_miss($lc);
665}
666
667 - 690
=head2 reverse_geocode

=head3 SYNOPSIS

    my $loc = $geocoder->reverse_geocode(latlng => '51.3341,-1.4159');
    # Returns the location string(s) for that lat/lon pair.

=head3 API SPECIFICATION

=head4 input

    # Input schema (Params::Validate::Strict) - latlng or lat+lon required
    latlng => { type => 'scalar', optional => 1 }  # "$lat,$long" comma-separated decimal degrees
    lat    => { type => 'scalar', optional => 1 }  # latitude  (alternative to latlng)
    lon    => { type => 'scalar', optional => 1 }  # longitude (alternative to latlng)
    long   => { type => 'scalar', optional => 1 }  # alias for lon

=head4 output

    # Output schema (Return::Set)
    # scalar context: { type => 'scalar',   optional => 1 }  # location string
    # list context:   { type => 'arrayref', of => { type => 'scalar' } }

=cut
691
692sub reverse_geocode {
693
64
1
33205
        my $self = shift;
694
695
64
111
        if (!ref($self)) {
696
3
6
                if (scalar @_) {
697
1
2
                        return __PACKAGE__->new()->reverse_geocode(@_);
698                } elsif (!defined($self)) {
699
1
9
                        Carp::croak('Usage: ', __PACKAGE__, '::reverse_geocode(latlng => "$lat,$long")');
700                } elsif ($self eq __PACKAGE__) {
701
1
6
                        Carp::croak("Usage: $self", '::reverse_geocode(latlng => "$lat,$long")');
702                }
703
0
0
                return __PACKAGE__->new()->reverse_geocode($self);
704        } elsif (ref($self) eq 'HASH') {
705
0
0
                return __PACKAGE__->new()->reverse_geocode($self);
706        }
707
708
61
90
        my %params = _normalize_args(
709                'Usage: ' . __PACKAGE__ . '::reverse_geocode(latlng => "$lat,$long")',
710                'latlng', @_
711        );
712
713
59
68
        my ($latitude, $longitude);
714
59
69
        if (my $latlng = $params{'latlng'}) {
715
36
57
                ($latitude, $longitude) = split /,/, $latlng;
716        } else {
717
23
20
                $latitude  = $params{'lat'};
718
23
45
                $longitude = $params{'lon'} // $params{'long'};
719        }
720
721
59
104
        if (!defined($latitude) || !defined($longitude)) {
722
5
20
                Carp::croak('Usage: ', __PACKAGE__, '::reverse_geocode(latlng => "$lat,$long")');
723        }
724
725
54
27
        my @rc;
726
54
54
38
64
        for my $row (@{$self->{'data'}}) {
727
4408
4667
                next unless defined($row->{'latitude'}) && defined($row->{'longitude'});
728                next unless _equal($row->{'latitude'}, $latitude, 4)
729
4408
2784
                         && _equal($row->{'longitude'}, $longitude, 4);
730
731                # Rows in $self->{'data'} are blessed as Geo::Location::Point by
732                # the index-building loop in new() - calling as_string() is safe here.
733
91
106
                my $location = uc($row->as_string());
734
91
282
                if (wantarray) {
735
83
57
                        push @rc, $location;
736                        # Add any reverse-alternative mappings for this location
737                        # keys, not each(): %alternatives is package-wide, and an each()
738                        # iterator left part-way would make the next pass skip entries
739
83
127
                        foreach my $left (sort keys %alternatives) {
740
332
216
                                my $right = $alternatives{$left};
741
332
959
                                if ($location =~ $right) {
742
102
149
                                        (my $l = $location) =~ s/$right/$left/;
743
102
107
                                        push @rc, $l;
744                                }
745                        }
746                } else {
747
8
18
                        return $location;
748                }
749        }
750
46
106
        return @rc;
751}
752
753 - 769
=head2 ua

Does nothing - present for drop-in compatibility with other Geo::Coder::* modules.

=head3 API SPECIFICATION

=head4 input

    # Input schema (Params::Validate::Strict)
    # any arguments are accepted and ignored

=head4 output

    # Output schema (Return::Set)
    # nothing: an empty list, or undef in scalar context

=cut
770
771
1
sub ua { }
772
773# -----------------------------------------------------------------------
774# Private helpers
775# -----------------------------------------------------------------------
776
777# Purpose:  Normalise the four calling conventions used by geocode/reverse_geocode:
778#           hashref, even-length key-val list, single bare string.
779# Entry:    $error_msg  - croak message if an unsupported ref type is passed.
780#           $bare_key   - hash key to use when a single bare string is given
781#                         ('location' for geocode, 'latlng' for reverse_geocode).
782#           @args       - raw @_ after $self has been shifted.
783# Exit:     Flat %params hash.
784# Side Effects: None; croaks on non-hash reference arguments.
785sub _normalize_args {
786
187
6291
        my ($error_msg, $bare_key, @args) = @_;
787
187
50
217
76
        return %{$args[0]}               if ref($args[0]) eq 'HASH';
788
137
143
        Carp::croak($error_msg)          if ref($args[0]);
789
132
374
        return @args                     if @args && @args % 2 == 0;
790
42
73
        return ($bare_key => $args[0])   if @args == 1;
791        # M7 (logic-gap closure): odd-count > 1 is not a valid calling convention.
792        # Fail fast rather than silently discarding trailing arguments.
793
9
27
        Carp::croak($error_msg)          if @args;
794
5
7
        return ();
795}
796
797# Purpose:  Store $rc in the lookup cache under $key and return it.
798#           Reduces the repeated "$self->{cache}{$k} = $r; return $r;" pattern
799#           that appeared ~12 times across geocode.
800# Entry:    $self, $key (lc string), $rc (Geo::Location::Point or undef).
801# Exit:     $rc (returned transparently).
802# Side Effects: Writes to $self->{cache}.
803sub _cache_and_return {
804
31
2927
        my ($self, $key, $rc) = @_;
805
31
38
        $self->{'cache'}{$key} = $rc;
806
31
65
        return $rc;
807}
808
809# Purpose:  Resolve a full state/province name to its two-letter code using
810#           the appropriate Locale:: module.  Returns the original string if
811#           no mapping is found or the input is already two characters.
812#           Locale::US and Locale::CA objects are cached as module-level
813#           singletons to avoid repeated construction overhead.
814# Entry:    $country - country name or ISO code; $state - state/province name.
815# Exit:     Two-letter code or $state unchanged.
816# Side Effects: May initialise $_locale_us or $_locale_ca singletons.
817sub _to_two_letter_state {
818
19
14940
        my ($country, $state) = @_;
819
19
68
        return $state // '' if !defined($state) || length($state) <= 2;
820
821
14
41
        if ($country =~ /^(United States|USA|US)$/i) {
822
8
27
                $_locale_us //= Locale::US->new();
823
8
812
                return $_locale_us->{'state2code'}{uc($state)} // $state;
824        } elsif ($country =~ /Canada/i) {
825
4
14
                $_locale_ca //= Locale::CA->new();
826
4
762
                return $_locale_ca->{'province2code'}{uc($state)} // $state;
827        }
828
2
2
        return $state;
829}
830
831# Purpose:  Match parsed address components against all data rows.
832#           Each named column must match (case-insensitively) the corresponding
833#           data field; a minimum of 3 columns must match for a result.
834# Entry:    $self, $data - hashref of address components,
835#           @columns - ordered list of keys to compare.
836# Exit:     Geo::Location::Point on match; undef otherwise.
837# Side Effects: May delete undef entries from $data (for undefined optional fields).
838sub _search {
839
55
10644
        my ($self, $data, @columns) = @_;
840
841
55
58
        $self->{'reads'}++;
842
843        # M5 (boolean reduction): the $failed flag was set-and-break in the inner loop
844        # then tested in the outer loop - eliminated by using a labeled 'next ROW'
845        # that short-circuits directly, removing one boolean gate per iteration.
846
55
55
26
77
        ROW: for my $row (@{$self->{'data'}}) {
847
3463
1669
                my $matched = 0;
848
3463
1542
                my @matched_columns;
849
850
3463
1779
                for my $column (@columns) {
851
3570
2116
                        if (defined($data->{$column})) {
852
3445
2550
                                next ROW unless defined($row->{$column});
853
2142
1959
                                next ROW if uc($row->{$column}) ne uc($data->{$column});
854
90
58
                                $matched++;
855                                # The accuracy is reported from the columns that matched, not
856                                # every column this call site offered: a caller trying
857                                # 'number road city...' for a town that has no road must not have
858                                # its match described as an address.
859
90
60
                                push @matched_columns, $column;
860                        } elsif (exists $data->{$column}) {
861
2
2
                                delete $data->{$column};
862                        }
863                }
864
865
108
57
                next if $matched < 3;
866
867                # Assign confidence based on how many columns contributed to the match
868
13
20
                my $confidence = $matched == scalar(@columns) ? $CONF_EXACT
869                               : $matched >= 4                ? $CONF_HIGH
870                               :                               $CONF_MEDIUM;
871
872                # Provenance (Phase 6 dependency specs section 2.1).  Set after %{$row}
873                # so that a dataset column that happens to be called accuracy cannot stand
874                # in for the match this code actually made.
875                return Geo::Location::Point->new(
876                        location   => $data->{'location'},
877                        confidence => $confidence,
878                        database   => __PACKAGE__,
879
13
13
21
29
                        %{$row},
880                        geocoder   => GEOCODER,
881                        accuracy   => Geo::Location::Point::accuracy_for(@matched_columns),
882                );
883        }
884
42
73
        return;
885}
886
887 - 899
=head2 _result_for

Build the result for a dataset row that the index matched.  The index holds the
rows themselves - the same hashrefs C<_search> scans - so the object is built
as a copy rather than blessed in place, and the provenance keys every result
carries (section 2.1 of F<BI-Phase6-Dependency-Specs.md>) are added where the
row does not already record them.

An index hit is an exact match on the row's string, so the confidence is
C<$CONF_EXACT>, and the accuracy is what the row's own fields justify, through
C<Geo::Location::Point::accuracy_for()>.

=cut
900
901sub _result_for {
902
20
21
        my ($self, $row) = @_;
903
904
20
20
14
97
        my %attributes = %{$row};
905
20
31
        $attributes{'geocoder'}   = GEOCODER;
906
20
24
        $attributes{'database'}   = __PACKAGE__;
907
20
19
        $attributes{'confidence'} = $CONF_EXACT;
908
20
49
        $attributes{'accuracy'}   = Geo::Location::Point::accuracy_for(keys %attributes);
909
910
20
862
        return Geo::Location::Point->new(\%attributes);
911}
912
913# Purpose:  Calculate the geographic centres (centroids) of all city/state/country
914#           clusters in the parsed dataset that contain three or more data points.
915#           Clusters with fewer than 3 entries are too sparse to be meaningful.
916# Entry:    $rows - arrayref of hashrefs with latitude/longitude/city/state/country.
917# Exit:     Arrayref of hashrefs (one per qualifying cluster), or undef if none.
918# Side Effects: None.
919sub _find_geographic_centres {
920
26
11209
        my $rows = $_[0];
921
922        # Group rows by normalised city|state|country key
923
26
30
        my %groups;
924
26
26
16
33
        for my $row (@{$rows}) {
925
1082
1092
                next unless defined($row->{'latitude'})  && defined($row->{'longitude'});
926
1081
1071
                next unless $row->{'latitude'}  =~ /^-?\d+\.?\d*$/;
927
1079
985
                next unless $row->{'longitude'} =~ /^-?\d+\.?\d*$/;
928                my $key = join '|',
929                        $row->{'city'}    // '',
930                        $row->{'state'}   // '',
931
1055
1191
                        $row->{'country'} // '';
932
1055
1055
452
844
                push @{$groups{$key}}, $row;
933        }
934
935
26
23
        my @centres;
936
26
51
        for my $key (keys %groups) {
937
513
239
                my $locs = $groups{$key};
938
513
513
221
346
                next if @{$locs} < 3;
939
940
77
105
                my ($city, $state, $country) = split /\|/, $key;
941
77
82
                my ($lat, $lon) = _calculate_centre($locs);
942
943
77
200
                push @centres, {
944                        city      => $city,
945                        state     => $state,
946                        country   => $country,
947                        lat       => $lat,
948                        latitude  => $lat,
949                        longitude => $lon,
950                        long      => $lon,
951                        lng       => $lon,
952                };
953        }
954
955
26
115
        return @centres ? \@centres : undef;
956}
957
958# Purpose:  Compute the arithmetic mean of latitude and longitude for a group
959#           of locations.  Accurate for small geographic areas; for large areas
960#           or locations crossing the antimeridian, a vector-mean is needed.
961# Entry:    $locs - arrayref of hashrefs each with 'latitude' and 'longitude'.
962# Exit:     ($centre_lat, $centre_lon) as decimal degree strings to 6dp.
963# Side Effects: None.
964sub _calculate_centre {
965
80
2367
        my $locs  = $_[0];
966
80
50
        my ($sum_lat, $sum_lon) = (0, 0);
967
80
80
34
424
        $sum_lat += $_->{'latitude'}, $sum_lon += $_->{'longitude'} for @{$locs};
968
80
80
46
37
        my $n = scalar @{$locs};
969
80
244
        return (sprintf('%.6f', $sum_lat / $n), sprintf('%.6f', $sum_lon / $n));
970}
971
972# https://www.oreilly.com/library/view/perl-cookbook/1565922433/ch02s03.html
973# Equal within $dp decimal places - avoids floating-point equality pitfalls.
974sub _equal {
975
4598
4246
        my ($A, $B, $dp) = @_;
976
4598
8892
        return sprintf("%.${dp}g", $A) eq sprintf("%.${dp}g", $B);
977}
978
979 - 1002
=head1 AUTHOR

Nigel Horne C<< <njh@nigelhorne.com> >>

=head1 BUGS

The data are stored in the module source and must be maintained by the author.
A future version should load them from an external file to allow community contributions.

See also: L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Geo-Coder-Free>

=head1 SEE ALSO

L<Geo::Coder::Free>, L<Geo::Coder::Free::OpenAddresses>, L<Geo::Coder::Free::MaxMind>

=head1 LICENSE AND COPYRIGHT

Copyright 2020-2026 Nigel Horne.

The program code is released under the following licence: GPL2 for personal use on a single computer.
All other users (including Commercial, Charity, Educational, and Government)
must apply in writing for a licence for use from Nigel Horne at C<< <njh at nigelhorne.com> >>.

=cut
1003
10041;
1005
1006# Use abbreviations in the data: RD not ROAD, ST not STREET, etc.