# -*- coding: utf-8 -*-
"""
Part of MarcheALOmbre QGIS Plugin
Copyright (C) 2025 Yolanda Seifert
Licensed under GPL v2+
"""
import math
from qgis.core import (QgsCoordinateTransform, QgsPointXY, QgsGeometry,
QgsRectangle, QgsCoordinateReferenceSystem,
QgsRasterLayer)
from .trail_point import TrailPoint
from .geo_definitions import REGIONS, MANUAL_DEFS
[docs]
class Trail:
def __init__(self, max_sep, speed, source_crs, transform_context, feedback=None):
"""
Initializes the Trail
Args:
max_sep (float): Maximum separation distance (in meters) between points after densification
speed (float): Average hiking speed in km/h
source_crs (QgsCoordinateReferenceSystem): The CRS of the input vector layer
transform_context (QgsCoordinateTransformContext): Context for coordinate transformations
feedback (QgsProcessingFeedback, optional): Feedback object for reporting logs
"""
self.speed = (5/18) * speed # km/h to m/s
self.max_sep = max_sep
self.src = source_crs
if not self.src.isValid():
self.src = QgsCoordinateReferenceSystem("EPSG:4326")
self.transform_context = transform_context
self.wgs84 = QgsCoordinateReferenceSystem("EPSG:4326")
self.trail_points = []
self.extent = QgsRectangle()
self.feedback = feedback
self.center_lat = 0.0
self.break_index = -1
self.break_duration = 0
[docs]
def log(self, message):
"""
Logs a message to the feedback object
Args:
message (str): The message to log
"""
if self.feedback:
self.feedback.pushInfo(message)
else:
print(message)
def _determine_best_crs(self, wgs84_extent):
"""
Check which region contains the center of the trail extent
Args:
wgs84_extent (QgsRectangle): The extent of the layer in WGS84.
Returns:
tuple: A tuple containing (epsg_code (str), region_name (str)).
"""
center = wgs84_extent.center()
lon = center.x()
lat = center.y()
for name, data in REGIONS.items():
b = data['bbox']
# Bounding box check
if b[0] <= lon <= b[2] and b[1] <= lat <= b[3]:
return data['epsg'], name
# Default to France
return "EPSG:2154", "France_Metropole (Default)"
[docs]
def reverse_trail(self, geometry):
"""
Reverses the order of the linestring
Args:
geometry (QgsGeometry): The input linestring geometry
Returns:
QgsGeometry: New linestring with reversed vertices
"""
nodes = geometry.asPolyline()
if nodes:
nodes.reverse()
return QgsGeometry.fromPolylineXY(nodes)
return geometry
[docs]
def calc_meridian_convergence(self, source_center):
"""Calculates Meridian Convergence correction
Args:
source_center (QgsPointXY): Center point of trail extent
Returns:
float: Convergence value
"""
convergence = 0.0
try:
center_l93 = self.transform.transform(source_center)
center_geo = self.to_wgs84.transform(center_l93)
# Create point slightly North (True North)
north_geo = QgsPointXY(center_geo.x(), center_geo.y() + 0.1)
north_l93 = self.to_wgs84.transform(north_geo, QgsCoordinateTransform.ReverseTransform)
# Calculate angle of the "True North" vector relative to Grid North (Y-axis)
dx = north_l93.x() - center_l93.x()
dy = north_l93.y() - center_l93.y()
convergence = math.atan2(dx, dy)
self.log(f"Meridian Convergence at trail center: {math.degrees(convergence):.4f} deg")
except Exception as e:
self.log(f"Warning: Could not calculate convergence: {e}. Defaulting to 0.")
return convergence
[docs]
def process_trail(self, source_tracks, start_time, break_point, picnic_duration=0, reverse=False, buffer=False, project_crs=None, adjust_for_slope=False):
"""Processes input GPX source tracks into a list of TrailPoint objects
Args:
source_tracks (QgsProcessingFeatureSource]): Tracks from the gpx trail
start_time (QDateTime): Hikers time of departure
break_point ( QgsPointXY): Coordinates of an optional picnic point
picnic_duration (float): Duration of the hikers picnic break (minutes)
reverse (bool, optional): Optional reversing of the trails direction. Defaults to False.
buffer (bool, optional): Optional buffering so that 10m left and right of the trail buffer trails are formed. Defaults to False.
project_crs (QgsCoordinateReferenceSystem, optional): The CRS for transforming break_point. Defaults to None.
adjust_for_slope (bool): If to adjust speed based on terrain slope. Defaults to False.
Raises:
Exception: If coordinate transformation fails or no valid trail points are generated
"""
wgs84_extent = source_tracks.sourceExtent()
self.center_lat = wgs84_extent.center().y()
self.adjust_for_slope = adjust_for_slope
self.target_crs, region_name = self._determine_best_crs(source_tracks.sourceExtent())
self.log(f"Detected Region: {region_name}. Switching to CRS: {self.target_crs}")
dest_crs = QgsCoordinateReferenceSystem(self.target_crs)
self.transform = QgsCoordinateTransform(self.src, dest_crs, self.transform_context)
self.to_wgs84 = QgsCoordinateTransform(dest_crs, self.wgs84, self.transform_context)
# Check if standard transformation failed if missing grids on machine
auth_id = dest_crs.authid()
if not self.transform.isValid() and auth_id in MANUAL_DEFS:
print(f"WARNING: Standard {auth_id} failed. Switching to Manual Definition.")
# Create CRS from raw Proj4 string
dest_crs = QgsCoordinateReferenceSystem.fromProj4(MANUAL_DEFS[auth_id])
# Redo tranformations
self.transform = QgsCoordinateTransform(self.src, dest_crs, self.transform_context)
self.transform.setBallparkTransformsAreAppropriate(True)
self.to_wgs84 = QgsCoordinateTransform(dest_crs, self.wgs84, self.transform_context)
self.to_wgs84.setBallparkTransformsAreAppropriate(True)
if not self.transform.isValid():
raise Exception(f"CRITICAL: Transformation to {dest_crs.authid()} could not be initialized.")
# Transform break_point from project CRS to target CRS if provided
transformed_break_point = None
if break_point and project_crs:
# Create transformation from project CRS to target CRS
project_to_target = QgsCoordinateTransform(project_crs, dest_crs, self.transform_context)
# Try manual fallback if standard transformation fails
if not project_to_target.isValid():
project_auth = project_crs.authid()
if project_auth in MANUAL_DEFS and auth_id in MANUAL_DEFS:
print(f"WARNING: Standard transformation {project_auth} -> {auth_id} failed. Using manual fallback.")
project_to_target = QgsCoordinateTransform(project_crs, dest_crs, self.transform_context)
project_to_target.setBallparkTransformsAreAppropriate(True)
try:
transformed_break_point = project_to_target.transform(break_point)
self.log(f"Transformed break point from {project_crs.authid()} to {dest_crs.authid()}")
print(f"Break point: {break_point.x():.2f}, {break_point.y():.2f} ({project_crs.authid()}) -> {transformed_break_point.x():.2f}, {transformed_break_point.y():.2f} ({dest_crs.authid()})")
except Exception as e:
print(f"ERROR: Failed to transform break point: {e}")
transformed_break_point = None
meridian_convergence = self.calc_meridian_convergence(source_tracks.sourceExtent().center())
total_dist = 0.0
center_points = []
for feature in source_tracks.getFeatures():
geom = feature.geometry()
if geom.isEmpty():
continue
# Standardize geometry to list of lines
if geom.isMultipart():
lines = geom.asMultiPolyline()
else:
try:
lines = [geom.asPolyline()]
except Exception as e:
raise Exception(f"{e}. Input layer must be tracks.")
for line in lines:
transformed_vertices = []
for pt in line:
try:
# Transform point
trans_pt = self.transform.transform(QgsPointXY(pt))
transformed_vertices.append(trans_pt)
except:
continue
if not transformed_vertices:
continue
if reverse:
transformed_vertices.reverse()
# Rebuild geometry in Lambert-93
new_geom = QgsGeometry.fromPolylineXY(transformed_vertices)
# Densify
densified_geom = new_geom.densifyByDistance(self.max_sep)
# Extract points and calculate Lat/Lon
verts = densified_geom.vertices()
prev_pt = None
for v in verts:
pt_l93 = QgsPointXY(v.x(), v.y())
geo_pt = self.to_wgs84.transform(pt_l93)
# Calculate Distance for Time
if prev_pt:
dist = pt_l93.distance(prev_pt)
total_dist += dist
if adjust_for_slope:
current_time = start_time # Placeholder, will recalculate
else:
seconds_elapsed = total_dist / self.speed
current_time = start_time.addSecs(int(seconds_elapsed))
# Create TrailPoint object
tp = TrailPoint(
x=pt_l93.x(),
y=pt_l93.y(),
z = 0, # implement with MNT
lat=geo_pt.y(), # Latitude
lon=geo_pt.x(), # Longitude
datetime=current_time,
convergence=meridian_convergence
)
center_points.append(tp)
prev_pt = pt_l93
if not center_points:
raise Exception("No trail points could be processed. Input layer must be tracks.")
if total_dist > 45000:
self.log(f"WARNING: Trail length is {total_dist/1000:.1f} km, Processing may be slow.")
if transformed_break_point:
# Find the closest point to break location
closest_idx = 0
min_dist = float('inf')
for i, tp in enumerate(center_points):
# Calculate distance
dist = math.sqrt((tp.x - transformed_break_point.x())**2 + (tp.y - transformed_break_point.y())**2)
if dist < min_dist:
min_dist = dist
closest_idx = i
# Add 1 hour to all points after the break
if min_dist < 5000: # only apply if the point is somewhat near the trail
print(f"Applying 1h break at point {closest_idx} (Dist: {min_dist:.1f}m)")
self.break_index = closest_idx
self.break_duration = int(60 * picnic_duration)
for i in range(closest_idx, len(center_points)):
tp = center_points[i]
tp.datetime = tp.datetime.addSecs(int(60*picnic_duration))
# Recalculate solar position for the new time
tp.solar_pos = tp.calc_solar_pos(tp.datetime)
else:
self.log(f"Break point too far from trail ({min_dist:.1f}m). Ignored.")
# Add center points to main list
self.trail_points.extend(center_points)
# Generate Buffer trails
if buffer:
left_points = []
right_points = []
offset_dist = 5.0 # meters
for i in range(len(center_points)):
current_tp = center_points[i]
# Determine direction
p1 = current_tp
p2 = None
if i < len(center_points) - 1:
p2 = center_points[i+1]
elif i > 0:
p2 = current_tp
p1 = center_points[i-1]
else:
continue
dx = p2.x - p1.x
dy = p2.y - p1.y
length = math.sqrt(dx*dx + dy*dy)
if length == 0:
ux, uy = 0, 0
else:
ux, uy = dx/length, dy/length
# Normal Vectors (Perpendicular to path)
# Left Normal: (-uy, ux)
# Right Normal: (uy, -ux)
# Left point
lx = current_tp.x + (offset_dist * (-uy))
ly = current_tp.y + (offset_dist * (ux))
# Convert to Lat/Lon
try:
l_geo = self.to_wgs84.transform(QgsPointXY(lx, ly))
tp_left = TrailPoint(
x=lx, y=ly, z=0,
lat=l_geo.y(), lon=l_geo.x(),
datetime=current_tp.datetime,
convergence=meridian_convergence
)
tp_left.trail_type = "Left"
tp_left.solar_pos = current_tp.solar_pos
left_points.append(tp_left)
except:
pass
# Right point
rx = current_tp.x + (offset_dist * (uy))
ry = current_tp.y + (offset_dist * (-ux))
try:
r_geo = self.to_wgs84.transform(QgsPointXY(rx, ry))
tp_right = TrailPoint(
x=rx, y=ry, z=0,
lat=r_geo.y(), lon=r_geo.x(),
datetime=current_tp.datetime,
convergence=meridian_convergence
)
tp_right.trail_type = "Right"
tp_right.solar_pos = current_tp.solar_pos
right_points.append(tp_right)
except:
pass
self.trail_points.extend(left_points)
self.trail_points.extend(right_points)
# Calculate extent
if self.trail_points:
temp_points = [QgsPointXY(tp.x, tp.y) for tp in self.trail_points]
multipoint = QgsGeometry.fromMultiPointXY(temp_points)
self.extent = multipoint.boundingBox()
# Buffer around extent
self.extent.grow(500.0)
print(f"Success! Trail Extent: {self.extent.toString()}")
else:
raise Exception("No trail points could be processed.")
[docs]
def calculate_times_with_slope(self, start_time, buffered):
"""
Recalculate arrival times for all trail points accounting for slope
Must be called after sample_elevation() has populated z values
Uses Tobler's hiking function:
- Flat terrain: base speed
- Uphill: speed decreases
- Downhill: speed increases
Args:
start_time (QDateTime): Start time for recalculating arrival times
buffer (bool): If True, only calculate for center trail and copy times to left/right
"""
if not self.trail_points:
return
self.log(f"Recalculating times with slope adjustment using Tobler's hiking function...")
# Determine which points to calculate
if buffered:
num_points = len(self.trail_points)
center_count = num_points // 3
points_to_calculate = self.trail_points[:center_count]
else:
points_to_calculate = self.trail_points
# Calculate times
total_time = 0.0 # seconds
points_to_calculate[0].datetime = start_time
for i in range(1, len(points_to_calculate)):
prev_tp = points_to_calculate[i-1]
curr_tp = points_to_calculate[i]
# Horizontal distance
dist_horizontal = math.sqrt(
(curr_tp.x - prev_tp.x)**2 +
(curr_tp.y - prev_tp.y)**2
)
if self.adjust_for_slope and dist_horizontal > 0:
# Calculate slope
delta_z = curr_tp.z - prev_tp.z
slope = delta_z / dist_horizontal
# Tobler's hiking function
speed_factor = math.exp(-3.5 * abs(slope + 0.05))
# Limit speed between 30% and 150% of base speed
speed_factor = max(0.3, min(speed_factor, 1.5))
adjusted_speed = self.speed * speed_factor
else:
adjusted_speed = self.speed
# Update time
segment_time = dist_horizontal / adjusted_speed
total_time += segment_time
if i == self.break_index:
total_time += self.break_duration
curr_tp.datetime = start_time.addSecs(int(total_time))
# Recalculate solar position since the time changed
curr_tp.solar_pos = curr_tp.calc_solar_pos(curr_tp.datetime)
# If buffered, copy times from center to left and right trails
if buffered:
left_points = self.trail_points[center_count:2*center_count]
right_points = self.trail_points[2*center_count:]
for i in range(len(points_to_calculate)):
if i < len(left_points):
left_points[i].datetime = points_to_calculate[i].datetime
left_points[i].solar_pos = points_to_calculate[i].solar_pos
if i < len(right_points):
right_points[i].datetime = points_to_calculate[i].datetime
right_points[i].solar_pos = points_to_calculate[i].solar_pos
self.log(f"Time calculation complete. Total hiking time: {total_time/3600:.2f} hours")
[docs]
def sample_elevation(self, mnt_path, start_time, buffered):
"""
Loads the MNT raster from the given path and updates the z-value of all trail points
Args:
mnt_path (str): File path to the MNT raster
start_time (QDateTime): Start time for recalculating arrival times
buffer (bool): For time recalculation in calculate_times_with_slope
"""
# Load the MNT as a raster layer
rlayer = QgsRasterLayer(mnt_path, "mnt_sampling")
if not rlayer.isValid():
print(f"Error: Could not load MNT from {mnt_path}")
return
provider = rlayer.dataProvider()
# Loop through all points and sample the raster
for tp in self.trail_points:
val, res = provider.sample(QgsPointXY(tp.x, tp.y), 1)
if res:
tp.z = val
else:
tp.z = 0.0 # Default if outside raster or nodata
# Recalculate times with slope adjustment if enabled
if self.adjust_for_slope:
self.calculate_times_with_slope(start_time, buffered)
else:
self.log("Slope adjustment disabled - using constant speed")