avwx.parsing.speech

Contains functions for converting translations into a speech string. Currently only supports METAR.

  1"""Contains functions for converting translations into a speech string.
  2Currently only supports METAR.
  3"""
  4
  5# stdlib
  6from __future__ import annotations
  7
  8import re
  9from typing import TYPE_CHECKING
 10
 11# module
 12import avwx.parsing.translate.base as translate_base
 13import avwx.parsing.translate.taf as translate_taf
 14from avwx.parsing import core
 15from avwx.static.core import SPOKEN_UNITS
 16
 17if TYPE_CHECKING:
 18    from avwx.structs import Code, MetarData, Number, TafData, TafLineData, Timestamp, Units
 19
 20
 21def ordinal(n: int) -> str | None:
 22    """Convert an int to it spoken ordinal representation."""
 23    if n < 0:
 24        return None
 25    return str(n) + "tsnrhtdd"[(n / 10 % 10 != 1) * (n % 10 < 4) * n % 10 :: 4]
 26
 27
 28def _format_plural_unit(value: str, unit: str) -> str:
 29    spoken = SPOKEN_UNITS.get(unit, unit)
 30    value = re.sub(r"(?<=\b1)" + unit, f" {spoken}", value)  # 1 knot
 31    return re.sub(r"(?<=\d)+" + unit, f" {spoken}s", value)  # 2 knots
 32
 33
 34def wind(
 35    direction: Number,
 36    speed: Number,
 37    gust: Number | None,
 38    vardir: list[Number] | None = None,
 39    unit: str = "kt",
 40) -> str:
 41    """Format wind details into a spoken word string."""
 42    val = translate_base.wind(direction, speed, gust, vardir, unit, cardinals=False, spoken=True)
 43    if val and unit in SPOKEN_UNITS:
 44        val = _format_plural_unit(val, unit)
 45    return "Winds " + (val or "unknown")
 46
 47
 48def temperature(header: str, temp: Number | None, unit: str = "C") -> str:
 49    """Format temperature details into a spoken word string."""
 50    if not temp or temp.value is None:
 51        return f"{header} unknown"
 52    unit = SPOKEN_UNITS.get(unit, unit)
 53    use_s = "" if temp.spoken in ("one", "minus one") else "s"
 54    return " ".join((header, temp.spoken, f"degree{use_s}", unit))
 55
 56
 57def visibility(vis: Number | None, unit: str = "m") -> str:
 58    """Format visibility details into a spoken word string."""
 59    if not vis:
 60        return "Visibility unknown"
 61    if vis.value is None or "/" in vis.repr:
 62        ret_vis = vis.spoken
 63    else:
 64        ret_vis = translate_base.visibility(vis, unit=unit)
 65        if unit == "m":
 66            unit = "km"
 67        ret_vis = ret_vis[: ret_vis.find(" (")].lower().replace(unit, "").strip()
 68        ret_vis = core.spoken_number(core.remove_leading_zeros(ret_vis))
 69    ret = f"Visibility {ret_vis}"
 70    if unit in SPOKEN_UNITS:
 71        if "/" in vis.repr and "half" not in ret:
 72            ret += " of a"
 73        ret += f" {SPOKEN_UNITS[unit]}"
 74        if ("one half" not in ret or " and " in ret) and "of a" not in ret:
 75            ret += "s"
 76    else:
 77        ret += unit
 78    return ret
 79
 80
 81def altimeter(alt: Number | None, unit: str = "inHg") -> str:
 82    """Format altimeter details into a spoken word string."""
 83    ret = "Altimeter "
 84    if not alt:
 85        ret += "unknown"
 86    elif unit == "inHg":
 87        ret += core.spoken_number(str(alt.value).ljust(5, "0"), literal=True)
 88    elif unit == "hPa":
 89        ret += core.spoken_number(str(alt.value).zfill(4), literal=True)
 90    return ret
 91
 92
 93def wx_codes(codes: list[Code]) -> str:
 94    """Format wx codes into a spoken word string."""
 95    ret = []
 96    for code in codes:
 97        item = code.value
 98        if item.startswith("Vicinity"):
 99            item = item.removeprefix("Vicinity ") + " in the Vicinity"
100        ret.append(item)
101    return ". ".join(ret)
102
103
104def type_and_times(
105    type: str | None,  # noqa: A002
106    start: Timestamp | None,
107    end: Timestamp | None,
108    probability: Number | None = None,
109) -> str:
110    """Format line type and times into the beginning of a spoken line string."""
111    if not type:
112        return ""
113    start_time = start.dt.hour if start and start.dt else "an unknown start time"
114    end_time = end.dt.hour if end and end.dt else "an unknown end time"
115    if type == "BECMG":
116        return f"At {start_time or 'midnight'} zulu becoming"
117    ret = f"From {start_time or 'midnight'} to {end_time or 'midnight'} zulu,"
118    if probability and probability.value:
119        ret += f" there's a {probability.value}% chance for"
120    if type == "INTER":
121        ret += " intermittent"
122    elif type == "TEMPO":
123        ret += " temporary"
124    return ret
125
126
127def wind_shear(shear: str, unit_alt: str = "ft", unit_wind: str = "kt") -> str:
128    """Format wind shear string into a spoken word string."""
129    value = translate_taf.wind_shear(shear, unit_alt, unit_wind, spoken=True)
130    if not value:
131        return "Wind shear unknown"
132    for unit in (unit_alt, unit_wind):
133        if unit in SPOKEN_UNITS:
134            value = _format_plural_unit(value, unit)
135    return value
136
137
138def metar(data: MetarData, units: Units) -> str:
139    """Convert MetarData into a string for text-to-speech."""
140    speech = []
141    if data.wind_direction and data.wind_speed:
142        speech.append(
143            wind(
144                data.wind_direction,
145                data.wind_speed,
146                data.wind_gust,
147                data.wind_variable_direction,
148                units.wind_speed,
149            )
150        )
151    if data.visibility:
152        speech.append(visibility(data.visibility, units.visibility))
153    speech.append(translate_base.clouds(data.clouds, units.altitude).replace(" - Reported AGL", ""))
154    if data.wx_codes:
155        speech.append(wx_codes(data.wx_codes))
156    if data.temperature:
157        speech.append(temperature("Temperature", data.temperature, units.temperature))
158    if data.dewpoint:
159        speech.append(temperature("Dew point", data.dewpoint, units.temperature))
160    if data.altimeter:
161        speech.append(altimeter(data.altimeter, units.altimeter))
162    return (". ".join([el for el in speech if el])).replace(",", ".")
163
164
165def taf_line(line: TafLineData, units: Units) -> str:
166    """Convert TafLineData into a string for text-to-speech."""
167    speech = []
168    start = type_and_times(line.type, line.start_time, line.end_time, line.probability)
169    if line.wind_direction and line.wind_speed:
170        speech.append(
171            wind(
172                line.wind_direction,
173                line.wind_speed,
174                line.wind_gust,
175                line.wind_variable_direction,
176                unit=units.wind_speed,
177            )
178        )
179    if line.wind_shear:
180        speech.append(wind_shear(line.wind_shear, units.altitude, units.wind_speed))
181    if line.visibility:
182        speech.append(visibility(line.visibility, units.visibility))
183    if line.altimeter:
184        speech.append(altimeter(line.altimeter, units.altimeter))
185    if line.wx_codes:
186        speech.append(wx_codes(line.wx_codes))
187    speech.append(translate_base.clouds(line.clouds, units.altitude).replace(" - Reported AGL", ""))
188    if line.turbulence:
189        speech.append(translate_taf.turb_ice(line.turbulence, units.altitude))
190    if line.icing:
191        speech.append(translate_taf.turb_ice(line.icing, units.altitude))
192    return f"{start} " + (". ".join([el for el in speech if el])).replace(",", ".")
193
194
195def taf(data: TafData, units: Units) -> str:
196    """Convert TafData into a string for text-to-speech."""
197    try:
198        month = data.start_time.dt.strftime(r"%B")  # type: ignore
199        day = ordinal(data.start_time.dt.day) or "Unknown"  # type: ignore
200        ret = f"Starting on {month} {day} - "
201    except AttributeError:
202        ret = ""
203    return ret + ". ".join([taf_line(line, units) for line in data.forecast])
def ordinal(n: int) -> str | None:
22def ordinal(n: int) -> str | None:
23    """Convert an int to it spoken ordinal representation."""
24    if n < 0:
25        return None
26    return str(n) + "tsnrhtdd"[(n / 10 % 10 != 1) * (n % 10 < 4) * n % 10 :: 4]

Convert an int to it spoken ordinal representation.

def wind( direction: avwx.structs.Number, speed: avwx.structs.Number, gust: avwx.structs.Number | None, vardir: list[avwx.structs.Number] | None = None, unit: str = 'kt') -> str:
35def wind(
36    direction: Number,
37    speed: Number,
38    gust: Number | None,
39    vardir: list[Number] | None = None,
40    unit: str = "kt",
41) -> str:
42    """Format wind details into a spoken word string."""
43    val = translate_base.wind(direction, speed, gust, vardir, unit, cardinals=False, spoken=True)
44    if val and unit in SPOKEN_UNITS:
45        val = _format_plural_unit(val, unit)
46    return "Winds " + (val or "unknown")

Format wind details into a spoken word string.

def temperature(header: str, temp: avwx.structs.Number | None, unit: str = 'C') -> str:
49def temperature(header: str, temp: Number | None, unit: str = "C") -> str:
50    """Format temperature details into a spoken word string."""
51    if not temp or temp.value is None:
52        return f"{header} unknown"
53    unit = SPOKEN_UNITS.get(unit, unit)
54    use_s = "" if temp.spoken in ("one", "minus one") else "s"
55    return " ".join((header, temp.spoken, f"degree{use_s}", unit))

Format temperature details into a spoken word string.

def visibility(vis: avwx.structs.Number | None, unit: str = 'm') -> str:
58def visibility(vis: Number | None, unit: str = "m") -> str:
59    """Format visibility details into a spoken word string."""
60    if not vis:
61        return "Visibility unknown"
62    if vis.value is None or "/" in vis.repr:
63        ret_vis = vis.spoken
64    else:
65        ret_vis = translate_base.visibility(vis, unit=unit)
66        if unit == "m":
67            unit = "km"
68        ret_vis = ret_vis[: ret_vis.find(" (")].lower().replace(unit, "").strip()
69        ret_vis = core.spoken_number(core.remove_leading_zeros(ret_vis))
70    ret = f"Visibility {ret_vis}"
71    if unit in SPOKEN_UNITS:
72        if "/" in vis.repr and "half" not in ret:
73            ret += " of a"
74        ret += f" {SPOKEN_UNITS[unit]}"
75        if ("one half" not in ret or " and " in ret) and "of a" not in ret:
76            ret += "s"
77    else:
78        ret += unit
79    return ret

Format visibility details into a spoken word string.

def altimeter(alt: avwx.structs.Number | None, unit: str = 'inHg') -> str:
82def altimeter(alt: Number | None, unit: str = "inHg") -> str:
83    """Format altimeter details into a spoken word string."""
84    ret = "Altimeter "
85    if not alt:
86        ret += "unknown"
87    elif unit == "inHg":
88        ret += core.spoken_number(str(alt.value).ljust(5, "0"), literal=True)
89    elif unit == "hPa":
90        ret += core.spoken_number(str(alt.value).zfill(4), literal=True)
91    return ret

Format altimeter details into a spoken word string.

def wx_codes(codes: list[avwx.structs.Code]) -> str:
 94def wx_codes(codes: list[Code]) -> str:
 95    """Format wx codes into a spoken word string."""
 96    ret = []
 97    for code in codes:
 98        item = code.value
 99        if item.startswith("Vicinity"):
100            item = item.removeprefix("Vicinity ") + " in the Vicinity"
101        ret.append(item)
102    return ". ".join(ret)

Format wx codes into a spoken word string.

def type_and_times( type: str | None, start: avwx.structs.Timestamp | None, end: avwx.structs.Timestamp | None, probability: avwx.structs.Number | None = None) -> str:
105def type_and_times(
106    type: str | None,  # noqa: A002
107    start: Timestamp | None,
108    end: Timestamp | None,
109    probability: Number | None = None,
110) -> str:
111    """Format line type and times into the beginning of a spoken line string."""
112    if not type:
113        return ""
114    start_time = start.dt.hour if start and start.dt else "an unknown start time"
115    end_time = end.dt.hour if end and end.dt else "an unknown end time"
116    if type == "BECMG":
117        return f"At {start_time or 'midnight'} zulu becoming"
118    ret = f"From {start_time or 'midnight'} to {end_time or 'midnight'} zulu,"
119    if probability and probability.value:
120        ret += f" there's a {probability.value}% chance for"
121    if type == "INTER":
122        ret += " intermittent"
123    elif type == "TEMPO":
124        ret += " temporary"
125    return ret

Format line type and times into the beginning of a spoken line string.

def wind_shear(shear: str, unit_alt: str = 'ft', unit_wind: str = 'kt') -> str:
128def wind_shear(shear: str, unit_alt: str = "ft", unit_wind: str = "kt") -> str:
129    """Format wind shear string into a spoken word string."""
130    value = translate_taf.wind_shear(shear, unit_alt, unit_wind, spoken=True)
131    if not value:
132        return "Wind shear unknown"
133    for unit in (unit_alt, unit_wind):
134        if unit in SPOKEN_UNITS:
135            value = _format_plural_unit(value, unit)
136    return value

Format wind shear string into a spoken word string.

def metar(data: avwx.structs.MetarData, units: avwx.structs.Units) -> str:
139def metar(data: MetarData, units: Units) -> str:
140    """Convert MetarData into a string for text-to-speech."""
141    speech = []
142    if data.wind_direction and data.wind_speed:
143        speech.append(
144            wind(
145                data.wind_direction,
146                data.wind_speed,
147                data.wind_gust,
148                data.wind_variable_direction,
149                units.wind_speed,
150            )
151        )
152    if data.visibility:
153        speech.append(visibility(data.visibility, units.visibility))
154    speech.append(translate_base.clouds(data.clouds, units.altitude).replace(" - Reported AGL", ""))
155    if data.wx_codes:
156        speech.append(wx_codes(data.wx_codes))
157    if data.temperature:
158        speech.append(temperature("Temperature", data.temperature, units.temperature))
159    if data.dewpoint:
160        speech.append(temperature("Dew point", data.dewpoint, units.temperature))
161    if data.altimeter:
162        speech.append(altimeter(data.altimeter, units.altimeter))
163    return (". ".join([el for el in speech if el])).replace(",", ".")

Convert MetarData into a string for text-to-speech.

def taf_line(line: avwx.structs.TafLineData, units: avwx.structs.Units) -> str:
166def taf_line(line: TafLineData, units: Units) -> str:
167    """Convert TafLineData into a string for text-to-speech."""
168    speech = []
169    start = type_and_times(line.type, line.start_time, line.end_time, line.probability)
170    if line.wind_direction and line.wind_speed:
171        speech.append(
172            wind(
173                line.wind_direction,
174                line.wind_speed,
175                line.wind_gust,
176                line.wind_variable_direction,
177                unit=units.wind_speed,
178            )
179        )
180    if line.wind_shear:
181        speech.append(wind_shear(line.wind_shear, units.altitude, units.wind_speed))
182    if line.visibility:
183        speech.append(visibility(line.visibility, units.visibility))
184    if line.altimeter:
185        speech.append(altimeter(line.altimeter, units.altimeter))
186    if line.wx_codes:
187        speech.append(wx_codes(line.wx_codes))
188    speech.append(translate_base.clouds(line.clouds, units.altitude).replace(" - Reported AGL", ""))
189    if line.turbulence:
190        speech.append(translate_taf.turb_ice(line.turbulence, units.altitude))
191    if line.icing:
192        speech.append(translate_taf.turb_ice(line.icing, units.altitude))
193    return f"{start} " + (". ".join([el for el in speech if el])).replace(",", ".")

Convert TafLineData into a string for text-to-speech.

def taf(data: avwx.structs.TafData, units: avwx.structs.Units) -> str:
196def taf(data: TafData, units: Units) -> str:
197    """Convert TafData into a string for text-to-speech."""
198    try:
199        month = data.start_time.dt.strftime(r"%B")  # type: ignore
200        day = ordinal(data.start_time.dt.day) or "Unknown"  # type: ignore
201        ret = f"Starting on {month} {day} - "
202    except AttributeError:
203        ret = ""
204    return ret + ". ".join([taf_line(line, units) for line in data.forecast])

Convert TafData into a string for text-to-speech.