mainegeo.elections

Functions for parsing Maine Secretary of State election reporting units.

The Maine SoS uses reporting units that provide the following challenges for parsing:
  1. Reporting units may be composed of one or several towns.
  2. Reporting units may include a non-reporting registration town.
  3. Reporting units may include an unspecified group of all townships that register at a particular town, or in a particular county.
  4. Delimiters, indicators of reporting vs. registration, and indicators of township aliases are not standard over time.

This module provides methods for parsing Maine election result strings into consistent objects, extracting reporting and registration towns, and standardizing the format of unspecified town groups.

The entry point methods of this module can all be run on a delimited result string containing multiple towns.

Consider the following raw election result strings:
  • T12/R13 & T9/R8 WELS (ASHLAND)
  • SHERMAN (AND BENEDICTA & SILVER RIDGE TWP)
  • BERRY/CATHANCE/MARION TWPS (EAST MACHIAS)
  • BARNARD TWP, EBEEMEE TWP (T5 R9 NWP), T4 R9 NWP TWP
  • MOUNT CHASE -- T5 R7 TWP
  • ISLAND FALLS -- T4-R3 TWP
  • DOVER-FOXCROFT

These examples display huge variation in how formatting is used to convey information.

Parentheses are used in three different ways in these examples:

  • In T12/R13 & T9/R8 WELS (ASHLAND), parentheses convey that Ashland is a non-reporting registration town.
  • In EBEEMEE TWP (T5 R9 NWP), parentheses convey that T5 R9 NWP is an alias of EBEEMEE TWP.
  • In SHERMAN (AND BENEDICTA & SILVER RIDGE TWP), parentheses convey that this reporting unit includes all of SHERMAN, BENEDICTA, and SILVER RIDGE, and that all three voted in SHERMAN.

Ampersands, commas, and forward slashes are all used interchangeably to delimit geographies within a string:

  • BARNARD TWP, EBEEMEE TWP (T5 R9 NWP), T4 R9 NWP TWP is delimited with commas.
  • BERRY/CATHANCE/MARION TWPS (EAST MACHIAS) is delimited with forward slashes (/).
  • SHERMAN (AND BENEDICTA & SILVER RIDGE TWP) contains two different delimiters--the ampersand character (&) and the substring AND.

Forward slashes are sometimes used as delimiters, but in T12/R13 and T9/R8 WELS they are used to separate township and range designators. Each of T12/R13 and T9/R8 WELS is a single township, correctly written as T12 R13 and T9 R8 WELS.

Hyphens and parentheses are used interchangeably to indicate a non-reporting registration town:

  • In MOUNT CHASE -- T5 R7 TWP, MOUNT CHASE is the registration town and T5 R7 TWP is the reporting town. MOUNT CHASE is not included in the vote totals for this reporting unit.
  • In BERRY/CATHANCE/MARION TWPS (EAST MACHIAS), EAST MACHIAS is the registration town and BERRY TWP, CATHANCE TWP, and MARION TWP are all reporting towns. EAST MACHIAS is not included in the vote totals for this reporting unit.

However, both characters are also used in other ways (see above).

Single hyphens occur in the canonical names of towns, such as DOVER-FOXCROFT, and are sometimes used non-canonically to separate township and range designators, as in the case of T4-R3 TWP (correctly written as T4 R3).

Ampersands also occur in the canonical names of some townships, e.g. King & Bartlett Twp.

Examples:

Example 1

>>> raw_string = 'T12/R13 & T9/R8 WELS (ASHLAND)'

Variations present:

  • Ampersand (&) is used as a token boundary
  • parentheses (()) indicate a non-reporting registration town
  • Forward slash (/), a character that sometimes serves as a token boundary, is used non-canonically to separate the township and range designators in a township name
>>> result = ResultString(raw_string)
>>> result.normalized_string
'T12 R13, T9 R8 WELS (ASHLAND)'
>>> result.reporting_town_names
['T12 R13', 'T9 R8 WELS']
>>> result.registration_town_names
['ASHLAND']

Example 2

>>> raw_string = 'SHERMAN (AND BENEDICTA & SILVER RIDGE TWP) '

Variations present:

  • Ampersand (&) and the substring AND are both used as token boundaries
  • Parentheses (()) used to group part of the reporting unit
  • Trailing whitespace
>>> result = ResultString(raw_string)
>>> result.normalized_string
'SHERMAN, BENEDICTA, SILVER RIDGE TWP'
>>> result.reporting_town_names
['SHERMAN', 'BENEDICTA', 'SILVER RIDGE TWP']
>>> result.registration_town_names
[]

Example 3

>>> raw_string = 'BERRY/CATHANCE/MARION TWPS (EAST MACHIAS)'

Variations present:

  • Forward slash (/) used as token boundary
  • Parentheses (()) used to indicate non-reporting registration town
>>> result = ResultString(raw_string)
>>> result.normalized_string
'BERRY, CATHANCE, MARION TWPS (EAST MACHIAS)'
>>> result.reporting_town_names
['BERRY', 'CATHANCE', 'MARION TWPS']
>>> result.registration_town_names
['EAST MACHIAS']

Note that the plural 'TWPS' is left unaltered. ResultString operations are not concerned with what real-world geography or geographies a token represents (e.g. whether MARION TWPS is a variation of MARION TWP or group of townships that vote at MARION, whether MARION TWP is even a valid town name, etc).

Example 4

>>> raw_string = 'BARNARD TWP, EBEEMEE TWP (T5 R9 NWP), T4 R9 NWP TWP'

Variations present:

  • Parentheses (()) used to indicate township alias, not registration town
>>> result = ResultString(raw_string)
>>> result.normalized_string
'BARNARD TWP, EBEEMEE TWP (T5 R9 NWP), T4 R9 NWP TWP'
>>> result.reporting_town_names
['BARNARD TWP', 'EBEEMEE TWP T5 R9 NWP', 'T4 R9 NWP']
>>> result.registration_town_names
[]

Example 5

>>> raw_string = 'MOUNT CHASE -- T5 R7 TWP'

Variations present:

  • Double hyphen (--) used to indicate non-reporting registration town
>>> result = ResultString(raw_string)
>>> result.normalized_string
'MOUNT CHASE--T5 R7 TWP'
>>> result.reporting_town_names
['T5 R7']
>>> result.registration_town_names
['MOUNT CHASE']

Example 6

>>> raw_string = 'ISLAND FALLS -- T4-R3 TWP'

Variations present:

  • Double hyphen (--) used to indicate non-reporting registration town
  • Hyphen, (-), a character that sometimes serves as a token boundary, is used non-canonically to separate the township and range designators in a township name
>>> result = ResultString(raw_string)
>>> result.normalized_string
'ISLAND FALLS--T4 R3 TWP'
>>> result.reporting_town_names
['T4 R3']
>>> result.registration_town_names
['ISLAND FALLS']

Example 7

>>> raw_string = 'DOVER-FOXCROFT'

Variations present:

  • Hyphen, (-), a character that sometimes indicates a registration town, is used in the canonical name for a town
>>> result = ResultString('DOVER-FOXCROFT')
>>> result.normalized_string
'DOVER-FOXCROFT'
>>> result.reporting_town_names
['DOVER-FOXCROFT']
>>> result.registration_town_names
[]
   1"""Functions for parsing Maine Secretary of State election reporting units.
   2
   3The Maine SoS uses reporting units that provide the following challenges for parsing:
   4    1. Reporting units may be composed of one or several towns.
   5    2. Reporting units may include a non-reporting registration town.
   6    3. Reporting units may include an unspecified group of all townships that register at a
   7       particular town, or in a particular county.
   8    4. Delimiters, indicators of reporting vs. registration, and indicators of township aliases
   9       are not standard over time.
  10
  11This module provides methods for parsing Maine election result strings into consistent objects, 
  12extracting reporting and registration towns, and standardizing the format of unspecified town groups.
  13
  14The entry point methods of this module can all be run on a delimited result string containing 
  15multiple towns.
  16
  17Consider the following raw election result strings:
  18    * `T12/R13 & T9/R8 WELS (ASHLAND)`
  19    * `SHERMAN (AND BENEDICTA & SILVER RIDGE TWP) `
  20    * `BERRY/CATHANCE/MARION TWPS (EAST MACHIAS)`
  21    * `BARNARD TWP, EBEEMEE TWP (T5 R9 NWP), T4 R9 NWP TWP`
  22    * `MOUNT CHASE -- T5 R7 TWP`
  23    * `ISLAND FALLS -- T4-R3 TWP`
  24    * `DOVER-FOXCROFT`
  25
  26These examples display huge variation in how formatting is used to convey information.
  27
  28
  29Parentheses are used in three different ways in these examples:
  30
  31* In `T12/R13 & T9/R8 WELS (ASHLAND)`, parentheses convey that `Ashland`
  32    is a non-reporting registration town.
  33* In `EBEEMEE TWP (T5 R9 NWP)`, parentheses convey that `T5 R9 NWP` is 
  34    an alias of `EBEEMEE TWP`.
  35* In `SHERMAN (AND BENEDICTA & SILVER RIDGE TWP)`, parentheses convey that
  36    this reporting unit includes all of `SHERMAN`, `BENEDICTA`, and `SILVER RIDGE`,
  37    and that all three voted in `SHERMAN`.
  38
  39
  40Ampersands, commas, and forward slashes are all used interchangeably to delimit 
  41geographies within a string:
  42
  43* `BARNARD TWP, EBEEMEE TWP (T5 R9 NWP), T4 R9 NWP TWP` is delimited with commas.
  44* `BERRY/CATHANCE/MARION TWPS (EAST MACHIAS)` is delimited with forward slashes (`/`).
  45* `SHERMAN (AND BENEDICTA & SILVER RIDGE TWP) ` contains two different 
  46    delimiters--the ampersand character (`&`) and the substring `AND`.
  47    
  48    
  49Forward slashes are sometimes used as delimiters, but in `T12/R13` and
  50`T9/R8 WELS` they are used to separate township and range designators. Each of 
  51`T12/R13` and `T9/R8 WELS` is a single township, correctly written as `T12 R13` 
  52and `T9 R8 WELS`.
  53
  54
  55Hyphens and parentheses are used interchangeably to indicate 
  56a non-reporting registration town:
  57
  58* In `MOUNT CHASE -- T5 R7 TWP`, `MOUNT CHASE` is the registration town and 
  59    `T5 R7 TWP` is the reporting town. `MOUNT CHASE` is not included in the vote totals 
  60    for this reporting unit. 
  61* In `BERRY/CATHANCE/MARION TWPS (EAST MACHIAS)`, `EAST MACHIAS` is the registration
  62    town and `BERRY TWP`, `CATHANCE TWP`, and `MARION TWP` are all reporting towns.
  63    `EAST MACHIAS` is not included in the vote totals for this reporting unit.
  64        
  65However, both characters are also used in other ways (see above).
  66    
  67    
  68Single hyphens occur in the canonical names of towns, such as 
  69`DOVER-FOXCROFT`, and are sometimes used non-canonically to separate township
  70and range designators, as in the case of `T4-R3 TWP` (correctly written as `T4 R3`).
  71
  72
  73Ampersands also occur in the canonical names of some townships,
  74e.g. `King & Bartlett Twp`.
  75
  76
  77Examples:
  78    **Example 1**
  79    >>> raw_string = 'T12/R13 & T9/R8 WELS (ASHLAND)'
  80    
  81    Variations present:
  82    
  83    * Ampersand (`&`) is used as a token boundary
  84    * parentheses (`()`) indicate a non-reporting registration town
  85    * Forward slash (`/`), a character that sometimes serves as a token boundary,
  86    is used non-canonically to separate the township and range designators in a 
  87    township name
  88    
  89    >>> result = ResultString(raw_string)
  90    >>> result.normalized_string
  91    'T12 R13, T9 R8 WELS (ASHLAND)'
  92    >>> result.reporting_town_names
  93    ['T12 R13', 'T9 R8 WELS']
  94    >>> result.registration_town_names
  95    ['ASHLAND']
  96
  97
  98    **Example 2**
  99    >>> raw_string = 'SHERMAN (AND BENEDICTA & SILVER RIDGE TWP) '
 100    
 101    Variations present:
 102    
 103    * Ampersand (`&`) and the substring `AND` are both used as token boundaries
 104    * Parentheses (`()`) used to group part of the reporting unit
 105    * Trailing whitespace
 106        
 107    >>> result = ResultString(raw_string)
 108    >>> result.normalized_string
 109    'SHERMAN, BENEDICTA, SILVER RIDGE TWP'
 110    >>> result.reporting_town_names
 111    ['SHERMAN', 'BENEDICTA', 'SILVER RIDGE TWP']
 112    >>> result.registration_town_names
 113    []
 114        
 115
 116    **Example 3**
 117    >>> raw_string = 'BERRY/CATHANCE/MARION TWPS (EAST MACHIAS)'
 118    
 119    Variations present:
 120    
 121    * Forward slash (`/`) used as token boundary
 122    * Parentheses (`()`) used to indicate non-reporting registration town
 123        
 124    >>> result = ResultString(raw_string)
 125    >>> result.normalized_string
 126    'BERRY, CATHANCE, MARION TWPS (EAST MACHIAS)'
 127    >>> result.reporting_town_names
 128    ['BERRY', 'CATHANCE', 'MARION TWPS']
 129    >>> result.registration_town_names
 130    ['EAST MACHIAS']
 131    
 132    Note that the plural 'TWPS' is left unaltered. ResultString operations are 
 133    not concerned with what real-world geography or geographies a token represents 
 134    (e.g. whether MARION TWPS is a variation of MARION TWP or group of townships 
 135    that vote at MARION, whether MARION TWP is even a valid town name, etc).
 136    
 137    
 138    **Example 4**
 139    >>> raw_string = 'BARNARD TWP, EBEEMEE TWP (T5 R9 NWP), T4 R9 NWP TWP'
 140    
 141    Variations present:
 142    
 143    * Parentheses (`()`) used to indicate township alias, not registration
 144    town
 145
 146    >>> result = ResultString(raw_string)
 147    >>> result.normalized_string
 148    'BARNARD TWP, EBEEMEE TWP (T5 R9 NWP), T4 R9 NWP TWP'
 149    >>> result.reporting_town_names
 150    ['BARNARD TWP', 'EBEEMEE TWP T5 R9 NWP', 'T4 R9 NWP']
 151    >>> result.registration_town_names
 152    []
 153        
 154        
 155    **Example 5**
 156    >>> raw_string = 'MOUNT CHASE -- T5 R7 TWP'
 157    
 158    Variations present:
 159    
 160    * Double hyphen (`--`) used to indicate non-reporting registration
 161    town
 162
 163    >>> result = ResultString(raw_string)
 164    >>> result.normalized_string
 165    'MOUNT CHASE--T5 R7 TWP'
 166    >>> result.reporting_town_names
 167    ['T5 R7']
 168    >>> result.registration_town_names
 169    ['MOUNT CHASE']
 170    
 171    
 172    **Example 6**
 173    >>> raw_string = 'ISLAND FALLS -- T4-R3 TWP'
 174    
 175    Variations present:
 176    
 177    * Double hyphen (`--`) used to indicate non-reporting registration
 178    town
 179    * Hyphen, (`-`), a character that sometimes serves as a token boundary,
 180    is used non-canonically to separate the township and range designators 
 181    in a township name
 182
 183    >>> result = ResultString(raw_string)
 184    >>> result.normalized_string
 185    'ISLAND FALLS--T4 R3 TWP'
 186    >>> result.reporting_town_names
 187    ['T4 R3']
 188    >>> result.registration_town_names
 189    ['ISLAND FALLS']
 190        
 191
 192    **Example 7**
 193    >>> raw_string = 'DOVER-FOXCROFT'
 194    
 195    Variations present:
 196    
 197    * Hyphen, (`-`), a character that sometimes indicates a registration town,
 198    is used in the canonical name for a town
 199
 200    >>> result = ResultString('DOVER-FOXCROFT')
 201    >>> result.normalized_string
 202    'DOVER-FOXCROFT'
 203    >>> result.reporting_town_names
 204    ['DOVER-FOXCROFT']
 205    >>> result.registration_town_names
 206    []
 207"""
 208
 209__docformat__ = 'google'
 210
 211__all__ = [
 212    'ResultString',
 213    'ReportingUnit'
 214]
 215
 216import re
 217from dataclasses import dataclass, asdict
 218from functools import cached_property
 219from typing import List, Type
 220from itertools import filterfalse
 221
 222from mainegeo._vendor import replace_all, normalize_whitespace, chain_operations
 223from mainegeo.matching import get_town_database
 224from mainegeo.entities import County, Cousub
 225from mainegeo.townships import (
 226    clean_code,
 227    clean_codes,
 228    clean_town,
 229    is_unnamed_township,
 230    has_alias,
 231    extract_alias
 232)
 233from mainegeo.patterns import (
 234    KNOWN_TYPOS,
 235    AMBIGUOUS_GROUPS,
 236    DROP_CHARACTERS_PATTERN,
 237    STANDARD_DELIMITER,
 238    NONSTANDARD_DELIMITER_PATTERN,
 239    ORPHAN_PARENTHESIS_PATTERN,
 240    REGISTRATION_PATTERN,
 241    UNSPECIFIED_FLAG,
 242    STANDARD_FLAG,
 243    MULTI_COUNTY_PATTERN,
 244    MULTI_COUNTY_FORMAT,
 245    MULTI_COUNTY_REGISTRATION_TOWNS,
 246    PLURAL,
 247    SINGULAR,
 248    PLURAL_PATTERN,
 249    SINGULAR_PATTERN,
 250    VALID_AMPERSANDS_PATTERN,
 251    FORMATTED_GROUP_PATTERN
 252)
 253
 254@dataclass
 255class ResultString:
 256    """
 257    A string containing one or more geographies from raw election results.
 258    
 259    This dataclass performs initial normalization and parsing operations on a raw 
 260    election result string. It owns operations that are fully agnostic to which
 261    geographies the tokens in the string represent.
 262    
 263    These operations normalize variations in meaningful formatting (including 
 264    whitespace, delimiters, and other punctuation) so that two types of parsing can occur:
 265
 266    1. The splitting of result strings into a list of tokens, each
 267        one representing a single geography.
 268    2. The separation of tokens representing reporting geographies from tokens 
 269        representing non-reporting registration towns.
 270    
 271    Args:
 272        raw_string: The raw string representation of the reporting unit
 273    """
 274    raw_string: str
 275    
 276    @cached_property
 277    def exists(self) -> bool:
 278        if self.raw_string is None:
 279            return False
 280        elif len(self.raw_string) > 5:
 281            return True
 282        elif re.search(r'\w', self.raw_string):
 283            return True
 284        else:
 285            return False
 286        
 287    @cached_property
 288    def normalized_string(self) -> str:
 289        """
 290        Result string with capitalization, whitespace and delimiters normalized.
 291
 292        Errors entered in `mainegeo.lookups.Overrides.known_typos` and 
 293        `mainegeo.lookups.Overrides.ambiguous_groups` are also repaired.
 294
 295        Example:
 296            >>> result = ResultString('FORT KENT/BIG TWENTY TWP/   T15 R15 WELS')
 297            >>> result.normalized_string
 298            'FORT KENT, BIG TWENTY TWP, T15 R15 WELS'
 299            
 300            >>> result = ResultString('T12/R13 WELS/T9 R8 WELS')
 301            >>> result.normalized_string
 302            'T12 R13 WELS, T9 R8 WELS'
 303            
 304            >>> result = ResultString('T10 SD TWP (CHERRYFIELD, FRANKLIN & MILBRIDGE)')
 305            >>> result.normalized_string
 306            'T10 SD TWP (CHERRYFIELD, FRANKLIN, MILBRIDGE)'
 307        """
 308        initial_cleanup = [
 309            str.upper
 310            , ResultString._fix_known_typos
 311            , ResultString._rename_ambiguous_groups
 312            , ResultString._drop_non_meaningful_chars
 313            , clean_codes
 314            , ResultString._normalize_delimiters
 315            , normalize_whitespace
 316        ]
 317        if self.exists:
 318            return chain_operations(self.raw_string, initial_cleanup)
 319    
 320    @cached_property
 321    def registration_town_names(self) -> List[str]:
 322        """
 323        List of registration town names extracted from result string.
 324
 325        Note:
 326            Splits strings by package-level `STANDARD_DELIMITER`.
 327
 328        Example:
 329            >>> result = ResultString('MOUNT CHASE -- T5 R7 TWP')
 330            >>> result.registration_town_names
 331            ['MOUNT CHASE']
 332            
 333            >>> result = ResultString('T7 SD TWP (STEUBEN)')
 334            >>> result.registration_town_names
 335            ['STEUBEN']
 336            
 337            >>> result = ResultString('CROSS LAKE TWP (T17 R5)')
 338            >>> result.registration_town_names
 339            []
 340            
 341            >>> result = ResultString('ARGYLE TWP (ALTON, EDINBURG)')
 342            >>> result.registration_town_names
 343            ['ALTON', 'EDINBURG']
 344        """
 345        if self._registration_town_substring is None:
 346            return []
 347        else:
 348            reg_towns = self._registration_town_substring.split(STANDARD_DELIMITER)
 349            return list(map(clean_town, reg_towns))
 350
 351    @cached_property
 352    def reporting_town_names(self) -> List[str]:
 353        """
 354        Extract list of reporting town names from result string.
 355
 356        Note:
 357            Drops parentheses around township alias, but does not remove alias.
 358
 359        Args:
 360            result_str: Delimited result string with one or more towns or townships
 361
 362        Returns:
 363            List: Reporting towns with formatting identifiers stripped
 364
 365        Example:
 366            >>> ResultString('MOUNT CHASE--T5 R7 TWP').reporting_town_names
 367            ['T5 R7']
 368            
 369            >>> ResultString('HERSEYTOWN, SOLDIERTOWN TWPS (MEDWAY)').reporting_town_names
 370            ['HERSEYTOWN', 'SOLDIERTOWN TWPS']
 371            
 372            >>> ResultString('ARGYLE TWP (ALTON, EDINBURG)').reporting_town_names
 373            ['ARGYLE TWP']
 374            
 375            >>> ResultString('BARNARD TWP, EBEEMEE TWP (T5 R9 NWP), T4 R9 NWP TWP').reporting_town_names
 376            ['BARNARD TWP', 'EBEEMEE TWP T5 R9 NWP', 'T4 R9 NWP']
 377            
 378            >>> ResultString('BERRY/CATHANCE/MARIONTWPS (EAST MACHIAS)').reporting_town_names
 379            ['BERRY', 'CATHANCE', 'MARION TWPS']
 380        """
 381        if self._registration_town_substring is None:
 382            reporting_substr = self.normalized_string
 383        else:
 384            reporting_substr = re.sub(self._registration_town_substring, '', self.normalized_string)
 385
 386        reporting = reporting_substr.split(STANDARD_DELIMITER)
 387        return list(map(clean_town, reporting))
 388
 389    @cached_property
 390    def _registration_town_substring(self) -> str:
 391        """
 392        Registration town substring extracted based on SoS formatting identifiers.
 393
 394        SoS formatting identifiers include parentheticals and double dashes (--).
 395        Parentheticals that are township aliases are ignored. 
 396
 397        Note:
 398            Substring includes formatting indicators.
 399
 400        Raises:
 401            ValueError: If result string contains multiple registration town substrings.
 402
 403        Examples:
 404            >>> result = ResultString('MOUNT CHASE -- T5 R7 TWP')
 405            >>> result._registration_town_substring
 406            'MOUNT CHASE--'
 407            
 408            >>> result = ResultString('T7 SD TWP (STEUBEN)')
 409            >>> result._registration_town_substring
 410            '(STEUBEN)'
 411            
 412            >>> result = ResultString('ARGYLE TWP (ALTON, EDINBURG)')
 413            >>> result._registration_town_substring
 414            '(ALTON, EDINBURG)'
 415        """
 416        if self.exists:
 417            flagged = REGISTRATION_PATTERN.findall(self.normalized_string)
 418            substrings = list(filterfalse(is_unnamed_township, flagged))
 419            
 420            if len(substrings) > 1:
 421                raise ValueError(f'Multiple registration town substrings found in result string: {self.normalized_string}')
 422            elif len(substrings) == 0:
 423                return None
 424            else:
 425                return substrings[0]
 426
 427    @staticmethod
 428    def _fix_known_typos(result_str: str) -> str:
 429        """
 430        Fix known typos in-place in string.
 431
 432        These typos are true misspellings, which are unique and unlikely to be repeated.
 433        They are stored in the KNOWN_TYPOS dict in the patterns module.
 434
 435        Args:
 436            result_str: Delimited result string with one or more towns or townships
 437        """
 438        return replace_all(KNOWN_TYPOS, result_str)
 439
 440    @staticmethod
 441    def _rename_ambiguous_groups(result_str: str) -> str:
 442        """
 443        Fix ambiguous unspecified group names in-place in string.
 444
 445        These typos recur from time to time, but follow a general pattern. It simplifies
 446        unspecified group name detection significantly if they are corrected early in
 447        processing. They are stored in `lookups.Overrides.ambiguous_groups`.
 448
 449        Args:
 450            result_str: Delimited result string with one or more towns or townships
 451
 452        Example:
 453            >>> ResultString._rename_ambiguous_groups('MILLINOCKET -- PISCATAQUIS TWP')
 454            'MILLINOCKET -- PISCATAQUIS TWPS'
 455            
 456            >>> ResultString._rename_ambiguous_groups('PENOBSCOT TWPS')
 457            'MILLINOCKET PENOBSCOT TWPS'
 458        """
 459        return replace_all(AMBIGUOUS_GROUPS, result_str)
 460
 461    @staticmethod
 462    def _drop_non_meaningful_chars(result_str: str) -> str:
 463        """
 464        Drop or replace special characters that don't communicate meaningful info.
 465
 466        Non-meaningful characters include all punctuation characters not found in
 467        `mainegeo.patterns.MEANINGFUL_CHARACTERS`.
 468
 469        Args:
 470            result_str: Delimited result string with one or more towns or townships
 471        """
 472        result = VALID_AMPERSANDS_PATTERN.sub('AND', normalize_whitespace(result_str))
 473        return DROP_CHARACTERS_PATTERN.sub('', result)
 474
 475    @staticmethod
 476    def _normalize_delimiters(result_str: str) -> str:
 477        """
 478        Replace all delimiters in result string with standard delimiter.
 479
 480        Args:
 481            result_str: Delimited result string with one or more towns or townships
 482        """
 483        delimit = NONSTANDARD_DELIMITER_PATTERN.sub(STANDARD_DELIMITER, result_str)
 484        cleanup = ORPHAN_PARENTHESIS_PATTERN.sub(r'\g<result>', delimit)
 485        return cleanup
 486
 487@dataclass(frozen=True)
 488class ResultGeo:
 489    name: str
 490    county: County
 491    strict: bool = False
 492    
 493    @classmethod
 494    def from_strings(cls, name: str, county_code: str, strict: bool = False) -> "ResultGeo":
 495        return cls(
 496            name = name,
 497            county = County(code=county_code),
 498            strict = strict
 499        )
 500
 501@dataclass(frozen=True)
 502class Municipality(ResultGeo):
 503    @cached_property
 504    def matched_town(self):
 505        return get_town_database().match(
 506            self.name,
 507            self.county.fips,
 508            cleaned = True,
 509            strict = self.strict
 510        )
 511    
 512    @property
 513    def is_matched(self):
 514        return self.matched_town is not None
 515    
 516    @property
 517    def canonical_name(self):
 518        if self.matched_town:
 519            return self.matched_town.name
 520
 521    @property
 522    def consensus_name(self):
 523        return self.canonical_name or self.name
 524    
 525    @cached_property
 526    def matched_geocode(self) -> str:
 527        if self.matched_town:
 528            return self.matched_town.geocode
 529        
 530    @cached_property
 531    def matched_cousub(self) -> Cousub:
 532        return self.matched_town.cousub if self.matched_town else Cousub()
 533        
 534    @cached_property
 535    def matched_county(self) -> County:
 536        return self.matched_town.county if self.matched_town else County()
 537    
 538    def to_dict(self) -> dict[str]:
 539        return {
 540            'name':             self.consensus_name,
 541            'canonical_name':   self.canonical_name,
 542            'raw_name':         self.name,
 543            'county':           asdict(self.matched_county),
 544            'cousub':           asdict(self.matched_cousub),
 545            'geocode':          self.matched_geocode,
 546            'is_matched':       self.is_matched
 547        }
 548
 549@dataclass(frozen=True)
 550class NamedTownship(Municipality):
 551    pass
 552    
 553@dataclass(frozen=True)
 554class UnnamedTownship(Municipality):
 555    @property
 556    def has_alias(self):
 557        return has_alias(self.name)
 558    
 559    @property
 560    def alias(self):
 561        return extract_alias(self.name)
 562    
 563    @property
 564    def code(self):
 565        return clean_code(self.name)
 566
 567@dataclass(frozen=True)
 568class UnspecifiedGroup(ResultGeo):
 569    @cached_property
 570    def _format_match(self) -> re.Match:
 571        return FORMATTED_GROUP_PATTERN.match(self.name)
 572    
 573    @property
 574    def is_matched(self) -> bool:
 575        return all((self._format_match, self.group_registration_town.is_matched))
 576
 577    @property
 578    def group_county(self) -> County:
 579        county_code = self._format_match.group('cty') or self.county.code
 580        return County(code = county_code)
 581    
 582    @property
 583    def group_registration_town(self) -> NamedTownship:
 584        reg_town_name = self._format_match.group('regtown')
 585        return NamedTownship(
 586            name = reg_town_name,
 587            county = self.county,
 588            strict = self.strict
 589        )
 590    
 591    @property
 592    def canonical_name(self):
 593        regtown = self.group_registration_town.consensus_name
 594        
 595        if regtown.upper() in MULTI_COUNTY_REGISTRATION_TOWNS:
 596            county = f' {self.group_county.name} County '
 597        else:
 598            county = ' '
 599            
 600        formatted = f'{STANDARD_FLAG}{county}{UNSPECIFIED_FLAG}'
 601        return formatted.title()
 602    
 603    @property
 604    def consensus_name(self):
 605        return self.canonical_name or self.name
 606    
 607    def to_dict(self):
 608        return {
 609            'name':                     self.consensus_name,
 610            'canonical_name':           self.canonical_name,
 611            'raw_name':                 self.name,
 612            'county':                   asdict(self.group_county),
 613            'group_registration_town':  self.group_registration_town.to_dict(),
 614            'is_matched':               self.is_matched
 615        }
 616
 617@dataclass
 618class ReportingUnit:
 619    """A collection of towns and unspecified groups parsed from a `ResultString`.
 620        
 621    This class performs normalization operations on individual fragments of a 
 622    delimited string, coerces them into objects representing different geography
 623    types, and attempts to match them to known Maine geographies. It provides several
 624    options for representing the fully parsed unit as a string or dictionary.
 625
 626    Args:
 627        result_string: A `ResultString` object
 628        county: A `County` object
 629        strict: True if exception should be raised when a match fails
 630    """
 631    result_string: ResultString
 632    county: County
 633    strict: bool = False
 634
 635    @classmethod
 636    def from_strings(
 637        cls,
 638        result_str: str,
 639        county_code: str,
 640        strict: bool = False
 641    ) -> "ReportingUnit":
 642        """ Factory method to create a fully processed ReportingUnit.
 643        
 644        Examples:
 645            >>> result = ReportingUnit.from_strings('PRENTISS TWP (WEBSTER PLT)', 'PEN')
 646            >>> result.formatted_string
 647            'Prentiss Twp T7 R3 NBPP [Webster Plt]'
 648            >>> result.reporting_town_names
 649            ['Prentiss Twp T7 R3 NBPP']
 650            >>> result.registration_town_names
 651            ['Webster Plt']
 652            >>> result.unspecified_groups
 653            []
 654            
 655            >>> result = ReportingUnit.from_strings('MEDWAY -- GRINDSTONE/SOLDIERTOWN TWPS', 'PEN')
 656            >>> result.formatted_string
 657            'Grindstone Twp [Medway], Soldiertown Twp T2 R7 WELS [Medway]'
 658            >>> result.reporting_town_names
 659            ['Grindstone Twp', 'Soldiertown Twp T2 R7 WELS']
 660            >>> result.registration_town_names
 661            ['Medway']
 662            
 663            >>> result = ReportingUnit.from_strings('FRANKLIN/T9 T10 SD TWPS', 'HAN')
 664            >>> result.formatted_string
 665            'Franklin, T9 SD BPP, T10 SD BPP'
 666            >>> result.reporting_town_names
 667            ['Franklin', 'T9 SD BPP', 'T10 SD BPP']
 668            >>> result.registration_town_names
 669            []
 670            
 671            >>> result = ReportingUnit.from_strings('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
 672            >>> result.formatted_string
 673            'Millinocket, Unspecified Piscataquis County Twps [Millinocket]'
 674            >>> result.reporting_town_names
 675            ['Millinocket', 'Unspecified Piscataquis County Twps']
 676            >>> result.registration_town_names
 677            ['Millinocket']
 678            >>> len(result.unspecified_groups)
 679            1
 680            
 681            >>> result = ReportingUnit.from_strings('MILLINOCKET/PIS TWPS/PEN TWPS', 'PEN')
 682            >>> result.reporting_town_names
 683            ['Millinocket', 'Unspecified Piscataquis County Twps', 'Unspecified Penobscot County Twps']
 684            >>> result.registration_town_names
 685            ['Millinocket']
 686            >>> len(result.unspecified_groups)
 687            2
 688            
 689            >>> result = ReportingUnit.from_strings('MEDWAY TOWNSHIPS', 'PEN')
 690            >>> result.formatted_string
 691            'Unspecified Twps [Medway]'
 692            >>> result.reporting_town_names
 693            ['Unspecified Twps']
 694            >>> result.registration_town_names
 695            ['Medway']
 696            >>> len(result.unspecified_groups)
 697            1
 698            """
 699        unit = cls(
 700            result_string = ResultString(result_str),
 701            county = County(code = county_code),
 702            strict = strict
 703        )
 704        return unit
 705    
 706    @property
 707    def raw_string(self) -> str:
 708        """
 709        Original SoS name for this reporting unit.
 710        
 711        Examples:
 712            >>> unit = ReportingUnit.from_strings('WEBSTER PLT -- PRENTISS TWP', 'PEN')
 713            >>> unit.raw_string
 714            'WEBSTER PLT -- PRENTISS TWP'
 715        """
 716        return self.result_string.raw_string
 717
 718    @cached_property
 719    def formatted_string(self) -> str:
 720        """
 721        A formatted string representation of this reporting unit.
 722        
 723        Examples:
 724            >>> args = ('T12/R13 & T9/R8 WELS (ASHLAND)', 'ARO')
 725            >>> ReportingUnit.from_strings(*args).formatted_string
 726            'T12 R13 WELS [Ashland], T9 R8 WELS [Ashland]'
 727            
 728            >>> args = ('GRINDSTONE/HERSEYTOWN/SOLDIERTOWN TWP', 'PEN')
 729            >>> ReportingUnit.from_strings(*args).formatted_string
 730            'Grindstone Twp, Herseytown Twp, Soldiertown Twp T2 R7 WELS'
 731            
 732            >>> args = ('SHERMAN (AND BENEDICTA & SILVER RIDGE TWPS) ', 'ARO')
 733            >>> ReportingUnit.from_strings(*args).formatted_string
 734            'Sherman, Benedicta Twp, Silver Ridge Twp'
 735            
 736            >>> args = ('JACKMAN TWPS', 'SOM')
 737            >>> ReportingUnit.from_strings(*args).formatted_string
 738            'Unspecified Twps [Jackman]'
 739            
 740            >>> args = ('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
 741            >>> ReportingUnit.from_strings(*args).formatted_string
 742            'Millinocket, Unspecified Piscataquis County Twps [Millinocket]'
 743        """
 744        reporting = [
 745            town + f' [{self.registration_string}]'
 746            if
 747                len(self.registration_town_names) > 0
 748                and town not in self.registration_town_names
 749            else town
 750            for town in self.reporting_town_names
 751        ]
 752        return STANDARD_DELIMITER.join(reporting)
 753    
 754    @cached_property
 755    def reporting_string(self) -> str:
 756        """
 757        A formatted string representation of reporting towns in this unit.
 758
 759        Examples:
 760            >>> args = ('WEBSTER PLT -- PRENTISS TWP', 'PEN')
 761            >>> ReportingUnit.from_strings(*args).reporting_string
 762            'Prentiss Twp T7 R3 NBPP'
 763            
 764            >>> args = ('T12/R13 & T9/R8 WELS (ASHLAND)', 'ARO')
 765            >>> ReportingUnit.from_strings(*args).reporting_string
 766            'T12 R13 WELS, T9 R8 WELS'
 767            
 768            >>> args = ('JACKMAN TWPS', 'SOM')
 769            >>> ReportingUnit.from_strings(*args).reporting_string
 770            'Unspecified Twps'
 771            
 772            >>> args = ('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
 773            >>> ReportingUnit.from_strings(*args).reporting_string
 774            'Millinocket, Unspecified Piscataquis County Twps'
 775            
 776            >>> args = ('MILLINOCKET/PEN TWPS/PIS TWPS', 'PEN')
 777            >>> ReportingUnit.from_strings(*args).reporting_string
 778            'Millinocket, Unspecified Penobscot County Twps, Unspecified Piscataquis County Twps'
 779        """
 780        return STANDARD_DELIMITER.join(self.reporting_town_names)
 781    
 782    @cached_property
 783    def registration_string(self) -> str:
 784        """
 785        A formatted string representation of registration towns in this unit.
 786        
 787        Examples:
 788            >>> args = ('WEBSTER PLT -- PRENTISS TWP', 'PEN')
 789            >>> ReportingUnit.from_strings(*args).registration_string
 790            'Webster Plt'
 791            
 792            >>> args = ('WYMAN TWP (CARRABASSETT VALLEY & EUSTIS)', 'FRA')
 793            >>> ReportingUnit.from_strings(*args).registration_string
 794            'Carrabassett Valley, Eustis'
 795            
 796            >>> args = ('JACKMAN TWPS', 'SOM')
 797            >>> ReportingUnit.from_strings(*args).registration_string
 798            'Jackman'
 799            
 800            >>> args = ('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
 801            >>> ReportingUnit.from_strings(*args).registration_string
 802            'Millinocket'
 803            
 804            >>> args = ('MILLINOCKET/PIS TWPS/PEN TWPS', 'PEN')
 805            >>> ReportingUnit.from_strings(*args).registration_string
 806            'Millinocket'
 807        """
 808        return STANDARD_DELIMITER.join(self.registration_town_names)
 809                
 810    @cached_property
 811    def reporting_town_names(self) -> list[str]:
 812        """
 813        List of reporting town names. Canonical name if match was found, else raw name.
 814        """
 815        return [town.consensus_name for town in self.reporting_towns]
 816    
 817    @cached_property
 818    def registration_town_names(self) -> list[str]:
 819        """
 820        List of registration town names. Canonical name if match was found, else raw name.
 821        """
 822        return [town.consensus_name for town in self.registration_towns]
 823
 824    @cached_property
 825    def registration_towns(self) -> List[NamedTownship]:
 826        """
 827        List of registration towns as `NamedTownship` objects.
 828        """
 829        towns = set()
 830        for name in self.result_string.registration_town_names:
 831            regtown = NamedTownship(name, self.county, strict = self.strict)
 832            towns.add(regtown)
 833        
 834        for group in self.unspecified_groups:
 835            towns.add(group.group_registration_town)
 836        
 837        return sorted(towns, key=lambda town: town.consensus_name)
 838
 839    @cached_property
 840    def reporting_towns(self) -> List[ResultGeo]:
 841        """
 842        List of reporting towns, townships and groups as `ResultGeo` child objects.
 843        """
 844        towns = []
 845        
 846        if self.result_string.exists:
 847            formatted_reporting_names = ReportingUnit._format_reporting_towns(
 848                self.result_string.reporting_town_names,
 849                self.result_string.registration_town_names,
 850                self.has_unspecified_group
 851            )
 852            for name in formatted_reporting_names:
 853                ResultClass = ReportingUnit._classify_fragment(name)
 854                reporting_town = ResultClass(name, self.county, strict = self.strict)
 855                towns.append(reporting_town)
 856        
 857        return towns
 858    
 859    @cached_property
 860    def specified_reporting_towns(self) -> List[Municipality]:
 861        """
 862        List of reporting units that are not unspecified groups.
 863        
 864        Examples:
 865            >>> unit = ReportingUnit.from_strings('WYMAN TWP/SPRING LAKE TWP', 'FRA')
 866            >>> [town.name for town in unit.specified_reporting_towns]
 867            ['WYMAN TWP', 'SPRING LAKE TWP']
 868            
 869            >>> unit = ReportingUnit.from_strings('WYMAN TWP (CARRABASSETT VALLEY)', 'FRA')
 870            >>> [town.name for town in unit.specified_reporting_towns]
 871            ['WYMAN TWP']
 872
 873            >>> unit = ReportingUnit.from_strings('MILLINOCKET/TWPS', 'PEN')
 874            >>> [town.name for town in unit.specified_reporting_towns]
 875            ['MILLINOCKET']
 876            
 877            >>> unit = ReportingUnit.from_strings('MILLINOCKET TWPS', 'PEN')
 878            >>> [town.name for town in unit.specified_reporting_towns]
 879            []
 880        """
 881        return [t for t in self.reporting_towns if type(t) != UnspecifiedGroup]
 882    
 883    @cached_property
 884    def unspecified_groups(self) -> List[ResultGeo]:
 885        """
 886        List of reporting units that are unspecified groups.
 887        
 888        Examples:
 889            >>> unit = ReportingUnit.from_strings('MILLINOCKET/PEN TWPS', 'PEN')
 890            >>> groups = unit.unspecified_groups
 891            >>> [group.name for group in groups]
 892            ['UNSPECIFIED MILLINOCKET TWPS [PEN]']
 893            >>> [group.canonical_name for group in groups]
 894            ['Unspecified Penobscot County Twps']
 895            >>> [group.group_registration_town.canonical_name for group in groups]
 896            ['Millinocket']
 897        """
 898        return [t for t in self.reporting_towns if type(t) == UnspecifiedGroup]
 899    
 900    @cached_property
 901    def has_unspecified_group(self) -> bool:
 902        """
 903        Return True if the result object includes an unspecified group, else False.
 904
 905        Examples:
 906            >>> unit = ReportingUnit.from_strings('MEDWAY/TOWNSHIPS', 'PEN')
 907            >>> unit.has_unspecified_group
 908            True
 909            
 910            >>> unit = ReportingUnit.from_strings('ADAMSTOWN/LOWER CUPSUPTIC TWPS (RANGELEY)', 'OXF')
 911            >>> unit.has_unspecified_group
 912            False
 913            
 914            >>> unit = ReportingUnit.from_strings('MILLINOCKET PISCATAQUIS TWPS', 'PIS')
 915            >>> unit.has_unspecified_group
 916            True
 917            
 918            >>> unit = ReportingUnit.from_strings('MILLINOCKET/PEN TWPS', 'PEN')
 919            >>> unit.has_unspecified_group
 920            True
 921            
 922            >>> unit = ReportingUnit.from_strings('LEXINGTON & SPRING LAKE TWPS', 'SOM')
 923            >>> unit.has_unspecified_group
 924            False
 925        """
 926        reporting = self.result_string.reporting_town_names
 927        registration = self.result_string.registration_town_names
 928        group_name = ' '.join(filter(None, [*registration, *reporting]))
 929
 930        if len(registration) > 1:
 931            return False
 932        elif len(reporting) in (1, 2) and UNSPECIFIED_FLAG in reporting:
 933            return True
 934        elif all(UNSPECIFIED_FLAG in town for town in reporting):
 935            return True
 936        elif MULTI_COUNTY_PATTERN.match(group_name):
 937            return True
 938        else:
 939            return False
 940        
 941    @cached_property
 942    def is_matched(self) -> bool:
 943        """True if every named geography in this unit matched a known town."""
 944        if not self.result_string.exists:
 945            return False
 946        else:
 947            tokens = [*self.reporting_towns, *self.registration_towns]
 948            return bool(tokens) and all(t.is_matched for t in tokens)
 949        
 950    def to_dict(self) -> dict[str]:
 951        return {
 952            'raw_str':          self.raw_string,
 953            'formatted_str':    self.formatted_string,
 954            'reporting_str':    self.reporting_string,
 955            'registration_str': self.registration_string,
 956            'reporting':        {
 957                'specified':        [town.to_dict() for town in self.specified_reporting_towns],
 958                'unspecified':      [group.to_dict() for group in self.unspecified_groups]
 959                                },
 960            'registration':     [town.to_dict() for town in self.registration_towns],
 961            'county':           asdict(self.county),
 962            'is_matched':       self.is_matched
 963        }
 964
 965    @staticmethod
 966    def _format_reporting_towns(
 967            reporting_town_names: List[str], 
 968            registration_town_names: List[str], 
 969            has_unspecified_group: bool) -> List[str]:
 970        """
 971        Apply consistent format to towns and unspecified groups.
 972
 973        Args:
 974            reporting_towns: List of one or more towns
 975            registration_towns: List of non-reporting registration towns (if any)
 976            has_unspecified_group: True if unit contains unspecified group, else False
 977
 978        Returns:
 979            list: Reporting towns with standard formatting applied.
 980
 981        Examples:
 982            >>> ReportingUnit._format_reporting_towns(['LEXINGTON', 'SPRING LAKE TWPS'], [], False)
 983            ['LEXINGTON', 'SPRING LAKE TWP']
 984            
 985            >>> ReportingUnit._format_reporting_towns(['FRANKLIN', 'TWPS'], [], True)
 986            ['FRANKLIN', 'UNSPECIFIED FRANKLIN TWPS']
 987            
 988            >>> ReportingUnit._format_reporting_towns(['PENOBSCOT TWPS'], ['MILLINOCKET'], True)
 989            ['UNSPECIFIED MILLINOCKET TWPS [PEN]']
 990        """
 991        reporting = [
 992            ReportingUnit._format_plural(town, has_unspecified_group)
 993            for town in reporting_town_names
 994        ]
 995        
 996        if has_unspecified_group:
 997            return ReportingUnit._name_unspecified_group(reporting, registration_town_names)
 998        else:
 999            return reporting
1000
1001    @staticmethod    
1002    def _format_plural(town: str, has_unspecified_group: bool) -> str:
1003        """
1004        Correct errors of pluralization in town or group names.
1005
1006        After this function is used, the presence or absence of a plural in a town name
1007        reliably indicates whether it is an unspecified group.
1008        """  
1009        if has_unspecified_group:
1010            return SINGULAR_PATTERN.sub(PLURAL, town)
1011        else:
1012            return PLURAL_PATTERN.sub(SINGULAR, town)
1013
1014    @staticmethod
1015    def _format_unspecified_group(group_name: str) -> str:
1016        """
1017        Apply special format to unspecified groups that include a county.
1018        """
1019        return MULTI_COUNTY_PATTERN.sub(MULTI_COUNTY_FORMAT, group_name)
1020        
1021    @staticmethod
1022    def _name_unspecified_group(
1023            reporting_town_names: List[str], 
1024            registration_town_names: List[str]) -> List[str]:
1025        """
1026        Label unspecified groups with their reporting town and a standard 'unspecified' flag.
1027        """
1028        is_specified_reporting = lambda token: UNSPECIFIED_FLAG not in token
1029        reporting_hosts = filter(is_specified_reporting, reporting_town_names)
1030        hosts = registration_town_names + list(reporting_hosts)
1031        
1032        formatted_tokens = []
1033        for town in reporting_town_names:
1034            if UNSPECIFIED_FLAG in town:                
1035                unformatted = ' '.join(filter(None, [STANDARD_FLAG, *hosts, town]))
1036                group_name = ReportingUnit._format_unspecified_group(unformatted)
1037                formatted_tokens.append(group_name)
1038            else:
1039                formatted_tokens.append(town)
1040        
1041        return formatted_tokens
1042    
1043    @staticmethod
1044    def _classify_fragment(fragment_name: str) -> Type[ResultGeo]:
1045        """
1046        Return correct ResultGeo child class for a reporting towns string fragment.
1047        """
1048        if is_unnamed_township(fragment_name):
1049            return UnnamedTownship
1050        elif UNSPECIFIED_FLAG in fragment_name:
1051            return UnspecifiedGroup
1052        else:
1053            return NamedTownship
@dataclass
class ResultString:
255@dataclass
256class ResultString:
257    """
258    A string containing one or more geographies from raw election results.
259    
260    This dataclass performs initial normalization and parsing operations on a raw 
261    election result string. It owns operations that are fully agnostic to which
262    geographies the tokens in the string represent.
263    
264    These operations normalize variations in meaningful formatting (including 
265    whitespace, delimiters, and other punctuation) so that two types of parsing can occur:
266
267    1. The splitting of result strings into a list of tokens, each
268        one representing a single geography.
269    2. The separation of tokens representing reporting geographies from tokens 
270        representing non-reporting registration towns.
271    
272    Args:
273        raw_string: The raw string representation of the reporting unit
274    """
275    raw_string: str
276    
277    @cached_property
278    def exists(self) -> bool:
279        if self.raw_string is None:
280            return False
281        elif len(self.raw_string) > 5:
282            return True
283        elif re.search(r'\w', self.raw_string):
284            return True
285        else:
286            return False
287        
288    @cached_property
289    def normalized_string(self) -> str:
290        """
291        Result string with capitalization, whitespace and delimiters normalized.
292
293        Errors entered in `mainegeo.lookups.Overrides.known_typos` and 
294        `mainegeo.lookups.Overrides.ambiguous_groups` are also repaired.
295
296        Example:
297            >>> result = ResultString('FORT KENT/BIG TWENTY TWP/   T15 R15 WELS')
298            >>> result.normalized_string
299            'FORT KENT, BIG TWENTY TWP, T15 R15 WELS'
300            
301            >>> result = ResultString('T12/R13 WELS/T9 R8 WELS')
302            >>> result.normalized_string
303            'T12 R13 WELS, T9 R8 WELS'
304            
305            >>> result = ResultString('T10 SD TWP (CHERRYFIELD, FRANKLIN & MILBRIDGE)')
306            >>> result.normalized_string
307            'T10 SD TWP (CHERRYFIELD, FRANKLIN, MILBRIDGE)'
308        """
309        initial_cleanup = [
310            str.upper
311            , ResultString._fix_known_typos
312            , ResultString._rename_ambiguous_groups
313            , ResultString._drop_non_meaningful_chars
314            , clean_codes
315            , ResultString._normalize_delimiters
316            , normalize_whitespace
317        ]
318        if self.exists:
319            return chain_operations(self.raw_string, initial_cleanup)
320    
321    @cached_property
322    def registration_town_names(self) -> List[str]:
323        """
324        List of registration town names extracted from result string.
325
326        Note:
327            Splits strings by package-level `STANDARD_DELIMITER`.
328
329        Example:
330            >>> result = ResultString('MOUNT CHASE -- T5 R7 TWP')
331            >>> result.registration_town_names
332            ['MOUNT CHASE']
333            
334            >>> result = ResultString('T7 SD TWP (STEUBEN)')
335            >>> result.registration_town_names
336            ['STEUBEN']
337            
338            >>> result = ResultString('CROSS LAKE TWP (T17 R5)')
339            >>> result.registration_town_names
340            []
341            
342            >>> result = ResultString('ARGYLE TWP (ALTON, EDINBURG)')
343            >>> result.registration_town_names
344            ['ALTON', 'EDINBURG']
345        """
346        if self._registration_town_substring is None:
347            return []
348        else:
349            reg_towns = self._registration_town_substring.split(STANDARD_DELIMITER)
350            return list(map(clean_town, reg_towns))
351
352    @cached_property
353    def reporting_town_names(self) -> List[str]:
354        """
355        Extract list of reporting town names from result string.
356
357        Note:
358            Drops parentheses around township alias, but does not remove alias.
359
360        Args:
361            result_str: Delimited result string with one or more towns or townships
362
363        Returns:
364            List: Reporting towns with formatting identifiers stripped
365
366        Example:
367            >>> ResultString('MOUNT CHASE--T5 R7 TWP').reporting_town_names
368            ['T5 R7']
369            
370            >>> ResultString('HERSEYTOWN, SOLDIERTOWN TWPS (MEDWAY)').reporting_town_names
371            ['HERSEYTOWN', 'SOLDIERTOWN TWPS']
372            
373            >>> ResultString('ARGYLE TWP (ALTON, EDINBURG)').reporting_town_names
374            ['ARGYLE TWP']
375            
376            >>> ResultString('BARNARD TWP, EBEEMEE TWP (T5 R9 NWP), T4 R9 NWP TWP').reporting_town_names
377            ['BARNARD TWP', 'EBEEMEE TWP T5 R9 NWP', 'T4 R9 NWP']
378            
379            >>> ResultString('BERRY/CATHANCE/MARIONTWPS (EAST MACHIAS)').reporting_town_names
380            ['BERRY', 'CATHANCE', 'MARION TWPS']
381        """
382        if self._registration_town_substring is None:
383            reporting_substr = self.normalized_string
384        else:
385            reporting_substr = re.sub(self._registration_town_substring, '', self.normalized_string)
386
387        reporting = reporting_substr.split(STANDARD_DELIMITER)
388        return list(map(clean_town, reporting))
389
390    @cached_property
391    def _registration_town_substring(self) -> str:
392        """
393        Registration town substring extracted based on SoS formatting identifiers.
394
395        SoS formatting identifiers include parentheticals and double dashes (--).
396        Parentheticals that are township aliases are ignored. 
397
398        Note:
399            Substring includes formatting indicators.
400
401        Raises:
402            ValueError: If result string contains multiple registration town substrings.
403
404        Examples:
405            >>> result = ResultString('MOUNT CHASE -- T5 R7 TWP')
406            >>> result._registration_town_substring
407            'MOUNT CHASE--'
408            
409            >>> result = ResultString('T7 SD TWP (STEUBEN)')
410            >>> result._registration_town_substring
411            '(STEUBEN)'
412            
413            >>> result = ResultString('ARGYLE TWP (ALTON, EDINBURG)')
414            >>> result._registration_town_substring
415            '(ALTON, EDINBURG)'
416        """
417        if self.exists:
418            flagged = REGISTRATION_PATTERN.findall(self.normalized_string)
419            substrings = list(filterfalse(is_unnamed_township, flagged))
420            
421            if len(substrings) > 1:
422                raise ValueError(f'Multiple registration town substrings found in result string: {self.normalized_string}')
423            elif len(substrings) == 0:
424                return None
425            else:
426                return substrings[0]
427
428    @staticmethod
429    def _fix_known_typos(result_str: str) -> str:
430        """
431        Fix known typos in-place in string.
432
433        These typos are true misspellings, which are unique and unlikely to be repeated.
434        They are stored in the KNOWN_TYPOS dict in the patterns module.
435
436        Args:
437            result_str: Delimited result string with one or more towns or townships
438        """
439        return replace_all(KNOWN_TYPOS, result_str)
440
441    @staticmethod
442    def _rename_ambiguous_groups(result_str: str) -> str:
443        """
444        Fix ambiguous unspecified group names in-place in string.
445
446        These typos recur from time to time, but follow a general pattern. It simplifies
447        unspecified group name detection significantly if they are corrected early in
448        processing. They are stored in `lookups.Overrides.ambiguous_groups`.
449
450        Args:
451            result_str: Delimited result string with one or more towns or townships
452
453        Example:
454            >>> ResultString._rename_ambiguous_groups('MILLINOCKET -- PISCATAQUIS TWP')
455            'MILLINOCKET -- PISCATAQUIS TWPS'
456            
457            >>> ResultString._rename_ambiguous_groups('PENOBSCOT TWPS')
458            'MILLINOCKET PENOBSCOT TWPS'
459        """
460        return replace_all(AMBIGUOUS_GROUPS, result_str)
461
462    @staticmethod
463    def _drop_non_meaningful_chars(result_str: str) -> str:
464        """
465        Drop or replace special characters that don't communicate meaningful info.
466
467        Non-meaningful characters include all punctuation characters not found in
468        `mainegeo.patterns.MEANINGFUL_CHARACTERS`.
469
470        Args:
471            result_str: Delimited result string with one or more towns or townships
472        """
473        result = VALID_AMPERSANDS_PATTERN.sub('AND', normalize_whitespace(result_str))
474        return DROP_CHARACTERS_PATTERN.sub('', result)
475
476    @staticmethod
477    def _normalize_delimiters(result_str: str) -> str:
478        """
479        Replace all delimiters in result string with standard delimiter.
480
481        Args:
482            result_str: Delimited result string with one or more towns or townships
483        """
484        delimit = NONSTANDARD_DELIMITER_PATTERN.sub(STANDARD_DELIMITER, result_str)
485        cleanup = ORPHAN_PARENTHESIS_PATTERN.sub(r'\g<result>', delimit)
486        return cleanup

A string containing one or more geographies from raw election results.

This dataclass performs initial normalization and parsing operations on a raw election result string. It owns operations that are fully agnostic to which geographies the tokens in the string represent.

These operations normalize variations in meaningful formatting (including whitespace, delimiters, and other punctuation) so that two types of parsing can occur:

  1. The splitting of result strings into a list of tokens, each one representing a single geography.
  2. The separation of tokens representing reporting geographies from tokens representing non-reporting registration towns.
Arguments:
  • raw_string: The raw string representation of the reporting unit
ResultString(raw_string: str)
raw_string: str
exists: bool
277    @cached_property
278    def exists(self) -> bool:
279        if self.raw_string is None:
280            return False
281        elif len(self.raw_string) > 5:
282            return True
283        elif re.search(r'\w', self.raw_string):
284            return True
285        else:
286            return False
normalized_string: str
288    @cached_property
289    def normalized_string(self) -> str:
290        """
291        Result string with capitalization, whitespace and delimiters normalized.
292
293        Errors entered in `mainegeo.lookups.Overrides.known_typos` and 
294        `mainegeo.lookups.Overrides.ambiguous_groups` are also repaired.
295
296        Example:
297            >>> result = ResultString('FORT KENT/BIG TWENTY TWP/   T15 R15 WELS')
298            >>> result.normalized_string
299            'FORT KENT, BIG TWENTY TWP, T15 R15 WELS'
300            
301            >>> result = ResultString('T12/R13 WELS/T9 R8 WELS')
302            >>> result.normalized_string
303            'T12 R13 WELS, T9 R8 WELS'
304            
305            >>> result = ResultString('T10 SD TWP (CHERRYFIELD, FRANKLIN & MILBRIDGE)')
306            >>> result.normalized_string
307            'T10 SD TWP (CHERRYFIELD, FRANKLIN, MILBRIDGE)'
308        """
309        initial_cleanup = [
310            str.upper
311            , ResultString._fix_known_typos
312            , ResultString._rename_ambiguous_groups
313            , ResultString._drop_non_meaningful_chars
314            , clean_codes
315            , ResultString._normalize_delimiters
316            , normalize_whitespace
317        ]
318        if self.exists:
319            return chain_operations(self.raw_string, initial_cleanup)

Result string with capitalization, whitespace and delimiters normalized.

Errors entered in mainegeo.lookups.Overrides.known_typos and mainegeo.lookups.Overrides.ambiguous_groups are also repaired.

Example:
>>> result = ResultString('FORT KENT/BIG TWENTY TWP/   T15 R15 WELS')
>>> result.normalized_string
'FORT KENT, BIG TWENTY TWP, T15 R15 WELS'
>>> result = ResultString('T12/R13 WELS/T9 R8 WELS')
>>> result.normalized_string
'T12 R13 WELS, T9 R8 WELS'
>>> result = ResultString('T10 SD TWP (CHERRYFIELD, FRANKLIN & MILBRIDGE)')
>>> result.normalized_string
'T10 SD TWP (CHERRYFIELD, FRANKLIN, MILBRIDGE)'
registration_town_names: List[str]
321    @cached_property
322    def registration_town_names(self) -> List[str]:
323        """
324        List of registration town names extracted from result string.
325
326        Note:
327            Splits strings by package-level `STANDARD_DELIMITER`.
328
329        Example:
330            >>> result = ResultString('MOUNT CHASE -- T5 R7 TWP')
331            >>> result.registration_town_names
332            ['MOUNT CHASE']
333            
334            >>> result = ResultString('T7 SD TWP (STEUBEN)')
335            >>> result.registration_town_names
336            ['STEUBEN']
337            
338            >>> result = ResultString('CROSS LAKE TWP (T17 R5)')
339            >>> result.registration_town_names
340            []
341            
342            >>> result = ResultString('ARGYLE TWP (ALTON, EDINBURG)')
343            >>> result.registration_town_names
344            ['ALTON', 'EDINBURG']
345        """
346        if self._registration_town_substring is None:
347            return []
348        else:
349            reg_towns = self._registration_town_substring.split(STANDARD_DELIMITER)
350            return list(map(clean_town, reg_towns))

List of registration town names extracted from result string.

Note:

Splits strings by package-level STANDARD_DELIMITER.

Example:
>>> result = ResultString('MOUNT CHASE -- T5 R7 TWP')
>>> result.registration_town_names
['MOUNT CHASE']
>>> result = ResultString('T7 SD TWP (STEUBEN)')
>>> result.registration_town_names
['STEUBEN']
>>> result = ResultString('CROSS LAKE TWP (T17 R5)')
>>> result.registration_town_names
[]
>>> result = ResultString('ARGYLE TWP (ALTON, EDINBURG)')
>>> result.registration_town_names
['ALTON', 'EDINBURG']
reporting_town_names: List[str]
352    @cached_property
353    def reporting_town_names(self) -> List[str]:
354        """
355        Extract list of reporting town names from result string.
356
357        Note:
358            Drops parentheses around township alias, but does not remove alias.
359
360        Args:
361            result_str: Delimited result string with one or more towns or townships
362
363        Returns:
364            List: Reporting towns with formatting identifiers stripped
365
366        Example:
367            >>> ResultString('MOUNT CHASE--T5 R7 TWP').reporting_town_names
368            ['T5 R7']
369            
370            >>> ResultString('HERSEYTOWN, SOLDIERTOWN TWPS (MEDWAY)').reporting_town_names
371            ['HERSEYTOWN', 'SOLDIERTOWN TWPS']
372            
373            >>> ResultString('ARGYLE TWP (ALTON, EDINBURG)').reporting_town_names
374            ['ARGYLE TWP']
375            
376            >>> ResultString('BARNARD TWP, EBEEMEE TWP (T5 R9 NWP), T4 R9 NWP TWP').reporting_town_names
377            ['BARNARD TWP', 'EBEEMEE TWP T5 R9 NWP', 'T4 R9 NWP']
378            
379            >>> ResultString('BERRY/CATHANCE/MARIONTWPS (EAST MACHIAS)').reporting_town_names
380            ['BERRY', 'CATHANCE', 'MARION TWPS']
381        """
382        if self._registration_town_substring is None:
383            reporting_substr = self.normalized_string
384        else:
385            reporting_substr = re.sub(self._registration_town_substring, '', self.normalized_string)
386
387        reporting = reporting_substr.split(STANDARD_DELIMITER)
388        return list(map(clean_town, reporting))

Extract list of reporting town names from result string.

Note:

Drops parentheses around township alias, but does not remove alias.

Arguments:
  • result_str: Delimited result string with one or more towns or townships
Returns:

List: Reporting towns with formatting identifiers stripped

Example:
>>> ResultString('MOUNT CHASE--T5 R7 TWP').reporting_town_names
['T5 R7']
>>> ResultString('HERSEYTOWN, SOLDIERTOWN TWPS (MEDWAY)').reporting_town_names
['HERSEYTOWN', 'SOLDIERTOWN TWPS']
>>> ResultString('ARGYLE TWP (ALTON, EDINBURG)').reporting_town_names
['ARGYLE TWP']
>>> ResultString('BARNARD TWP, EBEEMEE TWP (T5 R9 NWP), T4 R9 NWP TWP').reporting_town_names
['BARNARD TWP', 'EBEEMEE TWP T5 R9 NWP', 'T4 R9 NWP']
>>> ResultString('BERRY/CATHANCE/MARIONTWPS (EAST MACHIAS)').reporting_town_names
['BERRY', 'CATHANCE', 'MARION TWPS']
@dataclass
class ReportingUnit:
 618@dataclass
 619class ReportingUnit:
 620    """A collection of towns and unspecified groups parsed from a `ResultString`.
 621        
 622    This class performs normalization operations on individual fragments of a 
 623    delimited string, coerces them into objects representing different geography
 624    types, and attempts to match them to known Maine geographies. It provides several
 625    options for representing the fully parsed unit as a string or dictionary.
 626
 627    Args:
 628        result_string: A `ResultString` object
 629        county: A `County` object
 630        strict: True if exception should be raised when a match fails
 631    """
 632    result_string: ResultString
 633    county: County
 634    strict: bool = False
 635
 636    @classmethod
 637    def from_strings(
 638        cls,
 639        result_str: str,
 640        county_code: str,
 641        strict: bool = False
 642    ) -> "ReportingUnit":
 643        """ Factory method to create a fully processed ReportingUnit.
 644        
 645        Examples:
 646            >>> result = ReportingUnit.from_strings('PRENTISS TWP (WEBSTER PLT)', 'PEN')
 647            >>> result.formatted_string
 648            'Prentiss Twp T7 R3 NBPP [Webster Plt]'
 649            >>> result.reporting_town_names
 650            ['Prentiss Twp T7 R3 NBPP']
 651            >>> result.registration_town_names
 652            ['Webster Plt']
 653            >>> result.unspecified_groups
 654            []
 655            
 656            >>> result = ReportingUnit.from_strings('MEDWAY -- GRINDSTONE/SOLDIERTOWN TWPS', 'PEN')
 657            >>> result.formatted_string
 658            'Grindstone Twp [Medway], Soldiertown Twp T2 R7 WELS [Medway]'
 659            >>> result.reporting_town_names
 660            ['Grindstone Twp', 'Soldiertown Twp T2 R7 WELS']
 661            >>> result.registration_town_names
 662            ['Medway']
 663            
 664            >>> result = ReportingUnit.from_strings('FRANKLIN/T9 T10 SD TWPS', 'HAN')
 665            >>> result.formatted_string
 666            'Franklin, T9 SD BPP, T10 SD BPP'
 667            >>> result.reporting_town_names
 668            ['Franklin', 'T9 SD BPP', 'T10 SD BPP']
 669            >>> result.registration_town_names
 670            []
 671            
 672            >>> result = ReportingUnit.from_strings('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
 673            >>> result.formatted_string
 674            'Millinocket, Unspecified Piscataquis County Twps [Millinocket]'
 675            >>> result.reporting_town_names
 676            ['Millinocket', 'Unspecified Piscataquis County Twps']
 677            >>> result.registration_town_names
 678            ['Millinocket']
 679            >>> len(result.unspecified_groups)
 680            1
 681            
 682            >>> result = ReportingUnit.from_strings('MILLINOCKET/PIS TWPS/PEN TWPS', 'PEN')
 683            >>> result.reporting_town_names
 684            ['Millinocket', 'Unspecified Piscataquis County Twps', 'Unspecified Penobscot County Twps']
 685            >>> result.registration_town_names
 686            ['Millinocket']
 687            >>> len(result.unspecified_groups)
 688            2
 689            
 690            >>> result = ReportingUnit.from_strings('MEDWAY TOWNSHIPS', 'PEN')
 691            >>> result.formatted_string
 692            'Unspecified Twps [Medway]'
 693            >>> result.reporting_town_names
 694            ['Unspecified Twps']
 695            >>> result.registration_town_names
 696            ['Medway']
 697            >>> len(result.unspecified_groups)
 698            1
 699            """
 700        unit = cls(
 701            result_string = ResultString(result_str),
 702            county = County(code = county_code),
 703            strict = strict
 704        )
 705        return unit
 706    
 707    @property
 708    def raw_string(self) -> str:
 709        """
 710        Original SoS name for this reporting unit.
 711        
 712        Examples:
 713            >>> unit = ReportingUnit.from_strings('WEBSTER PLT -- PRENTISS TWP', 'PEN')
 714            >>> unit.raw_string
 715            'WEBSTER PLT -- PRENTISS TWP'
 716        """
 717        return self.result_string.raw_string
 718
 719    @cached_property
 720    def formatted_string(self) -> str:
 721        """
 722        A formatted string representation of this reporting unit.
 723        
 724        Examples:
 725            >>> args = ('T12/R13 & T9/R8 WELS (ASHLAND)', 'ARO')
 726            >>> ReportingUnit.from_strings(*args).formatted_string
 727            'T12 R13 WELS [Ashland], T9 R8 WELS [Ashland]'
 728            
 729            >>> args = ('GRINDSTONE/HERSEYTOWN/SOLDIERTOWN TWP', 'PEN')
 730            >>> ReportingUnit.from_strings(*args).formatted_string
 731            'Grindstone Twp, Herseytown Twp, Soldiertown Twp T2 R7 WELS'
 732            
 733            >>> args = ('SHERMAN (AND BENEDICTA & SILVER RIDGE TWPS) ', 'ARO')
 734            >>> ReportingUnit.from_strings(*args).formatted_string
 735            'Sherman, Benedicta Twp, Silver Ridge Twp'
 736            
 737            >>> args = ('JACKMAN TWPS', 'SOM')
 738            >>> ReportingUnit.from_strings(*args).formatted_string
 739            'Unspecified Twps [Jackman]'
 740            
 741            >>> args = ('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
 742            >>> ReportingUnit.from_strings(*args).formatted_string
 743            'Millinocket, Unspecified Piscataquis County Twps [Millinocket]'
 744        """
 745        reporting = [
 746            town + f' [{self.registration_string}]'
 747            if
 748                len(self.registration_town_names) > 0
 749                and town not in self.registration_town_names
 750            else town
 751            for town in self.reporting_town_names
 752        ]
 753        return STANDARD_DELIMITER.join(reporting)
 754    
 755    @cached_property
 756    def reporting_string(self) -> str:
 757        """
 758        A formatted string representation of reporting towns in this unit.
 759
 760        Examples:
 761            >>> args = ('WEBSTER PLT -- PRENTISS TWP', 'PEN')
 762            >>> ReportingUnit.from_strings(*args).reporting_string
 763            'Prentiss Twp T7 R3 NBPP'
 764            
 765            >>> args = ('T12/R13 & T9/R8 WELS (ASHLAND)', 'ARO')
 766            >>> ReportingUnit.from_strings(*args).reporting_string
 767            'T12 R13 WELS, T9 R8 WELS'
 768            
 769            >>> args = ('JACKMAN TWPS', 'SOM')
 770            >>> ReportingUnit.from_strings(*args).reporting_string
 771            'Unspecified Twps'
 772            
 773            >>> args = ('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
 774            >>> ReportingUnit.from_strings(*args).reporting_string
 775            'Millinocket, Unspecified Piscataquis County Twps'
 776            
 777            >>> args = ('MILLINOCKET/PEN TWPS/PIS TWPS', 'PEN')
 778            >>> ReportingUnit.from_strings(*args).reporting_string
 779            'Millinocket, Unspecified Penobscot County Twps, Unspecified Piscataquis County Twps'
 780        """
 781        return STANDARD_DELIMITER.join(self.reporting_town_names)
 782    
 783    @cached_property
 784    def registration_string(self) -> str:
 785        """
 786        A formatted string representation of registration towns in this unit.
 787        
 788        Examples:
 789            >>> args = ('WEBSTER PLT -- PRENTISS TWP', 'PEN')
 790            >>> ReportingUnit.from_strings(*args).registration_string
 791            'Webster Plt'
 792            
 793            >>> args = ('WYMAN TWP (CARRABASSETT VALLEY & EUSTIS)', 'FRA')
 794            >>> ReportingUnit.from_strings(*args).registration_string
 795            'Carrabassett Valley, Eustis'
 796            
 797            >>> args = ('JACKMAN TWPS', 'SOM')
 798            >>> ReportingUnit.from_strings(*args).registration_string
 799            'Jackman'
 800            
 801            >>> args = ('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
 802            >>> ReportingUnit.from_strings(*args).registration_string
 803            'Millinocket'
 804            
 805            >>> args = ('MILLINOCKET/PIS TWPS/PEN TWPS', 'PEN')
 806            >>> ReportingUnit.from_strings(*args).registration_string
 807            'Millinocket'
 808        """
 809        return STANDARD_DELIMITER.join(self.registration_town_names)
 810                
 811    @cached_property
 812    def reporting_town_names(self) -> list[str]:
 813        """
 814        List of reporting town names. Canonical name if match was found, else raw name.
 815        """
 816        return [town.consensus_name for town in self.reporting_towns]
 817    
 818    @cached_property
 819    def registration_town_names(self) -> list[str]:
 820        """
 821        List of registration town names. Canonical name if match was found, else raw name.
 822        """
 823        return [town.consensus_name for town in self.registration_towns]
 824
 825    @cached_property
 826    def registration_towns(self) -> List[NamedTownship]:
 827        """
 828        List of registration towns as `NamedTownship` objects.
 829        """
 830        towns = set()
 831        for name in self.result_string.registration_town_names:
 832            regtown = NamedTownship(name, self.county, strict = self.strict)
 833            towns.add(regtown)
 834        
 835        for group in self.unspecified_groups:
 836            towns.add(group.group_registration_town)
 837        
 838        return sorted(towns, key=lambda town: town.consensus_name)
 839
 840    @cached_property
 841    def reporting_towns(self) -> List[ResultGeo]:
 842        """
 843        List of reporting towns, townships and groups as `ResultGeo` child objects.
 844        """
 845        towns = []
 846        
 847        if self.result_string.exists:
 848            formatted_reporting_names = ReportingUnit._format_reporting_towns(
 849                self.result_string.reporting_town_names,
 850                self.result_string.registration_town_names,
 851                self.has_unspecified_group
 852            )
 853            for name in formatted_reporting_names:
 854                ResultClass = ReportingUnit._classify_fragment(name)
 855                reporting_town = ResultClass(name, self.county, strict = self.strict)
 856                towns.append(reporting_town)
 857        
 858        return towns
 859    
 860    @cached_property
 861    def specified_reporting_towns(self) -> List[Municipality]:
 862        """
 863        List of reporting units that are not unspecified groups.
 864        
 865        Examples:
 866            >>> unit = ReportingUnit.from_strings('WYMAN TWP/SPRING LAKE TWP', 'FRA')
 867            >>> [town.name for town in unit.specified_reporting_towns]
 868            ['WYMAN TWP', 'SPRING LAKE TWP']
 869            
 870            >>> unit = ReportingUnit.from_strings('WYMAN TWP (CARRABASSETT VALLEY)', 'FRA')
 871            >>> [town.name for town in unit.specified_reporting_towns]
 872            ['WYMAN TWP']
 873
 874            >>> unit = ReportingUnit.from_strings('MILLINOCKET/TWPS', 'PEN')
 875            >>> [town.name for town in unit.specified_reporting_towns]
 876            ['MILLINOCKET']
 877            
 878            >>> unit = ReportingUnit.from_strings('MILLINOCKET TWPS', 'PEN')
 879            >>> [town.name for town in unit.specified_reporting_towns]
 880            []
 881        """
 882        return [t for t in self.reporting_towns if type(t) != UnspecifiedGroup]
 883    
 884    @cached_property
 885    def unspecified_groups(self) -> List[ResultGeo]:
 886        """
 887        List of reporting units that are unspecified groups.
 888        
 889        Examples:
 890            >>> unit = ReportingUnit.from_strings('MILLINOCKET/PEN TWPS', 'PEN')
 891            >>> groups = unit.unspecified_groups
 892            >>> [group.name for group in groups]
 893            ['UNSPECIFIED MILLINOCKET TWPS [PEN]']
 894            >>> [group.canonical_name for group in groups]
 895            ['Unspecified Penobscot County Twps']
 896            >>> [group.group_registration_town.canonical_name for group in groups]
 897            ['Millinocket']
 898        """
 899        return [t for t in self.reporting_towns if type(t) == UnspecifiedGroup]
 900    
 901    @cached_property
 902    def has_unspecified_group(self) -> bool:
 903        """
 904        Return True if the result object includes an unspecified group, else False.
 905
 906        Examples:
 907            >>> unit = ReportingUnit.from_strings('MEDWAY/TOWNSHIPS', 'PEN')
 908            >>> unit.has_unspecified_group
 909            True
 910            
 911            >>> unit = ReportingUnit.from_strings('ADAMSTOWN/LOWER CUPSUPTIC TWPS (RANGELEY)', 'OXF')
 912            >>> unit.has_unspecified_group
 913            False
 914            
 915            >>> unit = ReportingUnit.from_strings('MILLINOCKET PISCATAQUIS TWPS', 'PIS')
 916            >>> unit.has_unspecified_group
 917            True
 918            
 919            >>> unit = ReportingUnit.from_strings('MILLINOCKET/PEN TWPS', 'PEN')
 920            >>> unit.has_unspecified_group
 921            True
 922            
 923            >>> unit = ReportingUnit.from_strings('LEXINGTON & SPRING LAKE TWPS', 'SOM')
 924            >>> unit.has_unspecified_group
 925            False
 926        """
 927        reporting = self.result_string.reporting_town_names
 928        registration = self.result_string.registration_town_names
 929        group_name = ' '.join(filter(None, [*registration, *reporting]))
 930
 931        if len(registration) > 1:
 932            return False
 933        elif len(reporting) in (1, 2) and UNSPECIFIED_FLAG in reporting:
 934            return True
 935        elif all(UNSPECIFIED_FLAG in town for town in reporting):
 936            return True
 937        elif MULTI_COUNTY_PATTERN.match(group_name):
 938            return True
 939        else:
 940            return False
 941        
 942    @cached_property
 943    def is_matched(self) -> bool:
 944        """True if every named geography in this unit matched a known town."""
 945        if not self.result_string.exists:
 946            return False
 947        else:
 948            tokens = [*self.reporting_towns, *self.registration_towns]
 949            return bool(tokens) and all(t.is_matched for t in tokens)
 950        
 951    def to_dict(self) -> dict[str]:
 952        return {
 953            'raw_str':          self.raw_string,
 954            'formatted_str':    self.formatted_string,
 955            'reporting_str':    self.reporting_string,
 956            'registration_str': self.registration_string,
 957            'reporting':        {
 958                'specified':        [town.to_dict() for town in self.specified_reporting_towns],
 959                'unspecified':      [group.to_dict() for group in self.unspecified_groups]
 960                                },
 961            'registration':     [town.to_dict() for town in self.registration_towns],
 962            'county':           asdict(self.county),
 963            'is_matched':       self.is_matched
 964        }
 965
 966    @staticmethod
 967    def _format_reporting_towns(
 968            reporting_town_names: List[str], 
 969            registration_town_names: List[str], 
 970            has_unspecified_group: bool) -> List[str]:
 971        """
 972        Apply consistent format to towns and unspecified groups.
 973
 974        Args:
 975            reporting_towns: List of one or more towns
 976            registration_towns: List of non-reporting registration towns (if any)
 977            has_unspecified_group: True if unit contains unspecified group, else False
 978
 979        Returns:
 980            list: Reporting towns with standard formatting applied.
 981
 982        Examples:
 983            >>> ReportingUnit._format_reporting_towns(['LEXINGTON', 'SPRING LAKE TWPS'], [], False)
 984            ['LEXINGTON', 'SPRING LAKE TWP']
 985            
 986            >>> ReportingUnit._format_reporting_towns(['FRANKLIN', 'TWPS'], [], True)
 987            ['FRANKLIN', 'UNSPECIFIED FRANKLIN TWPS']
 988            
 989            >>> ReportingUnit._format_reporting_towns(['PENOBSCOT TWPS'], ['MILLINOCKET'], True)
 990            ['UNSPECIFIED MILLINOCKET TWPS [PEN]']
 991        """
 992        reporting = [
 993            ReportingUnit._format_plural(town, has_unspecified_group)
 994            for town in reporting_town_names
 995        ]
 996        
 997        if has_unspecified_group:
 998            return ReportingUnit._name_unspecified_group(reporting, registration_town_names)
 999        else:
1000            return reporting
1001
1002    @staticmethod    
1003    def _format_plural(town: str, has_unspecified_group: bool) -> str:
1004        """
1005        Correct errors of pluralization in town or group names.
1006
1007        After this function is used, the presence or absence of a plural in a town name
1008        reliably indicates whether it is an unspecified group.
1009        """  
1010        if has_unspecified_group:
1011            return SINGULAR_PATTERN.sub(PLURAL, town)
1012        else:
1013            return PLURAL_PATTERN.sub(SINGULAR, town)
1014
1015    @staticmethod
1016    def _format_unspecified_group(group_name: str) -> str:
1017        """
1018        Apply special format to unspecified groups that include a county.
1019        """
1020        return MULTI_COUNTY_PATTERN.sub(MULTI_COUNTY_FORMAT, group_name)
1021        
1022    @staticmethod
1023    def _name_unspecified_group(
1024            reporting_town_names: List[str], 
1025            registration_town_names: List[str]) -> List[str]:
1026        """
1027        Label unspecified groups with their reporting town and a standard 'unspecified' flag.
1028        """
1029        is_specified_reporting = lambda token: UNSPECIFIED_FLAG not in token
1030        reporting_hosts = filter(is_specified_reporting, reporting_town_names)
1031        hosts = registration_town_names + list(reporting_hosts)
1032        
1033        formatted_tokens = []
1034        for town in reporting_town_names:
1035            if UNSPECIFIED_FLAG in town:                
1036                unformatted = ' '.join(filter(None, [STANDARD_FLAG, *hosts, town]))
1037                group_name = ReportingUnit._format_unspecified_group(unformatted)
1038                formatted_tokens.append(group_name)
1039            else:
1040                formatted_tokens.append(town)
1041        
1042        return formatted_tokens
1043    
1044    @staticmethod
1045    def _classify_fragment(fragment_name: str) -> Type[ResultGeo]:
1046        """
1047        Return correct ResultGeo child class for a reporting towns string fragment.
1048        """
1049        if is_unnamed_township(fragment_name):
1050            return UnnamedTownship
1051        elif UNSPECIFIED_FLAG in fragment_name:
1052            return UnspecifiedGroup
1053        else:
1054            return NamedTownship

A collection of towns and unspecified groups parsed from a ResultString.

This class performs normalization operations on individual fragments of a delimited string, coerces them into objects representing different geography types, and attempts to match them to known Maine geographies. It provides several options for representing the fully parsed unit as a string or dictionary.

Arguments:
  • result_string: A ResultString object
  • county: A County object
  • strict: True if exception should be raised when a match fails
ReportingUnit( result_string: ResultString, county: mainegeo.entities.County, strict: bool = False)
result_string: ResultString
strict: bool = False
@classmethod
def from_strings( cls, result_str: str, county_code: str, strict: bool = False) -> ReportingUnit:
636    @classmethod
637    def from_strings(
638        cls,
639        result_str: str,
640        county_code: str,
641        strict: bool = False
642    ) -> "ReportingUnit":
643        """ Factory method to create a fully processed ReportingUnit.
644        
645        Examples:
646            >>> result = ReportingUnit.from_strings('PRENTISS TWP (WEBSTER PLT)', 'PEN')
647            >>> result.formatted_string
648            'Prentiss Twp T7 R3 NBPP [Webster Plt]'
649            >>> result.reporting_town_names
650            ['Prentiss Twp T7 R3 NBPP']
651            >>> result.registration_town_names
652            ['Webster Plt']
653            >>> result.unspecified_groups
654            []
655            
656            >>> result = ReportingUnit.from_strings('MEDWAY -- GRINDSTONE/SOLDIERTOWN TWPS', 'PEN')
657            >>> result.formatted_string
658            'Grindstone Twp [Medway], Soldiertown Twp T2 R7 WELS [Medway]'
659            >>> result.reporting_town_names
660            ['Grindstone Twp', 'Soldiertown Twp T2 R7 WELS']
661            >>> result.registration_town_names
662            ['Medway']
663            
664            >>> result = ReportingUnit.from_strings('FRANKLIN/T9 T10 SD TWPS', 'HAN')
665            >>> result.formatted_string
666            'Franklin, T9 SD BPP, T10 SD BPP'
667            >>> result.reporting_town_names
668            ['Franklin', 'T9 SD BPP', 'T10 SD BPP']
669            >>> result.registration_town_names
670            []
671            
672            >>> result = ReportingUnit.from_strings('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
673            >>> result.formatted_string
674            'Millinocket, Unspecified Piscataquis County Twps [Millinocket]'
675            >>> result.reporting_town_names
676            ['Millinocket', 'Unspecified Piscataquis County Twps']
677            >>> result.registration_town_names
678            ['Millinocket']
679            >>> len(result.unspecified_groups)
680            1
681            
682            >>> result = ReportingUnit.from_strings('MILLINOCKET/PIS TWPS/PEN TWPS', 'PEN')
683            >>> result.reporting_town_names
684            ['Millinocket', 'Unspecified Piscataquis County Twps', 'Unspecified Penobscot County Twps']
685            >>> result.registration_town_names
686            ['Millinocket']
687            >>> len(result.unspecified_groups)
688            2
689            
690            >>> result = ReportingUnit.from_strings('MEDWAY TOWNSHIPS', 'PEN')
691            >>> result.formatted_string
692            'Unspecified Twps [Medway]'
693            >>> result.reporting_town_names
694            ['Unspecified Twps']
695            >>> result.registration_town_names
696            ['Medway']
697            >>> len(result.unspecified_groups)
698            1
699            """
700        unit = cls(
701            result_string = ResultString(result_str),
702            county = County(code = county_code),
703            strict = strict
704        )
705        return unit

Factory method to create a fully processed ReportingUnit.

Examples:
>>> result = ReportingUnit.from_strings('PRENTISS TWP (WEBSTER PLT)', 'PEN')
>>> result.formatted_string
'Prentiss Twp T7 R3 NBPP [Webster Plt]'
>>> result.reporting_town_names
['Prentiss Twp T7 R3 NBPP']
>>> result.registration_town_names
['Webster Plt']
>>> result.unspecified_groups
[]
>>> result = ReportingUnit.from_strings('MEDWAY -- GRINDSTONE/SOLDIERTOWN TWPS', 'PEN')
>>> result.formatted_string
'Grindstone Twp [Medway], Soldiertown Twp T2 R7 WELS [Medway]'
>>> result.reporting_town_names
['Grindstone Twp', 'Soldiertown Twp T2 R7 WELS']
>>> result.registration_town_names
['Medway']
>>> result = ReportingUnit.from_strings('FRANKLIN/T9 T10 SD TWPS', 'HAN')
>>> result.formatted_string
'Franklin, T9 SD BPP, T10 SD BPP'
>>> result.reporting_town_names
['Franklin', 'T9 SD BPP', 'T10 SD BPP']
>>> result.registration_town_names
[]
>>> result = ReportingUnit.from_strings('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
>>> result.formatted_string
'Millinocket, Unspecified Piscataquis County Twps [Millinocket]'
>>> result.reporting_town_names
['Millinocket', 'Unspecified Piscataquis County Twps']
>>> result.registration_town_names
['Millinocket']
>>> len(result.unspecified_groups)
1
>>> result = ReportingUnit.from_strings('MILLINOCKET/PIS TWPS/PEN TWPS', 'PEN')
>>> result.reporting_town_names
['Millinocket', 'Unspecified Piscataquis County Twps', 'Unspecified Penobscot County Twps']
>>> result.registration_town_names
['Millinocket']
>>> len(result.unspecified_groups)
2
>>> result = ReportingUnit.from_strings('MEDWAY TOWNSHIPS', 'PEN')
>>> result.formatted_string
'Unspecified Twps [Medway]'
>>> result.reporting_town_names
['Unspecified Twps']
>>> result.registration_town_names
['Medway']
>>> len(result.unspecified_groups)
1
raw_string: str
707    @property
708    def raw_string(self) -> str:
709        """
710        Original SoS name for this reporting unit.
711        
712        Examples:
713            >>> unit = ReportingUnit.from_strings('WEBSTER PLT -- PRENTISS TWP', 'PEN')
714            >>> unit.raw_string
715            'WEBSTER PLT -- PRENTISS TWP'
716        """
717        return self.result_string.raw_string

Original SoS name for this reporting unit.

Examples:
>>> unit = ReportingUnit.from_strings('WEBSTER PLT -- PRENTISS TWP', 'PEN')
>>> unit.raw_string
'WEBSTER PLT -- PRENTISS TWP'
formatted_string: str
719    @cached_property
720    def formatted_string(self) -> str:
721        """
722        A formatted string representation of this reporting unit.
723        
724        Examples:
725            >>> args = ('T12/R13 & T9/R8 WELS (ASHLAND)', 'ARO')
726            >>> ReportingUnit.from_strings(*args).formatted_string
727            'T12 R13 WELS [Ashland], T9 R8 WELS [Ashland]'
728            
729            >>> args = ('GRINDSTONE/HERSEYTOWN/SOLDIERTOWN TWP', 'PEN')
730            >>> ReportingUnit.from_strings(*args).formatted_string
731            'Grindstone Twp, Herseytown Twp, Soldiertown Twp T2 R7 WELS'
732            
733            >>> args = ('SHERMAN (AND BENEDICTA & SILVER RIDGE TWPS) ', 'ARO')
734            >>> ReportingUnit.from_strings(*args).formatted_string
735            'Sherman, Benedicta Twp, Silver Ridge Twp'
736            
737            >>> args = ('JACKMAN TWPS', 'SOM')
738            >>> ReportingUnit.from_strings(*args).formatted_string
739            'Unspecified Twps [Jackman]'
740            
741            >>> args = ('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
742            >>> ReportingUnit.from_strings(*args).formatted_string
743            'Millinocket, Unspecified Piscataquis County Twps [Millinocket]'
744        """
745        reporting = [
746            town + f' [{self.registration_string}]'
747            if
748                len(self.registration_town_names) > 0
749                and town not in self.registration_town_names
750            else town
751            for town in self.reporting_town_names
752        ]
753        return STANDARD_DELIMITER.join(reporting)

A formatted string representation of this reporting unit.

Examples:
>>> args = ('T12/R13 & T9/R8 WELS (ASHLAND)', 'ARO')
>>> ReportingUnit.from_strings(*args).formatted_string
'T12 R13 WELS [Ashland], T9 R8 WELS [Ashland]'
>>> args = ('GRINDSTONE/HERSEYTOWN/SOLDIERTOWN TWP', 'PEN')
>>> ReportingUnit.from_strings(*args).formatted_string
'Grindstone Twp, Herseytown Twp, Soldiertown Twp T2 R7 WELS'
>>> args = ('SHERMAN (AND BENEDICTA & SILVER RIDGE TWPS) ', 'ARO')
>>> ReportingUnit.from_strings(*args).formatted_string
'Sherman, Benedicta Twp, Silver Ridge Twp'
>>> args = ('JACKMAN TWPS', 'SOM')
>>> ReportingUnit.from_strings(*args).formatted_string
'Unspecified Twps [Jackman]'
>>> args = ('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
>>> ReportingUnit.from_strings(*args).formatted_string
'Millinocket, Unspecified Piscataquis County Twps [Millinocket]'
reporting_string: str
755    @cached_property
756    def reporting_string(self) -> str:
757        """
758        A formatted string representation of reporting towns in this unit.
759
760        Examples:
761            >>> args = ('WEBSTER PLT -- PRENTISS TWP', 'PEN')
762            >>> ReportingUnit.from_strings(*args).reporting_string
763            'Prentiss Twp T7 R3 NBPP'
764            
765            >>> args = ('T12/R13 & T9/R8 WELS (ASHLAND)', 'ARO')
766            >>> ReportingUnit.from_strings(*args).reporting_string
767            'T12 R13 WELS, T9 R8 WELS'
768            
769            >>> args = ('JACKMAN TWPS', 'SOM')
770            >>> ReportingUnit.from_strings(*args).reporting_string
771            'Unspecified Twps'
772            
773            >>> args = ('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
774            >>> ReportingUnit.from_strings(*args).reporting_string
775            'Millinocket, Unspecified Piscataquis County Twps'
776            
777            >>> args = ('MILLINOCKET/PEN TWPS/PIS TWPS', 'PEN')
778            >>> ReportingUnit.from_strings(*args).reporting_string
779            'Millinocket, Unspecified Penobscot County Twps, Unspecified Piscataquis County Twps'
780        """
781        return STANDARD_DELIMITER.join(self.reporting_town_names)

A formatted string representation of reporting towns in this unit.

Examples:
>>> args = ('WEBSTER PLT -- PRENTISS TWP', 'PEN')
>>> ReportingUnit.from_strings(*args).reporting_string
'Prentiss Twp T7 R3 NBPP'
>>> args = ('T12/R13 & T9/R8 WELS (ASHLAND)', 'ARO')
>>> ReportingUnit.from_strings(*args).reporting_string
'T12 R13 WELS, T9 R8 WELS'
>>> args = ('JACKMAN TWPS', 'SOM')
>>> ReportingUnit.from_strings(*args).reporting_string
'Unspecified Twps'
>>> args = ('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
>>> ReportingUnit.from_strings(*args).reporting_string
'Millinocket, Unspecified Piscataquis County Twps'
>>> args = ('MILLINOCKET/PEN TWPS/PIS TWPS', 'PEN')
>>> ReportingUnit.from_strings(*args).reporting_string
'Millinocket, Unspecified Penobscot County Twps, Unspecified Piscataquis County Twps'
registration_string: str
783    @cached_property
784    def registration_string(self) -> str:
785        """
786        A formatted string representation of registration towns in this unit.
787        
788        Examples:
789            >>> args = ('WEBSTER PLT -- PRENTISS TWP', 'PEN')
790            >>> ReportingUnit.from_strings(*args).registration_string
791            'Webster Plt'
792            
793            >>> args = ('WYMAN TWP (CARRABASSETT VALLEY & EUSTIS)', 'FRA')
794            >>> ReportingUnit.from_strings(*args).registration_string
795            'Carrabassett Valley, Eustis'
796            
797            >>> args = ('JACKMAN TWPS', 'SOM')
798            >>> ReportingUnit.from_strings(*args).registration_string
799            'Jackman'
800            
801            >>> args = ('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
802            >>> ReportingUnit.from_strings(*args).registration_string
803            'Millinocket'
804            
805            >>> args = ('MILLINOCKET/PIS TWPS/PEN TWPS', 'PEN')
806            >>> ReportingUnit.from_strings(*args).registration_string
807            'Millinocket'
808        """
809        return STANDARD_DELIMITER.join(self.registration_town_names)

A formatted string representation of registration towns in this unit.

Examples:
>>> args = ('WEBSTER PLT -- PRENTISS TWP', 'PEN')
>>> ReportingUnit.from_strings(*args).registration_string
'Webster Plt'
>>> args = ('WYMAN TWP (CARRABASSETT VALLEY & EUSTIS)', 'FRA')
>>> ReportingUnit.from_strings(*args).registration_string
'Carrabassett Valley, Eustis'
>>> args = ('JACKMAN TWPS', 'SOM')
>>> ReportingUnit.from_strings(*args).registration_string
'Jackman'
>>> args = ('MILLINOCKET/PISCATAQUIS TWPS', 'PEN')
>>> ReportingUnit.from_strings(*args).registration_string
'Millinocket'
>>> args = ('MILLINOCKET/PIS TWPS/PEN TWPS', 'PEN')
>>> ReportingUnit.from_strings(*args).registration_string
'Millinocket'
reporting_town_names: list[str]
811    @cached_property
812    def reporting_town_names(self) -> list[str]:
813        """
814        List of reporting town names. Canonical name if match was found, else raw name.
815        """
816        return [town.consensus_name for town in self.reporting_towns]

List of reporting town names. Canonical name if match was found, else raw name.

registration_town_names: list[str]
818    @cached_property
819    def registration_town_names(self) -> list[str]:
820        """
821        List of registration town names. Canonical name if match was found, else raw name.
822        """
823        return [town.consensus_name for town in self.registration_towns]

List of registration town names. Canonical name if match was found, else raw name.

registration_towns: List[mainegeo.elections.NamedTownship]
825    @cached_property
826    def registration_towns(self) -> List[NamedTownship]:
827        """
828        List of registration towns as `NamedTownship` objects.
829        """
830        towns = set()
831        for name in self.result_string.registration_town_names:
832            regtown = NamedTownship(name, self.county, strict = self.strict)
833            towns.add(regtown)
834        
835        for group in self.unspecified_groups:
836            towns.add(group.group_registration_town)
837        
838        return sorted(towns, key=lambda town: town.consensus_name)

List of registration towns as NamedTownship objects.

reporting_towns: List[mainegeo.elections.ResultGeo]
840    @cached_property
841    def reporting_towns(self) -> List[ResultGeo]:
842        """
843        List of reporting towns, townships and groups as `ResultGeo` child objects.
844        """
845        towns = []
846        
847        if self.result_string.exists:
848            formatted_reporting_names = ReportingUnit._format_reporting_towns(
849                self.result_string.reporting_town_names,
850                self.result_string.registration_town_names,
851                self.has_unspecified_group
852            )
853            for name in formatted_reporting_names:
854                ResultClass = ReportingUnit._classify_fragment(name)
855                reporting_town = ResultClass(name, self.county, strict = self.strict)
856                towns.append(reporting_town)
857        
858        return towns

List of reporting towns, townships and groups as ResultGeo child objects.

specified_reporting_towns: List[mainegeo.elections.Municipality]
860    @cached_property
861    def specified_reporting_towns(self) -> List[Municipality]:
862        """
863        List of reporting units that are not unspecified groups.
864        
865        Examples:
866            >>> unit = ReportingUnit.from_strings('WYMAN TWP/SPRING LAKE TWP', 'FRA')
867            >>> [town.name for town in unit.specified_reporting_towns]
868            ['WYMAN TWP', 'SPRING LAKE TWP']
869            
870            >>> unit = ReportingUnit.from_strings('WYMAN TWP (CARRABASSETT VALLEY)', 'FRA')
871            >>> [town.name for town in unit.specified_reporting_towns]
872            ['WYMAN TWP']
873
874            >>> unit = ReportingUnit.from_strings('MILLINOCKET/TWPS', 'PEN')
875            >>> [town.name for town in unit.specified_reporting_towns]
876            ['MILLINOCKET']
877            
878            >>> unit = ReportingUnit.from_strings('MILLINOCKET TWPS', 'PEN')
879            >>> [town.name for town in unit.specified_reporting_towns]
880            []
881        """
882        return [t for t in self.reporting_towns if type(t) != UnspecifiedGroup]

List of reporting units that are not unspecified groups.

Examples:
>>> unit = ReportingUnit.from_strings('WYMAN TWP/SPRING LAKE TWP', 'FRA')
>>> [town.name for town in unit.specified_reporting_towns]
['WYMAN TWP', 'SPRING LAKE TWP']
>>> unit = ReportingUnit.from_strings('WYMAN TWP (CARRABASSETT VALLEY)', 'FRA')
>>> [town.name for town in unit.specified_reporting_towns]
['WYMAN TWP']
>>> unit = ReportingUnit.from_strings('MILLINOCKET/TWPS', 'PEN')
>>> [town.name for town in unit.specified_reporting_towns]
['MILLINOCKET']
>>> unit = ReportingUnit.from_strings('MILLINOCKET TWPS', 'PEN')
>>> [town.name for town in unit.specified_reporting_towns]
[]
unspecified_groups: List[mainegeo.elections.ResultGeo]
884    @cached_property
885    def unspecified_groups(self) -> List[ResultGeo]:
886        """
887        List of reporting units that are unspecified groups.
888        
889        Examples:
890            >>> unit = ReportingUnit.from_strings('MILLINOCKET/PEN TWPS', 'PEN')
891            >>> groups = unit.unspecified_groups
892            >>> [group.name for group in groups]
893            ['UNSPECIFIED MILLINOCKET TWPS [PEN]']
894            >>> [group.canonical_name for group in groups]
895            ['Unspecified Penobscot County Twps']
896            >>> [group.group_registration_town.canonical_name for group in groups]
897            ['Millinocket']
898        """
899        return [t for t in self.reporting_towns if type(t) == UnspecifiedGroup]

List of reporting units that are unspecified groups.

Examples:
>>> unit = ReportingUnit.from_strings('MILLINOCKET/PEN TWPS', 'PEN')
>>> groups = unit.unspecified_groups
>>> [group.name for group in groups]
['UNSPECIFIED MILLINOCKET TWPS [PEN]']
>>> [group.canonical_name for group in groups]
['Unspecified Penobscot County Twps']
>>> [group.group_registration_town.canonical_name for group in groups]
['Millinocket']
has_unspecified_group: bool
901    @cached_property
902    def has_unspecified_group(self) -> bool:
903        """
904        Return True if the result object includes an unspecified group, else False.
905
906        Examples:
907            >>> unit = ReportingUnit.from_strings('MEDWAY/TOWNSHIPS', 'PEN')
908            >>> unit.has_unspecified_group
909            True
910            
911            >>> unit = ReportingUnit.from_strings('ADAMSTOWN/LOWER CUPSUPTIC TWPS (RANGELEY)', 'OXF')
912            >>> unit.has_unspecified_group
913            False
914            
915            >>> unit = ReportingUnit.from_strings('MILLINOCKET PISCATAQUIS TWPS', 'PIS')
916            >>> unit.has_unspecified_group
917            True
918            
919            >>> unit = ReportingUnit.from_strings('MILLINOCKET/PEN TWPS', 'PEN')
920            >>> unit.has_unspecified_group
921            True
922            
923            >>> unit = ReportingUnit.from_strings('LEXINGTON & SPRING LAKE TWPS', 'SOM')
924            >>> unit.has_unspecified_group
925            False
926        """
927        reporting = self.result_string.reporting_town_names
928        registration = self.result_string.registration_town_names
929        group_name = ' '.join(filter(None, [*registration, *reporting]))
930
931        if len(registration) > 1:
932            return False
933        elif len(reporting) in (1, 2) and UNSPECIFIED_FLAG in reporting:
934            return True
935        elif all(UNSPECIFIED_FLAG in town for town in reporting):
936            return True
937        elif MULTI_COUNTY_PATTERN.match(group_name):
938            return True
939        else:
940            return False

Return True if the result object includes an unspecified group, else False.

Examples:
>>> unit = ReportingUnit.from_strings('MEDWAY/TOWNSHIPS', 'PEN')
>>> unit.has_unspecified_group
True
>>> unit = ReportingUnit.from_strings('ADAMSTOWN/LOWER CUPSUPTIC TWPS (RANGELEY)', 'OXF')
>>> unit.has_unspecified_group
False
>>> unit = ReportingUnit.from_strings('MILLINOCKET PISCATAQUIS TWPS', 'PIS')
>>> unit.has_unspecified_group
True
>>> unit = ReportingUnit.from_strings('MILLINOCKET/PEN TWPS', 'PEN')
>>> unit.has_unspecified_group
True
>>> unit = ReportingUnit.from_strings('LEXINGTON & SPRING LAKE TWPS', 'SOM')
>>> unit.has_unspecified_group
False
is_matched: bool
942    @cached_property
943    def is_matched(self) -> bool:
944        """True if every named geography in this unit matched a known town."""
945        if not self.result_string.exists:
946            return False
947        else:
948            tokens = [*self.reporting_towns, *self.registration_towns]
949            return bool(tokens) and all(t.is_matched for t in tokens)

True if every named geography in this unit matched a known town.

def to_dict(self) -> dict[str]:
951    def to_dict(self) -> dict[str]:
952        return {
953            'raw_str':          self.raw_string,
954            'formatted_str':    self.formatted_string,
955            'reporting_str':    self.reporting_string,
956            'registration_str': self.registration_string,
957            'reporting':        {
958                'specified':        [town.to_dict() for town in self.specified_reporting_towns],
959                'unspecified':      [group.to_dict() for group in self.unspecified_groups]
960                                },
961            'registration':     [town.to_dict() for town in self.registration_towns],
962            'county':           asdict(self.county),
963            'is_matched':       self.is_matched
964        }