Compare commits
14 Commits
76f58c9a60
...
1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 74e1d96600 | |||
| 06261a80be | |||
| 517c4e5ebe | |||
| 2c0cb8dfcd | |||
| 1e9834337f | |||
| 8e74bebf23 | |||
| 20ac5675b3 | |||
| f73b8ffd08 | |||
| fc3ed4b4c9 | |||
| 37d60d0529 | |||
| fd03436d61 | |||
| 8de4db857e | |||
| 1c50c1ba4a | |||
| 5c9af1649b |
49
.dockerignore
Normal file
49
.dockerignore
Normal 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
3
.gitignore
vendored
@ -9,4 +9,5 @@ wheels/
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
*.tar
|
||||
*.tar
|
||||
RELEASE_CHECKLIST.md
|
||||
@ -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
134
CHANGELOG.md
Normal 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
|
||||
@ -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
|
||||
11
README.md
11
README.md
@ -1,7 +1,12 @@
|
||||
## Abstract
|
||||
一個基於 Python + Streamlit 的網頁應用程式,讀取使用者上傳的 GPX 檔案並提供詳細的路線統計、互動式地圖、海拔分析圖及天氣預報等。
|
||||
<img src="./hiking_assistant/assets/new_favicon.webp" alt="shenshen" width="180" /><br>
|
||||

|
||||

|
||||

|
||||
|
||||
[臺灣登山小幫手](https://hikingassistant.guineapig.love)
|
||||
## Abstract
|
||||
基於 Python + Streamlit 的網頁應用程式,讀取使用者上傳的 GPX 檔案並提供詳細的路線統計、互動式地圖、海拔分析圖及天氣預報等資訊。
|
||||
|
||||
[山山登山小助手](https://hikingassistant.guineapig.love)
|
||||
|
||||
## Requirements
|
||||
* Hardware
|
||||
|
||||
11
hiking_assistant/.streamlit/config.toml
Normal file
11
hiking_assistant/.streamlit/config.toml
Normal file
@ -0,0 +1,11 @@
|
||||
[theme]
|
||||
base = "light"
|
||||
|
||||
[server]
|
||||
port = 8800
|
||||
enableCORS = false
|
||||
enableXsrfProtection = false
|
||||
maxUploadSize = 30
|
||||
|
||||
[client]
|
||||
toolbarMode = "minimal"
|
||||
@ -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='依據[Naismith’s Rule](https://en.wikipedia.org/wiki/Naismith%27s_rule)計算',
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
BIN
hiking_assistant/assets/background.jpg
Normal file
BIN
hiking_assistant/assets/background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
BIN
hiking_assistant/assets/background_compressed.jpg
Normal file
BIN
hiking_assistant/assets/background_compressed.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
@ -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
|
||||
BIN
hiking_assistant/assets/new_favicon.webp
Normal file
BIN
hiking_assistant/assets/new_favicon.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@ -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):
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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='© <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
14
hiking_assistant/utils.py
Normal 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()
|
||||
@ -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
1
tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# __init__.py for tests package
|
||||
268
tests/test_gpx.py
Normal file
268
tests/test_gpx.py
Normal 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
62
tests/test_utils.py
Normal 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
262
tests/test_weather.py
Normal 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
|
||||
Reference in New Issue
Block a user