Compare commits
15 Commits
76f58c9a60
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 145b52490f | |||
| 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
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
*.tar
|
*.tar
|
||||||
|
RELEASE_CHECKLIST.md
|
||||||
@ -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
|
||||||
|
|
||||||
|
- id: pytest
|
||||||
|
name: pytest
|
||||||
|
entry: uv run pytest tests/ -v
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
always_run: true
|
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
|
# 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
|
||||||
11
README.md
11
README.md
@ -1,7 +1,12 @@
|
|||||||
## Abstract
|
<img src="./hiking_assistant/assets/new_favicon.webp" alt="shenshen" width="180" /><br>
|
||||||
一個基於 Python + Streamlit 的網頁應用程式,讀取使用者上傳的 GPX 檔案並提供詳細的路線統計、互動式地圖、海拔分析圖及天氣預報等。
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
[臺灣登山小幫手](https://hikingassistant.guineapig.love)
|
## Abstract
|
||||||
|
基於 Python + Streamlit 的網頁應用程式,讀取使用者上傳的 GPX 檔案並提供詳細的路線統計、互動式地圖、海拔分析圖及天氣預報等資訊。
|
||||||
|
|
||||||
|
[山山登山小助手](https://hikingassistant.guineapig.love)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
* Hardware
|
* 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,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='依據[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
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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:
|
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
|
||||||
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,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:
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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='© <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
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: str) -> str:
|
||||||
|
with open(image_path, 'rb') as f:
|
||||||
|
return base64.b64encode(f.read()).decode()
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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
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
|
||||||
2
uv.lock
generated
2
uv.lock
generated
@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user