mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-11-04 11:57:16 +00:00
gwx advertised hourly but parsed it as part of the place name. It now requests Open-Meteo's hourly precipitation probability alongside the hourly fields it already got and formats the hours after Open-Meteo's location-local current time in wx's compact one-reply style, sharing the hour label, description shortening and byte packing through WeatherCommandMixin. Hourly lines drop the wind arrow (both commands) so the arrow's six bytes do not push an hour out of the reply; wx's NOAA hourly now keeps the full 16-point letters instead of the first two. WXSIM sources answer hourly and alerts with source_option_not_available instead of their default conditions; wx no longer skips an MQTT alias for alerts (the alias then got geocoded as a city); gwx answers 'alerts' the same way. WXSIM highs and lows of exactly 0° are no longer treated as missing, and gwx warns about a stale WXSIM forecast as wx does. Only noaa_wx_hourly_seattle moved (WNW/NNW instead of WN/NN).
788 lines
27 KiB
Python
788 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
WXSIM Plaintext Parser
|
|
Parses WXSIM plaintext.txt forecast files into structured data
|
|
|
|
Based on the PHP parser by Ken True (Saratoga-Weather.org)
|
|
https://github.com/ktrue/WXSIM-forecast/blob/master/plaintext-parser.php
|
|
|
|
Usage:
|
|
from modules.clients.wxsim_parser import WXSIMParser
|
|
|
|
# Parse from URL
|
|
parser = WXSIMParser()
|
|
text = parser.fetch_from_url('https://example.com/plaintext.txt')
|
|
if text:
|
|
forecast = parser.parse(text)
|
|
current = parser.format_current_conditions(forecast, temp_unit='fahrenheit', wind_unit='mph')
|
|
summary = parser.format_forecast_summary(forecast, num_days=7, temp_unit='fahrenheit', wind_unit='mph')
|
|
|
|
# Or parse from file/string
|
|
with open('plaintext.txt', 'r') as f:
|
|
text = f.read()
|
|
forecast = parser.parse(text)
|
|
|
|
# Access structured data
|
|
for period in forecast.periods:
|
|
print(f"{period.day_name}: {period.conditions} {period.high_temp}°C/{period.low_temp}°C")
|
|
"""
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta
|
|
from enum import Enum
|
|
from typing import Any, Optional
|
|
|
|
import requests
|
|
|
|
|
|
class PeriodType(Enum):
|
|
"""Forecast period type"""
|
|
DAY = "day"
|
|
NIGHT = "night"
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
@dataclass
|
|
class HourlyData:
|
|
"""Single hour of forecast data"""
|
|
date: str # e.g., "May 5"
|
|
time: str # e.g., "7:00 A" or "12:00 P"
|
|
hour: int # 0-23
|
|
temperature: float
|
|
wind_speed: int
|
|
humidity: int
|
|
sky_cover: int # %SC
|
|
visibility: int # %VST
|
|
visibility_miles: float # VIS
|
|
precip_chance: int # PC/HR
|
|
rain_total: float # RN TOT
|
|
weather: str # Weather condition text
|
|
|
|
|
|
@dataclass
|
|
class ForecastPeriod:
|
|
"""A forecast period (day or night)"""
|
|
day_name: str # e.g., "Friday", "Today"
|
|
date: str # e.g., "May 5"
|
|
period_type: PeriodType
|
|
high_temp: Optional[float] = None
|
|
low_temp: Optional[float] = None
|
|
conditions: str = ""
|
|
wind_speed: Optional[int] = None
|
|
wind_direction: Optional[str] = None
|
|
precip_chance: Optional[int] = None
|
|
precip_amount: Optional[float] = None
|
|
hourly_data: list[HourlyData] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class WXSIMForecast:
|
|
"""Complete WXSIM forecast data"""
|
|
city: str = ""
|
|
station: str = ""
|
|
update_time: str = ""
|
|
update_date: str = ""
|
|
periods: list[ForecastPeriod] = field(default_factory=list)
|
|
hourly_data: list[HourlyData] = field(default_factory=list)
|
|
raw_text: str = ""
|
|
|
|
|
|
class WXSIMParser:
|
|
"""Parser for WXSIM plaintext.txt files"""
|
|
|
|
# Weather condition mappings (abbreviated to full descriptions)
|
|
WEATHER_CONDITIONS = {
|
|
'CLEAR': 'Clear',
|
|
'SUNNY': 'Sunny',
|
|
'FAIR': 'Fair',
|
|
'FAIR-P.C.': 'Fair to Partly Cloudy',
|
|
'P.CLOUDY': 'Partly Cloudy',
|
|
'P.-M.CLDY': 'Partly to Mostly Cloudy',
|
|
'M.CLOUDY': 'Mostly Cloudy',
|
|
'M.C.-CLDY': 'Mostly Cloudy',
|
|
'CLOUDY': 'Cloudy',
|
|
'DNS.OVCST': 'Dense Overcast',
|
|
'OVCST': 'Overcast',
|
|
'FOGGY': 'Foggy',
|
|
'DRIZZLE': 'Drizzle',
|
|
'DRZL': 'Drizzle',
|
|
'CHNC. DRZL': 'Chance Drizzle',
|
|
'CHNC. SHWR': 'Chance Showers',
|
|
'SHOWERS': 'Showers',
|
|
'RAIN': 'Rain',
|
|
'CHNC. RAIN': 'Chance Rain',
|
|
'SNOW': 'Snow',
|
|
'CHNC. SNOW': 'Chance Snow',
|
|
'T-STM': 'Thunderstorm',
|
|
'CHNC. T-STM': 'Chance Thunderstorm',
|
|
}
|
|
|
|
# Longest abbreviation first: matching is a substring test, so a short key
|
|
# would otherwise shadow every longer key that contains it ("RAIN" swallowing
|
|
# "CHNC. RAIN", dropping the chance qualifier — likewise SNOW/T-STM/DRZL).
|
|
_CONDITIONS_BY_SPECIFICITY: Optional[list[tuple[str, str]]] = None
|
|
|
|
@classmethod
|
|
def _condition_matches(cls) -> list[tuple[str, str]]:
|
|
if cls._CONDITIONS_BY_SPECIFICITY is None:
|
|
cls._CONDITIONS_BY_SPECIFICITY = sorted(
|
|
cls.WEATHER_CONDITIONS.items(), key=lambda kv: len(kv[0]), reverse=True
|
|
)
|
|
return cls._CONDITIONS_BY_SPECIFICITY
|
|
|
|
def __init__(self):
|
|
"""Initialize the parser"""
|
|
self._refresh_clock()
|
|
|
|
def _refresh_clock(self) -> None:
|
|
"""Re-anchor 'now'.
|
|
|
|
Instances are long-lived (wx_command builds one at startup), so caching
|
|
this at construction made forecast dates roll back a year and staleness
|
|
checks read permanently true once the process had been up for a while.
|
|
"""
|
|
now = datetime.now()
|
|
self.current_year = now.year
|
|
self.current_date = now
|
|
|
|
def parse(self, text: str) -> WXSIMForecast:
|
|
"""Parse WXSIM plaintext content.
|
|
|
|
Args:
|
|
text: The plaintext content from WXSIM plaintext.txt file
|
|
|
|
Returns:
|
|
WXSIMForecast: Parsed forecast data
|
|
"""
|
|
# Re-anchor "now" per parse — this instance may be days old.
|
|
self._refresh_clock()
|
|
|
|
forecast = WXSIMForecast()
|
|
forecast.raw_text = text
|
|
|
|
lines = text.split('\n')
|
|
|
|
# Find FORECAST RUN section (skip calibration)
|
|
forecast_start = self._find_forecast_start(lines)
|
|
if forecast_start == -1:
|
|
return forecast
|
|
|
|
# Parse header info (city, station, date)
|
|
self._parse_header(lines[:forecast_start], forecast)
|
|
|
|
# Parse forecast data
|
|
forecast_lines = lines[forecast_start:]
|
|
hourly_data = self._parse_hourly_data(forecast_lines)
|
|
forecast.hourly_data = hourly_data
|
|
|
|
# Extract forecast date/time from first data point
|
|
if hourly_data:
|
|
first_data = hourly_data[0]
|
|
forecast.update_date = first_data.date # e.g., "May 5"
|
|
forecast.update_time = first_data.time # e.g., "7:00 A"
|
|
|
|
# Group into periods (days)
|
|
forecast.periods = self._group_into_periods(hourly_data, forecast_lines)
|
|
|
|
return forecast
|
|
|
|
def _find_forecast_start(self, lines: list[str]) -> int:
|
|
"""Find the start of the FORECAST RUN section.
|
|
|
|
Args:
|
|
lines: All lines from the file
|
|
|
|
Returns:
|
|
int: Index of first forecast data line, or -1 if not found
|
|
"""
|
|
for i, line in enumerate(lines):
|
|
if 'FORECAST RUN:' in line.upper():
|
|
# Find the header line "DATE TIME TEMP..."
|
|
for j in range(i, min(i + 10, len(lines))):
|
|
if 'DATE' in lines[j] and 'TIME' in lines[j] and 'TEMP' in lines[j]:
|
|
# Return line after header
|
|
return j + 2 # Skip header and blank line
|
|
return -1
|
|
|
|
def _parse_header(self, lines: list[str], forecast: WXSIMForecast) -> None:
|
|
"""Parse header information (city, station, date).
|
|
|
|
Args:
|
|
lines: Header lines (before FORECAST RUN)
|
|
forecast: Forecast object to populate
|
|
"""
|
|
# Look for city/station info in header
|
|
# WXSIM format may vary, so we'll extract what we can
|
|
for line in lines:
|
|
# Look for common patterns
|
|
if 'FORECAST FOR' in line.upper():
|
|
# Extract city name
|
|
parts = line.split('FORECAST FOR', 1)
|
|
if len(parts) > 1:
|
|
forecast.city = parts[1].strip()
|
|
elif 'BY' in line.upper() and not forecast.station:
|
|
# Extract station/forecaster name
|
|
parts = line.split('BY', 1)
|
|
if len(parts) > 1:
|
|
forecast.station = parts[1].strip()
|
|
|
|
def _parse_hourly_data(self, lines: list[str]) -> list[HourlyData]:
|
|
"""Parse hourly forecast data rows.
|
|
|
|
Args:
|
|
lines: Lines from FORECAST RUN section onwards
|
|
|
|
Returns:
|
|
List[HourlyData]: Parsed hourly data
|
|
"""
|
|
hourly_data = []
|
|
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
# Skip non-data lines
|
|
if (line.startswith('DATE') or
|
|
line.startswith('FORECAST') or
|
|
line.startswith('CALIBRATION') or
|
|
line.startswith('SURFACE WIND') or
|
|
line.startswith('AUTO CLOUDS') or
|
|
line.startswith('Press') or
|
|
line.startswith('(Automatically') or
|
|
line.startswith('-') or
|
|
line.startswith('(')):
|
|
continue
|
|
|
|
# Try to parse as data row
|
|
# Format: "May 5 7:00 A 9.3 0 73 95 50 42.2 3 0.0 M.C.-CLDY CHNC. DRZL"
|
|
data = self._parse_data_row(line)
|
|
if data:
|
|
hourly_data.append(data)
|
|
|
|
return hourly_data
|
|
|
|
def _parse_data_row(self, line: str) -> Optional[HourlyData]:
|
|
"""Parse a single data row.
|
|
|
|
Args:
|
|
line: Single line of forecast data
|
|
|
|
Returns:
|
|
Optional[HourlyData]: Parsed data or None if invalid
|
|
"""
|
|
# Pattern: Month Day Time Temp Wind Hum %SC %VST VIS PC/HR RN TOT Weather
|
|
# Example: "May 5 7:00 A 9.3 0 73 95 50 42.2 3 0.0 M.C.-CLDY CHNC. DRZL"
|
|
|
|
# Match month and day at start
|
|
match = re.match(r'^([A-Za-z]+)\s+(\d+)\s+(\d{1,2}):(\d{2})\s+([AP])\s+', line)
|
|
if not match:
|
|
return None
|
|
|
|
month_name = match.group(1)
|
|
day = int(match.group(2))
|
|
hour_12 = int(match.group(3))
|
|
minute = int(match.group(4))
|
|
am_pm = match.group(5)
|
|
|
|
# Convert to 24-hour
|
|
hour_24 = hour_12
|
|
if am_pm == 'P' and hour_12 != 12:
|
|
hour_24 = hour_12 + 12
|
|
elif am_pm == 'A' and hour_12 == 12:
|
|
hour_24 = 0
|
|
|
|
# Extract remaining fields (split by whitespace)
|
|
rest = line[match.end():].strip()
|
|
parts = rest.split()
|
|
|
|
if len(parts) < 8:
|
|
return None
|
|
|
|
try:
|
|
# Parse numeric fields
|
|
temp = float(parts[0])
|
|
wind = int(parts[1])
|
|
humidity = int(parts[2])
|
|
sky_cover = int(parts[3])
|
|
visibility_pct = int(parts[4])
|
|
visibility_miles = float(parts[5])
|
|
precip_chance = int(parts[6])
|
|
rain_total = float(parts[7])
|
|
|
|
# Weather condition is everything after the numeric fields
|
|
weather = ' '.join(parts[8:]) if len(parts) > 8 else ''
|
|
|
|
# Format date string
|
|
date_str = f"{month_name} {day}"
|
|
time_str = f"{hour_12}:{minute:02d} {am_pm}"
|
|
|
|
return HourlyData(
|
|
date=date_str,
|
|
time=time_str,
|
|
hour=hour_24,
|
|
temperature=temp,
|
|
wind_speed=wind,
|
|
humidity=humidity,
|
|
sky_cover=sky_cover,
|
|
visibility=visibility_pct,
|
|
visibility_miles=visibility_miles,
|
|
precip_chance=precip_chance,
|
|
rain_total=rain_total,
|
|
weather=weather
|
|
)
|
|
except (ValueError, IndexError):
|
|
return None
|
|
|
|
def _group_into_periods(self, hourly_data: list[HourlyData], lines: list[str]) -> list[ForecastPeriod]:
|
|
"""Group hourly data into forecast periods (days).
|
|
|
|
Args:
|
|
hourly_data: List of hourly data points
|
|
lines: Original lines (to find day separators)
|
|
|
|
Returns:
|
|
List[ForecastPeriod]: Forecast periods
|
|
"""
|
|
periods: list[Any] = []
|
|
|
|
if not hourly_data:
|
|
return periods
|
|
|
|
# Find day separators in original lines
|
|
day_separators = self._find_day_separators(lines)
|
|
|
|
# Group hourly data by day
|
|
current_day: Optional[str] = None
|
|
current_period_data: list[Any] = []
|
|
|
|
for data in hourly_data:
|
|
# Check if this is a new day (by date string or hour reset)
|
|
if current_day is None or data.date != current_day:
|
|
# Save previous period if exists
|
|
if current_period_data and current_day is not None:
|
|
period = self._create_period_from_hourly(current_day, current_period_data, day_separators)
|
|
if period:
|
|
periods.append(period)
|
|
|
|
# Start new day
|
|
current_day = data.date
|
|
current_period_data = [data]
|
|
else:
|
|
current_period_data.append(data)
|
|
|
|
# Add final period
|
|
if current_period_data and current_day is not None:
|
|
period = self._create_period_from_hourly(current_day, current_period_data, day_separators)
|
|
if period:
|
|
periods.append(period)
|
|
|
|
return periods
|
|
|
|
def _find_day_separators(self, lines: list[str]) -> dict[str, str]:
|
|
"""Find day name separators in the file.
|
|
|
|
Args:
|
|
lines: All lines from the file
|
|
|
|
Returns:
|
|
Dict[str, str]: Mapping of date string to day name
|
|
"""
|
|
separators = {}
|
|
|
|
# Look for lines like " Friday"
|
|
for i, line in enumerate(lines):
|
|
line_stripped = line.strip()
|
|
# Check if line contains a day name
|
|
day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
|
for day_name in day_names:
|
|
if day_name in line_stripped:
|
|
# Try to find the date from nearby lines
|
|
# Look at next few lines for date pattern
|
|
for j in range(i + 1, min(i + 5, len(lines))):
|
|
date_match = re.search(r'([A-Za-z]+)\s+(\d+)', lines[j])
|
|
if date_match:
|
|
date_str = f"{date_match.group(1)} {date_match.group(2)}"
|
|
separators[date_str] = day_name
|
|
break
|
|
break
|
|
|
|
return separators
|
|
|
|
def _create_period_from_hourly(self, date: str, hourly_data: list[HourlyData],
|
|
day_separators: dict[str, str]) -> Optional[ForecastPeriod]:
|
|
"""Create a forecast period from hourly data.
|
|
|
|
Args:
|
|
date: Date string (e.g., "May 5")
|
|
hourly_data: Hourly data for this day
|
|
day_separators: Mapping of dates to day names
|
|
|
|
Returns:
|
|
Optional[ForecastPeriod]: Forecast period or None
|
|
"""
|
|
if not hourly_data:
|
|
return None
|
|
|
|
# Get day name - try to determine from date or use separator
|
|
day_name = day_separators.get(date)
|
|
if not day_name:
|
|
# Try to determine day name from date
|
|
try:
|
|
# Parse date (e.g., "May 5")
|
|
month_name, day_num = date.split()
|
|
month_map = {
|
|
'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
|
|
'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12
|
|
}
|
|
month = month_map.get(month_name[:3], 1)
|
|
day = int(day_num)
|
|
|
|
# Create datetime and get day name
|
|
forecast_date = datetime(self.current_year, month, day)
|
|
day_name = forecast_date.strftime('%A')
|
|
|
|
# If it's today, use "Today" instead
|
|
today = datetime.now()
|
|
if forecast_date.date() == today.date():
|
|
day_name = "Today"
|
|
elif forecast_date.date() == (today + timedelta(days=1)).date():
|
|
day_name = "Tomorrow"
|
|
except (ValueError, KeyError):
|
|
day_name = "Today"
|
|
|
|
# Determine period type (day vs night)
|
|
# Day: roughly 6 AM to 6 PM, Night: 6 PM to 6 AM
|
|
day_hours = [d for d in hourly_data if 6 <= d.hour < 18]
|
|
night_hours = [d for d in hourly_data if d.hour < 6 or d.hour >= 18]
|
|
|
|
# Use the period with more data, or default to day
|
|
period_type = PeriodType.DAY if len(day_hours) >= len(night_hours) else PeriodType.NIGHT
|
|
|
|
# Calculate high/low temps
|
|
temps = [d.temperature for d in hourly_data]
|
|
high_temp = max(temps) if temps else None
|
|
low_temp = min(temps) if temps else None
|
|
|
|
# Get most common weather condition
|
|
conditions = self._get_primary_condition(hourly_data)
|
|
|
|
# Get average wind speed
|
|
wind_speeds = [d.wind_speed for d in hourly_data if d.wind_speed > 0]
|
|
avg_wind = int(sum(wind_speeds) / len(wind_speeds)) if wind_speeds else None
|
|
|
|
# Get max precip chance
|
|
precip_chances = [d.precip_chance for d in hourly_data]
|
|
max_precip_chance = max(precip_chances) if precip_chances else None
|
|
|
|
# Get total precipitation
|
|
total_precip = sum(d.rain_total for d in hourly_data)
|
|
|
|
period = ForecastPeriod(
|
|
day_name=day_name,
|
|
date=date,
|
|
period_type=period_type,
|
|
high_temp=high_temp,
|
|
low_temp=low_temp,
|
|
conditions=conditions,
|
|
wind_speed=avg_wind,
|
|
precip_chance=max_precip_chance,
|
|
precip_amount=total_precip if total_precip > 0 else None,
|
|
hourly_data=hourly_data
|
|
)
|
|
|
|
return period
|
|
|
|
def _get_primary_condition(self, hourly_data: list[HourlyData]) -> str:
|
|
"""Get the primary weather condition from hourly data.
|
|
|
|
Args:
|
|
hourly_data: List of hourly data points
|
|
|
|
Returns:
|
|
str: Primary weather condition description
|
|
"""
|
|
if not hourly_data:
|
|
return "Unknown"
|
|
|
|
# Count condition occurrences
|
|
condition_counts: dict[str, int] = {}
|
|
for data in hourly_data:
|
|
# Normalize condition text
|
|
condition = data.weather.strip().upper()
|
|
if condition:
|
|
# Expand abbreviations (longest match first)
|
|
for abbrev, full in self._condition_matches():
|
|
if abbrev in condition:
|
|
condition = full
|
|
break
|
|
condition_counts[condition] = condition_counts.get(condition, 0) + 1
|
|
|
|
if not condition_counts:
|
|
return "Unknown"
|
|
|
|
# Return most common condition
|
|
return max(condition_counts.items(), key=lambda x: x[1])[0]
|
|
|
|
def format_current_conditions(self, forecast: WXSIMForecast,
|
|
temp_unit: str = 'celsius',
|
|
wind_unit: str = 'kph') -> str:
|
|
"""Format current conditions for display.
|
|
|
|
Args:
|
|
forecast: Parsed forecast data
|
|
temp_unit: Temperature unit ('celsius' or 'fahrenheit')
|
|
wind_unit: Wind speed unit ('kph', 'mph', or 'ms')
|
|
|
|
Returns:
|
|
str: Formatted current conditions string
|
|
"""
|
|
if not forecast.hourly_data:
|
|
return "No current data available"
|
|
|
|
# Get the first hour from FORECAST RUN (this is the current/starting conditions)
|
|
# The last hour would be in the future, so we use the first one
|
|
current = forecast.hourly_data[0]
|
|
|
|
# Convert temperature
|
|
temp = self._convert_temp(current.temperature, temp_unit)
|
|
temp_symbol = "°F" if temp_unit == 'fahrenheit' else "°C"
|
|
|
|
# Convert wind speed
|
|
wind = self._convert_wind(current.wind_speed, wind_unit)
|
|
wind_unit_str = self._get_wind_unit_str(wind_unit)
|
|
|
|
# Format condition
|
|
condition = self._normalize_condition(current.weather)
|
|
|
|
# Build string
|
|
result = f"{condition} {temp}{temp_symbol}"
|
|
|
|
if current.wind_speed > 0:
|
|
result += f" Wind {wind}{wind_unit_str}"
|
|
|
|
if current.humidity > 0:
|
|
result += f" {current.humidity}%RH"
|
|
|
|
if current.precip_chance > 0:
|
|
result += f" {current.precip_chance}% PoP"
|
|
|
|
return result
|
|
|
|
def format_forecast_summary(self, forecast: WXSIMForecast, num_days: int = 7,
|
|
temp_unit: str = 'celsius',
|
|
wind_unit: str = 'kph') -> str:
|
|
"""Format forecast summary for display.
|
|
|
|
Args:
|
|
forecast: Parsed forecast data
|
|
num_days: Number of days to include
|
|
temp_unit: Temperature unit ('celsius' or 'fahrenheit')
|
|
wind_unit: Wind speed unit ('kph', 'mph', or 'ms')
|
|
|
|
Returns:
|
|
str: Formatted forecast summary
|
|
"""
|
|
if not forecast.periods:
|
|
return "No forecast data available"
|
|
|
|
parts = []
|
|
for period in forecast.periods[:num_days]:
|
|
# Convert temps
|
|
high = self._convert_temp(period.high_temp, temp_unit) if period.high_temp is not None else None
|
|
low = self._convert_temp(period.low_temp, temp_unit) if period.low_temp is not None else None
|
|
temp_symbol = "°F" if temp_unit == 'fahrenheit' else "°C"
|
|
|
|
# Format day
|
|
day_abbrev = period.day_name[:3] if len(period.day_name) > 3 else period.day_name
|
|
|
|
# Build period string
|
|
period_str = f"{day_abbrev}: {period.conditions}"
|
|
if high is not None and low is not None:
|
|
period_str += f" {high}{temp_symbol}/{low}{temp_symbol}"
|
|
elif high is not None:
|
|
period_str += f" {high}{temp_symbol}"
|
|
elif low is not None:
|
|
period_str += f" {low}{temp_symbol}"
|
|
|
|
if period.precip_chance and period.precip_chance > 30:
|
|
period_str += f" {period.precip_chance}% PoP"
|
|
|
|
parts.append(period_str)
|
|
|
|
return "\n".join(parts)
|
|
|
|
def _convert_temp(self, temp_c: float, unit: str) -> float:
|
|
"""Convert temperature from Celsius to requested unit.
|
|
|
|
Args:
|
|
temp_c: Temperature in Celsius
|
|
unit: Target unit ('celsius' or 'fahrenheit')
|
|
|
|
Returns:
|
|
float: Converted temperature
|
|
"""
|
|
if unit == 'fahrenheit':
|
|
return round((temp_c * 9/5) + 32, 1)
|
|
return round(temp_c, 1)
|
|
|
|
def _convert_wind(self, wind_kph: int, unit: str) -> float:
|
|
"""Convert wind speed from km/h to requested unit.
|
|
|
|
Args:
|
|
wind_kph: Wind speed in km/h (WXSIM default unit)
|
|
unit: Target unit ('kph', 'mph', or 'ms')
|
|
|
|
Returns:
|
|
float: Converted wind speed
|
|
"""
|
|
# WXSIM outputs wind in km/h, but the values might be in different units
|
|
# depending on configuration. We'll assume km/h as default.
|
|
if unit == 'mph':
|
|
return round(wind_kph * 0.621371, 1)
|
|
elif unit == 'ms':
|
|
return round(wind_kph / 3.6, 1)
|
|
return float(wind_kph)
|
|
|
|
def _get_wind_unit_str(self, unit: str) -> str:
|
|
"""Get wind speed unit string.
|
|
|
|
Args:
|
|
unit: Wind unit ('kph', 'mph', or 'ms')
|
|
|
|
Returns:
|
|
str: Unit string
|
|
"""
|
|
unit_map = {
|
|
'kph': 'km/h',
|
|
'mph': 'mph',
|
|
'ms': 'm/s'
|
|
}
|
|
return unit_map.get(unit, 'km/h')
|
|
|
|
def _normalize_condition(self, condition: str) -> str:
|
|
"""Normalize weather condition text.
|
|
|
|
Args:
|
|
condition: Raw condition text from WXSIM
|
|
|
|
Returns:
|
|
str: Normalized condition description
|
|
"""
|
|
condition_upper = condition.strip().upper()
|
|
|
|
# Try to match abbreviations (longest match first)
|
|
for abbrev, full in self._condition_matches():
|
|
if abbrev in condition_upper:
|
|
return full
|
|
|
|
# Return original if no match
|
|
return condition.strip() if condition else "Unknown"
|
|
|
|
def get_forecast_date(self, forecast: WXSIMForecast) -> Optional[datetime]:
|
|
"""Get the forecast date as a datetime object.
|
|
|
|
Args:
|
|
forecast: Parsed forecast data
|
|
|
|
Returns:
|
|
Optional[datetime]: Forecast date/time or None if unavailable
|
|
"""
|
|
if not forecast.update_date:
|
|
return None
|
|
|
|
try:
|
|
# Parse date string like "May 5"
|
|
month_name, day_num = forecast.update_date.split()
|
|
month_map = {
|
|
'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
|
|
'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12
|
|
}
|
|
month = month_map.get(month_name[:3], 1)
|
|
day = int(day_num)
|
|
|
|
# Parse time if available
|
|
hour = 0
|
|
minute = 0
|
|
if forecast.update_time:
|
|
# Parse time like "7:00 A" or "12:30 P"
|
|
time_match = re.match(r'(\d{1,2}):(\d{2})\s+([AP])', forecast.update_time)
|
|
if time_match:
|
|
hour_12 = int(time_match.group(1))
|
|
minute = int(time_match.group(2))
|
|
am_pm = time_match.group(3)
|
|
|
|
# Convert to 24-hour
|
|
hour = hour_12
|
|
if am_pm == 'P' and hour_12 != 12:
|
|
hour = hour_12 + 12
|
|
elif am_pm == 'A' and hour_12 == 12:
|
|
hour = 0
|
|
|
|
# Create datetime - try current year first, but if that's in the future,
|
|
# it's likely from last year
|
|
forecast_datetime = datetime(self.current_year, month, day, hour, minute)
|
|
|
|
# If forecast date is in the future, assume it's from last year
|
|
# (WXSIM forecasts are typically generated for the current/upcoming period)
|
|
if forecast_datetime > self.current_date:
|
|
# Try previous year
|
|
forecast_datetime = datetime(self.current_year - 1, month, day, hour, minute)
|
|
# If that's also in the future (shouldn't happen), keep original
|
|
if forecast_datetime > self.current_date:
|
|
forecast_datetime = datetime(self.current_year, month, day, hour, minute)
|
|
|
|
return forecast_datetime
|
|
except (ValueError, KeyError, AttributeError):
|
|
return None
|
|
|
|
def is_forecast_stale(self, forecast: WXSIMForecast, max_age_hours: int = 48) -> tuple[bool, Optional[str]]:
|
|
"""Check if forecast is stale (too old).
|
|
|
|
Args:
|
|
forecast: Parsed forecast data
|
|
max_age_hours: Maximum age in hours before considered stale (default: 48)
|
|
|
|
Returns:
|
|
Tuple[bool, Optional[str]]: (is_stale, reason_message)
|
|
"""
|
|
forecast_date = self.get_forecast_date(forecast)
|
|
if not forecast_date:
|
|
return True, "Could not determine forecast date"
|
|
|
|
now = datetime.now()
|
|
age = now - forecast_date
|
|
age_hours = age.total_seconds() / 3600
|
|
|
|
if age_hours > max_age_hours:
|
|
return True, f"Forecast is {age_hours:.1f} hours old (max: {max_age_hours}h)"
|
|
|
|
if age_hours < 0:
|
|
# Forecast is in the future (shouldn't happen, but handle gracefully)
|
|
return True, f"Forecast date is in the future: {forecast_date}"
|
|
|
|
return False, None
|
|
|
|
@staticmethod
|
|
def fetch_from_url(url: str, timeout: int = 10) -> Optional[str]:
|
|
"""Fetch WXSIM plaintext data from a URL.
|
|
|
|
Args:
|
|
url: URL to fetch plaintext.txt from
|
|
timeout: Request timeout in seconds
|
|
|
|
Returns:
|
|
Optional[str]: Plaintext content or None on error
|
|
"""
|
|
try:
|
|
response = requests.get(url, timeout=timeout)
|
|
response.raise_for_status()
|
|
text = response.text
|
|
# Verify it looks like WXSIM data
|
|
if 'FORECAST RUN' in text.upper() or 'DATE' in text:
|
|
return text
|
|
return None
|
|
except (requests.RequestException, ValueError, AttributeError):
|
|
return None
|