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