mainegeo.matching

This module generates or loads a database of Maine town names and their aliases, and provides a function for matching.

  1"""This module generates or loads a database of Maine town names and their
  2aliases, and provides a function for matching.
  3"""
  4
  5__docformat__ = 'google'
  6
  7__all__ = [
  8    'TownReference',
  9    'TownDatabase',
 10    'get_town_database'
 11]
 12
 13from dataclasses import dataclass
 14from functools import cached_property, cache
 15from typing import List, Dict, Optional, ClassVar
 16from pathlib import Path
 17from ruamel.yaml import YAML
 18from mainegeo.paths import TOWNSHIPS_JSON, TOWNSHIPS_YAML
 19from mainegeo.entities import (County, Cousub, TownType)
 20from mainegeo.townships import (
 21    clean_code,
 22    clean_town,
 23    strip_suffix,
 24    strip_region,
 25    toggle_suffix,
 26    extract_alias
 27)
 28
 29yaml = YAML()
 30
 31class MatchError(Exception):
 32    def __init__(self, message):
 33        super().__init__(message)
 34
 35@dataclass
 36class TownReference:
 37    name: str
 38    geocode: str
 39    gnis_id: int
 40    town_type: TownType
 41    county: County
 42    cousub: Cousub
 43    aliases: List[str]
 44    processed: Optional[bool] = False
 45
 46    def __post_init__(self):
 47        if not self.processed:
 48            self._clean_aliases()
 49            self._infer_aliases()
 50            self.processed = True
 51
 52    def _clean_aliases(self):
 53        aliases = sum(self.aliases, [])
 54        aliases = map(str.upper, filter(None, aliases))
 55        self.aliases = list(set(aliases))
 56        
 57    def _infer_aliases(self):
 58        aliases = self.aliases
 59        
 60        aliases.extend(list(map(clean_code, aliases)))
 61        aliases.extend(list(map(clean_town, aliases)))
 62        aliases.extend(list(map(strip_suffix, aliases)))
 63        aliases.extend(list(map(strip_region, aliases)))
 64        aliases.extend(list(map(strip_region, aliases))) # 2x
 65
 66        township_aliases = filter(None, map(extract_alias, aliases))
 67        aliases.extend(list(township_aliases))
 68
 69        if self.town_type in (TownType.UNORGANIZED, TownType.ISLAND):
 70            aliases.extend(list(map(toggle_suffix, aliases)))
 71        
 72        self.aliases = list(set(aliases))
 73        self.aliases.sort()
 74        
 75    @staticmethod
 76    def json_object_hook(json_record, processed = False):
 77        return TownReference(
 78            name = json_record['town'],
 79            geocode = json_record['town_geocode'],
 80            gnis_id = json_record['gnis_id'],
 81            town_type = TownType(json_record['geotype']),
 82            county = County(
 83                fips = json_record['county_fips'], 
 84                name = json_record['county_name'],
 85                code = json_record['sos_county']
 86            ),
 87            cousub = Cousub(
 88                geocode = json_record['cousub_geocode'],
 89                name = json_record['cousub_name'],
 90                basename = json_record['cousub_basename'],
 91                geoclass = json_record['classfp']
 92            ),
 93            aliases = [
 94                [
 95                    json_record['maine_gis_name'],
 96                    json_record['town'],
 97                    json_record['cousub_basename'],
 98                    json_record['voting_name'],
 99                    json_record['tribal_name']
100                ],
101                json_record['gnis_variants'],
102                json_record['historical_names'],
103                json_record['misspellings'],
104                json_record['islands']
105            ],
106            processed = processed
107        )
108        
109@dataclass(frozen=True)
110class TownAlias:
111    """ A lightweight frozen container holding the minimum elements required for matching.
112    """
113    name: str
114    county_fips: Optional[int] = None
115
116@dataclass
117class TownDatabase:
118    data: List[TownReference] = None
119    processed: bool = False
120    
121    YAML_PATH: ClassVar[Path] = TOWNSHIPS_YAML
122    JSON_PATH: ClassVar[Path] = TOWNSHIPS_JSON
123    
124    def __post_init__(self):
125        if self.processed is False:
126            self._process_data()
127            self._validate_data()
128
129    @classmethod
130    def build(cls):
131        file_path = cls.YAML_PATH    
132        # first call
133        if Path(file_path).exists():
134            return cls.load_from_yaml(file_path)
135        # subsequent calls
136        else:
137            towndb = cls.create_from_raw_data()
138            towndb.save_to_yaml(file_path)
139            return towndb
140
141    @classmethod
142    def create_from_raw_data(cls, json_path: Path = None):
143        import json
144        
145        if json_path is None:
146            json_path = cls.JSON_PATH
147            
148        with open(json_path) as file:
149            towns = json.load(file, object_hook = TownReference.json_object_hook)
150            return cls(towns, processed = False)
151    
152    @classmethod
153    def load_from_yaml(cls, file_path = None):
154        file_path = file_path or cls.YAML_PATH
155         
156        with open(file_path, 'r') as f:
157            data = yaml.load(f)
158            towns = []
159            
160            for yml in data['towns']:
161                town = TownReference(
162                    name = yml['name'],
163                    geocode = yml['geocode'],
164                    gnis_id = yml['gnis_id'],
165                    town_type = TownType(yml['town_type']),
166                    county = County(
167                        fips = yml['county']['fips'], 
168                        name = yml['county']['name'],
169                        code = yml['county']['code']
170                    ),
171                    cousub = Cousub(
172                        geocode = yml['cousub']['geocode'],
173                        name = yml['cousub']['name'],
174                        basename = yml['cousub']['basename'],
175                        geoclass = yml['cousub']['geoclass']
176                    ),
177                    aliases = yml['aliases'],
178                    processed = True
179                )
180                towns.append(town)
181            
182            return cls(towns, processed = True)
183        
184    def save_to_yaml(self, file_path = None):
185        file_path = file_path or self.YAML_PATH
186        
187        serializable_data = {
188            'towns': [
189                {
190                    'name': town.name,
191                    'geocode': town.geocode,
192                    'town_type': town.town_type.value,
193                    'gnis_id': town.gnis_id,
194                    'county': {
195                        'fips': town.county.fips,
196                        'name': town.county.name,
197                        'code': town.county.code
198                    },
199                    'cousub' : {
200                        'geocode': town.cousub.geocode,
201                        'name': town.cousub.name,
202                        'basename': town.cousub.basename,
203                        'geoclass': town.cousub.geoclass
204                    },
205                    'aliases': town.aliases
206                } for town in self.data
207            ]
208        }
209
210        file_path.parent.mkdir(parents = True, exist_ok = True)
211        
212        with open(file_path, 'w') as f:
213            yaml.dump(serializable_data, f)
214
215    def _process_data(self):
216        if self.data is not None:
217            self._remove_duplicate_aliases()
218            self.data.sort(key=lambda x: x.name)
219            self.processed = True
220
221    def _validate_data(self):
222        geocodes = [town.geocode for town in self.data]
223        if not all(geocodes):
224            raise ValueError("Missing geocodes in source data")
225        elif len(geocodes) != len(set(geocodes)):
226            raise ValueError("Non-unique geocodes in source data")
227
228    def _remove_duplicate_aliases(self):
229        for town in self.data:
230            town.aliases = [
231                alias_name for alias_name in town.aliases
232                if TownAlias(alias_name, town.county.fips) in self.alias_lookup.keys()
233            ]
234
235    @cached_property
236    def _suggested_aliases(self) -> List[TownAlias]:
237        all = []
238        for town in self.data:
239            for alias_name in town.aliases:
240                all.append(TownAlias(alias_name))
241                all.append(TownAlias(alias_name, town.county.fips))
242        return all
243    
244    @cached_property
245    def _unique_aliases(self) -> List[TownAlias]:
246        counts = {}
247        for alias in self._suggested_aliases:
248            counts[alias] = counts.get(alias, 0) + 1
249        return [alias for alias, count in counts.items() if count == 1]
250    
251    @cached_property
252    def alias_lookup(self) -> Dict[TownAlias, TownReference]:
253        records = {}
254        for town in self.data:
255            canonical = town.name.upper()
256            
257            for alias_name in town.aliases:
258                state_alias = TownAlias(alias_name)
259                county_alias = TownAlias(alias_name, town.county.fips)
260                
261                for alias in (state_alias, county_alias):
262                    if alias in self._unique_aliases or alias_name == canonical:
263                        records[alias] = town
264        return records
265            
266    def search(self, **kwargs) -> List[TownReference]:
267        return [
268            town for town in self.data
269            if all(getattr(town, k) == v for k, v in kwargs.items())
270        ]
271            
272    def match(
273        self,
274        town: str,
275        county_fips: int = None,
276        cleaned: bool = False,
277        strict: bool = False
278    ) -> TownReference:
279        """
280        Clean and match a town name to the alias database and return the `TownReference` object.
281        
282        For faster matches on already clean data, 
283        pass `cleaned = True` to match without cleaning.
284
285        Args:
286            town: A single town or township name
287            county_fips: Integer code for county. If used, will improve match rate.
288            cleaned: True if the town name is already clean, False if it should be cleaned.
289            strict: True if exception should be raised when a match fails
290
291        Examples:
292            >>> towndb = TownDatabase.build()
293            >>> towndb.match('Cross Lake Twp (T17 R5)').name
294            'Cross Lake Twp'
295            >>> towndb.match('Prentiss Twp', cleaned = True) is None
296            True
297            >>> towndb.match('Prentiss Twp', county_fips = 19, cleaned = True).name
298            'Prentiss Twp T7 R3 NBPP'
299        """
300        town_name = town.upper() if cleaned else clean_town(town.upper())
301
302        def lazy_get_code(unmatched_town: str):
303            township_code = clean_code(unmatched_town)
304            if township_code != unmatched_town:
305                return township_code
306            
307        def lazy_get_alias(unmatched_town: str):
308            township_alias = extract_alias(unmatched_town)
309            if township_alias != unmatched_town:
310                return township_alias
311        
312        # lazy evaluation
313        names = [
314            lambda: town_name,
315            lambda: lazy_get_code(town_name),
316            lambda: lazy_get_alias(town_name)
317        ]
318
319        for get_name in names:
320            name = get_name()
321            if name:           
322                state_alias = TownAlias(name)
323                state_match = self.alias_lookup.get(state_alias)
324                
325                if state_match:
326                    return state_match
327                
328                if county_fips is None:
329                    continue
330
331                county_alias = TownAlias(name, county_fips)
332                county_match = self.alias_lookup.get(county_alias)
333
334                if county_match:
335                    return county_match
336                
337        if strict:
338            message = f'No match found for {town}.'
339            raise MatchError(message = message)
340            
341    def canonical_name(self, town: str, county_fips: int = None, cleaned: bool = False) -> str:
342        """
343        Match a town to the alias database and return the canonical name only.
344        
345        Convenience method; equivalent to `TownDatabase.build().match(...).name`.
346        
347        For faster matches on already clean data, 
348        pass `cleaned = True` to match without cleaning.
349
350        Args:
351            town: A single town or township name
352            county_fips: Integer code for county. If used, will improve match rate.
353            cleaned: True if the town name is already clean, False if it should be cleaned.
354
355        Examples:
356            >>> towndb = TownDatabase.build()
357            >>> towndb.canonical_name('Cross Lake Twp (T17 R5)')
358            'Cross Lake Twp'
359            >>> towndb.canonical_name('Soldiertown Twp') is None
360            True
361            >>> towndb.canonical_name('Soldiertown Twp', county_fips = 25)
362            'Soldiertown Twp T2 R3 NBKP'
363        """
364        match = self.match(town, county_fips, cleaned = cleaned)
365        if match:
366            return match.name
367
368@cache        
369def get_town_database() -> TownDatabase:
370    return TownDatabase.build()
@dataclass
class TownReference:
 36@dataclass
 37class TownReference:
 38    name: str
 39    geocode: str
 40    gnis_id: int
 41    town_type: TownType
 42    county: County
 43    cousub: Cousub
 44    aliases: List[str]
 45    processed: Optional[bool] = False
 46
 47    def __post_init__(self):
 48        if not self.processed:
 49            self._clean_aliases()
 50            self._infer_aliases()
 51            self.processed = True
 52
 53    def _clean_aliases(self):
 54        aliases = sum(self.aliases, [])
 55        aliases = map(str.upper, filter(None, aliases))
 56        self.aliases = list(set(aliases))
 57        
 58    def _infer_aliases(self):
 59        aliases = self.aliases
 60        
 61        aliases.extend(list(map(clean_code, aliases)))
 62        aliases.extend(list(map(clean_town, aliases)))
 63        aliases.extend(list(map(strip_suffix, aliases)))
 64        aliases.extend(list(map(strip_region, aliases)))
 65        aliases.extend(list(map(strip_region, aliases))) # 2x
 66
 67        township_aliases = filter(None, map(extract_alias, aliases))
 68        aliases.extend(list(township_aliases))
 69
 70        if self.town_type in (TownType.UNORGANIZED, TownType.ISLAND):
 71            aliases.extend(list(map(toggle_suffix, aliases)))
 72        
 73        self.aliases = list(set(aliases))
 74        self.aliases.sort()
 75        
 76    @staticmethod
 77    def json_object_hook(json_record, processed = False):
 78        return TownReference(
 79            name = json_record['town'],
 80            geocode = json_record['town_geocode'],
 81            gnis_id = json_record['gnis_id'],
 82            town_type = TownType(json_record['geotype']),
 83            county = County(
 84                fips = json_record['county_fips'], 
 85                name = json_record['county_name'],
 86                code = json_record['sos_county']
 87            ),
 88            cousub = Cousub(
 89                geocode = json_record['cousub_geocode'],
 90                name = json_record['cousub_name'],
 91                basename = json_record['cousub_basename'],
 92                geoclass = json_record['classfp']
 93            ),
 94            aliases = [
 95                [
 96                    json_record['maine_gis_name'],
 97                    json_record['town'],
 98                    json_record['cousub_basename'],
 99                    json_record['voting_name'],
100                    json_record['tribal_name']
101                ],
102                json_record['gnis_variants'],
103                json_record['historical_names'],
104                json_record['misspellings'],
105                json_record['islands']
106            ],
107            processed = processed
108        )
TownReference( name: str, geocode: str, gnis_id: int, town_type: mainegeo.entities.TownType, county: mainegeo.entities.County, cousub: mainegeo.entities.Cousub, aliases: List[str], processed: Optional[bool] = False)
name: str
geocode: str
gnis_id: int
aliases: List[str]
processed: Optional[bool] = False
@staticmethod
def json_object_hook(json_record, processed=False):
 76    @staticmethod
 77    def json_object_hook(json_record, processed = False):
 78        return TownReference(
 79            name = json_record['town'],
 80            geocode = json_record['town_geocode'],
 81            gnis_id = json_record['gnis_id'],
 82            town_type = TownType(json_record['geotype']),
 83            county = County(
 84                fips = json_record['county_fips'], 
 85                name = json_record['county_name'],
 86                code = json_record['sos_county']
 87            ),
 88            cousub = Cousub(
 89                geocode = json_record['cousub_geocode'],
 90                name = json_record['cousub_name'],
 91                basename = json_record['cousub_basename'],
 92                geoclass = json_record['classfp']
 93            ),
 94            aliases = [
 95                [
 96                    json_record['maine_gis_name'],
 97                    json_record['town'],
 98                    json_record['cousub_basename'],
 99                    json_record['voting_name'],
100                    json_record['tribal_name']
101                ],
102                json_record['gnis_variants'],
103                json_record['historical_names'],
104                json_record['misspellings'],
105                json_record['islands']
106            ],
107            processed = processed
108        )
@dataclass
class TownDatabase:
117@dataclass
118class TownDatabase:
119    data: List[TownReference] = None
120    processed: bool = False
121    
122    YAML_PATH: ClassVar[Path] = TOWNSHIPS_YAML
123    JSON_PATH: ClassVar[Path] = TOWNSHIPS_JSON
124    
125    def __post_init__(self):
126        if self.processed is False:
127            self._process_data()
128            self._validate_data()
129
130    @classmethod
131    def build(cls):
132        file_path = cls.YAML_PATH    
133        # first call
134        if Path(file_path).exists():
135            return cls.load_from_yaml(file_path)
136        # subsequent calls
137        else:
138            towndb = cls.create_from_raw_data()
139            towndb.save_to_yaml(file_path)
140            return towndb
141
142    @classmethod
143    def create_from_raw_data(cls, json_path: Path = None):
144        import json
145        
146        if json_path is None:
147            json_path = cls.JSON_PATH
148            
149        with open(json_path) as file:
150            towns = json.load(file, object_hook = TownReference.json_object_hook)
151            return cls(towns, processed = False)
152    
153    @classmethod
154    def load_from_yaml(cls, file_path = None):
155        file_path = file_path or cls.YAML_PATH
156         
157        with open(file_path, 'r') as f:
158            data = yaml.load(f)
159            towns = []
160            
161            for yml in data['towns']:
162                town = TownReference(
163                    name = yml['name'],
164                    geocode = yml['geocode'],
165                    gnis_id = yml['gnis_id'],
166                    town_type = TownType(yml['town_type']),
167                    county = County(
168                        fips = yml['county']['fips'], 
169                        name = yml['county']['name'],
170                        code = yml['county']['code']
171                    ),
172                    cousub = Cousub(
173                        geocode = yml['cousub']['geocode'],
174                        name = yml['cousub']['name'],
175                        basename = yml['cousub']['basename'],
176                        geoclass = yml['cousub']['geoclass']
177                    ),
178                    aliases = yml['aliases'],
179                    processed = True
180                )
181                towns.append(town)
182            
183            return cls(towns, processed = True)
184        
185    def save_to_yaml(self, file_path = None):
186        file_path = file_path or self.YAML_PATH
187        
188        serializable_data = {
189            'towns': [
190                {
191                    'name': town.name,
192                    'geocode': town.geocode,
193                    'town_type': town.town_type.value,
194                    'gnis_id': town.gnis_id,
195                    'county': {
196                        'fips': town.county.fips,
197                        'name': town.county.name,
198                        'code': town.county.code
199                    },
200                    'cousub' : {
201                        'geocode': town.cousub.geocode,
202                        'name': town.cousub.name,
203                        'basename': town.cousub.basename,
204                        'geoclass': town.cousub.geoclass
205                    },
206                    'aliases': town.aliases
207                } for town in self.data
208            ]
209        }
210
211        file_path.parent.mkdir(parents = True, exist_ok = True)
212        
213        with open(file_path, 'w') as f:
214            yaml.dump(serializable_data, f)
215
216    def _process_data(self):
217        if self.data is not None:
218            self._remove_duplicate_aliases()
219            self.data.sort(key=lambda x: x.name)
220            self.processed = True
221
222    def _validate_data(self):
223        geocodes = [town.geocode for town in self.data]
224        if not all(geocodes):
225            raise ValueError("Missing geocodes in source data")
226        elif len(geocodes) != len(set(geocodes)):
227            raise ValueError("Non-unique geocodes in source data")
228
229    def _remove_duplicate_aliases(self):
230        for town in self.data:
231            town.aliases = [
232                alias_name for alias_name in town.aliases
233                if TownAlias(alias_name, town.county.fips) in self.alias_lookup.keys()
234            ]
235
236    @cached_property
237    def _suggested_aliases(self) -> List[TownAlias]:
238        all = []
239        for town in self.data:
240            for alias_name in town.aliases:
241                all.append(TownAlias(alias_name))
242                all.append(TownAlias(alias_name, town.county.fips))
243        return all
244    
245    @cached_property
246    def _unique_aliases(self) -> List[TownAlias]:
247        counts = {}
248        for alias in self._suggested_aliases:
249            counts[alias] = counts.get(alias, 0) + 1
250        return [alias for alias, count in counts.items() if count == 1]
251    
252    @cached_property
253    def alias_lookup(self) -> Dict[TownAlias, TownReference]:
254        records = {}
255        for town in self.data:
256            canonical = town.name.upper()
257            
258            for alias_name in town.aliases:
259                state_alias = TownAlias(alias_name)
260                county_alias = TownAlias(alias_name, town.county.fips)
261                
262                for alias in (state_alias, county_alias):
263                    if alias in self._unique_aliases or alias_name == canonical:
264                        records[alias] = town
265        return records
266            
267    def search(self, **kwargs) -> List[TownReference]:
268        return [
269            town for town in self.data
270            if all(getattr(town, k) == v for k, v in kwargs.items())
271        ]
272            
273    def match(
274        self,
275        town: str,
276        county_fips: int = None,
277        cleaned: bool = False,
278        strict: bool = False
279    ) -> TownReference:
280        """
281        Clean and match a town name to the alias database and return the `TownReference` object.
282        
283        For faster matches on already clean data, 
284        pass `cleaned = True` to match without cleaning.
285
286        Args:
287            town: A single town or township name
288            county_fips: Integer code for county. If used, will improve match rate.
289            cleaned: True if the town name is already clean, False if it should be cleaned.
290            strict: True if exception should be raised when a match fails
291
292        Examples:
293            >>> towndb = TownDatabase.build()
294            >>> towndb.match('Cross Lake Twp (T17 R5)').name
295            'Cross Lake Twp'
296            >>> towndb.match('Prentiss Twp', cleaned = True) is None
297            True
298            >>> towndb.match('Prentiss Twp', county_fips = 19, cleaned = True).name
299            'Prentiss Twp T7 R3 NBPP'
300        """
301        town_name = town.upper() if cleaned else clean_town(town.upper())
302
303        def lazy_get_code(unmatched_town: str):
304            township_code = clean_code(unmatched_town)
305            if township_code != unmatched_town:
306                return township_code
307            
308        def lazy_get_alias(unmatched_town: str):
309            township_alias = extract_alias(unmatched_town)
310            if township_alias != unmatched_town:
311                return township_alias
312        
313        # lazy evaluation
314        names = [
315            lambda: town_name,
316            lambda: lazy_get_code(town_name),
317            lambda: lazy_get_alias(town_name)
318        ]
319
320        for get_name in names:
321            name = get_name()
322            if name:           
323                state_alias = TownAlias(name)
324                state_match = self.alias_lookup.get(state_alias)
325                
326                if state_match:
327                    return state_match
328                
329                if county_fips is None:
330                    continue
331
332                county_alias = TownAlias(name, county_fips)
333                county_match = self.alias_lookup.get(county_alias)
334
335                if county_match:
336                    return county_match
337                
338        if strict:
339            message = f'No match found for {town}.'
340            raise MatchError(message = message)
341            
342    def canonical_name(self, town: str, county_fips: int = None, cleaned: bool = False) -> str:
343        """
344        Match a town to the alias database and return the canonical name only.
345        
346        Convenience method; equivalent to `TownDatabase.build().match(...).name`.
347        
348        For faster matches on already clean data, 
349        pass `cleaned = True` to match without cleaning.
350
351        Args:
352            town: A single town or township name
353            county_fips: Integer code for county. If used, will improve match rate.
354            cleaned: True if the town name is already clean, False if it should be cleaned.
355
356        Examples:
357            >>> towndb = TownDatabase.build()
358            >>> towndb.canonical_name('Cross Lake Twp (T17 R5)')
359            'Cross Lake Twp'
360            >>> towndb.canonical_name('Soldiertown Twp') is None
361            True
362            >>> towndb.canonical_name('Soldiertown Twp', county_fips = 25)
363            'Soldiertown Twp T2 R3 NBKP'
364        """
365        match = self.match(town, county_fips, cleaned = cleaned)
366        if match:
367            return match.name
TownDatabase( data: List[TownReference] = None, processed: bool = False)
data: List[TownReference] = None
processed: bool = False
YAML_PATH: ClassVar[pathlib.Path] = PosixPath('/Users/lydia-rosekesich/repositories/maine-geography/src/mainegeo/data/townships.yaml')
JSON_PATH: ClassVar[pathlib.Path] = PosixPath('/Users/lydia-rosekesich/repositories/maine-geography/src/mainegeo/data/townships.json')
@classmethod
def build(cls):
130    @classmethod
131    def build(cls):
132        file_path = cls.YAML_PATH    
133        # first call
134        if Path(file_path).exists():
135            return cls.load_from_yaml(file_path)
136        # subsequent calls
137        else:
138            towndb = cls.create_from_raw_data()
139            towndb.save_to_yaml(file_path)
140            return towndb
@classmethod
def create_from_raw_data(cls, json_path: pathlib.Path = None):
142    @classmethod
143    def create_from_raw_data(cls, json_path: Path = None):
144        import json
145        
146        if json_path is None:
147            json_path = cls.JSON_PATH
148            
149        with open(json_path) as file:
150            towns = json.load(file, object_hook = TownReference.json_object_hook)
151            return cls(towns, processed = False)
@classmethod
def load_from_yaml(cls, file_path=None):
153    @classmethod
154    def load_from_yaml(cls, file_path = None):
155        file_path = file_path or cls.YAML_PATH
156         
157        with open(file_path, 'r') as f:
158            data = yaml.load(f)
159            towns = []
160            
161            for yml in data['towns']:
162                town = TownReference(
163                    name = yml['name'],
164                    geocode = yml['geocode'],
165                    gnis_id = yml['gnis_id'],
166                    town_type = TownType(yml['town_type']),
167                    county = County(
168                        fips = yml['county']['fips'], 
169                        name = yml['county']['name'],
170                        code = yml['county']['code']
171                    ),
172                    cousub = Cousub(
173                        geocode = yml['cousub']['geocode'],
174                        name = yml['cousub']['name'],
175                        basename = yml['cousub']['basename'],
176                        geoclass = yml['cousub']['geoclass']
177                    ),
178                    aliases = yml['aliases'],
179                    processed = True
180                )
181                towns.append(town)
182            
183            return cls(towns, processed = True)
def save_to_yaml(self, file_path=None):
185    def save_to_yaml(self, file_path = None):
186        file_path = file_path or self.YAML_PATH
187        
188        serializable_data = {
189            'towns': [
190                {
191                    'name': town.name,
192                    'geocode': town.geocode,
193                    'town_type': town.town_type.value,
194                    'gnis_id': town.gnis_id,
195                    'county': {
196                        'fips': town.county.fips,
197                        'name': town.county.name,
198                        'code': town.county.code
199                    },
200                    'cousub' : {
201                        'geocode': town.cousub.geocode,
202                        'name': town.cousub.name,
203                        'basename': town.cousub.basename,
204                        'geoclass': town.cousub.geoclass
205                    },
206                    'aliases': town.aliases
207                } for town in self.data
208            ]
209        }
210
211        file_path.parent.mkdir(parents = True, exist_ok = True)
212        
213        with open(file_path, 'w') as f:
214            yaml.dump(serializable_data, f)
alias_lookup: Dict[mainegeo.matching.TownAlias, TownReference]
252    @cached_property
253    def alias_lookup(self) -> Dict[TownAlias, TownReference]:
254        records = {}
255        for town in self.data:
256            canonical = town.name.upper()
257            
258            for alias_name in town.aliases:
259                state_alias = TownAlias(alias_name)
260                county_alias = TownAlias(alias_name, town.county.fips)
261                
262                for alias in (state_alias, county_alias):
263                    if alias in self._unique_aliases or alias_name == canonical:
264                        records[alias] = town
265        return records
def search(self, **kwargs) -> List[TownReference]:
267    def search(self, **kwargs) -> List[TownReference]:
268        return [
269            town for town in self.data
270            if all(getattr(town, k) == v for k, v in kwargs.items())
271        ]
def match( self, town: str, county_fips: int = None, cleaned: bool = False, strict: bool = False) -> TownReference:
273    def match(
274        self,
275        town: str,
276        county_fips: int = None,
277        cleaned: bool = False,
278        strict: bool = False
279    ) -> TownReference:
280        """
281        Clean and match a town name to the alias database and return the `TownReference` object.
282        
283        For faster matches on already clean data, 
284        pass `cleaned = True` to match without cleaning.
285
286        Args:
287            town: A single town or township name
288            county_fips: Integer code for county. If used, will improve match rate.
289            cleaned: True if the town name is already clean, False if it should be cleaned.
290            strict: True if exception should be raised when a match fails
291
292        Examples:
293            >>> towndb = TownDatabase.build()
294            >>> towndb.match('Cross Lake Twp (T17 R5)').name
295            'Cross Lake Twp'
296            >>> towndb.match('Prentiss Twp', cleaned = True) is None
297            True
298            >>> towndb.match('Prentiss Twp', county_fips = 19, cleaned = True).name
299            'Prentiss Twp T7 R3 NBPP'
300        """
301        town_name = town.upper() if cleaned else clean_town(town.upper())
302
303        def lazy_get_code(unmatched_town: str):
304            township_code = clean_code(unmatched_town)
305            if township_code != unmatched_town:
306                return township_code
307            
308        def lazy_get_alias(unmatched_town: str):
309            township_alias = extract_alias(unmatched_town)
310            if township_alias != unmatched_town:
311                return township_alias
312        
313        # lazy evaluation
314        names = [
315            lambda: town_name,
316            lambda: lazy_get_code(town_name),
317            lambda: lazy_get_alias(town_name)
318        ]
319
320        for get_name in names:
321            name = get_name()
322            if name:           
323                state_alias = TownAlias(name)
324                state_match = self.alias_lookup.get(state_alias)
325                
326                if state_match:
327                    return state_match
328                
329                if county_fips is None:
330                    continue
331
332                county_alias = TownAlias(name, county_fips)
333                county_match = self.alias_lookup.get(county_alias)
334
335                if county_match:
336                    return county_match
337                
338        if strict:
339            message = f'No match found for {town}.'
340            raise MatchError(message = message)

Clean and match a town name to the alias database and return the TownReference object.

For faster matches on already clean data, pass cleaned = True to match without cleaning.

Arguments:
  • town: A single town or township name
  • county_fips: Integer code for county. If used, will improve match rate.
  • cleaned: True if the town name is already clean, False if it should be cleaned.
  • strict: True if exception should be raised when a match fails
Examples:
>>> towndb = TownDatabase.build()
>>> towndb.match('Cross Lake Twp (T17 R5)').name
'Cross Lake Twp'
>>> towndb.match('Prentiss Twp', cleaned = True) is None
True
>>> towndb.match('Prentiss Twp', county_fips = 19, cleaned = True).name
'Prentiss Twp T7 R3 NBPP'
def canonical_name(self, town: str, county_fips: int = None, cleaned: bool = False) -> str:
342    def canonical_name(self, town: str, county_fips: int = None, cleaned: bool = False) -> str:
343        """
344        Match a town to the alias database and return the canonical name only.
345        
346        Convenience method; equivalent to `TownDatabase.build().match(...).name`.
347        
348        For faster matches on already clean data, 
349        pass `cleaned = True` to match without cleaning.
350
351        Args:
352            town: A single town or township name
353            county_fips: Integer code for county. If used, will improve match rate.
354            cleaned: True if the town name is already clean, False if it should be cleaned.
355
356        Examples:
357            >>> towndb = TownDatabase.build()
358            >>> towndb.canonical_name('Cross Lake Twp (T17 R5)')
359            'Cross Lake Twp'
360            >>> towndb.canonical_name('Soldiertown Twp') is None
361            True
362            >>> towndb.canonical_name('Soldiertown Twp', county_fips = 25)
363            'Soldiertown Twp T2 R3 NBKP'
364        """
365        match = self.match(town, county_fips, cleaned = cleaned)
366        if match:
367            return match.name

Match a town to the alias database and return the canonical name only.

Convenience method; equivalent to TownDatabase.build().match(...).name.

For faster matches on already clean data, pass cleaned = True to match without cleaning.

Arguments:
  • town: A single town or township name
  • county_fips: Integer code for county. If used, will improve match rate.
  • cleaned: True if the town name is already clean, False if it should be cleaned.
Examples:
>>> towndb = TownDatabase.build()
>>> towndb.canonical_name('Cross Lake Twp (T17 R5)')
'Cross Lake Twp'
>>> towndb.canonical_name('Soldiertown Twp') is None
True
>>> towndb.canonical_name('Soldiertown Twp', county_fips = 25)
'Soldiertown Twp T2 R3 NBKP'
@cache
def get_town_database() -> TownDatabase:
369@cache        
370def get_town_database() -> TownDatabase:
371    return TownDatabase.build()