mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-16 23:50:05 +00:00
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.
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 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
|