"""Support NWS VTEC encoding"""
import re
from datetime import datetime, timedelta, timezone
from pyiem.util import LOG
VTEC_RE = (
r"(/([A-Z])\.([A-Z]+)\.([A-Z]+)\.([A-Z]+)\.([A-Z])\."
r"([0-9]+)\.([0-9TZ]+)-([0-9TZ]+)/)"
)
VTEC_CLASS = {
"O": "Operational",
"T": "Test",
"E": "Experimental",
"X": "Experimental VTEC",
}
VTEC_ACTION = {
"NEW": "issues",
"CON": "continues",
"EXA": "expands area to include",
"EXT": "extends time of",
"EXB": "extends time and expands area to include",
"UPG": "issues upgrade to",
"CAN": "cancels",
"EXP": "expires",
"ROU": "routine",
"COR": "corrects",
}
VTEC_SIGNIFICANCE = {
"W": "Warning",
"Y": "Advisory",
"A": "Watch",
"S": "Statement",
"O": "Outlook",
"N": "Synopsis",
"F": "Forecast",
}
# https://www.weather.gov/media/directives/010_pdfs/pd01017003curr.pdf
VTEC_PHENOMENA = {
"AF": "Ashfall",
"AS": "Air Stagnation",
"BH": "Beach Hazard",
"BS": "Blowing Snow",
"BW": "Brisk Wind",
"BZ": "Blizzard",
"CF": "Coastal Flood",
"CW": "Cold Weather",
"DF": "Debris Flow",
"DS": "Dust Storm",
"DU": "Blowing Dust",
"EC": "Extreme Cold",
"EH": "Excessive Heat",
"EW": "Extreme Wind",
"FA": "Flood",
"FF": "Flash Flood",
"FG": "Dense Fog",
"FL": "Flood",
"FR": "Frost",
"FW": "Red Flag",
"FZ": "Freeze",
"UP": "Freezing Spray",
"GL": "Gale",
"HF": "Hurricane Force Wind",
"HI": "Inland Hurricane",
"HS": "Heavy Snow",
"HT": "Heat",
"HU": "Hurricane",
"HW": "High Wind",
"HY": "Hydrologic",
"HZ": "Hard Freeze",
"IP": "Sleet",
"IS": "Ice Storm",
"LB": "Lake Effect Snow and Blowing Snow",
"LE": "Lake Effect Snow",
"LO": "Low Water",
"LS": "Lakeshore Flood",
"LW": "Lake Wind",
"MA": "Marine",
"MF": "Marine Dense Fog",
"MH": "Marine Ashfall",
"MS": "Marine Dense Smoke",
"RB": "Small Craft for Rough",
"RP": "Rip Currents",
"SB": "Snow and Blowing",
"SC": "Small Craft",
"SE": "Hazardous Seas",
"SI": "Small Craft for Winds",
"SM": "Dense Smoke",
"SN": "Snow",
"SQ": "Snow Squall",
"SR": "Storm",
"SS": "Storm Surge",
"SU": "High Surf",
"SV": "Severe Thunderstorm",
"SW": "Small Craft for Hazardous Seas",
"TI": "Inland Tropical Storm",
"TO": "Tornado",
"TR": "Tropical Storm",
"TS": "Tsunami",
"TY": "Typhoon",
"WC": "Wind Chill",
"WI": "Wind",
"WS": "Winter Storm",
"WW": "Winter Weather",
"XH": "Extreme Heat", # March 2025
"ZF": "Freezing Fog",
"ZR": "Freezing Rain",
}
# Taken from http://www.weather.gov/help-map
# Not all of these are an exact match.
NWS_COLORS = {
"AF.W": "#A9A9A9",
"AF.Y": "#696969",
"AS.O": "#808080",
"AS.Y": "#808080",
"BH.S": "#40E0D0",
"BW.Y": "#D8BFD8",
"BZ.A": "#ADFF2F",
"BZ.W": "#FF4500",
"CF.A": "#66CDAA",
"CF.S": "#6B8E23",
"CF.W": "#228B22",
"CF.Y": "#7CFC00",
"CW.Y": "#AFEEEE",
"DS.W": "#FFE4C4",
"DS.Y": "#BDB76B",
"DU.W": "#FFE4C4",
"DU.Y": "#BDB76B",
"EC.A": "#5F9EA0",
"EC.W": "#0000FF",
"EH.A": "#800000",
"EH.W": "#C71585",
"EH.Y": "#800000",
"EW.W": "#FF8C00",
"FA.A": "#2E8B57",
"FA.W": "#00FF00",
"FA.Y": "#00FF7F",
"FF.A": "#2E8B57",
"FF.S": "#8B0000",
"FF.W": "#8B0000",
"FG.Y": "#708090",
"FL.A": "#2E8B57",
"FL.S": "#00FF00",
"FL.W": "#00FF00",
"FL.Y": "#00FF7F",
"FR.Y": "#6495ED",
"FW.A": "#FFDEAD",
"FW.W": "#FF1493",
"FZ.A": "#00FFFF",
"FZ.W": "#483D8B",
"GL.A": "#FFC0CB",
"GL.W": "#DDA0DD",
"HF.A": "#9932CC",
"HF.W": "#CD5C5C",
"HT.Y": "#FF7F50",
"HU.A": "#FF00FF",
"HU.S": "#FFE4B5",
"HU.W": "#DC143C",
"HW.A": "#B8860B",
"HW.W": "#DAA520",
"HY.Y": "#00FF7F",
"HZ.A": "#4169E1",
"HZ.W": "#9400D3",
"IS.W": "#8B008B",
"LE.A": "#87CEFA",
"LE.W": "#008B8B",
"LE.Y": "#48D1CC",
"LO.Y": "#A52A2A",
"LS.A": "#66CDAA",
"LS.S": "#6B8E23",
"LS.W": "#228B22",
"LS.Y": "#7CFC00",
"LW.Y": "#D2B48C",
"MA.S": "#FFDAB9",
"MA.W": "#FFA500",
"MF.Y": "#708090",
"MH.Y": "#696969",
"MS.Y": "#F0E68C",
"RB.Y": "#D8BFD8",
"RP.S": "#40E0D0",
"SC.Y": "#D8BFD8",
"SE.A": "#483D8B",
"SE.W": "#D8BFD8",
"SI.Y": "#D8BFD8",
"SM.Y": "#F0E68C",
"SQ.W": "#C71585",
"SR.A": "#FFE4B5",
"SR.W": "#9400D3",
"SS.A": "#DB7FF7",
"SS.W": "#C0C0C0",
"SU.W": "#228B22",
"SU.Y": "#BA55D3",
"SV.A": "#DB7093",
"SV.W": "#FFA500",
"SW.Y": "#D8BFD8",
"TO.A": "#FFFF00",
"TO.W": "#FF0000",
"TR.A": "#F08080",
"TR.S": "#FFE4B5",
"TR.W": "#B22222",
"TS.A": "#FF00FF",
"TS.W": "#FD6347",
"TS.Y": "#D2691E",
"TY.A": "#FF00FF",
"TY.W": "#DC143C",
"UP.A": "#4682B4",
"UP.W": "#8B008B",
"UP.Y": "#8B008B",
"WC.A": "#5F9EA0",
"WC.W": "#B0C4DE",
"WC.Y": "#AFEEEE",
"WI.Y": "#D2B48C",
"WS.A": "#4682B4",
"WS.W": "#FF69B4",
"WW.Y": "#7B68EE",
"XH.A": "#800000", # maroon
"XH.W": "#C71585", # medium violet red
"ZF.Y": "#008080",
"ZR.Y": "#DA70D6",
}
[docs]
def contime(text):
"""Convert text into a UTC datetime."""
# The 0000 is the standard VTEC undefined time
if text.startswith("0000"):
return None
try:
ts = datetime.strptime(text, "%y%m%dT%H%MZ")
except Exception as err:
LOG.exception(err)
return None
# NWS has a bug sometimes whereby 1969 or 1970s timestamps are emitted
if ts.year < 1971:
return None
return ts.replace(tzinfo=timezone.utc)
[docs]
def get_ps_string(phenomena, significance):
"""Return the combination of Phenomena + Significance as string"""
pstr = VTEC_PHENOMENA.get(phenomena, f"Unknown {phenomena}")
astr = VTEC_SIGNIFICANCE.get(significance, f"Unknown {significance}")
# Hack for special FW case
if significance == "A" and phenomena == "FW":
pstr = "Fire Weather"
return f"{pstr} {astr}"
[docs]
def get_action_string(action):
"""Return the action string"""
return VTEC_ACTION.get(action, f"unknown {action}")
[docs]
class VTEC:
"""A single VTEC encoding instance"""
def __init__(self, tokens):
self.line = tokens[0]
self.status = tokens[1]
self.action = tokens[2]
self.office = tokens[3][1:]
self.office4 = tokens[3]
self.phenomena = tokens[4]
self.significance = tokens[5]
self.etn = int(tokens[6])
self.begints = contime(tokens[7])
self.endts = contime(tokens[8])
# Not explicitly defined, but set later by product parsing logic
self.year = None
[docs]
def s3(self):
"""Return a commonly used string representation."""
return f"{self.phenomena}.{self.significance}.{self.etn}"
[docs]
def s2(self):
"""Return a commonly used string representation."""
return f"{self.phenomena}.{self.significance}"
[docs]
def get_end_string(self, prod):
"""Return an appropriate end string for this VTEC"""
if self.action in ["CAN", "EXP"]:
return ""
if self.endts is None:
return "until further notice"
fmt = "%b %-d, %-I:%M %p"
if self.endts < (prod.valid + timedelta(hours=1)):
fmt = "%-I:%M %p"
if prod.tz is None:
fmt = "%b %-d, %-H:%M"
localts = self.endts
if prod.tz is not None:
localts = self.endts.astimezone(prod.tz)
# A bit of complexity as offices may not implement daylight saving
if prod.z is not None and prod.z.endswith("ST") and localts.dst():
localts -= timedelta(hours=1)
tt = prod.z if prod.z is not None else "UTC"
return f"till {localts.strftime(fmt)} {tt}"
[docs]
def get_begin_string(self, prod):
"""Return an appropriate beginning string for this VTEC"""
if self.begints is None:
return ""
fmt = "%b %-d, %-I:%M %p"
if self.begints < (prod.valid + timedelta(hours=1)):
fmt = "%-I:%M %p"
localts = self.begints.astimezone(prod.tz)
# A bit of complexity as offices may not implement daylight saving
if prod.z.endswith("ST") and localts.dst():
localts -= timedelta(hours=1)
return f"valid at {localts.strftime(fmt)} {prod.z}"
[docs]
def url(self, year):
"""Generate a VTEC url string needed"""
tt = year if self.year is None else self.year
return (
f"{tt}-{self.status}-{self.action}-{self.office4}-"
f"{self.phenomena}-{self.significance}-{self.etn:04.0f}"
)
[docs]
def get_id(self, year):
"""Return a custom string identifier for this VTEC product
This is used by the Live client
"""
tt = year if self.year is None else self.year
return (
f"{tt}-{self.office4}-{self.phenomena}-{self.significance}-"
f"{self.etn:04.0f}"
)
[docs]
def __str__(self):
"""Return string representation"""
return self.line
[docs]
def get_ps_string(self):
"""Return the combination of Phenomena + Significance as string"""
return get_ps_string(self.phenomena, self.significance)
[docs]
def get_action_string(self):
"""Return the action string"""
return get_action_string(self.action)
[docs]
def product_string(self):
"""Return the combination of action and phenomena+significance"""
return f"{self.get_action_string()} {self.get_ps_string()}"
[docs]
def parse(text: str) -> list[VTEC]:
"""I look for and return vtec objects as I find them"""
return [VTEC(token) for token in re.findall(VTEC_RE, text)]