pronounced() rewrites a radio text for speech in four passes: paths become file or folder with name and parent (/tmp as temp, ~/ as home), dates with or without a clock become a date-time group converted to Zulu, times become four ICAO digits with Zulu for UTC, and hex runs, version numbers and the remaining digits are spelled in NATO and ICAO words with Decimal between digits. Invalid dates and times stay as written; the displayed text is unchanged. 🎯 Fix: 90 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 86 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
6.6 KiB
Python
154 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import UTC, datetime, timedelta, timezone
|
|
|
|
from .fields import LINE
|
|
|
|
SPEECH_LIMIT = 1000
|
|
SPOKEN_TEXT = 500
|
|
SPOKEN_NAME = 60
|
|
SPOKEN_GROUP = 120
|
|
ALARM_WORDS = {"pan-pan": "PAN-PAN", "security": "say-cure-ee-tay"}
|
|
ADDRESSED = ("post", "call", "receipt")
|
|
CALLS = re.compile(r"^(?:(?:MAYDAY|PAN[ -]?PAN|SECURIT[EÉY])\b[\s:,.;!-]*)+", re.IGNORECASE)
|
|
HUB_MARK = re.compile(r"\[kvb-orchestrator msg [0-9a-f]{16}\]")
|
|
HUB_FILE = re.compile(r"\s+file: /\S*$")
|
|
SPOKEN_LINE = re.compile(LINE.format(SPEECH_LIMIT))
|
|
NATO = dict(zip("abcdef", ("Alfa", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot")))
|
|
ICAO = dict(zip("0123456789", ("Ze-ro", "One", "Two", "Tree", "Fow-er", "Fife", "Six", "Seven", "Eight", "Ni-ner")))
|
|
SPOKEN = {**NATO, **ICAO, ".": "Decimal"}
|
|
MONTHS = ("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December")
|
|
MONTH_NUMBER = {name[:3].lower(): number for number, name in enumerate(MONTHS, 1)}
|
|
MONTH = "|".join(f"{name[:3]}(?:{name[3:]})?" for name in MONTHS)
|
|
SEGMENT = r"[\w.@+-]+"
|
|
PATH = re.compile(rf"(?<![\w.~/:@+-])(?:(?P<root>~?/){SEGMENT}(?:/{SEGMENT})*|{SEGMENT}(?:/{SEGMENT})+)/?(?<=[\w@+/-])")
|
|
EXTENSION = re.compile(r"\.[0-9A-Za-z]{1,5}$")
|
|
NAME_SPACES = str.maketrans({".": " dot ", "-": " ", "_": " "})
|
|
CLOCK = r"(?P<hour>[0-9]{1,2}):(?P<minute>[0-9]{2})(?::(?P<second>[0-9]{2}))?"
|
|
UTC_MARK = r"Z\b|\s*(?i:UTC|Zulu)\b(?![+-][0-9])"
|
|
DATE_TIME = re.compile(
|
|
r"(?<![\w.:/+-])(?:(?P<year>[0-9]{4})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})"
|
|
rf"|(?P<named_day>[0-9]{{1,2}})(?:st|nd|rd|th)?\s+(?P<name>(?i:{MONTH}))\s+(?P<named_year>[0-9]{{4}}))"
|
|
rf"(?:(?:T|,?\s+(?:at\s+)?){CLOCK}(?:\.[0-9]+)?(?P<zone>{UTC_MARK}|[+-][0-9]{{2}}(?::?[0-9]{{2}})?)?)?(?![\w:])"
|
|
)
|
|
TIME = re.compile(rf"(?<![\w.:])(?:{CLOCK}(?P<zone>{UTC_MARK})?(?![0-9:])|(?P<packed>[0-9]{{4}})Z\b)")
|
|
SPELLED = re.compile(r"\b(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]{7,}\b|(?:\bv)?[0-9]+(?:\.[0-9]+)+|[0-9]", re.IGNORECASE)
|
|
|
|
|
|
def pronounced(text: str) -> str:
|
|
"""Args:
|
|
text: a radio text as displayed.
|
|
|
|
Returns:
|
|
The text as the radio speaks it, in this order: paths as "file" or
|
|
"folder" with their name and parent, date-times as DTG converted to
|
|
Zulu, times as four digits with Zulu for UTC, a dot between digits
|
|
as Decimal, each hex run of 7 or more characters holding a letter
|
|
and a digit in the NATO alphabet and every other digit on its own in
|
|
ICAO words.
|
|
"""
|
|
for pattern, speak in ((PATH, path_words), (DATE_TIME, date_words), (TIME, time_words), (SPELLED, spelled)):
|
|
text = pattern.sub(speak, text)
|
|
return text
|
|
|
|
|
|
def padded(found: re.Match, words: str) -> str:
|
|
start, end = found.span()
|
|
before = " " if start and found.string[start - 1].isalnum() else ""
|
|
after = " " if end < len(found.string) and found.string[end].isalnum() else ""
|
|
return before + words + after
|
|
|
|
|
|
def digits(text: str) -> str:
|
|
return " ".join(ICAO[char] for char in text)
|
|
|
|
|
|
def spelled(found: re.Match) -> str:
|
|
return padded(found, " ".join(SPOKEN[char] for char in found[0].lower().removeprefix("v")))
|
|
|
|
|
|
def segment_words(segment: str) -> str:
|
|
return " ".join(SPELLED.sub(spelled, segment.translate(NAME_SPACES)).split())
|
|
|
|
|
|
def path_words(found: re.Match) -> str:
|
|
root = found["root"] or ""
|
|
chain = found[0].removeprefix(root).rstrip("/").split("/")
|
|
typed = EXTENSION.search(chain[-1])
|
|
if not root and not typed:
|
|
return found[0]
|
|
names = [segment_words(segment) for segment in chain]
|
|
if root == "/" and chain[0] == "tmp":
|
|
names[0] = "temp"
|
|
if root == "~/":
|
|
names.insert(0, "home")
|
|
kind = "file" if typed and not found[0].endswith("/") else "folder"
|
|
return padded(found, f"{kind} {names[-1]}" + (f" in {names[-2]}" if len(names) > 1 else ""))
|
|
|
|
|
|
def date_words(found: re.Match) -> str:
|
|
if found["year"]:
|
|
year, month, day = int(found["year"]), int(found["month"]), int(found["day"])
|
|
else:
|
|
year, month, day = int(found["named_year"]), MONTH_NUMBER[found["name"][:3].lower()], int(found["named_day"])
|
|
zone = (found["zone"] or "").strip()
|
|
try:
|
|
moment = datetime(year, month, day, int(found["hour"] or 0), int(found["minute"] or 0))
|
|
if zone[:1] in ("+", "-"):
|
|
shift = timedelta(hours=int(zone[1:3]), minutes=int(zone[3:].lstrip(":") or 0))
|
|
moment = moment.replace(tzinfo=timezone(-shift if zone[0] == "-" else shift)).astimezone(UTC)
|
|
except (ValueError, OverflowError):
|
|
return found[0]
|
|
clock = digits(f"{moment:%d%H%M}" if found["hour"] else f"{moment:%d}")
|
|
return padded(found, f"{clock}{' Zulu' if zone else ''} {MONTHS[moment.month - 1]} {digits(f'{moment.year % 100:02}')}")
|
|
|
|
|
|
def time_words(found: re.Match) -> str:
|
|
clock = found["packed"] or found["hour"].zfill(2) + found["minute"] + (found["second"] or "")
|
|
if int(clock[:2]) > 23 or int(clock[2:4]) > 59 or int(clock[4:] or 0) > 59:
|
|
return found[0]
|
|
return padded(found, digits(clock) + (" Zulu" if found["packed"] or found["zone"] else ""))
|
|
|
|
|
|
def clip(text: str, limit: int) -> str:
|
|
words = " ".join(pronounced(text).split())
|
|
if len(words) <= limit:
|
|
return words
|
|
return words[:limit].rsplit(" ", 1)[0] or words[:limit]
|
|
|
|
|
|
def spoken_text(cls: str, text: str) -> str:
|
|
body = HUB_MARK.sub("", text)
|
|
if body != text:
|
|
body = HUB_FILE.sub("", body)
|
|
if cls in ("mayday", *ALARM_WORDS):
|
|
body = CALLS.sub("", body.strip())
|
|
body = clip(body, SPOKEN_TEXT)
|
|
return "" if body == "-" else body
|
|
|
|
|
|
def phrase(cls: str, sender: str, called: list[str], text: str) -> str:
|
|
"""Args:
|
|
cls: the radio class of the row, one of council.CLASSES or receipt.
|
|
sender: the sender's name valid at the row's time.
|
|
called: the names of the called stations valid at the row's time.
|
|
text: the radio text.
|
|
|
|
Returns:
|
|
The row in maritime radio phraseology (SMCP style), one line of at
|
|
most SPEECH_LIMIT characters.
|
|
"""
|
|
station = clip(sender, SPOKEN_NAME) or "unknown station"
|
|
if cls == "mayday":
|
|
head, end = f"MAYDAY MAYDAY MAYDAY, this is {station} {station} {station}", "over"
|
|
elif cls in ALARM_WORDS:
|
|
word = ALARM_WORDS[cls]
|
|
head, end = f"{word} {word} {word}, all stations, this is {station}", "over"
|
|
elif cls in ADDRESSED and called:
|
|
group = clip(" and ".join(clip(name, SPOKEN_NAME) for name in called), SPOKEN_GROUP)
|
|
head, end = f"{group}, {group}, this is {station}", "over"
|
|
else:
|
|
head, end = f"all stations, this is {station}", "out"
|
|
return ", ".join(part for part in (head, spoken_text(cls, text), end) if part)
|