From 1c0e72f0e17611d45144032dea4d377d6dc3986e Mon Sep 17 00:00:00 2001 From: deng Date: Thu, 27 Nov 2025 22:20:54 +0800 Subject: [PATCH] 1) add uv to weather, 2) add caching to accelerate loading speed --- hiking_assistant/app.py | 219 ++++++++++++++++++++-------- hiking_assistant/assets/config.yaml | 4 +- hiking_assistant/weather.py | 91 ++++++------ 3 files changed, 206 insertions(+), 108 deletions(-) diff --git a/hiking_assistant/app.py b/hiking_assistant/app.py index d6f8ab1..b9268bd 100644 --- a/hiking_assistant/app.py +++ b/hiking_assistant/app.py @@ -3,6 +3,8 @@ # author: deng # date: 20251127 +from datetime import datetime + import streamlit as st import yaml from elevation_profile import ElevationProfileRenderer @@ -30,6 +32,7 @@ class HikingAssistant: initial_sidebar_state='collapsed', ) + # [Block1] # Title and description st.title('⛰️ ' + self._config['app']['page_title']) @@ -41,25 +44,61 @@ class HikingAssistant: '[Hikingbook](https://zh-tw.hikingbook.net/explore/trails?regions=Taiwan)等網站下載 GPX 檔案', ) + # [Block2] + # Analysis if uploaded_file is not None: try: - # Process GPX file - with st.spinner('正在解析 GPX 檔案...'): - gpx_processor = GPXProcessor(uploaded_file) + # Create cache key based on file name and size + file_key = f'{uploaded_file.name}_{uploaded_file.size}' - if not gpx_processor.validate_and_parse(): - st.error('❌ GPX 檔案格式錯誤或不包含有效的軌跡點') - return + # Check if file is already processed + if 'gpx_file_key' not in st.session_state or st.session_state.gpx_file_key != file_key: + # Process GPX file (only when it's a new file) + with st.spinner('正在解析 GPX 檔案...'): + gpx_processor = GPXProcessor(uploaded_file) - # Calculate statistics - total_distance = gpx_processor.calculate_distance() - elevation_gain, elevation_loss = gpx_processor.calculate_elevation_gain_loss() - min_elevation, max_elevation = gpx_processor.get_min_max_elevation() - start_point, end_point = gpx_processor.get_start_end_points() - all_points = gpx_processor.get_all_points() - distances, elevations = gpx_processor.get_elevation_profile_data() - gradients = gpx_processor.get_gradients() - estimated_time = gpx_processor.calculate_naismith_time() + if not gpx_processor.validate_and_parse(): + st.error('❌ GPX 檔案格式錯誤或不包含有效的軌跡點') + return + + # Calculate statistics + total_distance = gpx_processor.calculate_distance() + elevation_gain, elevation_loss = gpx_processor.calculate_elevation_gain_loss() + min_elevation, max_elevation = gpx_processor.get_min_max_elevation() + start_point, end_point = gpx_processor.get_start_end_points() + all_points = gpx_processor.get_all_points() + distances, elevations = gpx_processor.get_elevation_profile_data() + gradients = gpx_processor.get_gradients() + estimated_time = gpx_processor.calculate_naismith_time() + + # Cache all processed data + st.session_state.gpx_file_key = file_key + st.session_state.total_distance = total_distance + st.session_state.elevation_gain = elevation_gain + st.session_state.elevation_loss = elevation_loss + st.session_state.min_elevation = min_elevation + st.session_state.max_elevation = max_elevation + st.session_state.start_point = start_point + st.session_state.end_point = end_point + st.session_state.all_points = all_points + st.session_state.distances = distances + st.session_state.elevations = elevations + st.session_state.gradients = gradients + st.session_state.estimated_time = estimated_time + + # Use cached data + total_distance = st.session_state.total_distance + elevation_gain = st.session_state.elevation_gain + elevation_loss = st.session_state.elevation_loss + min_elevation = st.session_state.min_elevation + max_elevation = st.session_state.max_elevation + start_point = st.session_state.start_point + end_point = st.session_state.end_point + all_points = st.session_state.all_points + distances = st.session_state.distances + elevations = st.session_state.elevations + gradients = st.session_state.gradients + estimated_time = st.session_state.estimated_time # Display statistics section st.header('📊 路線五四三') @@ -126,70 +165,128 @@ class HikingAssistant: st.header('☀️ 天氣安抓') if start_point: - with st.spinner('正在獲取天氣資訊...'): - weather_fetcher = WeatherFetcher() - weather_data = weather_fetcher.get_weather(start_point[0], start_point[1]) + weather_fetcher = WeatherFetcher(forecast_days=self._config['app']['weather']['forecast_days']) + weather_key = f'weather_{start_point[0]:.6f}_{start_point[1]:.6f}' - if weather_data: - weather_info = weather_fetcher.format_weather_display(weather_data) + # Check if weather data is already cached + if 'weather_cache_key' not in st.session_state or st.session_state.weather_cache_key != weather_key: + # Fetch weather data (only when coordinates change) + with st.spinner('正在獲取天氣資訊...'): + weather_data = weather_fetcher.get_weather(start_point[0], start_point[1]) - if weather_info: - # Current weather - st.subheader('此刻起點天氣') - col1, col2, col3, col4, col5 = st.columns(5) + # Cache weather data + st.session_state.weather_cache_key = weather_key + st.session_state.weather_data = weather_data + else: + # Use cached weather data + weather_data = st.session_state.weather_data - with col1: - st.metric(label='🌡️ 溫度', value=weather_info['temperature']) + if weather_data: + daily_times = weather_data.get('daily_time', []) + if daily_times: + # Date selector + date_labels = [datetime.fromisoformat(d).strftime('%Y/%m/%d') for d in daily_times] + selected_date_label = st.selectbox( + '出發日期', + options=date_labels, + index=0, + key='weather_date_selector', + ) + selected_index = date_labels.index(selected_date_label) - with col2: - st.metric(label='💧 濕度', value=weather_info['humidity']) + st.subheader('起點天氣') - with col3: - # Get precipitation probability from weather_data - precip_prob = weather_data.get('current_precipitation_prob') - if precip_prob is not None: - st.metric(label='☔ 降雨機率', value=f'{precip_prob}%') - else: - st.metric(label='☔ 降雨機率', value='N/A') + # Get weather data + # Current + current_humidity = weather_data.get('current_humidity') + current_apparent_temperature = weather_data.get('current_apparent_temperature') + current_precipitation_probability = weather_data.get('current_precipitation_probability') + current_precipitation = weather_data.get('current_precipitation') + current_wind_speed = weather_data.get('current_wind_speed') + current_wind_direction = weather_data.get('current_wind_direction') + # Daily + daily_temp_max = weather_data.get('daily_temp_max', []) + daily_temp_min = weather_data.get('daily_temp_min', []) + daily_apparent_max = weather_data.get('daily_apparent_temp_max', []) + daily_apparent_min = weather_data.get('daily_apparent_temp_min', []) + daily_mean_relative_humidity = weather_data.get('daily_mean_relative_humidity', []) + daily_precip_sum = weather_data.get('daily_precipitation_sum', []) + daily_precip_prob = weather_data.get('daily_precipitation_prob', []) + daily_wind_speed_max = weather_data.get('daily_wind_speed_max', []) + daily_wind_direction_dominant = weather_data.get('daily_wind_direction_dominant', []) + daily_sunrise = weather_data.get('daily_sunrise', []) + daily_sunset = weather_data.get('daily_sunset', []) + daily_uv_index_max = weather_data.get('daily_uv_index_max', []) - with col4: - st.metric(label='🌧️ 降雨量', value=weather_info['precipitation']) + # Row 1: Temperature (3 columns) + col1, col2, col3 = st.columns(3) + with col1: + st.metric('🔥 最高溫度', f'{daily_temp_max[selected_index]:.1f}°C') + with col2: + st.metric('❄️ 最低溫度', f'{daily_temp_min[selected_index]:.1f}°C') + with col3: + if selected_index == 0: + apparent_temp = current_apparent_temperature + else: + apparent_temp = (daily_apparent_max[selected_index] + daily_apparent_min[selected_index]) / 2 + st.metric('🌡️ 體感溫度', f'{apparent_temp:.1f}°C') - with col5: - # Get wind direction and convert to flow direction - wind_dir_degrees = weather_data.get('current_wind_direction') - wind_dir_text = weather_fetcher.convert_wind_degrees_to_flow_direction(wind_dir_degrees) + # Row 2: Humidity, Precipitation, Wind (3 columns) + col1, col2, col3 = st.columns(3) + with col1: + if selected_index == 0: + relative_humidity = current_humidity + else: + relative_humidity = daily_mean_relative_humidity[selected_index] + st.metric('💧 相對濕度', f'{relative_humidity}%') - # Get wind speed and level indicator - wind_speed_value = weather_data.get('current_wind_speed') - wind_level = weather_fetcher.get_wind_speed_indicator(wind_speed_value) + with col2: + if selected_index == 0: + precipitation_probability = current_precipitation_probability + precipitation = current_precipitation + else: + precipitation_probability = daily_precip_prob[selected_index] + precipitation = daily_precip_sum[selected_index] + st.metric('🌧️ 降雨機率 / 雨量', f'{precipitation_probability}% / {precipitation:.1f}mm') - st.metric( - label='💨 風速與流向', - value=f'{weather_info["wind_speed"]} {wind_dir_text} {wind_level}', - ) + with col3: + wind_dir_text = weather_fetcher.convert_wind_degrees_to_flow_direction( + degrees=current_wind_direction if selected_index == 0 else daily_wind_direction_dominant[selected_index] + ) + wind_level = weather_fetcher.get_wind_speed_indicator( + wind_speed=current_wind_speed if selected_index == 0 else daily_wind_speed_max[selected_index] + ) + st.metric('💨 風速 / 流向', f'{daily_wind_speed_max[selected_index]:.1f} km/h {wind_dir_text} {wind_level}') - # Forecast - if weather_info['forecast']: - st.subheader('未來三天預報') - forecast_cols = st.columns(3) + # Row 3: Sunrise, Sunset + col1, col2, col3 = st.columns(3) + with col1: + sunrise_time = datetime.fromisoformat(daily_sunrise[selected_index]).strftime('%H:%M') + st.metric('🌅 日出時間', sunrise_time) - for idx, forecast in enumerate(weather_info['forecast']): - with forecast_cols[idx]: - st.markdown(f'**{forecast["date"]}**') - st.write(f'🌡️ 高溫: {forecast["temp_max"]}°C') - st.write(f'🌡️ 低溫: {forecast["temp_min"]}°C') - st.write(f'☔ 降雨機率: {forecast["precip_prob"]}%') - else: - st.warning('⚠️ 無法格式化天氣資料') + with col2: + sunset_time = datetime.fromisoformat(daily_sunset[selected_index]).strftime('%H:%M') + st.metric('🌇 日落時間', sunset_time) + + with col3: + uv_index = daily_uv_index_max[selected_index] + uv_level = weather_fetcher.get_uv_index_indicator(uv_index) + st.metric('☀️ UV指數', f'{uv_index} {uv_level}') + + if selected_index >= 3: + st.info('❗ 三天後的天氣資訊誤差可能較大') else: - st.warning('⚠️ 無法獲取天氣資訊,請稍後再試') + st.warning('⚠️ 無法獲取足夠天數的天氣資料') + else: + st.warning('⚠️ 無法獲取天氣資訊,請稍後再試') else: st.warning('⚠️ 無法確定起點位置') except Exception as e: st.error(f'❌ 處理檔案時發生錯誤: {str(e)}') + # [Block3] + # Footer st.divider() footer_text = f'

{self._config["app"]["page_footer_text"]}

' st.markdown(footer_text, unsafe_allow_html=True) diff --git a/hiking_assistant/assets/config.yaml b/hiking_assistant/assets/config.yaml index ada9889..a9597e7 100644 --- a/hiking_assistant/assets/config.yaml +++ b/hiking_assistant/assets/config.yaml @@ -1,4 +1,6 @@ app: page_title: 臺灣登山小幫手 page_favicon_path: ./assets/favicon_compressed.jpg - page_footer_text: ⚠️ 本服務提供之資訊僅供規劃參考,山區氣候瞬息萬變,請務必依據現場狀況與自身能力進行風險評估
Made with ❤️ by deng \ No newline at end of file + page_footer_text: ⚠️ 本服務提供之資訊僅供規劃參考,山區氣候瞬息萬變,請務必依據現場狀況與自身能力進行風險評估
Made with ❤️ by deng + weather: + forecast_days: 7 \ No newline at end of file diff --git a/hiking_assistant/weather.py b/hiking_assistant/weather.py index 1bbda61..aa93b1e 100644 --- a/hiking_assistant/weather.py +++ b/hiking_assistant/weather.py @@ -9,10 +9,11 @@ import requests class WeatherFetcher: """Fetch weather data for hiking locations.""" - def __init__(self): + def __init__(self, api_url='https://api.open-meteo.com/v1/forecast', request_timeout=8, forecast_days=7): """Initialize weather fetcher.""" - self.api_url = 'https://api.open-meteo.com/v1/forecast' - self.request_timeout = 8 # unit: second + self.api_url = api_url + self.request_timeout = request_timeout + self.forecast_days = max(forecast_days, 1) def convert_wind_degrees_to_flow_direction(self, degrees): """Convert wind direction from degrees to flow direction emoji. @@ -50,6 +51,29 @@ class WeatherFetcher: else: return '🔴' # Dangerous + def get_uv_index_indicator(self, uv_index): + """Get UV index level indicator based on index. + + Args: + uv_index: UV index + + Returns: + str: Emoji indicator (🟢/🟡/🟠/🔴/🟣) + """ + if uv_index is None: + return '' + + if uv_index <= 2: + return '🟢' # Safe + elif uv_index <= 5: + return '🟡' # Caution + elif uv_index <= 7: + return '🟠' # Alert + elif uv_index <= 10: + return '🔴' # Dangerous + else: + return '🟣' # Extreme + def get_weather(self, latitude, longitude): """Fetch weather data for given coordinates. @@ -65,10 +89,14 @@ class WeatherFetcher: params = { 'latitude': latitude, 'longitude': longitude, - 'current': 'temperature_2m,relative_humidity_2m,precipitation,wind_speed_10m,wind_direction_10m', - 'daily': 'temperature_2m_max,temperature_2m_min,precipitation_probability_max', + 'current': ('relative_humidity_2m,apparent_temperature,precipitation_probability,precipitation,wind_speed_10m,wind_direction_10m',), + 'daily': ( + 'temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,' + 'relative_humidity_2m_mean,precipitation_sum,precipitation_probability_max,sunrise,sunset,' + 'wind_speed_10m_max,wind_direction_10m_dominant,uv_index_max' + ), 'timezone': 'auto', - 'forecast_days': 3, + 'forecast_days': self.forecast_days, } response = requests.get(self.api_url, params=params, timeout=self.request_timeout) @@ -81,15 +109,24 @@ class WeatherFetcher: daily = data.get('daily', {}) weather_info = { - 'current_temperature': current.get('temperature_2m'), 'current_humidity': current.get('relative_humidity_2m'), + 'current_apparent_temperature': current.get('apparent_temperature'), + 'current_precipitation_probability': current.get('precipitation_probability'), 'current_precipitation': current.get('precipitation'), - 'current_precipitation_prob': daily.get('precipitation_probability_max', [None])[0], # Today's rain probability 'current_wind_speed': current.get('wind_speed_10m'), 'current_wind_direction': current.get('wind_direction_10m'), 'daily_temp_max': daily.get('temperature_2m_max', []), 'daily_temp_min': daily.get('temperature_2m_min', []), + 'daily_apparent_temp_max': daily.get('apparent_temperature_max', []), + 'daily_apparent_temp_min': daily.get('apparent_temperature_min', []), + 'daily_mean_relative_humidity': daily.get('relative_humidity_2m_mean', []), + 'daily_precipitation_sum': daily.get('precipitation_sum', []), 'daily_precipitation_prob': daily.get('precipitation_probability_max', []), + 'daily_wind_speed_max': daily.get('wind_speed_10m_max', []), + 'daily_wind_direction_dominant': daily.get('wind_direction_10m_dominant', []), + 'daily_sunrise': daily.get('sunrise', []), + 'daily_sunset': daily.get('sunset', []), + 'daily_uv_index_max': daily.get('uv_index_max', []), 'daily_time': daily.get('time', []), } @@ -101,41 +138,3 @@ class WeatherFetcher: except Exception as e: print(f'天氣資料處理失敗: {str(e)}') return None - - def format_weather_display(self, weather_info): - """Format weather data for display. - - Args: - weather_info: Weather data dictionary from get_weather() - - Returns: - dict: Formatted weather data for UI display - """ - if not weather_info: - return None - - formatted = { - 'temperature': f'{weather_info.get("current_temperature", "N/A")}°C', - 'humidity': f'{weather_info.get("current_humidity", "N/A")}%', - 'precipitation': f'{weather_info.get("current_precipitation", 0)} mm', - 'wind_speed': f'{weather_info.get("current_wind_speed", "N/A")} km/h', - 'forecast': [], - } - - # Add 3-day forecast - times = weather_info.get('daily_time', []) - temp_max = weather_info.get('daily_temp_max', []) - temp_min = weather_info.get('daily_temp_min', []) - precip_prob = weather_info.get('daily_precipitation_prob', []) - - for i in range(min(3, len(times))): - formatted['forecast'].append( - { - 'date': times[i], - 'temp_max': temp_max[i] if i < len(temp_max) else 'N/A', - 'temp_min': temp_min[i] if i < len(temp_min) else 'N/A', - 'precip_prob': precip_prob[i] if i < len(precip_prob) else 'N/A', - } - ) - - return formatted