avwx.parsing.translate.base

Functions for translating report data.

  1"""Functions for translating report data."""
  2
  3# stdlib
  4from __future__ import annotations
  5
  6from contextlib import suppress
  7
  8# module
  9from avwx.static.core import CLOUD_TRANSLATIONS
 10from avwx.structs import Cloud, Code, Number, ReportTrans, SharedData, Units
 11
 12
 13def get_cardinal_direction(direction: float) -> str:
 14    """Return the cardinal direction (NSEW) for a degree direction.
 15
 16    Wind Direction - Cheat Sheet:
 17
 18    (360) -- 011/012 -- 033/034 -- (045) -- 056/057 -- 078/079 -- (090)
 19
 20    (090) -- 101/102 -- 123/124 -- (135) -- 146/147 -- 168/169 -- (180)
 21
 22    (180) -- 191/192 -- 213/214 -- (225) -- 236/237 -- 258/259 -- (270)
 23
 24    (270) -- 281/282 -- 303/304 -- (315) -- 326/327 -- 348/349 -- (360)
 25    """
 26    ret = ""
 27    if not isinstance(direction, int):
 28        direction = int(direction)
 29    # Convert to range [0 360]
 30    while direction < 0:
 31        direction += 360
 32    direction = direction % 360
 33    if 304 <= direction <= 360 or 0 <= direction <= 56:
 34        ret += "N"
 35        if 304 <= direction <= 348:
 36            if 327 <= direction <= 348:
 37                ret += "N"
 38            ret += "W"
 39        elif 12 <= direction <= 56:
 40            if 12 <= direction <= 33:
 41                ret += "N"
 42            ret += "E"
 43    elif 124 <= direction <= 236:
 44        ret += "S"
 45        if 124 <= direction <= 168:
 46            if 147 <= direction <= 168:
 47                ret += "S"
 48            ret += "E"
 49        elif 192 <= direction <= 236:
 50            if 192 <= direction <= 213:
 51                ret += "S"
 52            ret += "W"
 53    elif 57 <= direction <= 123:
 54        ret += "E"
 55        if 57 <= direction <= 78:
 56            ret += "NE"
 57        elif 102 <= direction <= 123:
 58            ret += "SE"
 59    elif 237 <= direction <= 303:
 60        ret += "W"
 61        if 237 <= direction <= 258:
 62            ret += "SW"
 63        elif 282 <= direction <= 303:
 64            ret += "NW"
 65    return ret
 66
 67
 68WIND_DIR_REPR = {"000": "Calm", "VRB": "Variable"}
 69
 70
 71def wind(
 72    direction: Number | None,
 73    speed: Number | None,
 74    gust: Number | None,
 75    vardir: list[Number] | None = None,
 76    unit: str = "kt",
 77    *,
 78    cardinals: bool = True,
 79    spoken: bool = False,
 80) -> str:
 81    """Format wind elements into a readable sentence.
 82
 83    Returns the translation string.
 84
 85    Ex: NNE-020 (variable 010 to 040) at 14kt gusting to 20kt
 86    """
 87    ret = ""
 88    target = "spoken" if spoken else "repr"
 89    # Wind direction
 90    if direction:
 91        if direction.repr in WIND_DIR_REPR:
 92            ret += WIND_DIR_REPR[direction.repr]
 93        elif direction.value is None:
 94            ret += direction.repr
 95        else:
 96            if cardinals:
 97                ret += f"{get_cardinal_direction(direction.value)}-"
 98            ret += getattr(direction, target)
 99    # Variable direction
100    if vardir and isinstance(vardir, list):
101        vardir = [getattr(var, target) for var in vardir]
102        ret += f" (variable {vardir[0]} to {vardir[1]})"
103    # Speed
104    if speed and speed.value:
105        ret += f" at {speed.value}{unit}"
106    # Gust
107    if gust and gust.value:
108        ret += f" gusting to {gust.value}{unit}"
109    return ret
110
111
112VIS_REPR = {
113    "P6": "Greater than 6sm ( >10km )",
114    "M1/2": "Less than .5sm ( <0.8km )",
115    "M1/4": "Less than .25sm ( <0.4km )",
116    "M1/8": "Less than .125sm ( <0.2km )",
117}
118
119
120def visibility(vis: Number | None, unit: str = "m") -> str:
121    """Format a visibility element into a string with both km and sm values.
122
123    Ex: 8km ( 5sm )
124    """
125    if not (vis and unit in {"m", "sm"}):
126        return ""
127    with suppress(KeyError):
128        return VIS_REPR[vis.repr]
129    if vis.value is None:
130        return ""
131    if unit == "m":
132        meters = vis.value
133        miles = meters * 0.000621371
134        converted = str(round(miles, 1)).replace(".0", "") + "sm"
135        value = str(round(meters / 1000, 1)).replace(".0", "")
136        unit = "km"
137    elif unit == "sm":
138        miles = vis.value or 0
139        kilometers = miles / 0.621371
140        converted = str(round(kilometers, 1)).replace(".0", "") + "km"
141        value = str(miles).replace(".0", "")
142    else:
143        return ""
144    return f"{value}{unit} ({converted})"
145
146
147def temperature(temp: Number | None, unit: str = "C") -> str:
148    """Format a temperature element into a string with both C and F values.
149
150    Used for both Temp and Dew.
151
152    Ex: 34°C (93°F)
153    """
154    unit = unit.upper()
155    if not (temp and temp.value is not None and unit in {"C", "F"}):
156        return ""
157    if unit == "C":
158        fahrenheit = temp.value * 1.8 + 32
159        converted = f"{int(round(fahrenheit))}°F"
160    elif unit == "F":
161        celsius = (temp.value - 32) / 1.8
162        converted = f"{int(round(celsius))}°C"
163    else:
164        return ""
165    return f"{temp.value}°{unit} ({converted})"
166
167
168def altimeter(alt: Number | None, unit: str = "hPa") -> str:
169    """Format the altimeter element into a string with hPa and inHg values.
170
171    Ex: 30.11 inHg (10.20 hPa)
172    """
173    if not (alt and alt.value is not None and unit in {"hPa", "inHg"}):
174        return ""
175    if unit == "hPa":
176        value = str(alt.value)
177        inches = round(alt.value / 33.8638866667, 2)
178        converted = str(inches).ljust(5, "0") + " inHg"
179    elif unit == "inHg":
180        value = str(alt.value).ljust(5, "0")
181        pascals = alt.value * 33.8638866667
182        converted = f"{int(round(pascals))} hPa"
183    else:
184        return ""
185    return f"{value} {unit} ({converted})"
186
187
188def clouds(values: list[Cloud] | None, unit: str = "ft") -> str:
189    """Format cloud list into a readable sentence.
190
191    Returns the translation string.
192
193    Ex: Broken layer at 2200ft (Cumulonimbus), Overcast layer at 3600ft - Reported AGL
194    """
195    if values is None:
196        return ""
197    ret = []
198    for cloud in values:
199        if cloud.base is None:
200            continue
201        cloud_str = CLOUD_TRANSLATIONS[cloud.type]
202        if cloud.modifier and cloud.modifier in CLOUD_TRANSLATIONS:
203            cloud_str += f" ({CLOUD_TRANSLATIONS[cloud.modifier]})"
204        ret.append(cloud_str.format(cloud.base * 100, unit))
205    return ", ".join(ret) + " - Reported AGL" if ret else "Sky clear"
206
207
208def wx_codes(codes: list[Code]) -> str:
209    """Join WX code values,
210
211    Returns the translation string,
212    """
213    return ", ".join(code.value for code in codes)
214
215
216def current_shared(wxdata: SharedData, units: Units) -> ReportTrans:
217    """Translate Visibility, Altimeter, Clouds, and Other,"""
218    return ReportTrans(
219        visibility=visibility(wxdata.visibility, units.visibility),
220        altimeter=altimeter(wxdata.altimeter, units.altimeter),
221        clouds=clouds(wxdata.clouds, units.altitude),
222        wx_codes=wx_codes(wxdata.wx_codes),
223    )
def get_cardinal_direction(direction: float) -> str:
14def get_cardinal_direction(direction: float) -> str:
15    """Return the cardinal direction (NSEW) for a degree direction.
16
17    Wind Direction - Cheat Sheet:
18
19    (360) -- 011/012 -- 033/034 -- (045) -- 056/057 -- 078/079 -- (090)
20
21    (090) -- 101/102 -- 123/124 -- (135) -- 146/147 -- 168/169 -- (180)
22
23    (180) -- 191/192 -- 213/214 -- (225) -- 236/237 -- 258/259 -- (270)
24
25    (270) -- 281/282 -- 303/304 -- (315) -- 326/327 -- 348/349 -- (360)
26    """
27    ret = ""
28    if not isinstance(direction, int):
29        direction = int(direction)
30    # Convert to range [0 360]
31    while direction < 0:
32        direction += 360
33    direction = direction % 360
34    if 304 <= direction <= 360 or 0 <= direction <= 56:
35        ret += "N"
36        if 304 <= direction <= 348:
37            if 327 <= direction <= 348:
38                ret += "N"
39            ret += "W"
40        elif 12 <= direction <= 56:
41            if 12 <= direction <= 33:
42                ret += "N"
43            ret += "E"
44    elif 124 <= direction <= 236:
45        ret += "S"
46        if 124 <= direction <= 168:
47            if 147 <= direction <= 168:
48                ret += "S"
49            ret += "E"
50        elif 192 <= direction <= 236:
51            if 192 <= direction <= 213:
52                ret += "S"
53            ret += "W"
54    elif 57 <= direction <= 123:
55        ret += "E"
56        if 57 <= direction <= 78:
57            ret += "NE"
58        elif 102 <= direction <= 123:
59            ret += "SE"
60    elif 237 <= direction <= 303:
61        ret += "W"
62        if 237 <= direction <= 258:
63            ret += "SW"
64        elif 282 <= direction <= 303:
65            ret += "NW"
66    return ret

Return the cardinal direction (NSEW) for a degree direction.

Wind Direction - Cheat Sheet:

(360) -- 011/012 -- 033/034 -- (045) -- 056/057 -- 078/079 -- (090)

(090) -- 101/102 -- 123/124 -- (135) -- 146/147 -- 168/169 -- (180)

(180) -- 191/192 -- 213/214 -- (225) -- 236/237 -- 258/259 -- (270)

(270) -- 281/282 -- 303/304 -- (315) -- 326/327 -- 348/349 -- (360)

WIND_DIR_REPR = {'000': 'Calm', 'VRB': 'Variable'}
def wind( direction: avwx.structs.Number | None, speed: avwx.structs.Number | None, gust: avwx.structs.Number | None, vardir: list[avwx.structs.Number] | None = None, unit: str = 'kt', *, cardinals: bool = True, spoken: bool = False) -> str:
 72def wind(
 73    direction: Number | None,
 74    speed: Number | None,
 75    gust: Number | None,
 76    vardir: list[Number] | None = None,
 77    unit: str = "kt",
 78    *,
 79    cardinals: bool = True,
 80    spoken: bool = False,
 81) -> str:
 82    """Format wind elements into a readable sentence.
 83
 84    Returns the translation string.
 85
 86    Ex: NNE-020 (variable 010 to 040) at 14kt gusting to 20kt
 87    """
 88    ret = ""
 89    target = "spoken" if spoken else "repr"
 90    # Wind direction
 91    if direction:
 92        if direction.repr in WIND_DIR_REPR:
 93            ret += WIND_DIR_REPR[direction.repr]
 94        elif direction.value is None:
 95            ret += direction.repr
 96        else:
 97            if cardinals:
 98                ret += f"{get_cardinal_direction(direction.value)}-"
 99            ret += getattr(direction, target)
100    # Variable direction
101    if vardir and isinstance(vardir, list):
102        vardir = [getattr(var, target) for var in vardir]
103        ret += f" (variable {vardir[0]} to {vardir[1]})"
104    # Speed
105    if speed and speed.value:
106        ret += f" at {speed.value}{unit}"
107    # Gust
108    if gust and gust.value:
109        ret += f" gusting to {gust.value}{unit}"
110    return ret

Format wind elements into a readable sentence.

Returns the translation string.

Ex: NNE-020 (variable 010 to 040) at 14kt gusting to 20kt

VIS_REPR = {'P6': 'Greater than 6sm ( >10km )', 'M1/2': 'Less than .5sm ( <0.8km )', 'M1/4': 'Less than .25sm ( <0.4km )', 'M1/8': 'Less than .125sm ( <0.2km )'}
def visibility(vis: avwx.structs.Number | None, unit: str = 'm') -> str:
121def visibility(vis: Number | None, unit: str = "m") -> str:
122    """Format a visibility element into a string with both km and sm values.
123
124    Ex: 8km ( 5sm )
125    """
126    if not (vis and unit in {"m", "sm"}):
127        return ""
128    with suppress(KeyError):
129        return VIS_REPR[vis.repr]
130    if vis.value is None:
131        return ""
132    if unit == "m":
133        meters = vis.value
134        miles = meters * 0.000621371
135        converted = str(round(miles, 1)).replace(".0", "") + "sm"
136        value = str(round(meters / 1000, 1)).replace(".0", "")
137        unit = "km"
138    elif unit == "sm":
139        miles = vis.value or 0
140        kilometers = miles / 0.621371
141        converted = str(round(kilometers, 1)).replace(".0", "") + "km"
142        value = str(miles).replace(".0", "")
143    else:
144        return ""
145    return f"{value}{unit} ({converted})"

Format a visibility element into a string with both km and sm values.

Ex: 8km ( 5sm )

def temperature(temp: avwx.structs.Number | None, unit: str = 'C') -> str:
148def temperature(temp: Number | None, unit: str = "C") -> str:
149    """Format a temperature element into a string with both C and F values.
150
151    Used for both Temp and Dew.
152
153    Ex: 34°C (93°F)
154    """
155    unit = unit.upper()
156    if not (temp and temp.value is not None and unit in {"C", "F"}):
157        return ""
158    if unit == "C":
159        fahrenheit = temp.value * 1.8 + 32
160        converted = f"{int(round(fahrenheit))}°F"
161    elif unit == "F":
162        celsius = (temp.value - 32) / 1.8
163        converted = f"{int(round(celsius))}°C"
164    else:
165        return ""
166    return f"{temp.value}°{unit} ({converted})"

Format a temperature element into a string with both C and F values.

Used for both Temp and Dew.

Ex: 34°C (93°F)

def altimeter(alt: avwx.structs.Number | None, unit: str = 'hPa') -> str:
169def altimeter(alt: Number | None, unit: str = "hPa") -> str:
170    """Format the altimeter element into a string with hPa and inHg values.
171
172    Ex: 30.11 inHg (10.20 hPa)
173    """
174    if not (alt and alt.value is not None and unit in {"hPa", "inHg"}):
175        return ""
176    if unit == "hPa":
177        value = str(alt.value)
178        inches = round(alt.value / 33.8638866667, 2)
179        converted = str(inches).ljust(5, "0") + " inHg"
180    elif unit == "inHg":
181        value = str(alt.value).ljust(5, "0")
182        pascals = alt.value * 33.8638866667
183        converted = f"{int(round(pascals))} hPa"
184    else:
185        return ""
186    return f"{value} {unit} ({converted})"

Format the altimeter element into a string with hPa and inHg values.

Ex: 30.11 inHg (10.20 hPa)

def clouds(values: list[avwx.structs.Cloud] | None, unit: str = 'ft') -> str:
189def clouds(values: list[Cloud] | None, unit: str = "ft") -> str:
190    """Format cloud list into a readable sentence.
191
192    Returns the translation string.
193
194    Ex: Broken layer at 2200ft (Cumulonimbus), Overcast layer at 3600ft - Reported AGL
195    """
196    if values is None:
197        return ""
198    ret = []
199    for cloud in values:
200        if cloud.base is None:
201            continue
202        cloud_str = CLOUD_TRANSLATIONS[cloud.type]
203        if cloud.modifier and cloud.modifier in CLOUD_TRANSLATIONS:
204            cloud_str += f" ({CLOUD_TRANSLATIONS[cloud.modifier]})"
205        ret.append(cloud_str.format(cloud.base * 100, unit))
206    return ", ".join(ret) + " - Reported AGL" if ret else "Sky clear"

Format cloud list into a readable sentence.

Returns the translation string.

Ex: Broken layer at 2200ft (Cumulonimbus), Overcast layer at 3600ft - Reported AGL

def wx_codes(codes: list[avwx.structs.Code]) -> str:
209def wx_codes(codes: list[Code]) -> str:
210    """Join WX code values,
211
212    Returns the translation string,
213    """
214    return ", ".join(code.value for code in codes)

Join WX code values,

Returns the translation string,

def current_shared( wxdata: avwx.structs.SharedData, units: avwx.structs.Units) -> avwx.structs.ReportTrans:
217def current_shared(wxdata: SharedData, units: Units) -> ReportTrans:
218    """Translate Visibility, Altimeter, Clouds, and Other,"""
219    return ReportTrans(
220        visibility=visibility(wxdata.visibility, units.visibility),
221        altimeter=altimeter(wxdata.altimeter, units.altimeter),
222        clouds=clouds(wxdata.clouds, units.altitude),
223        wx_codes=wx_codes(wxdata.wx_codes),
224    )

Translate Visibility, Altimeter, Clouds, and Other,