Files
meshcore-bot/modules/clients/wxsim_parser.py
T
agessaman 96c4a01788 fix(commands,clients): correct scheduling, parsing and formatting defects
Remaining findings from the same triage pass. None are security
relevant; each produces a wrong user-visible result.

- greeter: treat rollout_started_at as UTC. It is written by SQLite
  CURRENT_TIMESTAMP, but datetime.timestamp() read the naive value as
  local time, shifting the backfill cutoff by the host's offset (7h on
  PT). Users who posted inside that window were never marked as already
  greeted and could be sent a welcome they should not have received.

- greeter: write greeted_at in SQLite's own format. The rollout backfill
  used isoformat() while every other path used CURRENT_TIMESTAMP. "T"
  sorts above a space, so ORDER BY greeted_at interleaved the two
  formats wrongly, corrupting duplicate cleanup and the web viewer's
  recently-greeted list.

- greeter: let an empty `channels =` disable the command. BaseCommand
  reads an empty value as disabled-on-channels, but the greeter
  collapsed "key absent" and "key present but empty" into one fallback
  and kept greeting via monitor_channels.

- greeter: stop comma-splitting greeting text. channel_greetings split
  entries on ",", so "Public:Welcome to the mesh, {sender}!" was stored
  as "Welcome to the mesh" with the placeholder silently dropped. A
  fragment now starts a new entry only when the text before its first
  colon looks like a channel name, which keeps commas, URLs and clock
  times attached to the greeting they belong to.

- wxsim_parser: match condition abbreviations longest-first. Substring
  matching in dict order let RAIN shadow CHNC. RAIN, so five conditions
  lost their "chance" qualifier (rain, snow, drizzle, t-storm, and
  FAIR-P.C.).

- wxsim_parser: re-anchor "now" on each parse. current_date and
  current_year were fixed at construction and wx_command builds one
  parser at startup, so after a few days forecast dates rolled back a
  year and staleness checks read permanently true.

- thesportsdb_client: hold the rate-limit read/sleep/write under a lock.
  Concurrent callers read the same last_request_time, slept the same
  interval and fired together, bursting past the 2.1s throttle. This
  exposes an unrelated request fan-out problem in fetch_league_scores;
  filed in TODO.md rather than fixed here.

- alert_command: stop duplicating the first incident. The tail treated
  any single-line buffer as "header only" and appended incidents[0],
  but messages after the first carry no header, so a final chunk holding
  one incident had incident 0 pasted onto it. The same confusion inside
  the loop also let a second incident be appended past the 130-character
  limit.

- feed: reject non-positive poll intervals. -1 is truthy and was stored,
  and the poller's `now - last_check >= interval` then treats the feed
  as permanently due and re-fetches the URL every cycle; 0 was ignored
  while still reporting success. The web viewer, which is the primary
  editor, had no validation at all, and a JSON null there raised a
  TypeError that aborted the poll cycle for every feed rather than one.
  feed_manager falls back to the default for rows written before this.

- transmission_tracker: age out confirmed transmissions that have
  repeats. Cleanup removed only repeat_count == 0, so repeated records
  accumulated for the lifetime of the process, which matters on a Pi
  Zero. Repeat counts are already persisted to packet_stream, so the
  longer 30-minute retention loses nothing.

- mesh_graph: weighted-merge avg_hop_position when promoting an edge.
  Promotion overwrote the average with the single new observation, so an
  edge averaging 2.0 over 4 observations became 9.0 instead of 3.4 and
  skewed subsequent path scoring.

- multitest_command: show the path that ends exactly at the display LCP.
  The suffix helpers are correct in isolation; the loss happens in the
  cluster formatters, where _shrink_display_lcp refuses to shrink a
  single-token LCP and the trunk then rendered as "96 ┐", meaning
  "everything continues past here" and dropping the bare 96 route while
  the header still counted it. Now uses the file's existing "├ common"
  marker. Empty suffixes are also no longer handed to the nested
  renderer, which mapped them onto the same [] as a route ending at the
  inner LCP and drew a row for a route that did not exist.

- sports_mappings: stop shadowing seven unique team nicknames. In one
  flat dict a repeated key silently drops the earlier team, so hawks
  resolved to the NBA Hawks rather than the Seahawks alias it was added
  as, blazers to Kamloops rather than Portland, and rockets to Kelowna
  rather than Houston. First definition now wins for hawks, giants,
  jets, rangers, kings, blazers and rockets; every shadowed team keeps
  its unambiguous full-name alias. The 55 city and abbreviation
  collisions (chicago, sf, la, ...) are genuinely ambiguous and stay
  last-wins, now pinned by a test so a new collision fails loudly
  instead of passing unnoticed.
2026-07-28 20:08:57 -07:00

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 else None
low = self._convert_temp(period.low_temp, temp_unit) if period.low_temp 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