mod naismith’s rule
This commit is contained in:
@ -69,7 +69,10 @@ 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'],
|
||||
)
|
||||
|
||||
# Cache all processed data
|
||||
st.session_state.gpx_file_key = file_key
|
||||
@ -127,7 +130,7 @@ 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
|
||||
|
||||
@ -6,5 +6,8 @@ app:
|
||||
elevation_threshold: 2100
|
||||
warning_text: 此路線海拔較高,請留意[高山症](https://www.ysnp.gov.tw/StaticPage/MountainSickness)發生風險
|
||||
emoji: 💊
|
||||
estimated_time:
|
||||
horizontal_speed: 3
|
||||
vertical_speed: 400
|
||||
weather:
|
||||
forecast_days: 7
|
||||
@ -4,7 +4,7 @@
|
||||
# date: 20251127
|
||||
|
||||
import gpxpy
|
||||
import gpxpy.gpx
|
||||
import numpy as np
|
||||
from geopy.distance import geodesic
|
||||
|
||||
|
||||
@ -19,10 +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
|
||||
|
||||
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.
|
||||
|
||||
@ -43,8 +56,9 @@ class GPXProcessor:
|
||||
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 +84,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
|
||||
|
||||
@ -169,12 +176,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 +194,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)
|
||||
|
||||
|
||||
@ -32,17 +32,17 @@ 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 ojb
|
||||
# Create map obj
|
||||
route_map = folium.Map(
|
||||
location=[center_lat, center_lon],
|
||||
zoom_start=13,
|
||||
tiles=None, # Don't add default tiles, we'll add custom ones
|
||||
tiles=None,
|
||||
)
|
||||
|
||||
# Add OpenStreetMap layer
|
||||
folium.TileLayer(
|
||||
tiles='OpenStreetMap',
|
||||
name='OpenStreetMap',
|
||||
name='平面(OpenStreetMap)',
|
||||
overlay=False,
|
||||
control=True,
|
||||
show=tile_layer == 'OpenStreetMap',
|
||||
@ -52,7 +52,7 @@ class MapRenderer:
|
||||
folium.TileLayer(
|
||||
tiles='https://tile.happyman.idv.tw/map/moi_osm/{z}/{x}/{y}.png',
|
||||
attr='© <a href="https://rudy.basecamp.tw/">魯地圖</a>',
|
||||
name='魯地圖 (Rudy Map)',
|
||||
name='等高線(魯地圖)',
|
||||
overlay=False,
|
||||
control=True,
|
||||
show=tile_layer == 'RudyMap',
|
||||
@ -80,7 +80,7 @@ class MapRenderer:
|
||||
fill=True,
|
||||
fill_color='blue',
|
||||
fill_opacity=0.7,
|
||||
popup='起點',
|
||||
popup=start_point,
|
||||
tooltip='起點',
|
||||
).add_to(route_map)
|
||||
|
||||
@ -93,7 +93,7 @@ class MapRenderer:
|
||||
fill=True,
|
||||
fill_color='blue',
|
||||
fill_opacity=0.7,
|
||||
popup='終點',
|
||||
popup=end_point,
|
||||
tooltip='終點',
|
||||
).add_to(route_map)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user