From 5e8e51ee82810aa2240e2f9a9f1a2a0ed846fcf7 Mon Sep 17 00:00:00 2001 From: deng Date: Thu, 27 Nov 2025 19:33:26 +0800 Subject: [PATCH] 1) refactor, 2) add dockerfile --- .gitignore | 2 + Dockerfile | 51 +++++++ README.md | 3 +- hiking_assistant/app.py | 200 ++++++++++++++++++++++++++ hiking_assistant/assets/config.yaml | 4 + hiking_assistant/elevation_profile.py | 105 ++++++++------ hiking_assistant/gpx.py | 33 ++++- hiking_assistant/main.py | 182 ----------------------- hiking_assistant/map_render.py | 5 +- hiking_assistant/weather.py | 54 ++++++- pyproject.toml | 1 + test_hehuan.gpx | 54 ------- uv.lock | 2 + 13 files changed, 406 insertions(+), 290 deletions(-) create mode 100644 Dockerfile create mode 100644 hiking_assistant/app.py create mode 100644 hiking_assistant/assets/config.yaml delete mode 100644 hiking_assistant/main.py delete mode 100644 test_hehuan.gpx diff --git a/.gitignore b/.gitignore index 505a3b1..6ca5ec0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ wheels/ # Virtual environments .venv + +*.tar \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fa58168 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# Modified by official dockerfile sample(https://github.com/astral-sh/uv-docker-example/blob/main/Dockerfile) +# Use a Python image with uv pre-installed +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim + +# Setup a non-root user +RUN groupadd --system --gid 999 nonroot \ + && useradd --system --gid 999 --uid 999 --create-home nonroot + +# Install the project into `/app` +WORKDIR /app + +# Enable bytecode compilation +ENV UV_COMPILE_BYTECODE=1 + +# Copy from the cache instead of linking since it's a mounted volume +ENV UV_LINK_MODE=copy + +# Ensure installed tools can be executed out of the box +ENV UV_TOOL_BIN_DIR=/usr/local/bin + +# Install the project's dependencies using the lockfile and settings +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project --no-dev + +# Then, add the rest of the project source code and install it +# Installing separately from its dependencies allows optimal layer caching +COPY ./hiking_assistant /app +COPY pyproject.toml /app +COPY uv.lock /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev + +# Place executables in the environment at the front of the path +ENV PATH="/app/.venv/bin:$PATH" + +# Reset the entrypoint, don't invoke `uv` +ENTRYPOINT [] + +# Use the non-root user to run our application +USER nonroot + +# Run the Streamlit application by default +ENV TZ="Asia/Taipei" +EXPOSE 8504 +CMD ["python", "-m", "streamlit", "run", "app.py", "--server.port=8504", "--server.enableCORS=false", "--server.enableXsrfProtection=false"] + +# Build +# docker build --platform=linux/amd64 -t hiking_assistant:latest . +# docker save -o hiking_assistant.tar hiking_assistant:latest \ No newline at end of file diff --git a/README.md b/README.md index 29dbabb..7c66702 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ ## Installation ```bash uv sync -uv run streamlit run hiking_assistant/main.py +cd hiking_assistant +uv run streamlit run app.py ``` ## Dirs diff --git a/hiking_assistant/app.py b/hiking_assistant/app.py new file mode 100644 index 0000000..d6f8ab1 --- /dev/null +++ b/hiking_assistant/app.py @@ -0,0 +1,200 @@ +# app.py +# +# author: deng +# date: 20251127 + +import streamlit as st +import yaml +from elevation_profile import ElevationProfileRenderer +from gpx import GPXProcessor +from map_render import MapRenderer +from streamlit_folium import st_folium +from weather import WeatherFetcher + + +class HikingAssistant: + def __init__(self): + self._config = self._load_config() + + def _load_config(self): + with open('assets/config.yaml', 'r') as f: + return yaml.safe_load(f) + + def run(self): + """Hiking Assistant application""" + # Page configuration + st.set_page_config( + page_title=self._config['app']['page_title'], + page_icon=self._config['app']['page_favicon_path'], + layout='wide', + initial_sidebar_state='collapsed', + ) + + # Title and description + st.title('⛰️ ' + self._config['app']['page_title']) + + # File uploader + uploaded_file = st.file_uploader( + '上傳您的 GPX 檔案開始分析路線!', + type=['gpx'], + help='可以從[健行筆記](https://hiking.biji.co/index.php?q=trail&act=gpx_list)、' + '[Hikingbook](https://zh-tw.hikingbook.net/explore/trails?regions=Taiwan)等網站下載 GPX 檔案', + ) + + if uploaded_file is not None: + try: + # Process GPX file + with st.spinner('正在解析 GPX 檔案...'): + gpx_processor = GPXProcessor(uploaded_file) + + 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() + + # Display statistics section + st.header('📊 路線五四三') + col1, col2, col3, col4, col5, col6 = st.columns(6) + + with col1: + st.metric(label='總距離', value=f'{total_distance:.1f}km') + + with col2: + st.metric(label='總爬升', value=f'{elevation_gain:.0f}m') + + with col3: + st.metric(label='總下降', value=f'{elevation_loss:.0f}m') + + with col4: + st.metric(label='最高海拔', value=f'{max_elevation:.0f}m') + + with col5: + st.metric(label='最低海拔', value=f'{min_elevation:.0f}m') + + with col6: + st.metric( + label='預估行進時間', + value=f'{estimated_time // 60}h {estimated_time % 60}m', + help='依據[Naismith’s Rule](https://en.wikipedia.org/wiki/Naismith%27s_rule)計算', + ) + + # Map section + with st.spinner('正在渲染地圖...'): + map_renderer = MapRenderer() + route_map = map_renderer.create_route_map(all_points, start_point, end_point) + + if route_map: + st_folium(route_map, width=None, height=500, key='route_map', returned_objects=[]) + else: + st.error('無法渲染地圖') + + # Create two columns: elevation profile on left, pie chart on right + profile_col, pie_col = st.columns([2, 1]) + + with profile_col: + with st.spinner('正在繪製海拔剖面圖...'): + profile_renderer = ElevationProfileRenderer() + elevation_fig = profile_renderer.create_elevation_profile(distances, elevations, gradients) + + if elevation_fig: + # elevation_fig = profile_renderer.add_gradient_legend(elevation_fig) + st.plotly_chart(elevation_fig, width='stretch') + else: + st.error('無法繪製海拔剖面圖') + + with pie_col: + with st.spinner('正在分析坡度分布...'): + pie_fig = profile_renderer.create_gradient_pie_chart(distances, gradients) + + if pie_fig: + st.plotly_chart(pie_fig, width='stretch') + else: + st.error('無法繪製坡度分布圖') + + st.divider() + + # Weather section + st.header('☀️ 天氣安抓') + + if start_point: + with st.spinner('正在獲取天氣資訊...'): + weather_fetcher = WeatherFetcher() + weather_data = weather_fetcher.get_weather(start_point[0], start_point[1]) + + if weather_data: + weather_info = weather_fetcher.format_weather_display(weather_data) + + if weather_info: + # Current weather + st.subheader('此刻起點天氣') + col1, col2, col3, col4, col5 = st.columns(5) + + with col1: + st.metric(label='🌡️ 溫度', value=weather_info['temperature']) + + with col2: + st.metric(label='💧 濕度', value=weather_info['humidity']) + + 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') + + with col4: + st.metric(label='🌧️ 降雨量', value=weather_info['precipitation']) + + 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) + + # 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) + + st.metric( + label='💨 風速與流向', + value=f'{weather_info["wind_speed"]} {wind_dir_text} {wind_level}', + ) + + # Forecast + if weather_info['forecast']: + st.subheader('未來三天預報') + forecast_cols = st.columns(3) + + 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('⚠️ 無法格式化天氣資料') + else: + st.warning('⚠️ 無法獲取天氣資訊,請稍後再試') + else: + st.warning('⚠️ 無法確定起點位置') + + except Exception as e: + st.error(f'❌ 處理檔案時發生錯誤: {str(e)}') + + st.divider() + footer_text = f'

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

' + st.markdown(footer_text, unsafe_allow_html=True) + + +if __name__ == '__main__': + app = HikingAssistant() + app.run() diff --git a/hiking_assistant/assets/config.yaml b/hiking_assistant/assets/config.yaml new file mode 100644 index 0000000..ada9889 --- /dev/null +++ b/hiking_assistant/assets/config.yaml @@ -0,0 +1,4 @@ +app: + page_title: 臺灣登山小幫手 + page_favicon_path: ./assets/favicon_compressed.jpg + page_footer_text: ⚠️ 本服務提供之資訊僅供規劃參考,山區氣候瞬息萬變,請務必依據現場狀況與自身能力進行風險評估
Made with ❤️ by deng \ No newline at end of file diff --git a/hiking_assistant/elevation_profile.py b/hiking_assistant/elevation_profile.py index f145141..8b731a6 100644 --- a/hiking_assistant/elevation_profile.py +++ b/hiking_assistant/elevation_profile.py @@ -8,7 +8,44 @@ class ElevationProfileRenderer: def __init__(self): """Initialize elevation profile renderer.""" - pass + self.color = { + 'steep_ascent': 'rgb(139, 69, 19)', + 'gentle_ascent': 'rgb(210, 180, 140)', + 'steep_descent': 'rgb(34, 139, 34)', + 'gentle_descent': 'rgb(154, 205, 50)', + } + + self.slope = { + 'steep_ascent': [15, float('inf')], + 'gentle_ascent': [0, 15], + 'steep_descent': [float('-inf'), -20], + 'gentle_descent': [-20, 0], + } + + self.label = { + 'steep_ascent': '陡上坡 (>= 15%)', + 'gentle_ascent': '緩上坡 (< 15%)', + 'steep_descent': '陡下坡 (<= -20%)', + 'gentle_descent': '緩下坡 (> -20%)', + } + + def _get_gradient_category(self, gradient): + """Determine gradient category based on slope thresholds. + + Args: + gradient: Gradient value in percentage + + Returns: + str: Category key ('steep_ascent', 'gentle_ascent', 'steep_descent', 'gentle_descent') + """ + if gradient >= self.slope['steep_ascent'][0]: + return 'steep_ascent' + elif gradient >= self.slope['gentle_ascent'][0]: + return 'gentle_ascent' + elif gradient <= self.slope['steep_descent'][1]: + return 'steep_descent' + else: + return 'gentle_descent' def _downsample_data(self, distances, elevations, gradients, max_points=800): """Downsample data if there are too many points. @@ -84,23 +121,10 @@ class ElevationProfileRenderer: while i < len(distances) - 1: gradient = gradients_extended[i + 1] - # Determine color based on gradient thresholds (earth-tone palette) - if gradient > 0: # Ascending - if gradient >= 15: - # Steep ascent: dark red-brown (brick/terracotta) - color = 'rgb(139, 69, 19)' # Saddle brown - else: - # Gentle ascent: light red-brown (sandy/tan) - color = 'rgb(210, 180, 140)' # Tan - is_ascending = True - else: # Descending - if gradient <= -20: - # Steep descent: dark green (forest green) - color = 'rgb(34, 139, 34)' # Forest green - else: - # Gentle descent: light green (sage/olive) - color = 'rgb(154, 205, 50)' # Yellow green - is_ascending = False + # Determine color based on gradient category + category = self._get_gradient_category(gradient) + color = self.color[category] + is_ascending = gradient > 0 # Collect consecutive points with similar direction (ascending/descending) segment_x = [distances[i]] @@ -179,14 +203,15 @@ class ElevationProfileRenderer: if i + 1 < len(distances): segment_distance = distances[i + 1] - distances[i] gradient = gradients[i] + category = self._get_gradient_category(gradient) - if gradient >= 15: + if category == 'steep_ascent': steep_ascent_km += segment_distance - elif gradient > 0: + elif category == 'gentle_ascent': gentle_ascent_km += segment_distance - elif gradient <= -20: + elif category == 'steep_descent': steep_descent_km += segment_distance - else: # gradient < 0 and > -20 + else: # gentle_descent gentle_descent_km += segment_distance return { @@ -211,33 +236,23 @@ class ElevationProfileRenderer: if not stats: return None - # Prepare data - labels = ['陡上坡 (≥15%)', '緩上坡 (<15%)', '陡下坡 (≤-20%)', '緩下坡 (>-20%)'] - values = [stats['steep_ascent'], stats['gentle_ascent'], stats['steep_descent'], stats['gentle_descent']] - colors = [ - 'rgb(139, 69, 19)', # Steep ascent - dark brown - 'rgb(210, 180, 140)', # Gentle ascent - tan - 'rgb(34, 139, 34)', # Steep descent - forest green - 'rgb(154, 205, 50)', # Gentle descent - yellow green - ] - # Create pie chart fig = go.Figure( data=[ go.Pie( - labels=labels, - values=values, - marker=dict(colors=colors), + labels=list(self.label.values()), + values=list(stats.values()), + marker=dict(colors=list(self.color.values())), textinfo='label+percent', textposition='inside', - hovertemplate='%{label}
%{value:.2f} km
%{percent}', + hovertemplate='%{value:.2f} km', showlegend=False, # Don't show legend as requested ) ] ) fig.update_layout( - title='坡度分布', + title='', height=400, margin=dict(l=20, r=20, t=40, b=20), ) @@ -259,8 +274,8 @@ class ElevationProfileRenderer: x=[None], y=[None], mode='lines', - line=dict(color='rgb(139,69,19)', width=3), - name='陡上坡 (≥15%)', + line=dict(color=self.color['steep_ascent'], width=3), + name=self.label['steep_ascent'], ) ) @@ -269,8 +284,8 @@ class ElevationProfileRenderer: x=[None], y=[None], mode='lines', - line=dict(color='rgb(210,180,140)', width=3), - name='緩上坡 (<15%)', + line=dict(color=self.color['gentle_ascent'], width=3), + name=self.label['gentle_ascent'], ) ) @@ -279,8 +294,8 @@ class ElevationProfileRenderer: x=[None], y=[None], mode='lines', - line=dict(color='rgb(34,139,34)', width=3), - name='陡下坡 (≤-20%)', + line=dict(color=self.color['steep_descent'], width=3), + name=self.label['steep_descent'], ) ) @@ -289,8 +304,8 @@ class ElevationProfileRenderer: x=[None], y=[None], mode='lines', - line=dict(color='rgb(154,205,50)', width=3), - name='緩下坡 (>-20%)', + line=dict(color=self.color['gentle_descent'], width=3), + name=self.label['gentle_descent'], ) ) diff --git a/hiking_assistant/gpx.py b/hiking_assistant/gpx.py index dda48c5..db6ec89 100644 --- a/hiking_assistant/gpx.py +++ b/hiking_assistant/gpx.py @@ -1,4 +1,7 @@ -"""GPX file processing module for hiking route analysis.""" +# gpx.py +# +# author: deng +# date: 20251127 import gpxpy import gpxpy.gpx @@ -165,3 +168,31 @@ class GPXProcessor: gradients.append(gradient) return gradients + + def calculate_naismith_time(self): + """Calculate estimated hiking time using Naismith's Rule. + + Naismith's Rule: + - Base time: 1 hour per 5 km of horizontal distance + - Add time: 1 hour per 600 meters of ascent + + Returns: + int: Estimated time in minutes + """ + if len(self.points) < 2: + return 0 + + # Get total distance and elevation gain + total_distance = self.calculate_distance() if not self.distances else self.distances[-1] + elevation_gain, _ = self.calculate_elevation_gain_loss() + + # Naismith's Rule calculation + # 1 hour per 5 km = 0.2 hours per km + time_for_distance = total_distance * 0.2 + + # 1 hour per 600 meters of ascent + time_for_ascent = elevation_gain / 600.0 + + total_time = int((time_for_distance + time_for_ascent) * 60) + + return total_time diff --git a/hiking_assistant/main.py b/hiking_assistant/main.py deleted file mode 100644 index cc08ed4..0000000 --- a/hiking_assistant/main.py +++ /dev/null @@ -1,182 +0,0 @@ -"""台灣登山路線規劃輔助系統 - Streamlit Web Application""" - -import streamlit as st -from elevation_profile import ElevationProfileRenderer -from gpx import GPXProcessor -from map_render import MapRenderer -from streamlit_folium import st_folium -from weather import WeatherFetcher - - -def main(): - """Main Streamlit application.""" - # Page configuration - st.set_page_config( - page_title='臺灣登山路線分析小幫手', - page_icon='assets/favicon_compressed.jpg', - layout='wide', - initial_sidebar_state='collapsed', - ) - - # Title and description - st.title('⛰️ 臺灣登山路線分析小幫手') - st.markdown('上傳您的 GPX 檔案開始分析路線!') - - # File uploader - uploaded_file = st.file_uploader( - '上傳檔案', - type=['gpx'], - help='請上傳包含登山路線的 GPX 檔案', - ) - - if uploaded_file is not None: - try: - # Process GPX file - with st.spinner('正在解析 GPX 檔案...'): - gpx_processor = GPXProcessor(uploaded_file) - - 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() - - # Display statistics section - st.header('📊 路線統計資訊') - col1, col2, col3, col4, col5 = st.columns(5) - - with col1: - st.metric(label='總距離', value=f'{total_distance:.2f}', delta='公里') - - with col2: - st.metric(label='總爬升', value=f'{elevation_gain:.0f}', delta='公尺') - - with col3: - st.metric(label='總下降', value=f'{elevation_loss:.0f}', delta='公尺') - - with col4: - st.metric(label='最高海拔', value=f'{max_elevation:.0f}', delta='公尺') - - with col5: - st.metric(label='最低海拔', value=f'{min_elevation:.0f}', delta='公尺') - - st.divider() - - # Map and Elevation profile section (no divider between them) - st.header('🗺️ 路線地圖') - - # Map section - with st.spinner('正在渲染地圖...'): - map_renderer = MapRenderer() - route_map = map_renderer.create_route_map(all_points, start_point, end_point) - - if route_map: - st_folium(route_map, width=None, height=500) - else: - st.error('無法渲染地圖') - - # Elevation profile section (directly below map, no divider) - # st.markdown('🔴 **紅色**表示上升路段(顏色越深坡度越陡) | 🟢 **綠色**表示下降路段(顏色越深坡度越陡)') - - # Create two columns: elevation profile on left, pie chart on right - profile_col, pie_col = st.columns([2, 1]) - - with profile_col: - with st.spinner('正在繪製海拔剖面圖...'): - profile_renderer = ElevationProfileRenderer() - elevation_fig = profile_renderer.create_elevation_profile(distances, elevations, gradients) - - if elevation_fig: - # Add legend - elevation_fig = profile_renderer.add_gradient_legend(elevation_fig) - st.plotly_chart(elevation_fig, width='stretch') - else: - st.error('無法繪製海拔剖面圖') - - with pie_col: - with st.spinner('正在分析坡度分布...'): - pie_fig = profile_renderer.create_gradient_pie_chart(distances, gradients) - - if pie_fig: - st.plotly_chart(pie_fig, width='stretch') - else: - st.error('無法繪製坡度分布圖') - - st.divider() - - # Weather section - st.header('☀️ 起點天氣預報') - - if start_point: - with st.spinner('正在獲取天氣資訊...'): - weather_fetcher = WeatherFetcher() - weather_data = weather_fetcher.get_weather(start_point[0], start_point[1]) - - if weather_data: - weather_info = weather_fetcher.format_weather_display(weather_data) - - if weather_info: - # Current weather - st.subheader('當前天氣') - col1, col2, col3, col4 = st.columns(4) - - with col1: - st.metric(label='🌡️ 溫度', value=weather_info['temperature']) - - with col2: - st.metric(label='💧 濕度', value=weather_info['humidity']) - - with col3: - st.metric(label='🌧️ 降雨量', value=weather_info['precipitation']) - - with col4: - st.metric(label='💨 風速', value=weather_info['wind_speed']) - - # Forecast - if weather_info['forecast']: - st.subheader('未來三天預報') - forecast_cols = st.columns(3) - - 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('⚠️ 無法格式化天氣資料') - else: - st.warning('⚠️ 無法獲取天氣資訊,請稍後再試') - else: - st.warning('⚠️ 無法確定起點位置') - - except Exception as e: - st.error(f'❌ 處理檔案時發生錯誤: {str(e)}') - - else: - # # Show instructions when no file is uploaded - # st.info('👆 請上傳 GPX 檔案開始分析') - # st.markdown( - # """ - # ### 使用說明 - # 1. 點擊上方的「選擇 GPX 檔案」按鈕 - # 2. 選擇您的登山路線 GPX 檔案 - # 3. 系統將自動分析並顯示: - # - 📊 路線統計資訊(距離、爬升、海拔等) - # - 🗺️ 互動式路線地圖 - # - 📈 海拔剖面圖(顏色顯示坡度變化) - # - ☀️ 起點天氣預報 - # """ - # ) - pass - - -if __name__ == '__main__': - main() diff --git a/hiking_assistant/map_render.py b/hiking_assistant/map_render.py index b4ee5f4..223ae3c 100644 --- a/hiking_assistant/map_render.py +++ b/hiking_assistant/map_render.py @@ -1,4 +1,7 @@ -"""Map rendering module using Folium for interactive maps.""" +# map_render.py +# +# author: deng +# date: 20251127 import folium diff --git a/hiking_assistant/weather.py b/hiking_assistant/weather.py index 63b9639..1bbda61 100644 --- a/hiking_assistant/weather.py +++ b/hiking_assistant/weather.py @@ -1,4 +1,7 @@ -"""Weather data fetching module using Open-Meteo API.""" +# weather.py +# +# author: deng +# date: 20251127 import requests @@ -8,7 +11,44 @@ class WeatherFetcher: def __init__(self): """Initialize weather fetcher.""" - self.base_url = 'https://api.open-meteo.com/v1/forecast' + self.api_url = 'https://api.open-meteo.com/v1/forecast' + self.request_timeout = 8 # unit: second + + def convert_wind_degrees_to_flow_direction(self, degrees): + """Convert wind direction from degrees to flow direction emoji. + + Args: + degrees: Wind direction in degrees (0-360) + + Returns: + str: Flow direction as emoji arrow (pointing where wind is blowing to) + """ + if degrees is None: + return 'N/A' + directions = ['⬇️', '↙️', '⬅️', '↖️', '⬆️', '↗️', '➡️', '↘️'] + index = round(degrees / 45) % 8 + return directions[index] + + def get_wind_speed_indicator(self, wind_speed): + """Get wind speed level indicator based on speed. + + Args: + wind_speed: Wind speed in km/h + + Returns: + str: Emoji indicator (🟢/🟡/🟠/🔴) + """ + if wind_speed is None: + return '' + + if wind_speed < 20: + return '🟢' # Safe + elif wind_speed < 40: + return '🟡' # Caution + elif wind_speed < 60: + return '🟠' # Alert + else: + return '🔴' # Dangerous def get_weather(self, latitude, longitude): """Fetch weather data for given coordinates. @@ -25,18 +65,18 @@ class WeatherFetcher: params = { 'latitude': latitude, 'longitude': longitude, - 'current': 'temperature_2m,relative_humidity_2m,precipitation,wind_speed_10m', + 'current': 'temperature_2m,relative_humidity_2m,precipitation,wind_speed_10m,wind_direction_10m', 'daily': 'temperature_2m_max,temperature_2m_min,precipitation_probability_max', - 'timezone': 'Asia/Taipei', + 'timezone': 'auto', 'forecast_days': 3, } - response = requests.get(self.base_url, params=params, timeout=10) + response = requests.get(self.api_url, params=params, timeout=self.request_timeout) response.raise_for_status() data = response.json() - # Parse current weather + # Parse weather data current = data.get('current', {}) daily = data.get('daily', {}) @@ -44,7 +84,9 @@ class WeatherFetcher: 'current_temperature': current.get('temperature_2m'), 'current_humidity': current.get('relative_humidity_2m'), '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_precipitation_prob': daily.get('precipitation_probability_max', []), diff --git a/pyproject.toml b/pyproject.toml index 82c3713..e0c7878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "requests>=2.32.3", "geopy>=2.4.1", "streamlit-plotly-events>=0.0.6", + "pyyaml>=6.0.3", ] [dependency-groups] diff --git a/test_hehuan.gpx b/test_hehuan.gpx deleted file mode 100644 index b173861..0000000 --- a/test_hehuan.gpx +++ /dev/null @@ -1,54 +0,0 @@ - - - - 合歡山主峰測試路線 - 從合歡山主峰登山口到山頂的測試路線 - - - 合歡山主峰路線 - - - 3150 - - - 3180 - - - 3210 - - - 3250 - - - 3290 - - - 3320 - - - 3350 - - - 3360 - - - 3380 - - - 3400 - - - 3410 - - - 3416 - - - 3417 - - - - diff --git a/uv.lock b/uv.lock index 7ee1a07..9e4c913 100644 --- a/uv.lock +++ b/uv.lock @@ -234,6 +234,7 @@ dependencies = [ { name = "geopy" }, { name = "gpxpy" }, { name = "plotly" }, + { name = "pyyaml" }, { name = "requests" }, { name = "streamlit" }, { name = "streamlit-folium" }, @@ -253,6 +254,7 @@ requires-dist = [ { name = "geopy", specifier = ">=2.4.1" }, { name = "gpxpy", specifier = ">=1.6.2" }, { name = "plotly", specifier = ">=5.24.1" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "requests", specifier = ">=2.32.3" }, { name = "streamlit", specifier = ">=1.51.0" }, { name = "streamlit-folium", specifier = ">=0.23.1" },