Compare commits

..

15 Commits

Author SHA1 Message Date
145b52490f add annotation 2025-12-10 19:33:52 +08:00
74e1d96600 release 1.0.0 2025-12-03 09:06:10 +08:00
06261a80be test gpx, weather, utils 2025-11-29 06:56:07 +08:00
517c4e5ebe add caching 2025-11-28 22:51:33 +08:00
2c0cb8dfcd add favicon and badges 2025-11-28 22:27:51 +08:00
1e9834337f add new compressed background and favicon 2025-11-28 22:11:40 +08:00
8e74bebf23 typo 2025-11-28 21:15:25 +08:00
20ac5675b3 1) mod start and end point popou, 2) change bg image 2025-11-28 21:13:39 +08:00
f73b8ffd08 add background image 2025-11-28 20:51:35 +08:00
fc3ed4b4c9 add waypoint to map 2025-11-28 19:47:40 +08:00
37d60d0529 mod naismith’s rule 2025-11-28 18:50:42 +08:00
fd03436d61 rename scripts 2025-11-28 10:11:05 +08:00
8de4db857e add ruby map layer to map ojb 2025-11-28 10:05:07 +08:00
1c50c1ba4a add desc from yushan nation park 2025-11-28 08:00:08 +08:00
5c9af1649b add altitude sickness warning 2025-11-28 07:53:26 +08:00
23 changed files with 1048 additions and 94 deletions

49
.dockerignore Normal file
View File

@ -0,0 +1,49 @@
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
.pytest_cache/
# Virtual environments
.venv/
venv/
ENV/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Git
.git/
.gitignore
.gitattributes
# Documentation
*.md
!README.md
# Test files
tests/
test_*.py
*_test.py
# GPX files in assets (exclude user test files)
hiking_assistant/assets/*.gpx
# macOS
.DS_Store
# Archives
*.tar
*.tar.gz
*.zip
# Logs
*.log

1
.gitignore vendored
View File

@ -10,3 +10,4 @@ wheels/
.venv .venv
*.tar *.tar
RELEASE_CHECKLIST.md

View File

@ -6,6 +6,7 @@ repos:
rev: v4.3.0 rev: v4.3.0
hooks: hooks:
- id: check-added-large-files - id: check-added-large-files
args: ["--maxkb=2048"]
- id: check-yaml - id: check-yaml
- id: check-docstring-first - id: check-docstring-first
@ -13,7 +14,14 @@ repos:
hooks: hooks:
- id: ruff - id: ruff
name: ruff name: ruff
entry: ruff check . entry: uv run ruff check .
language: python language: system
types: [python] types: [python]
always_run: true always_run: true
- id: pytest
name: pytest
entry: uv run pytest tests/ -v
language: system
pass_filenames: false
always_run: true

134
CHANGELOG.md Normal file
View File

@ -0,0 +1,134 @@
# 更新紀錄
## [1.0.0] - 2025-12-03
### 🎉 首次正式發布
歡迎使用**山山登山小助手** v1.0.0!這是第一個穩定版本,提供完整的登山路線分析與天氣查詢功能。
---
## ✨ 新功能
### 📊 路線分析
上傳您的 GPX 檔案,立即獲得詳細的路線資訊:
- **基本統計**
- 📏 總距離
- ⬆️ 累積爬升
- ⬇️ 累積下降
- 🏔️ 最高/最低海拔
- ⏱️ 預估行進時間
- **智慧計算**
- 自動過濾 GPS 雜訊,提供更準確的爬升/下降數據
- 根據路線距離與爬升,智慧估算所需時間
- 支援自訂行進速度參數
### 🗺️ 互動式地圖
輕鬆查看完整路線與重要地標:
- **雙地圖切換**
- 🌍 OpenStreetMap - 適合查看周邊設施
- 🗻 魯地圖 - 等高線地圖,適合登山路線規劃
- **清楚標示**
- 🔵 起點終點:顯示座標與海拔
- 🟠 航點標記:自動顯示 GPX 檔案中的所有航點(如山屋、水源、岔路等)
- 🔴 路線軌跡:完整呈現登山路徑
- **互動功能**
- 點擊航點查看名稱與海拔
- 縮放與拖曳地圖
- 自由切換地圖圖層
### 📈 視覺化圖表
一目了然的路線特性:
- **海拔剖面圖**
- 互動式圖表,滑鼠移動即顯示該點資訊
- 清楚呈現整段路線的海拔變化
- 幫助識別陡峭路段與平緩路段
- **坡度分析圓餅圖**
- 依坡度分為:平緩、緩坡、陡坡、急坡
- 快速了解路線難度分布
### 🌤️ 天氣預報
出發前掌握天氣狀況,避免遇到惡劣天候:
- **7 天天氣預報**
- 🌡️ 溫度:最高/最低/體感溫度
- 💧 濕度:相對濕度
- 🌧️ 降雨:降雨機率與預估雨量
- 💨 風況:風速與風向(附安全等級指標 🟢🟡🟠🔴)
- ☀️ UV 指數:附等級指標(🟢🟡🟠🔴🟣)
- 🌅 日出/日落時間
- **日期選擇**
- 點選任一日期查看詳細天氣
- 方便規劃多日行程
### 🏔️ 安全提醒
貼心的登山安全提示:
- **高山症警示**
- 當路線海拔較高超過2100公尺提醒登山者注意高山症風險
- 附上高山症預防資訊連結,幫助登山者提早準備與注意
### 🎨 使用體驗
簡潔的介面設計:
- ✨ 臺灣山岳背景(南湖圈谷)
- 📱 響應式設計,支援電腦、手機
- ⚡ 快速載入
---
## 💡 使用提示
1. **GPX 檔案來源**
- 可從健行筆記、Hikingbook 等網站下載
- 也可使用登山 GPS App 記錄的航跡
2. **最佳使用方式**
- 載入路線 GPX 檔,搭配海拔剖面圖,規劃每日行程與體力分配
- 出發前一天查看天氣預報,注意溫度、降雨機率與 UV 指數,準備適當裝備
3. **注意事項**
- 預估時間僅供參考,請依個人體能調整
- 山區天氣變化快,仍需隨時注意實際天氣狀況
- 行經高海拔地區,請留意高山症症狀
---
## 🙏 感謝使用
感謝您使用山山登山小助手!如果有任何建議或問題,歡迎回饋(<gt810034@gmail.com>)。
祝您登山愉快,平安歸來!🏔️
---
## 📝 技術資訊
**適用環境**
- 瀏覽器Chrome、Firefox、Safari建議使用最新版本
- 裝置:桌機、筆電、平板、手機
**檔案限制**
- 支援標準 GPX 格式
- 檔案大小上限30 MB
**資料來源**
- 天氣資料Open-Meteo API
- 地圖圖層OpenStreetMap、魯地圖
---
更新日期2025-12-03

View File

@ -44,8 +44,8 @@ USER nonroot
# Run the Streamlit application by default # Run the Streamlit application by default
ENV TZ="Asia/Taipei" ENV TZ="Asia/Taipei"
EXPOSE 8800 EXPOSE 8800
CMD ["python", "-m", "streamlit", "run", "app.py", "--server.port=8800", "--server.enableCORS=false", "--server.enableXsrfProtection=false"] CMD ["python", "-m", "streamlit", "run", "app.py"]
# Build # Build
# docker build --platform=linux/amd64 -t hiking_assistant:latest . # docker build --platform=linux/amd64 -t hiking_assistant:1.0.1 .
# docker save -o hiking_assistant.tar hiking_assistant:latest # docker save -o hiking_assistant.tar hiking_assistant:1.0.1

View File

@ -1,7 +1,12 @@
## Abstract <img src="./hiking_assistant/assets/new_favicon.webp" alt="shenshen" width="180" /><br>
一個基於 Python + Streamlit 的網頁應用程式,讀取使用者上傳的 GPX 檔案並提供詳細的路線統計、互動式地圖、海拔分析圖及天氣預報等。 ![Python](https://img.shields.io/badge/python3.13-3670A0?style=for-the-badge&logo=python&logoColor=ffdd54)
![Streamlit](https://img.shields.io/badge/Streamlit-%23FE4B4B.svg?style=for-the-badge&logo=streamlit&logoColor=white)
![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)
[臺灣登山小幫手](https://hikingassistant.guineapig.love) ## Abstract
基於 Python + Streamlit 的網頁應用程式,讀取使用者上傳的 GPX 檔案並提供詳細的路線統計、互動式地圖、海拔分析圖及天氣預報等資訊。
[山山登山小助手](https://hikingassistant.guineapig.love)
## Requirements ## Requirements
* Hardware * Hardware

View File

@ -0,0 +1,11 @@
[theme]
base = "light"
[server]
port = 8800
enableCORS = false
enableXsrfProtection = false
maxUploadSize = 30
[client]
toolbarMode = "minimal"

View File

@ -7,22 +7,60 @@ from datetime import datetime
import streamlit as st import streamlit as st
import yaml import yaml
from elevation_profile import ElevationProfileRenderer from elevation import ElevationRenderer
from gpx import GPXProcessor from gpx import GPXProcessor
from map_render import MapRenderer from map import MapRenderer
from streamlit_folium import st_folium from streamlit_folium import st_folium
from utils import convert_image_to_base64
from weather import WeatherFetcher from weather import WeatherFetcher
class HikingAssistant: class HikingAssistant:
def __init__(self): def __init__(self, config_path: str = 'assets/config.yaml') -> None:
self._config = self._load_config() self._config = self._load_config(config_path)
def _load_config(self): @staticmethod
with open('assets/config.yaml', 'r') as f: def _load_config(config_path: str) -> dict:
with open(config_path, 'r') as f:
return yaml.safe_load(f) return yaml.safe_load(f)
def run(self): def _set_page_background(self, image_path: str, opacity: float = 0.8) -> None:
"""Set background image for the application.
Args:
image_path: Path to the background image
"""
image_data = convert_image_to_base64(image_path)
ext = image_path.split('.')[-1]
overlay = f'rgba(255, 255, 255, {1 - opacity})'
st.markdown(
f"""
<style>
.stApp {{
background-image: linear-gradient({overlay}, {overlay}), url(data:image/{ext};base64,{image_data});
background-size: cover;
background-position: center;
background-attachment: fixed;
}}
</style>
""",
unsafe_allow_html=True,
)
def _add_custom_title(self, title: str, image_path: str) -> None:
favicon_data = convert_image_to_base64(image_path)
ext = image_path.split('.')[-1]
st.markdown(
f"""
<h1 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="data:image/{ext};base64,{favicon_data}" width="48" height="48" style="border-radius: 8px;">
{title}
</h1>
""",
unsafe_allow_html=True,
)
def run(self) -> None:
"""Hiking Assistant application""" """Hiking Assistant application"""
# Page configuration # Page configuration
st.set_page_config( st.set_page_config(
@ -31,10 +69,11 @@ class HikingAssistant:
layout='wide', layout='wide',
initial_sidebar_state='collapsed', initial_sidebar_state='collapsed',
) )
self._set_page_background(self._config['app']['page_background_path'], self._config['app']['page_background_opacity'])
# [Block1] # [Block1]
# Title and description # Title with favicon
st.title('⛰️ ' + self._config['app']['page_title']) self._add_custom_title(self._config['app']['page_title'], self._config['app']['page_favicon_path'])
# File uploader # File uploader
uploaded_file = st.file_uploader( uploaded_file = st.file_uploader(
@ -69,7 +108,11 @@ class HikingAssistant:
all_points = gpx_processor.get_all_points() all_points = gpx_processor.get_all_points()
distances, elevations = gpx_processor.get_elevation_profile_data() distances, elevations = gpx_processor.get_elevation_profile_data()
gradients = gpx_processor.get_gradients() gradients = gpx_processor.get_gradients()
estimated_time = gpx_processor.calculate_naismith_time() estimated_time = gpx_processor.calculate_naismith_time(
horizontal_speed=self._config['app']['estimated_time']['horizontal_speed'],
vertical_speed=self._config['app']['estimated_time']['vertical_speed'],
)
waypoints = gpx_processor.waypoints
# Cache all processed data # Cache all processed data
st.session_state.gpx_file_key = file_key st.session_state.gpx_file_key = file_key
@ -85,6 +128,11 @@ class HikingAssistant:
st.session_state.elevations = elevations st.session_state.elevations = elevations
st.session_state.gradients = gradients st.session_state.gradients = gradients
st.session_state.estimated_time = estimated_time st.session_state.estimated_time = estimated_time
st.session_state.waypoints = waypoints
# Show altitude sickness warning for first gpx loading
if max_elevation >= self._config['app']['altitude_sickness']['elevation_threshold']:
st.toast(self._config['app']['altitude_sickness']['warning_text'], icon=self._config['app']['altitude_sickness']['emoji'])
# Use cached data # Use cached data
total_distance = st.session_state.total_distance total_distance = st.session_state.total_distance
@ -99,6 +147,7 @@ class HikingAssistant:
elevations = st.session_state.elevations elevations = st.session_state.elevations
gradients = st.session_state.gradients gradients = st.session_state.gradients
estimated_time = st.session_state.estimated_time estimated_time = st.session_state.estimated_time
waypoints = st.session_state.waypoints
# Display statistics section # Display statistics section
st.header('📊 路線五四三') st.header('📊 路線五四三')
@ -123,25 +172,25 @@ class HikingAssistant:
st.metric( st.metric(
label='預估行進時間', label='預估行進時間',
value=f'{estimated_time // 60}h {estimated_time % 60}m', value=f'{estimated_time // 60}h {estimated_time % 60}m',
help='依據[Naismiths Rule](https://en.wikipedia.org/wiki/Naismith%27s_rule)計算', help='基於[Naismiths Rule](https://en.wikipedia.org/wiki/Naismith%27s_rule)修正後計算',
) )
# Map section # Map section
with st.spinner('正在渲染地圖...'): with st.spinner('正在渲染地圖...'):
map_renderer = MapRenderer() map_renderer = MapRenderer()
route_map = map_renderer.create_route_map(all_points, start_point, end_point) route_map = map_renderer.create_route_map(all_points, start_point, end_point, waypoints)
if route_map: if route_map:
st_folium(route_map, width=None, height=500, key='route_map', returned_objects=[]) st_folium(route_map, width=None, height=self._config['app']['map']['height'], key='route_map', returned_objects=[])
else: else:
st.error('無法渲染地圖') st.error('無法渲染地圖')
# Create two columns: elevation profile on left, pie chart on right # Create two columns: elevation profile on left, pie chart on right
profile_col, pie_col = st.columns([2, 1]) profile_col, pie_col = st.columns([3, 1])
with profile_col: with profile_col:
with st.spinner('正在繪製海拔剖面圖...'): with st.spinner('正在繪製海拔剖面圖...'):
profile_renderer = ElevationProfileRenderer() profile_renderer = ElevationRenderer()
elevation_fig = profile_renderer.create_elevation_profile(distances, elevations, gradients) elevation_fig = profile_renderer.create_elevation_profile(distances, elevations, gradients)
if elevation_fig: if elevation_fig:
@ -288,7 +337,10 @@ class HikingAssistant:
# [Block3] # [Block3]
# Footer # Footer
st.divider() st.divider()
footer_text = f'<div style="text-align:center; font-size: 0.8em"><p>{self._config["app"]["page_footer_text"]}</p></div>' footer_text = (
f'<div style="text-align:center; font-size: 0.8em">'
f'<p>{self._config["app"]["page_footer_text"]}<br>v{self._config["app"]["version"]}</p></div>'
)
st.markdown(footer_text, unsafe_allow_html=True) st.markdown(footer_text, unsafe_allow_html=True)

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@ -1,6 +1,18 @@
app: app:
page_title: 臺灣登山小幫手 version: 1.0.0
page_favicon_path: ./assets/favicon_compressed.jpg page_title: 山山登山小助手
page_favicon_path: ./assets/new_favicon.webp
page_footer_text: ⚠️ 本服務提供之資訊僅供規劃參考,山區氣候瞬息萬變,請務必依據現場狀況與自身能力進行風險評估<br>Made with ❤️ by <a href="https://gitea.guineapig.love/deng">deng</a> page_footer_text: ⚠️ 本服務提供之資訊僅供規劃參考,山區氣候瞬息萬變,請務必依據現場狀況與自身能力進行風險評估<br>Made with ❤️ by <a href="https://gitea.guineapig.love/deng">deng</a>
page_background_path: ./assets/background_compressed.jpg
page_background_opacity: 0.8
altitude_sickness:
elevation_threshold: 2100
warning_text: 此路線海拔較高,請留意[高山症](https://www.ysnp.gov.tw/StaticPage/MountainSickness)發生風險
emoji: 💊
estimated_time:
horizontal_speed: 3
vertical_speed: 400
map:
height: 550
weather: weather:
forecast_days: 7 forecast_days: 7

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,12 +1,17 @@
"""Elevation profile visualization module using Plotly.""" # elevation.py
#
# author: deng
# date: 20251127
from typing import Optional
import plotly.graph_objects as go import plotly.graph_objects as go
class ElevationProfileRenderer: class ElevationRenderer:
"""Render elevation profiles with gradient-based coloring.""" """Render elevation profiles with gradient-based coloring."""
def __init__(self): def __init__(self) -> None:
"""Initialize elevation profile renderer.""" """Initialize elevation profile renderer."""
self.color = { self.color = {
'steep_ascent': 'rgb(139, 69, 19)', 'steep_ascent': 'rgb(139, 69, 19)',
@ -29,7 +34,7 @@ class ElevationProfileRenderer:
'gentle_descent': '緩下坡 (> -20%)', 'gentle_descent': '緩下坡 (> -20%)',
} }
def _get_gradient_category(self, gradient): def _get_gradient_category(self, gradient: float) -> str:
"""Determine gradient category based on slope thresholds. """Determine gradient category based on slope thresholds.
Args: Args:
@ -47,7 +52,9 @@ class ElevationProfileRenderer:
else: else:
return 'gentle_descent' return 'gentle_descent'
def _downsample_data(self, distances, elevations, gradients, max_points=800): def _downsample_data(
self, distances: list[float], elevations: list[float], gradients: list[float], max_points: int = 800
) -> tuple[list[float], list[float], list[float]]:
"""Downsample data if there are too many points. """Downsample data if there are too many points.
Args: Args:
@ -93,7 +100,7 @@ class ElevationProfileRenderer:
return downsampled_distances, downsampled_elevations, downsampled_gradients return downsampled_distances, downsampled_elevations, downsampled_gradients
def create_elevation_profile(self, distances, elevations, gradients): def create_elevation_profile(self, distances: list[float], elevations: list[float], gradients: list[float]) -> Optional[go.Figure]:
"""Create an interactive elevation profile chart. """Create an interactive elevation profile chart.
Args: Args:
@ -181,7 +188,7 @@ class ElevationProfileRenderer:
return fig return fig
def calculate_gradient_distribution(self, distances, gradients): def calculate_gradient_distribution(self, distances: list[float], gradients: list[float]) -> Optional[dict[str, float]]:
"""Calculate distance distribution across different gradient categories. """Calculate distance distribution across different gradient categories.
Args: Args:
@ -221,7 +228,7 @@ class ElevationProfileRenderer:
'gentle_descent': gentle_descent_km, 'gentle_descent': gentle_descent_km,
} }
def create_gradient_pie_chart(self, distances, gradients): def create_gradient_pie_chart(self, distances: list[float], gradients: list[float]) -> Optional[go.Figure]:
"""Create a pie chart showing gradient distribution. """Create a pie chart showing gradient distribution.
Args: Args:
@ -259,7 +266,7 @@ class ElevationProfileRenderer:
return fig return fig
def add_gradient_legend(self, fig): def add_gradient_legend(self, fig: go.Figure) -> go.Figure:
"""Add a custom legend explaining the color scheme. """Add a custom legend explaining the color scheme.
Args: Args:

View File

@ -3,15 +3,17 @@
# author: deng # author: deng
# date: 20251127 # date: 20251127
from typing import IO, Optional
import gpxpy import gpxpy
import gpxpy.gpx import numpy as np
from geopy.distance import geodesic from geopy.distance import geodesic
class GPXProcessor: class GPXProcessor:
"""Handle GPX file parsing and route analysis.""" """Handle GPX file parsing and route analysis."""
def __init__(self, gpx_file): def __init__(self, gpx_file: IO) -> None:
"""Initialize GPX processor with uploaded file. """Initialize GPX processor with uploaded file.
Args: Args:
@ -19,11 +21,25 @@ class GPXProcessor:
""" """
self.gpx_file = gpx_file self.gpx_file = gpx_file
self.gpx = None self.gpx = None
self.time = []
self.points = [] self.points = []
self.elevations = [] self.elevations = [] # unit: m
self.distances = [] # unit: km self.distances = [] # unit: km
self.waypoints = [] # list of (lat, lon, name, elevation)
def validate_and_parse(self): def _get_sample_rate(self) -> float:
"""Get the median time interval between points.
Returns:
float: Median sample time interval in seconds
"""
if len(self.time) < 2:
return 0
diff = np.diff(self.time)
median_sample_time = np.median(diff).total_seconds()
return median_sample_time
def validate_and_parse(self) -> bool:
"""Validate and parse the GPX file. """Validate and parse the GPX file.
Returns: Returns:
@ -39,19 +55,31 @@ class GPXProcessor:
self.gpx = gpxpy.parse(gpx_data) self.gpx = gpxpy.parse(gpx_data)
# Extract waypoints
for waypoint in self.gpx.waypoints:
self.waypoints.append(
{
'lat': round(waypoint.latitude, 6),
'lon': round(waypoint.longitude, 6),
'name': waypoint.name if waypoint.name else '航點',
'elevation': round(waypoint.elevation, 1) if waypoint.elevation else None,
}
)
# Extract points from all tracks and segments # Extract points from all tracks and segments
for track in self.gpx.tracks: for track in self.gpx.tracks:
for segment in track.segments: for segment in track.segments:
for point in segment.points: for point in segment.points:
self.points.append((point.latitude, point.longitude)) self.time.append(point.time)
self.elevations.append(point.elevation if point.elevation else 0) self.points.append((round(point.latitude, 6), round(point.longitude, 6)))
self.elevations.append(round(point.elevation, 1) if point.elevation else 0)
return len(self.points) > 0 return len(self.points) > 0
except Exception as e: except Exception as e:
raise Exception(f'GPX 檔案解析失敗: {str(e)}') raise Exception(f'GPX 檔案解析失敗: {str(e)}')
def calculate_distance(self): def calculate_distance(self) -> float:
"""Calculate total distance of the route. """Calculate total distance of the route.
Returns: Returns:
@ -70,45 +98,38 @@ class GPXProcessor:
return total_distance return total_distance
def calculate_elevation_gain_loss(self, threshold=3.0): def calculate_elevation_gain_loss(self, threshold: float = 1.0) -> tuple[float, float]:
"""Calculate total elevation gain and loss with noise filtering. """Calculate total elevation gain and loss with noise filtering.
GPS altitude data is notoriously inaccurate. Small fluctuations (noise) can GPS altitude data is notoriously inaccurate. Small fluctuations (noise) can
accumulate to create inflated elevation gain/loss values. This method uses accumulate to create inflated elevation gain/loss values. This method uses
a threshold to filter out GPS noise. a threshold to filter out GPS noise and downsampling to reduce computation.
Args: Args:
threshold: Minimum elevation change in meters to be counted (default: 3.0) threshold: Minimum elevation change in meters to be counted (default: 1.0)
This filters out GPS noise while preserving real elevation changes. This filters out GPS noise while preserving real elevation changes.
Returns: Returns:
tuple: (elevation_gain, elevation_loss) in meters tuple: (elevation_gain, elevation_loss) in meters
""" """
if len(self.elevations) < 2: window_size = int(120 / self._get_sample_rate())
if len(self.elevations) <= window_size:
return 0.0, 0.0 return 0.0, 0.0
gain = 0.0 gain = 0.0
loss = 0.0 loss = 0.0
# Use cumulative approach to avoid missing real elevation changes for i in range(window_size, len(self.elevations), window_size):
# Track cumulative change since last significant threshold crossing diff = self.elevations[i] - self.elevations[i - window_size]
cumulative_change = 0.0
for i in range(1, len(self.elevations)): if diff >= threshold:
diff = self.elevations[i] - self.elevations[i - 1] gain += diff
cumulative_change += diff elif diff <= -threshold:
loss += abs(diff)
# Only count if cumulative change exceeds threshold
if cumulative_change >= threshold:
gain += cumulative_change
cumulative_change = 0.0
elif cumulative_change <= -threshold:
loss += abs(cumulative_change)
cumulative_change = 0.0
return gain, loss return gain, loss
def get_min_max_elevation(self): def get_min_max_elevation(self) -> tuple[float, float]:
"""Get minimum and maximum elevation. """Get minimum and maximum elevation.
Returns: Returns:
@ -119,18 +140,18 @@ class GPXProcessor:
return min(self.elevations), max(self.elevations) return min(self.elevations), max(self.elevations)
def get_start_end_points(self): def get_start_end_points(self) -> tuple[Optional[tuple[float, float, float]], Optional[tuple[float, float, float]]]:
"""Get start and end point coordinates. """Get start and end point coordinates with elevation.
Returns: Returns:
tuple: ((start_lat, start_lon), (end_lat, end_lon)) tuple: ((start_lat, start_lon, start_elevation), (end_lat, end_lon, end_elevation))
""" """
if len(self.points) < 2: if len(self.points) < 2:
return None, None return None, None
return self.points[0], self.points[-1] return (self.points[0][0], self.points[0][1], self.elevations[0]), (self.points[-1][0], self.points[-1][1], self.elevations[-1])
def get_all_points(self): def get_all_points(self) -> list[tuple[float, float]]:
"""Get all route points. """Get all route points.
Returns: Returns:
@ -138,7 +159,7 @@ class GPXProcessor:
""" """
return self.points return self.points
def get_elevation_profile_data(self): def get_elevation_profile_data(self) -> tuple[list[float], list[float]]:
"""Get data for elevation profile visualization. """Get data for elevation profile visualization.
Returns: Returns:
@ -146,7 +167,7 @@ class GPXProcessor:
""" """
return self.distances, self.elevations return self.distances, self.elevations
def get_gradients(self): def get_gradients(self) -> list[float]:
"""Calculate gradient (slope) for each segment. """Calculate gradient (slope) for each segment.
Returns: Returns:
@ -169,12 +190,12 @@ class GPXProcessor:
return gradients return gradients
def calculate_naismith_time(self): def calculate_naismith_time(self, horizontal_speed: float = 5, vertical_speed: float = 600) -> int:
"""Calculate estimated hiking time using Naismith's Rule. """Calculate estimated hiking time using Naismith's Rule.
Naismith's Rule: Naismith's Rule:
- Base time: 1 hour per 5 km of horizontal distance - Horizontal speed: 1 hour per 5 km of horizontal distance
- Add time: 1 hour per 600 meters of ascent - Vertical speed: 1 hour per 600 meters of ascent
Returns: Returns:
int: Estimated time in minutes int: Estimated time in minutes
@ -187,11 +208,8 @@ class GPXProcessor:
elevation_gain, _ = self.calculate_elevation_gain_loss() elevation_gain, _ = self.calculate_elevation_gain_loss()
# Naismith's Rule calculation # Naismith's Rule calculation
# 1 hour per 5 km = 0.2 hours per km time_for_distance = total_distance / horizontal_speed
time_for_distance = total_distance * 0.2 time_for_ascent = elevation_gain / vertical_speed
# 1 hour per 600 meters of ascent
time_for_ascent = elevation_gain / 600.0
total_time = int((time_for_distance + time_for_ascent) * 60) total_time = int((time_for_distance + time_for_ascent) * 60)

View File

@ -1,25 +1,36 @@
# map_render.py # map.py
# #
# author: deng # author: deng
# date: 20251127 # date: 20251127
from typing import Optional
import folium import folium
class MapRenderer: class MapRenderer:
"""Render hiking routes on interactive maps.""" """Render hiking routes on interactive maps."""
def __init__(self): def __init__(self) -> None:
"""Initialize map renderer.""" """Initialize map renderer."""
pass pass
def create_route_map(self, points, start_point, end_point): def create_route_map(
self,
points: list[tuple[float, float]],
start_point: Optional[tuple[float, float, float]],
end_point: Optional[tuple[float, float, float]],
waypoints: Optional[list[dict]] = None,
tile_layer: str = 'OpenStreetMap',
) -> Optional[folium.Map]:
"""Create an interactive map with the hiking route. """Create an interactive map with the hiking route.
Args: Args:
points: List of (latitude, longitude) tuples for the route points: List of (latitude, longitude) tuples for the route
start_point: (latitude, longitude) tuple for start start_point: (latitude, longitude) tuple for start
end_point: (latitude, longitude) tuple for end end_point: (latitude, longitude) tuple for end
waypoints: List of waypoint dictionaries with 'lat', 'lon', 'name', 'elevation'
tile_layer: Map tile layer to use ('OpenStreetMap' or 'RudyMap')
Returns: Returns:
folium.Map: Interactive map object folium.Map: Interactive map object
@ -31,13 +42,36 @@ class MapRenderer:
center_lat = sum(p[0] for p in points) / len(points) center_lat = sum(p[0] for p in points) / len(points)
center_lon = sum(p[1] for p in points) / len(points) center_lon = sum(p[1] for p in points) / len(points)
# Create map centered on the route # Create map obj
route_map = folium.Map( route_map = folium.Map(
location=[center_lat, center_lon], location=[center_lat, center_lon],
zoom_start=13, zoom_start=13,
tiles='OpenStreetMap', tiles=None,
) )
# Add OpenStreetMap layer
folium.TileLayer(
tiles='OpenStreetMap',
name='平面OpenStreetMap',
overlay=False,
control=True,
show=tile_layer == 'OpenStreetMap',
).add_to(route_map)
# Add Rudy Map (魯地圖) layer
folium.TileLayer(
tiles='https://tile.happyman.idv.tw/map/moi_osm/{z}/{x}/{y}.png',
attr='&copy; <a href="https://rudy.basecamp.tw/">魯地圖</a>',
name='等高線(魯地圖)',
overlay=False,
control=True,
show=tile_layer == 'RudyMap',
max_zoom=18,
).add_to(route_map)
# Add layer control to switch between maps
folium.LayerControl(position='topright').add_to(route_map)
# Add the route as a red polyline # Add the route as a red polyline
folium.PolyLine( folium.PolyLine(
locations=points, locations=points,
@ -47,29 +81,43 @@ class MapRenderer:
tooltip='登山路線', tooltip='登山路線',
).add_to(route_map) ).add_to(route_map)
# Add waypoints if provided
if waypoints:
for waypoint in waypoints:
popup_text = f'<b>{waypoint["name"]}</b>'
if waypoint.get('elevation'):
popup_text += f'<br>海拔: {waypoint["elevation"]} m'
folium.Marker(
location=[waypoint['lat'], waypoint['lon']],
popup=folium.Popup(popup_text, max_width=200),
tooltip=waypoint['name'],
icon=folium.Icon(color='orange', icon='info-sign'),
).add_to(route_map)
# Add start point marker (blue circle) # Add start point marker (blue circle)
if start_point: if start_point:
folium.CircleMarker( folium.CircleMarker(
location=start_point, location=(start_point[0], start_point[1]),
radius=8, radius=8,
color='blue', color='blue',
fill=True, fill=True,
fill_color='blue', fill_color='blue',
fill_opacity=0.7, fill_opacity=0.7,
popup='起點', popup=folium.Popup(f'<b>起點</b><br>海拔: {start_point[2]} m', max_width=200),
tooltip='起點', tooltip='起點',
).add_to(route_map) ).add_to(route_map)
# Add end point marker (blue circle) # Add end point marker (blue circle)
if end_point: if end_point:
folium.CircleMarker( folium.CircleMarker(
location=end_point, location=(end_point[0], end_point[1]),
radius=8, radius=8,
color='blue', color='blue',
fill=True, fill=True,
fill_color='blue', fill_color='blue',
fill_opacity=0.7, fill_opacity=0.7,
popup='終點', popup=folium.Popup(f'<b>終點</b><br>海拔: {end_point[2]} m', max_width=200),
tooltip='終點', tooltip='終點',
).add_to(route_map) ).add_to(route_map)
@ -78,7 +126,7 @@ class MapRenderer:
return route_map return route_map
def add_hover_marker(self, route_map, position, label='當前位置'): def add_hover_marker(self, route_map: folium.Map, position: tuple[float, float], label: str = '當前位置') -> folium.Map:
"""Add a hover position marker to the map. """Add a hover position marker to the map.
Args: Args:

14
hiking_assistant/utils.py Normal file
View File

@ -0,0 +1,14 @@
# utils.py
#
# author: deng
# date: 20251128
import base64
import streamlit as st
@st.cache_data
def convert_image_to_base64(image_path: str) -> str:
with open(image_path, 'rb') as f:
return base64.b64encode(f.read()).decode()

View File

@ -3,19 +3,21 @@
# author: deng # author: deng
# date: 20251127 # date: 20251127
from typing import Optional
import requests import requests
class WeatherFetcher: class WeatherFetcher:
"""Fetch weather data for hiking locations.""" """Fetch weather data for hiking locations."""
def __init__(self, api_url='https://api.open-meteo.com/v1/forecast', request_timeout=8, forecast_days=7): def __init__(self, api_url: str = 'https://api.open-meteo.com/v1/forecast', request_timeout: int = 8, forecast_days: int = 7) -> None:
"""Initialize weather fetcher.""" """Initialize weather fetcher."""
self.api_url = api_url self.api_url = api_url
self.request_timeout = request_timeout self.request_timeout = request_timeout
self.forecast_days = max(forecast_days, 1) self.forecast_days = max(forecast_days, 1)
def convert_wind_degrees_to_flow_direction(self, degrees): def convert_wind_degrees_to_flow_direction(self, degrees: Optional[float]) -> str:
"""Convert wind direction from degrees to flow direction emoji. """Convert wind direction from degrees to flow direction emoji.
Args: Args:
@ -30,7 +32,7 @@ class WeatherFetcher:
index = round(degrees / 45) % 8 index = round(degrees / 45) % 8
return directions[index] return directions[index]
def get_wind_speed_indicator(self, wind_speed): def get_wind_speed_indicator(self, wind_speed: Optional[float]) -> str:
"""Get wind speed level indicator based on speed. """Get wind speed level indicator based on speed.
Args: Args:
@ -51,7 +53,7 @@ class WeatherFetcher:
else: else:
return '🔴' # Dangerous return '🔴' # Dangerous
def get_uv_index_indicator(self, uv_index): def get_uv_index_indicator(self, uv_index: Optional[float]) -> str:
"""Get UV index level indicator based on index. """Get UV index level indicator based on index.
Args: Args:
@ -74,7 +76,7 @@ class WeatherFetcher:
else: else:
return '🟣' # Extreme return '🟣' # Extreme
def get_weather(self, latitude, longitude): def get_weather(self, latitude: float, longitude: float) -> Optional[dict]:
"""Fetch weather data for given coordinates. """Fetch weather data for given coordinates.
Args: Args:

View File

@ -1,6 +1,6 @@
[project] [project]
name = "hiking-assistant" name = "hiking-assistant"
version = "0.1.0" version = "1.0.1"
description = "This is a web app to analyze gpx for hiking planning" description = "This is a web app to analyze gpx for hiking planning"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@
# __init__.py for tests package

268
tests/test_gpx.py Normal file
View File

@ -0,0 +1,268 @@
# test_gpx.py
#
# author: deng
# date: 20251129
import io
import pytest
class TestGPXProcessor:
"""Test cases for GPXProcessor class."""
@pytest.fixture
def simple_gpx_data(self):
"""Create a simple GPX file data for testing."""
gpx_xml = """<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test">
<wpt lat="25.0" lon="121.0">
<ele>100</ele>
<name>Test Waypoint</name>
</wpt>
<trk>
<name>Test Track</name>
<trkseg>
<trkpt lat="25.0" lon="121.0">
<ele>100</ele>
<time>2024-01-01T00:00:00Z</time>
</trkpt>
<trkpt lat="25.001" lon="121.001">
<ele>150</ele>
<time>2024-01-01T00:01:00Z</time>
</trkpt>
<trkpt lat="25.002" lon="121.002">
<ele>120</ele>
<time>2024-01-01T00:02:00Z</time>
</trkpt>
</trkseg>
</trk>
</gpx>"""
return io.BytesIO(gpx_xml.encode('utf-8'))
@pytest.fixture
def gpx_processor(self, simple_gpx_data):
"""Create a GPXProcessor instance with simple data."""
from hiking_assistant.gpx import GPXProcessor
processor = GPXProcessor(simple_gpx_data)
processor.validate_and_parse()
return processor
def test_init(self):
"""Test GPXProcessor initialization."""
from hiking_assistant.gpx import GPXProcessor
gpx_file = io.BytesIO(b'test data')
processor = GPXProcessor(gpx_file)
assert processor.gpx_file == gpx_file
assert processor.gpx is None
assert processor.points == []
assert processor.elevations == []
assert processor.waypoints == []
def test_validate_and_parse_success(self, simple_gpx_data):
"""Test successful GPX parsing."""
from hiking_assistant.gpx import GPXProcessor
processor = GPXProcessor(simple_gpx_data)
result = processor.validate_and_parse()
assert result is True
assert len(processor.points) == 3
assert len(processor.elevations) == 3
assert len(processor.waypoints) == 1
def test_validate_and_parse_invalid_gpx_raises_exception(self):
"""Test that invalid GPX data raises exception."""
from hiking_assistant.gpx import GPXProcessor
invalid_data = io.BytesIO(b'not a valid gpx file')
processor = GPXProcessor(invalid_data)
with pytest.raises(Exception, match='GPX 檔案解析失敗'):
processor.validate_and_parse()
def test_waypoint_extraction(self, gpx_processor):
"""Test waypoint extraction from GPX."""
assert len(gpx_processor.waypoints) == 1
waypoint = gpx_processor.waypoints[0]
assert waypoint['lat'] == 25.0
assert waypoint['lon'] == 121.0
assert waypoint['name'] == 'Test Waypoint'
assert waypoint['elevation'] == 100.0
def test_calculate_distance(self, gpx_processor):
"""Test distance calculation."""
distance = gpx_processor.calculate_distance()
assert distance > 0
assert isinstance(distance, float)
assert len(gpx_processor.distances) == 3
assert gpx_processor.distances[0] == 0.0
def test_calculate_distance_empty_points_returns_zero(self):
"""Test distance calculation with no points."""
from hiking_assistant.gpx import GPXProcessor
processor = GPXProcessor(io.BytesIO(b''))
distance = processor.calculate_distance()
assert distance == 0.0
def test_calculate_elevation_gain_loss(self, gpx_processor):
"""Test elevation gain/loss calculation."""
gain, loss = gpx_processor.calculate_elevation_gain_loss()
assert isinstance(gain, float)
assert isinstance(loss, float)
assert gain >= 0
assert loss >= 0
def test_get_min_max_elevation(self, gpx_processor):
"""Test min/max elevation retrieval."""
min_elev, max_elev = gpx_processor.get_min_max_elevation()
assert min_elev == 100.0
assert max_elev == 150.0
def test_get_min_max_elevation_empty_returns_zero(self):
"""Test min/max elevation with empty data."""
from hiking_assistant.gpx import GPXProcessor
processor = GPXProcessor(io.BytesIO(b''))
min_elev, max_elev = processor.get_min_max_elevation()
assert min_elev == 0.0
assert max_elev == 0.0
def test_get_start_end_points(self, gpx_processor):
"""Test start/end points retrieval."""
start, end = gpx_processor.get_start_end_points()
assert start == (25.0, 121.0, 100.0)
assert end == (25.002, 121.002, 120.0)
def test_get_start_end_points_insufficient_data_returns_none(self):
"""Test start/end points with insufficient data."""
from hiking_assistant.gpx import GPXProcessor
processor = GPXProcessor(io.BytesIO(b''))
start, end = processor.get_start_end_points()
assert start is None
assert end is None
def test_get_all_points(self, gpx_processor):
"""Test getting all route points."""
points = gpx_processor.get_all_points()
assert len(points) == 3
assert all(isinstance(p, tuple) and len(p) == 2 for p in points)
def test_get_elevation_profile_data(self, gpx_processor):
"""Test elevation profile data retrieval."""
# Need to calculate distances first
gpx_processor.calculate_distance()
distances, elevations = gpx_processor.get_elevation_profile_data()
assert len(distances) == len(elevations)
assert len(distances) == 3
def test_get_gradients(self, gpx_processor):
"""Test gradient calculation."""
gradients = gpx_processor.get_gradients()
assert len(gradients) == 2 # n-1 gradients for n points
assert all(isinstance(g, (int, float)) for g in gradients)
def test_get_gradients_empty_returns_empty_list(self):
"""Test gradient calculation with no points."""
from hiking_assistant.gpx import GPXProcessor
processor = GPXProcessor(io.BytesIO(b''))
gradients = processor.get_gradients()
assert gradients == []
def test_calculate_naismith_time(self, gpx_processor):
"""Test Naismith's Rule time calculation."""
time_minutes = gpx_processor.calculate_naismith_time()
assert isinstance(time_minutes, int)
assert time_minutes >= 0
def test_calculate_naismith_time_with_custom_speeds(self, gpx_processor):
"""Test Naismith calculation with custom speeds."""
time_default = gpx_processor.calculate_naismith_time()
time_fast = gpx_processor.calculate_naismith_time(horizontal_speed=10, vertical_speed=1200)
# Faster speeds should result in less time
assert time_fast < time_default
def test_calculate_naismith_time_no_points_returns_zero(self):
"""Test Naismith calculation with no points."""
from hiking_assistant.gpx import GPXProcessor
processor = GPXProcessor(io.BytesIO(b''))
time_minutes = processor.calculate_naismith_time()
assert time_minutes == 0
class TestGPXProcessorEdgeCases:
"""Test edge cases for GPXProcessor."""
def test_gpx_with_bytes_data(self):
"""Test GPX parsing with bytes input."""
from hiking_assistant.gpx import GPXProcessor
gpx_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1">
<trk>
<trkseg>
<trkpt lat="25.0" lon="121.0"><ele>100</ele></trkpt>
</trkseg>
</trk>
</gpx>"""
processor = GPXProcessor(io.BytesIO(gpx_xml))
result = processor.validate_and_parse()
assert result is True
def test_gpx_with_string_data(self):
"""Test GPX parsing with string input."""
from hiking_assistant.gpx import GPXProcessor
gpx_xml = """<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1">
<trk>
<trkseg>
<trkpt lat="25.0" lon="121.0"><ele>100</ele></trkpt>
</trkseg>
</trk>
</gpx>"""
processor = GPXProcessor(io.StringIO(gpx_xml))
result = processor.validate_and_parse()
assert result is True
def test_gpx_with_no_elevation_data(self):
"""Test GPX parsing when elevation data is missing."""
from hiking_assistant.gpx import GPXProcessor
gpx_xml = """<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1">
<trk>
<trkseg>
<trkpt lat="25.0" lon="121.0"></trkpt>
<trkpt lat="25.001" lon="121.001"></trkpt>
</trkseg>
</trk>
</gpx>"""
processor = GPXProcessor(io.BytesIO(gpx_xml.encode('utf-8')))
processor.validate_and_parse()
# Should default to 0 elevation
assert all(e == 0 for e in processor.elevations)

62
tests/test_utils.py Normal file
View File

@ -0,0 +1,62 @@
# test_utils.py
#
# author: deng
# date: 20251129
import base64
import tempfile
from pathlib import Path
import pytest
class TestConvertImageToBase64:
"""Test cases for convert_image_to_base64 function."""
@pytest.fixture
def temp_image_file(self):
"""Create a temporary image file for testing."""
with tempfile.NamedTemporaryFile(mode='wb', suffix='.jpg', delete=False) as f:
# Create a simple test image (1x1 red pixel)
f.write(b'\xff\xd8\xff\xe0\x00\x10JFIF') # JPEG header
temp_path = f.name
yield temp_path
# Cleanup
Path(temp_path).unlink(missing_ok=True)
def test_convert_image_to_base64_returns_string(self, temp_image_file):
"""Test that function returns a string."""
from hiking_assistant.utils import convert_image_to_base64
result = convert_image_to_base64(temp_image_file)
assert isinstance(result, str)
def test_convert_image_to_base64_returns_valid_base64(self, temp_image_file):
"""Test that returned string is valid base64."""
from hiking_assistant.utils import convert_image_to_base64
result = convert_image_to_base64(temp_image_file)
# Try to decode - should not raise exception
decoded = base64.b64decode(result)
assert isinstance(decoded, bytes)
def test_convert_image_to_base64_roundtrip(self, temp_image_file):
"""Test roundtrip conversion (file -> base64 -> binary)."""
from hiking_assistant.utils import convert_image_to_base64
# Read original file
with open(temp_image_file, 'rb') as f:
original_data = f.read()
# Convert to base64 and back
base64_str = convert_image_to_base64(temp_image_file)
decoded_data = base64.b64decode(base64_str)
assert decoded_data == original_data
def test_convert_image_to_base64_nonexistent_file_raises_error(self):
"""Test that function raises error for non-existent file."""
from hiking_assistant.utils import convert_image_to_base64
with pytest.raises(FileNotFoundError):
convert_image_to_base64('/nonexistent/path/to/image.jpg')

262
tests/test_weather.py Normal file
View File

@ -0,0 +1,262 @@
# test_weather.py
#
# author: deng
# date: 20251129
from unittest.mock import Mock, patch
import pytest
import requests
class TestWeatherFetcher:
"""Test cases for WeatherFetcher class."""
@pytest.fixture
def weather_fetcher(self):
"""Create a WeatherFetcher instance."""
from hiking_assistant.weather import WeatherFetcher
return WeatherFetcher()
@pytest.fixture
def mock_weather_response(self):
"""Mock weather API response data."""
return {
'current': {
'relative_humidity_2m': 75,
'apparent_temperature': 18.5,
'precipitation_probability': 30,
'precipitation': 0.5,
'wind_speed_10m': 15.2,
'wind_direction_10m': 180,
},
'daily': {
'temperature_2m_max': [22.0, 23.5, 21.0],
'temperature_2m_min': [15.0, 16.0, 14.5],
'apparent_temperature_max': [20.0, 21.5, 19.0],
'apparent_temperature_min': [13.0, 14.0, 12.5],
'relative_humidity_2m_mean': [70, 75, 68],
'precipitation_sum': [0.0, 2.5, 0.0],
'precipitation_probability_max': [20, 60, 15],
'wind_speed_10m_max': [25.0, 35.0, 20.0],
'wind_direction_10m_dominant': [180, 90, 270],
'sunrise': ['2024-01-01T06:00:00', '2024-01-02T06:01:00', '2024-01-03T06:02:00'],
'sunset': ['2024-01-01T18:00:00', '2024-01-02T18:01:00', '2024-01-03T18:02:00'],
'uv_index_max': [5.0, 7.0, 3.0],
'time': ['2024-01-01', '2024-01-02', '2024-01-03'],
},
}
def test_init_default_values(self):
"""Test WeatherFetcher initialization with defaults."""
from hiking_assistant.weather import WeatherFetcher
fetcher = WeatherFetcher()
assert fetcher.api_url == 'https://api.open-meteo.com/v1/forecast'
assert fetcher.request_timeout == 8
assert fetcher.forecast_days == 7
def test_init_custom_values(self):
"""Test WeatherFetcher initialization with custom values."""
from hiking_assistant.weather import WeatherFetcher
fetcher = WeatherFetcher(api_url='https://custom.api.com', request_timeout=10, forecast_days=3)
assert fetcher.api_url == 'https://custom.api.com'
assert fetcher.request_timeout == 10
assert fetcher.forecast_days == 3
def test_convert_wind_degrees_to_flow_direction_north(self, weather_fetcher):
"""Test wind direction conversion for north."""
result = weather_fetcher.convert_wind_degrees_to_flow_direction(0)
assert result in ['⬇️', '⬆️'] # 0 and 360 degrees
def test_convert_wind_degrees_to_flow_direction_east(self, weather_fetcher):
"""Test wind direction conversion for east."""
result = weather_fetcher.convert_wind_degrees_to_flow_direction(90)
assert result == '⬅️' # 90 degrees = east wind blowing west
def test_convert_wind_degrees_to_flow_direction_south(self, weather_fetcher):
"""Test wind direction conversion for south."""
result = weather_fetcher.convert_wind_degrees_to_flow_direction(180)
assert result == '⬆️'
def test_convert_wind_degrees_to_flow_direction_west(self, weather_fetcher):
"""Test wind direction conversion for west."""
result = weather_fetcher.convert_wind_degrees_to_flow_direction(270)
assert result == '➡️' # 270 degrees = west wind blowing east
def test_convert_wind_degrees_to_flow_direction_none(self, weather_fetcher):
"""Test wind direction conversion with None input."""
result = weather_fetcher.convert_wind_degrees_to_flow_direction(None)
assert result == 'N/A'
def test_get_wind_speed_indicator_safe(self, weather_fetcher):
"""Test wind speed indicator for safe level."""
result = weather_fetcher.get_wind_speed_indicator(15)
assert result == '🟢'
def test_get_wind_speed_indicator_caution(self, weather_fetcher):
"""Test wind speed indicator for caution level."""
result = weather_fetcher.get_wind_speed_indicator(30)
assert result == '🟡'
def test_get_wind_speed_indicator_alert(self, weather_fetcher):
"""Test wind speed indicator for alert level."""
result = weather_fetcher.get_wind_speed_indicator(50)
assert result == '🟠'
def test_get_wind_speed_indicator_dangerous(self, weather_fetcher):
"""Test wind speed indicator for dangerous level."""
result = weather_fetcher.get_wind_speed_indicator(65)
assert result == '🔴'
def test_get_wind_speed_indicator_none(self, weather_fetcher):
"""Test wind speed indicator with None input."""
result = weather_fetcher.get_wind_speed_indicator(None)
assert result == ''
def test_get_uv_index_indicator_low(self, weather_fetcher):
"""Test UV index indicator for low level."""
result = weather_fetcher.get_uv_index_indicator(2)
assert result == '🟢'
def test_get_uv_index_indicator_moderate(self, weather_fetcher):
"""Test UV index indicator for moderate level."""
result = weather_fetcher.get_uv_index_indicator(4)
assert result == '🟡'
def test_get_uv_index_indicator_high(self, weather_fetcher):
"""Test UV index indicator for high level."""
result = weather_fetcher.get_uv_index_indicator(6)
assert result == '🟠'
def test_get_uv_index_indicator_very_high(self, weather_fetcher):
"""Test UV index indicator for very high level."""
result = weather_fetcher.get_uv_index_indicator(9)
assert result == '🔴'
def test_get_uv_index_indicator_extreme(self, weather_fetcher):
"""Test UV index indicator for extreme level."""
result = weather_fetcher.get_uv_index_indicator(11)
assert result == '🟣'
def test_get_uv_index_indicator_none(self, weather_fetcher):
"""Test UV index indicator with None input."""
result = weather_fetcher.get_uv_index_indicator(None)
assert result == ''
@patch('hiking_assistant.weather.requests.get')
def test_get_weather_success(self, mock_get, weather_fetcher, mock_weather_response):
"""Test successful weather data retrieval."""
mock_response = Mock()
mock_response.json.return_value = mock_weather_response
mock_get.return_value = mock_response
result = weather_fetcher.get_weather(25.0, 121.0)
assert result is not None
assert 'current_humidity' in result
assert 'daily_temp_max' in result
assert result['current_humidity'] == 75
assert len(result['daily_temp_max']) == 3
@patch('hiking_assistant.weather.requests.get')
def test_get_weather_request_exception(self, mock_get, weather_fetcher):
"""Test weather retrieval with request exception."""
mock_get.side_effect = requests.exceptions.RequestException('Connection error')
result = weather_fetcher.get_weather(25.0, 121.0)
assert result is None
@patch('hiking_assistant.weather.requests.get')
def test_get_weather_json_parsing_error(self, mock_get, weather_fetcher):
"""Test weather retrieval with JSON parsing error."""
mock_response = Mock()
mock_response.json.side_effect = ValueError('Invalid JSON')
mock_get.return_value = mock_response
result = weather_fetcher.get_weather(25.0, 121.0)
assert result is None
@patch('hiking_assistant.weather.requests.get')
def test_get_weather_api_parameters(self, mock_get, weather_fetcher, mock_weather_response):
"""Test that correct API parameters are sent."""
mock_response = Mock()
mock_response.json.return_value = mock_weather_response
mock_get.return_value = mock_response
weather_fetcher.get_weather(25.123, 121.456)
# Verify the call was made with correct parameters
mock_get.assert_called_once()
call_args = mock_get.call_args
params = call_args[1]['params']
assert params['latitude'] == 25.123
assert params['longitude'] == 121.456
assert params['timezone'] == 'auto'
assert params['forecast_days'] == 7
@patch('hiking_assistant.weather.requests.get')
def test_get_weather_timeout_parameter(self, mock_get, weather_fetcher, mock_weather_response):
"""Test that timeout parameter is used."""
mock_response = Mock()
mock_response.json.return_value = mock_weather_response
mock_get.return_value = mock_response
weather_fetcher.get_weather(25.0, 121.0)
# Verify timeout was passed
call_args = mock_get.call_args
assert call_args[1]['timeout'] == 8
class TestWeatherFetcherEdgeCases:
"""Test edge cases for WeatherFetcher."""
def test_forecast_days_minimum_is_one(self):
"""Test that forecast_days is at least 1."""
from hiking_assistant.weather import WeatherFetcher
fetcher = WeatherFetcher(forecast_days=0)
assert fetcher.forecast_days == 1
fetcher = WeatherFetcher(forecast_days=-5)
assert fetcher.forecast_days == 1
def test_wind_direction_boundary_values(self):
"""Test wind direction conversion at boundary values."""
from hiking_assistant.weather import WeatherFetcher
fetcher = WeatherFetcher()
# Test 360 degrees (same as 0)
result_0 = fetcher.convert_wind_degrees_to_flow_direction(0)
result_360 = fetcher.convert_wind_degrees_to_flow_direction(360)
assert result_0 == result_360
def test_wind_speed_boundary_values(self):
"""Test wind speed indicators at exact boundary values."""
from hiking_assistant.weather import WeatherFetcher
fetcher = WeatherFetcher()
assert fetcher.get_wind_speed_indicator(20) == '🟡' # Exactly 20
assert fetcher.get_wind_speed_indicator(40) == '🟠' # Exactly 40
assert fetcher.get_wind_speed_indicator(60) == '🔴' # Exactly 60
def test_uv_index_boundary_values(self):
"""Test UV index indicators at exact boundary values."""
from hiking_assistant.weather import WeatherFetcher
fetcher = WeatherFetcher()
assert fetcher.get_uv_index_indicator(2) == '🟢' # Exactly 2
assert fetcher.get_uv_index_indicator(5) == '🟡' # Exactly 5
assert fetcher.get_uv_index_indicator(7) == '🟠' # Exactly 7
assert fetcher.get_uv_index_indicator(10) == '🔴' # Exactly 10

2
uv.lock generated
View File

@ -227,7 +227,7 @@ wheels = [
[[package]] [[package]]
name = "hiking-assistant" name = "hiking-assistant"
version = "0.1.0" version = "1.0.1"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "folium" }, { name = "folium" },