Compare commits

...

14 Commits

Author SHA1 Message Date
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
22 changed files with 1003 additions and 67 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

3
.gitignore vendored
View File

@ -9,4 +9,5 @@ wheels/
# Virtual environments
.venv
*.tar
*.tar
RELEASE_CHECKLIST.md

View File

@ -6,6 +6,7 @@ repos:
rev: v4.3.0
hooks:
- id: check-added-large-files
args: ["--maxkb=2048"]
- id: check-yaml
- id: check-docstring-first
@ -13,7 +14,14 @@ repos:
hooks:
- id: ruff
name: ruff
entry: ruff check .
language: python
entry: uv run ruff check .
language: system
types: [python]
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
ENV TZ="Asia/Taipei"
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
# docker build --platform=linux/amd64 -t hiking_assistant:latest .
# docker save -o hiking_assistant.tar hiking_assistant:latest
# docker build --platform=linux/amd64 -t hiking_assistant:1.0.0 .
# docker save -o hiking_assistant.tar hiking_assistant:1.0.0

View File

@ -1,7 +1,12 @@
## Abstract
一個基於 Python + Streamlit 的網頁應用程式,讀取使用者上傳的 GPX 檔案並提供詳細的路線統計、互動式地圖、海拔分析圖及天氣預報等。
<img src="./hiking_assistant/assets/new_favicon.webp" alt="shenshen" width="180" /><br>
![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
* 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,10 +7,11 @@ from datetime import datetime
import streamlit as st
import yaml
from elevation_profile import ElevationProfileRenderer
from elevation import ElevationRenderer
from gpx import GPXProcessor
from map_render import MapRenderer
from map import MapRenderer
from streamlit_folium import st_folium
from utils import convert_image_to_base64
from weather import WeatherFetcher
@ -22,6 +23,42 @@ class HikingAssistant:
with open('assets/config.yaml', 'r') as f:
return yaml.safe_load(f)
def _set_page_background(self, image_path, opacity=0.8):
"""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, image_path):
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):
"""Hiking Assistant application"""
# Page configuration
@ -31,10 +68,11 @@ class HikingAssistant:
layout='wide',
initial_sidebar_state='collapsed',
)
self._set_page_background(self._config['app']['page_background_path'], self._config['app']['page_background_opacity'])
# [Block1]
# Title and description
st.title('⛰️ ' + self._config['app']['page_title'])
# Title with favicon
self._add_custom_title(self._config['app']['page_title'], self._config['app']['page_favicon_path'])
# File uploader
uploaded_file = st.file_uploader(
@ -69,7 +107,11 @@ class HikingAssistant:
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()
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
st.session_state.gpx_file_key = file_key
@ -85,6 +127,11 @@ class HikingAssistant:
st.session_state.elevations = elevations
st.session_state.gradients = gradients
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
total_distance = st.session_state.total_distance
@ -99,6 +146,7 @@ class HikingAssistant:
elevations = st.session_state.elevations
gradients = st.session_state.gradients
estimated_time = st.session_state.estimated_time
waypoints = st.session_state.waypoints
# Display statistics section
st.header('📊 路線五四三')
@ -123,25 +171,25 @@ class HikingAssistant:
st.metric(
label='預估行進時間',
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
with st.spinner('正在渲染地圖...'):
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:
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:
st.error('無法渲染地圖')
# 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 st.spinner('正在繪製海拔剖面圖...'):
profile_renderer = ElevationProfileRenderer()
profile_renderer = ElevationRenderer()
elevation_fig = profile_renderer.create_elevation_profile(distances, elevations, gradients)
if elevation_fig:
@ -288,7 +336,10 @@ class HikingAssistant:
# [Block3]
# Footer
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)

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:
page_title: 臺灣登山小幫手
page_favicon_path: ./assets/favicon_compressed.jpg
version: 1.0.0
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_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:
forecast_days: 7

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,9 +1,12 @@
"""Elevation profile visualization module using Plotly."""
# elevation.py
#
# author: deng
# date: 20251127
import plotly.graph_objects as go
class ElevationProfileRenderer:
class ElevationRenderer:
"""Render elevation profiles with gradient-based coloring."""
def __init__(self):

View File

@ -4,7 +4,7 @@
# date: 20251127
import gpxpy
import gpxpy.gpx
import numpy as np
from geopy.distance import geodesic
@ -19,9 +19,23 @@ class GPXProcessor:
"""
self.gpx_file = gpx_file
self.gpx = None
self.time = []
self.points = []
self.elevations = []
self.elevations = [] # unit: m
self.distances = [] # unit: km
self.waypoints = [] # list of (lat, lon, name, elevation)
def _get_sample_rate(self):
"""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):
"""Validate and parse the GPX file.
@ -39,12 +53,24 @@ class GPXProcessor:
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
for track in self.gpx.tracks:
for segment in track.segments:
for point in segment.points:
self.points.append((point.latitude, point.longitude))
self.elevations.append(point.elevation if point.elevation else 0)
self.time.append(point.time)
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
@ -70,41 +96,34 @@ class GPXProcessor:
return total_distance
def calculate_elevation_gain_loss(self, threshold=3.0):
def calculate_elevation_gain_loss(self, threshold=1.0):
"""Calculate total elevation gain and loss with noise filtering.
GPS altitude data is notoriously inaccurate. Small fluctuations (noise) can
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:
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.
Returns:
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
gain = 0.0
loss = 0.0
# Use cumulative approach to avoid missing real elevation changes
# Track cumulative change since last significant threshold crossing
cumulative_change = 0.0
for i in range(window_size, len(self.elevations), window_size):
diff = self.elevations[i] - self.elevations[i - window_size]
for i in range(1, len(self.elevations)):
diff = self.elevations[i] - self.elevations[i - 1]
cumulative_change += 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
if diff >= threshold:
gain += diff
elif diff <= -threshold:
loss += abs(diff)
return gain, loss
@ -120,15 +139,15 @@ class GPXProcessor:
return min(self.elevations), max(self.elevations)
def get_start_end_points(self):
"""Get start and end point coordinates.
"""Get start and end point coordinates with elevation.
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:
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):
"""Get all route points.
@ -169,12 +188,12 @@ class GPXProcessor:
return gradients
def calculate_naismith_time(self):
def calculate_naismith_time(self, horizontal_speed=5, vertical_speed=600):
"""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
- Horizontal speed: 1 hour per 5 km of horizontal distance
- Vertical speed: 1 hour per 600 meters of ascent
Returns:
int: Estimated time in minutes
@ -187,11 +206,8 @@ class GPXProcessor:
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
time_for_distance = total_distance / horizontal_speed
time_for_ascent = elevation_gain / vertical_speed
total_time = int((time_for_distance + time_for_ascent) * 60)

View File

@ -1,4 +1,4 @@
# map_render.py
# map.py
#
# author: deng
# date: 20251127
@ -13,13 +13,15 @@ class MapRenderer:
"""Initialize map renderer."""
pass
def create_route_map(self, points, start_point, end_point):
def create_route_map(self, points, start_point, end_point, waypoints=None, tile_layer='OpenStreetMap'):
"""Create an interactive map with the hiking route.
Args:
points: List of (latitude, longitude) tuples for the route
start_point: (latitude, longitude) tuple for start
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:
folium.Map: Interactive map object
@ -31,13 +33,36 @@ class MapRenderer:
center_lat = sum(p[0] 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(
location=[center_lat, center_lon],
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
folium.PolyLine(
locations=points,
@ -47,29 +72,43 @@ class MapRenderer:
tooltip='登山路線',
).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)
if start_point:
folium.CircleMarker(
location=start_point,
location=(start_point[0], start_point[1]),
radius=8,
color='blue',
fill=True,
fill_color='blue',
fill_opacity=0.7,
popup='起點',
popup=folium.Popup(f'<b>起點</b><br>海拔: {start_point[2]} m', max_width=200),
tooltip='起點',
).add_to(route_map)
# Add end point marker (blue circle)
if end_point:
folium.CircleMarker(
location=end_point,
location=(end_point[0], end_point[1]),
radius=8,
color='blue',
fill=True,
fill_color='blue',
fill_opacity=0.7,
popup='終點',
popup=folium.Popup(f'<b>終點</b><br>海拔: {end_point[2]} m', max_width=200),
tooltip='終點',
).add_to(route_map)

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):
with open(image_path, 'rb') as f:
return base64.b64encode(f.read()).decode()

View File

@ -1,6 +1,6 @@
[project]
name = "hiking-assistant"
version = "0.1.0"
version = "1.0.0"
description = "This is a web app to analyze gpx for hiking planning"
readme = "README.md"
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]]
name = "hiking-assistant"
version = "0.1.0"
version = "1.0.0"
source = { virtual = "." }
dependencies = [
{ name = "folium" },