Refactor ocr.py: dedupe the coordinate/type-id regex fragments

ocr.py grew through many incremental patches and had accumulated the
same two shapes copy-pasted with minor variation across the file:

- The full coordinate shape ('H3 5:5', letter + big-cell number + sub-
  grid x:y) was hand-restated as a regex literal in six places
  (_COORD_RE, _GRID_COORD_RE, _REQUESTED_ON_COORD_RE, the FO-report
  patterns, _NAMED_AT_COORD_RE, _STATION_LINE_RE), each pairing its own
  copy with its own try/except Coord(...) construction. Factored into
  one _COORD_FRAGMENT regex piece and one _coord_from_groups() helper,
  every call site now just embeds/calls it.
- The '<word><junk><digits>' type-id shape ('AmmoCache#3') was
  similarly restated across _NAMED_HEADER_RE, _REF_NAMED_RE, and
  _DESTROYED_RE. Factored into _TYPE_ID_FRAGMENT.
- Dropped _SEP, an unused leftover regex fragment.

No parsing behavior changed: every format ocr.py understands (standard
target/RP blocks, calibration line, destroyed reports, train-arrival
intel, ad-hoc Enemy installations and their destroyed reports,
Listening Post/Coastal Battery, Marine Garrison fire-support requests,
multi-word RP names, bare-name-header targets, bold-span coordinate
squashing, FO-report triangulation, the '<ref>: <value>' clue grammar,
16-point compass tolerance, grid-only coords) is still covered by the
full test suite, all 21 tests pass unchanged, plus a direct re-run of
the exact 'Enemy#name' example that prompted this session's bug report
to confirm detection by name still works.
This commit is contained in:
Dominik Moritz Roth 2026-08-09 18:26:06 +02:00
parent 4b427e5b0d
commit ddb7867a88

View File

@ -94,10 +94,26 @@ _DIGIT_FIX = str.maketrans({
"Z": "2", "z": "2", "Z": "2", "z": "2",
"G": "6", "g": "6", "G": "6", "g": "6",
}) })
_SEP = r"\s*[-–—]\s*" # The full-precision coordinate shape ('H3 5:5', 'Q4 4:2'), one big-grid
_COORD_RE = re.compile( # letter, one big-grid number, then the sub-grid x:y pair. Every coord
rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})" # regex in this module that captures a full coordinate (not just the
) # large-cell letter+number, see _LARGE_GRID_ONLY_RE) embeds this exact
# fragment rather than restating it, so its four capture groups are
# always (letter, big_y, x, y) in that order, matching what
# _coord_from_groups() below expects.
_COORD_FRAGMENT = rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})"
_COORD_RE = re.compile(_COORD_FRAGMENT)
def _coord_from_groups(letter: str, y: str, x: str, yy: str) -> Coord | None:
"""Build a Coord from a _COORD_FRAGMENT match's four groups, digit-
fixing each numeric one, None on an out-of-range result rather than
raising (OCR garbage can still parse as *a* number, just not a valid
grid position)."""
try:
return Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy)))
except ValueError:
return None
# No literal '#' required, it's just as OCR-corruptible as anything else # No literal '#' required, it's just as OCR-corruptible as anything else
# (missing entirely, or misread as e.g. 'H'). We instead anchor to *where* # (missing entirely, or misread as e.g. 'H'). We instead anchor to *where*
# the fuzzy keyword match ended and take the first run of digit-shaped # the fuzzy keyword match ended and take the first run of digit-shaped
@ -137,29 +153,19 @@ def _fix_id_digits(raw: str) -> str:
# A target can also be spotted with an absolute grid ref directly # A target can also be spotted with an absolute grid ref directly
# ("Target#10 Spotted. Grid Q3 9:0") instead of/alongside bearing/distance # ("Target#10 Spotted. Grid Q3 9:0") instead of/alongside bearing/distance
# clues, same coordinate shape as _COORD_RE, just anchored after "Grid". # clues, same coordinate shape as _COORD_RE, just anchored after "Grid".
_GRID_COORD_RE = re.compile( _GRID_COORD_RE = re.compile(rf"Grid\s+{_COORD_FRAGMENT}", re.IGNORECASE)
rf"Grid\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})",
re.IGNORECASE,
)
def _extract_grid_coord(text: str) -> Coord | None: def _extract_grid_coord(text: str) -> Coord | None:
m = _GRID_COORD_RE.search(text) m = _GRID_COORD_RE.search(text)
if not m: if not m:
return None return None
letter, y, x, yy = m.groups() return _coord_from_groups(*m.groups())
try:
return Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy)))
except ValueError:
return None
# A fire-support request gives its coord differently again: "<Shell> # A fire-support request gives its coord differently again: "<Shell>
# Shells requested on <coord>", no "Grid" keyword. Same shape otherwise. # Shells requested on <coord>", no "Grid" keyword. Same shape otherwise.
_REQUESTED_ON_COORD_RE = re.compile( _REQUESTED_ON_COORD_RE = re.compile(rf"requested\s+on\s+{_COORD_FRAGMENT}", re.IGNORECASE)
rf"requested\s+on\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})",
re.IGNORECASE,
)
# "SMK Shells requested on ..." names the shell by its short code, one of # "SMK Shells requested on ..." names the shell by its short code, one of
# the Shell enum's own member names. # the Shell enum's own member names.
_SHELL_REQUEST_RE = re.compile(r"([A-Za-z]+)\s+Shells?\s+requested", re.IGNORECASE) _SHELL_REQUEST_RE = re.compile(r"([A-Za-z]+)\s+Shells?\s+requested", re.IGNORECASE)
@ -173,11 +179,7 @@ def _extract_requested_on_coord(text: str) -> Coord | None:
m = _REQUESTED_ON_COORD_RE.search(text) m = _REQUESTED_ON_COORD_RE.search(text)
if not m: if not m:
return None return None
letter, y, x, yy = m.groups() return _coord_from_groups(*m.groups())
try:
return Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy)))
except ValueError:
return None
def _extract_shell_request(text: str) -> Shell | None: def _extract_shell_request(text: str) -> Shell | None:
@ -219,16 +221,7 @@ def _extract_coord(line: str) -> Coord | None:
m = _COORD_RE.search(line) m = _COORD_RE.search(line)
if not m: if not m:
return None return None
letter, y, x, yy = m.groups() return _coord_from_groups(*m.groups())
try:
return Coord(
X=letter.upper(),
Y=int(_fix_digits(y)),
x=int(_fix_digits(x)),
y=int(_fix_digits(yy)),
)
except ValueError:
return None # out-of-range numbers -> not actually a coordinate
def _extract_leading_id(remainder: str) -> int | None: def _extract_leading_id(remainder: str) -> int | None:
@ -268,7 +261,13 @@ _RP_HEADER_RE = re.compile(r"Reference\s+Point\s+([A-Za-z][\w-]*)\s*:?", re.IGNO
# header. Requiring real punctuation between word and digits (true of # header. Requiring real punctuation between word and digits (true of
# every observed header: '#', a misread substitute, ...) rules that out, # every observed header: '#', a misread substitute, ...) rules that out,
# and also stops a bare "Word 094" clue line (space only) from matching. # and also stops a bare "Word 094" clue line (space only) from matching.
_NAMED_HEADER_RE = re.compile(rf"^([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)\s*:?\s*(.*)$") #
# This '<word><junk><digits>' shape ('AmmoCache#3', 'HostileTank#3') is
# what a "Type#id" reference/header looks like anywhere in this module,
# _NAMED_HEADER_RE, _REF_NAMED_RE, and _DESTROYED_RE below all embed this
# same fragment rather than restating it.
_TYPE_ID_FRAGMENT = rf"([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)"
_NAMED_HEADER_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}\s*:?\s*(.*)$")
# Last-resort header: a name with no keyword and no id at all, just # Last-resort header: a name with no keyword and no id at all, just
# '<Name>:' after squashing ('HMS Rockingham:' -> 'HMSRockingham:'). The # '<Name>:' after squashing ('HMS Rockingham:' -> 'HMSRockingham:'). The
@ -330,11 +329,7 @@ def squash_multiword_ids(text: str) -> str:
# anywhere rather than tied to a section keyword, this shape is generic # anywhere rather than tied to a section keyword, this shape is generic
# enough (not specific to "Listening Post") to catch whatever else turns # enough (not specific to "Listening Post") to catch whatever else turns
# up named this way. # up named this way.
_NAMED_AT_COORD_RE = re.compile( _NAMED_AT_COORD_RE = re.compile(rf"([A-Za-z]+)#({_DIGIT_CLASS}+)\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE)
rf"([A-Za-z]+)#({_DIGIT_CLASS}+)\s+at\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})"
rf"\s*[:;.,]\s*({_DIGIT_CLASS})",
re.IGNORECASE,
)
def parse_named_at_coord(text: str) -> list[dict]: def parse_named_at_coord(text: str) -> list[dict]:
@ -344,9 +339,8 @@ def parse_named_at_coord(text: str) -> list[dict]:
entries = [] entries = []
for m in _NAMED_AT_COORD_RE.finditer(text): for m in _NAMED_AT_COORD_RE.finditer(text):
word, num, letter, y, x, yy = m.groups() word, num, letter, y, x, yy = m.groups()
try: coord = _coord_from_groups(letter, y, x, yy)
coord = Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy))) if coord is None:
except ValueError:
continue continue
entries.append({ entries.append({
"kind": "rp", "name": f"{word}#{_fix_id_digits(num)}", "type_word": None, "id": None, "kind": "rp", "name": f"{word}#{_fix_id_digits(num)}", "type_word": None, "id": None,
@ -368,15 +362,14 @@ def parse_named_at_coord(text: str) -> list[dict]:
# let the bearing pattern's optional '°' swallow part of a distance # let the bearing pattern's optional '°' swallow part of a distance
# reading if bearing ran first. # reading if bearing ran first.
_FO_TARGET_NAME = r"([A-Za-z][A-Za-z0-9'\s]*?)" _FO_TARGET_NAME = r"([A-Za-z][A-Za-z0-9'\s]*?)"
_FO_COORD = rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})"
_FO_DISTANCE_RE = re.compile( _FO_DISTANCE_RE = re.compile(
rf"FO(?:#({_DIGIT_CLASS}+))?\s+(?:Audio\s+report\s+on|Eyes\s+on)\s+{_FO_TARGET_NAME}\s*:\s*" rf"FO(?:#({_DIGIT_CLASS}+))?\s+(?:Audio\s+report\s+on|Eyes\s+on)\s+{_FO_TARGET_NAME}\s*:\s*"
rf"([\d.]+)\s*k?m\s+From\s+{_FO_COORD}", rf"([\d.]+)\s*k?m\s+From\s+{_COORD_FRAGMENT}",
re.IGNORECASE, re.IGNORECASE,
) )
_FO_BEARING_RE = re.compile( _FO_BEARING_RE = re.compile(
rf"FO(?:#({_DIGIT_CLASS}+))?\s+(?:Audio\s+report\s+on|Eyes\s+on)\s+{_FO_TARGET_NAME}\s*:\s*" rf"FO(?:#({_DIGIT_CLASS}+))?\s+(?:Audio\s+report\s+on|Eyes\s+on)\s+{_FO_TARGET_NAME}\s*:\s*"
rf"({_DIGIT_CLASS}{{1,3}})\s*°?\s+From\s+{_FO_COORD}", rf"({_DIGIT_CLASS}{{1,3}})\s*°?\s+From\s+{_COORD_FRAGMENT}",
re.IGNORECASE, re.IGNORECASE,
) )
@ -407,9 +400,8 @@ def parse_fo_reports(text: str) -> dict[str, tuple[str, Coord | None]]:
consumed.append(span) consumed.append(span)
fo_num, target_name, value, letter, y, x, yy = m.groups() fo_num, target_name, value, letter, y, x, yy = m.groups()
fo_name = f"FO#{_fix_id_digits(fo_num)}" if fo_num else "FO" fo_name = f"FO#{_fix_id_digits(fo_num)}" if fo_num else "FO"
try: coord = _coord_from_groups(letter, y, x, yy)
coord = Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy))) if coord is None:
except ValueError:
return return
target_key = "".join(re.sub(r"[^A-Za-z0-9]", "", w) for w in target_name.split()) target_key = "".join(re.sub(r"[^A-Za-z0-9]", "", w) for w in target_name.split())
bearing = None if is_distance else float(_fix_digits(value)) bearing = None if is_distance else float(_fix_digits(value))
@ -527,7 +519,7 @@ _TYPE_BY_SHORT = {t.short: t for t in TargetType}
_TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache"} _TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache"}
_REF_NAMED_RE = re.compile(rf"^([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)") _REF_NAMED_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}")
def _clean_reference(raw: str) -> str: def _clean_reference(raw: str) -> str:
@ -770,9 +762,7 @@ _ARRIVAL_STATION_KEYWORD = "ARRIVAL STATION"
_TRACK_ALIGNMENT_KEYWORD = "TRACK ALIGNMENT" _TRACK_ALIGNMENT_KEYWORD = "TRACK ALIGNMENT"
_FINAL_APPROACH_KEYWORD = "FINAL APPROACH" _FINAL_APPROACH_KEYWORD = "FINAL APPROACH"
_STATION_LINE_RE = re.compile( _STATION_LINE_RE = re.compile(rf"(\S+):\s*{_COORD_FRAGMENT}")
rf"(\S+):\s*([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})"
)
_WAYPOINT_RE = re.compile(r"Waypoint\s+(\S+)\s*-\s*([\d.]+)\s*k?m\s+from\s+(\S+)", re.IGNORECASE) _WAYPOINT_RE = re.compile(r"Waypoint\s+(\S+)\s*-\s*([\d.]+)\s*k?m\s+from\s+(\S+)", re.IGNORECASE)
@ -810,9 +800,8 @@ def parse_train_intel(text: str) -> list[dict]:
if station_m is None: if station_m is None:
return [] return []
station_name, letter, y, x, yy = station_m.groups() station_name, letter, y, x, yy = station_m.groups()
try: station_coord = _coord_from_groups(letter, y, x, yy)
station_coord = Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy))) if station_coord is None:
except ValueError:
return [] return []
entries = [{ entries = [{
@ -848,9 +837,7 @@ def parse_train_intel(text: str) -> list[dict]:
# Just need "<Type>#<id>" immediately followed by "Destroyed", the # Just need "<Type>#<id>" immediately followed by "Destroyed", the
# "Direct Hit!" prefix (or its absence) doesn't matter, search() finds # "Direct Hit!" prefix (or its absence) doesn't matter, search() finds
# the name+Destroyed pair anywhere in the line either way. # the name+Destroyed pair anywhere in the line either way.
_DESTROYED_RE = re.compile( _DESTROYED_RE = re.compile(rf"{_TYPE_ID_FRAGMENT}\s*Destroyed", re.IGNORECASE)
rf"([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)\s*Destroyed", re.IGNORECASE
)
# Same digit-vs-letter-id split as the header regexes: an ad-hoc "Enemy # Same digit-vs-letter-id split as the header regexes: an ad-hoc "Enemy
# X Y" installation's id (after squash_enemy_names()) is letters, not # X Y" installation's id (after squash_enemy_names()) is letters, not
# digits, so it needs its own pattern, _DESTROYED_RE's digit class won't # digits, so it needs its own pattern, _DESTROYED_RE's digit class won't