diff --git a/config.template b/config.template index df6e2b0..91f448e 100644 --- a/config.template +++ b/config.template @@ -211,6 +211,11 @@ NOAAalertCount = 2 # use Open-Meteo API for weather data not NOAA useful for non US locations UseMeteoWxAPI = False +# Global Tide Prediction using tidepredict (for non-US locations) +# When enabled, uses tidepredict library for global tide predictions instead of NOAA API +# tidepredict uses University of Hawaii's Research Quality Dataset for worldwide coverage +useTidePredict = False + # NOAA Coastal Data Enable NOAA Coastal Waters Forecasts and Tide coastalEnabled = False # Find the correct costal weather directory at https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/ diff --git a/modules/locationdata.py b/modules/locationdata.py index b713d22..499697a 100644 --- a/modules/locationdata.py +++ b/modules/locationdata.py @@ -175,6 +175,17 @@ def getArtSciRepeaters(lat=0, lon=0): return msg def get_NOAAtide(lat=0, lon=0): + # Check if tidepredict (xtide) is enabled + if my_settings.useTidePredict: + try: + from modules import xtide + if xtide.is_enabled(): + logger.debug("Location: Using tidepredict for global tide data") + return xtide.get_tide_predictions(lat, lon) + except Exception as e: + logger.warning(f"Location: Failed to use tidepredict, falling back to NOAA: {e}") + + # Original NOAA implementation station_id = "" location = lat,lon if float(lat) == 0 and float(lon) == 0: diff --git a/modules/settings.py b/modules/settings.py index 96315a5..4bb5a13 100644 --- a/modules/settings.py +++ b/modules/settings.py @@ -315,6 +315,7 @@ try: n2yoAPIKey = config['location'].get('n2yoAPIKey', '') # default empty satListConfig = config['location'].get('satList', '25544').split(',') # default 25544 ISS riverListDefault = config['location'].get('riverList', '').split(',') # default None + useTidePredict = config['location'].getboolean('useTidePredict', False) # default False use NOAA coastalEnabled = config['location'].getboolean('coastalEnabled', False) # default False myCoastalZone = config['location'].get('myCoastalZone', None) # default None coastalForecastDays = config['location'].getint('coastalForecastDays', 3) # default 3 days diff --git a/modules/xtide.py b/modules/xtide.py new file mode 100644 index 0000000..81bbdea --- /dev/null +++ b/modules/xtide.py @@ -0,0 +1,195 @@ +# xtide.py - Global tide prediction using tidepredict library +# K7MHI Kelly Keeton 2025 + +import json +from datetime import datetime, timedelta +from modules.log import logger +import modules.settings as my_settings + +try: + from tidepredict import processdata, process_station_list, constants, timefunc + from tidepredict.tide import Tide + import pandas as pd + TIDEPREDICT_AVAILABLE = True +except ImportError: + TIDEPREDICT_AVAILABLE = False + logger.warning("xtide: tidepredict module not installed. Install with: pip install tidepredict") + +def get_nearest_station(lat, lon): + """ + Find the nearest tide station to the given lat/lon coordinates. + Returns station code (e.g., 'h001a') or None if not found. + """ + if not TIDEPREDICT_AVAILABLE: + return None + + try: + # Read the station list + try: + stations = pd.read_csv(constants.STATIONFILE) + except: + # If station file doesn't exist, create it + logger.info("xtide: Creating station database from online source") + stations = process_station_list.create_station_dataframe() + + if stations.empty: + logger.error("xtide: No stations found in database") + return None + + # Calculate distance to each station + # Using simple haversine-like calculation + def calc_distance(row): + try: + # Parse lat/lon from the format like "43-36S", "172-43E" + station_lat, station_lon = parse_station_coords(row['Lat'], row['Lon']) + + # Simple distance calculation (not precise but good enough) + dlat = lat - station_lat + dlon = lon - station_lon + return (dlat**2 + dlon**2)**0.5 + except: + return float('inf') + + stations['distance'] = stations.apply(calc_distance, axis=1) + + # Find the nearest station + nearest = stations.loc[stations['distance'].idxmin()] + + if nearest['distance'] > 10: # More than ~10 degrees away, might be too far + logger.warning(f"xtide: Nearest station is {nearest['distance']:.1f}° away at {nearest['loc_name']}") + + station_code = "h" + nearest['stat_idx'].lower() + logger.debug(f"xtide: Found nearest station: {nearest['loc_name']} ({station_code}) at {nearest['distance']:.2f}° away") + + return station_code, nearest['loc_name'], nearest['country'] + + except Exception as e: + logger.error(f"xtide: Error finding nearest station: {e}") + return None + +def parse_station_coords(lat_str, lon_str): + """ + Parse station coordinates from format like "43-36S", "172-43E" + Returns tuple of (latitude, longitude) as floats + """ + try: + # Parse latitude + lat_parts = lat_str.split('-') + lat_deg = float(lat_parts[0]) + lat_min = float(lat_parts[1][:-1]) # Remove N/S + lat_dir = lat_parts[1][-1] # Get N/S + lat_val = lat_deg + lat_min/60.0 + if lat_dir == 'S': + lat_val = -lat_val + + # Parse longitude + lon_parts = lon_str.split('-') + lon_deg = float(lon_parts[0]) + lon_min = float(lon_parts[1][:-1]) # Remove E/W + lon_dir = lon_parts[1][-1] # Get E/W + lon_val = lon_deg + lon_min/60.0 + if lon_dir == 'W': + lon_val = -lon_val + + return lat_val, lon_val + except Exception as e: + logger.debug(f"xtide: Error parsing coordinates {lat_str}, {lon_str}: {e}") + return 0.0, 0.0 + +def get_tide_predictions(lat=0, lon=0, days=1): + """ + Get tide predictions for the given location using tidepredict library. + Returns formatted string with tide predictions. + + Parameters: + - lat: Latitude + - lon: Longitude + - days: Number of days to predict (default: 1) + + Returns: + - Formatted string with tide predictions or error message + """ + if not TIDEPREDICT_AVAILABLE: + return "tidepredict library not installed" + + if float(lat) == 0 and float(lon) == 0: + return "No GPS data for tide prediction" + + try: + # Find nearest station + station_info = get_nearest_station(float(lat), float(lon)) + if not station_info: + return "No tide station found nearby" + + station_code, station_name, station_country = station_info + + # Load station data + station_dict, harmfileloc = process_station_list.read_station_info_file() + + # Check if harmonic data exists for this station + if station_code not in station_dict: + logger.warning(f"xtide: No harmonic data for {station_name}. Generating from online data...") + return f"Tide data not available for {station_name}. Use 'tide ?' for more info." + + # Reconstruct tide model + tide = processdata.reconstruct_tide_model(station_dict, station_code) + if tide is None: + return f"Tide model unavailable for {station_name}" + + # Set up time range (today only) + now = datetime.now() + start_time = now.strftime("%Y-%m-%d 00:00") + end_time = (now + timedelta(days=days)).strftime("%Y-%m-%d 00:00") + + # Create time object + timeobj = timefunc.Tidetime( + st_time=start_time, + en_time=end_time, + station_tz=station_dict[station_code].get('tzone', 'UTC') + ) + + # Get predictions + predictions = processdata.predict_plain(tide, station_dict[station_code], 't', timeobj) + + # Format output for mesh + lines = predictions.strip().split('\n') + if len(lines) > 2: + # Skip the header lines and format for mesh display + result = f"Tide: {station_name}\n" + tide_lines = lines[2:] # Skip first 2 header lines + + # Format each tide prediction + for line in tide_lines[:8]: # Limit to 8 entries + parts = line.split() + if len(parts) >= 4: + date_str = parts[0] + time_str = parts[1] + height = parts[3] + tide_type = ' '.join(parts[4:]) + + # Convert to 12-hour format if not using zulu time + if not my_settings.zuluTime: + try: + time_obj = datetime.strptime(time_str, "%H%M") + hour = time_obj.hour + minute = time_obj.minute + if hour >= 12: + time_str = f"{hour-12 if hour > 12 else 12}:{minute:02d} PM" + else: + time_str = f"{hour if hour > 0 else 12}:{minute:02d} AM" + except: + pass + + result += f"{tide_type} {time_str}, {height}\n" + + return result.strip() + else: + return predictions + + except Exception as e: + logger.error(f"xtide: Error getting tide predictions: {e}") + return f"Error getting tide data: {str(e)}" + +def is_enabled(): + """Check if xtide/tidepredict is enabled in config""" + return getattr(my_settings, 'useTidePredict', False) and TIDEPREDICT_AVAILABLE diff --git a/requirements.txt b/requirements.txt index 1b70d42..5b86b75 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ maidenhead beautifulsoup4 dadjokes geopy -schedule \ No newline at end of file +schedule +tidepredict \ No newline at end of file