diff --git a/hiking_assistant/app.py b/hiking_assistant/app.py index f7d646c..5d23639 100644 --- a/hiking_assistant/app.py +++ b/hiking_assistant/app.py @@ -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 diff --git a/hiking_assistant/assets/config.yaml b/hiking_assistant/assets/config.yaml index e040fb4..de0115b 100644 --- a/hiking_assistant/assets/config.yaml +++ b/hiking_assistant/assets/config.yaml @@ -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 \ No newline at end of file diff --git a/hiking_assistant/gpx.py b/hiking_assistant/gpx.py index db6ec89..4cddf5e 100644 --- a/hiking_assistant/gpx.py +++ b/hiking_assistant/gpx.py @@ -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) diff --git a/hiking_assistant/map.py b/hiking_assistant/map.py index 7fdbf90..15e9b90 100644 --- a/hiking_assistant/map.py +++ b/hiking_assistant/map.py @@ -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='© 魯地圖', - 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)