# -*- coding: utf-8 -*-
# /***************************************************************************
# MarcheALOmbre
# A QGIS plugin
# This plugin calculates for a given hike the shady and sunny parts
# Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/
# -------------------
# begin : 2025-12-11
# copyright : (C) 2025 by Yolanda Seifert
# email : yolanda.seifert@gmx.de
# ***************************************************************************/
# /***************************************************************************
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU General Public License as published by *
# * the Free Software Foundation; either version 2 of the License, or *
# * (at your option) any later version. *
# * *
# ***************************************************************************/
__author__ = 'Yolanda Seifert'
__date__ = '2025-12-11'
__copyright__ = '(C) 2025 by Yolanda Seifert'
# This will get replaced with a git SHA1 when you do a git archive
__revision__ = '$Format:%H$'
import os
import inspect
import math
from osgeo import gdal
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtCore import QCoreApplication, QMetaType
from qgis.core import (QgsProcessing,
QgsFeatureSink,
QgsProcessingAlgorithm,
QgsProcessingParameterFeatureSource,
QgsProcessingParameterFeatureSink,
QgsProcessingParameterDateTime,
QgsProcessingParameterNumber,
QgsProcessingParameterPoint,
QgsProcessingParameterBoolean,
QgsPoint,
QgsProcessingException,
QgsProcessingParameterRasterDestination,
QgsProcessingParameterFileDestination,
QgsCoordinateReferenceSystem,
QgsFields,
QgsWkbTypes,
QgsFeature,
QgsGeometry,
QgsField,
QgsProcessingUtils,
QgsCategorizedSymbolRenderer,
QgsRendererCategory,
QgsSymbol,
QgsSimpleMarkerSymbolLayer,
QgsVectorLayer,
QgsProperty,
QgsSymbolLayer)
from .mns_downloader import MNSDownloader
from .trail import Trail
from .shadow_calculator import ShadowCalculator
[docs]
class MarcheALOmbreAlgorithm(QgsProcessingAlgorithm):
"""
This is an example algorithm that takes a vector layer and
creates a new identical one.
It is meant to be used as an example of how to create your own
algorithms and explain methods and variables used to do it. An
algorithm like this will be available in all elements, and there
is not need for additional work.
All Processing algorithms should extend the QgsProcessingAlgorithm
class.
"""
# Constants used to refer to parameters and outputs. They will be
# used when calling the algorithm from another algorithm, or when
# calling from the QGIS console.
OUTPUT = 'OUTPUT'
INPUT = 'INPUT'
DEPARTURE_TIME = 'DEPARTURE_TIME'
HIKING_SPEED = 'HIKING_SPEED'
ADJUST_FOR_SLOPE = 'ADJUST_FOR_SLOPE'
PICNIC_POINT = 'PICNIC_POINT'
PICNIC_DURATION = 'PICNIC_DURATION'
REVERSE_DIRECTION = 'REVERSE_DIRECTION'
BUFFER_MODE = 'BUFFER_MODE'
OUTPUT_POINTS = 'OUTPUT_POINTS'
LOW_RES_MNS = 'LOW_RES_MNS'
OUTPUT_CSV = 'OUTPUT_CSV'
[docs]
def initAlgorithm(self, config):
"""
Here we define the inputs and output of the algorithm, along
with some other properties.
"""
# We add the input vector features source. It can have any kind of
# geometry.
self.addParameter(
QgsProcessingParameterFeatureSource(
self.INPUT,
self.tr('Input layer - tracks'),
[QgsProcessing.TypeVectorAnyGeometry]
)
)
self.addParameter(
QgsProcessingParameterDateTime(
self.DEPARTURE_TIME,
self.tr('Departure Date and Time (Local Time)'),
type=QgsProcessingParameterDateTime.DateTime # Allows selecting both Date and Time
)
)
self.addParameter(
QgsProcessingParameterNumber(
self.HIKING_SPEED,
self.tr('Average Hiking Speed (km/h)'),
type=QgsProcessingParameterNumber.Double,
defaultValue=4.0, # A standard hiking speed
minValue=0.0
)
)
self.addParameter(
QgsProcessingParameterBoolean(
'ADJUST_FOR_SLOPE',
self.tr('Adjust hiking speed for slope (recommended for mountainous regions)'),
defaultValue=False
)
)
self.addParameter(
QgsProcessingParameterPoint(
self.PICNIC_POINT,
self.tr('Picnic Break Location (1h stop)'),
optional=True # Optional: user might not want a break
)
)
self.addParameter(
QgsProcessingParameterNumber(
self.PICNIC_DURATION,
self.tr('Picnic Duration (minutes)'),
type=QgsProcessingParameterNumber.Integer,
defaultValue=60,
minValue=0,
optional=True
)
)
self.addParameter(
QgsProcessingParameterBoolean(
self.REVERSE_DIRECTION,
self.tr('Reverse Trail Direction (Finish to Start)'),
defaultValue=False
)
)
self.addParameter(
QgsProcessingParameterBoolean(
self.BUFFER_MODE,
self.tr('Calculate with Buffer (Center, Left 5m, Right 5m)'),
defaultValue=False
)
)
self.addParameter(
QgsProcessingParameterFeatureSink(
self.OUTPUT_POINTS,
self.tr('Densified Trail Points'),
type=QgsProcessing.TypeVectorPoint
)
)
self.addParameter(
QgsProcessingParameterRasterDestination(
self.LOW_RES_MNS,
self.tr('Low Resolution MNS (15m)')
)
)
self.addParameter(
QgsProcessingParameterRasterDestination(
self.OUTPUT,
self.tr('High Resolution MNS (0.5m)')
)
)
self.addParameter(
QgsProcessingParameterFileDestination(
self.OUTPUT_CSV,
self.tr('Statistics Table'),
fileFilter='CSV files (*.csv)'
)
)
[docs]
def processAlgorithm(self, parameters, context, feedback):
"""
Here is where the processing itself takes place.
"""
# Retrieve the feature source and sink. The 'dest_id' variable is used
# to uniquely identify the feature sink, and must be included in the
# dictionary returned by the processAlgorithm function.
source = self.parameterAsSource(parameters, self.INPUT, context)
departure_dt = self.parameterAsDateTime(parameters, self.DEPARTURE_TIME, context)
speed = self.parameterAsDouble(parameters, self.HIKING_SPEED, context)
adjust_for_slope = self.parameterAsBool(parameters, 'ADJUST_FOR_SLOPE', context)
picnic_point = self.parameterAsPoint(parameters, self.PICNIC_POINT, context)
picnic_duration = self.parameterAsDouble(parameters, self.PICNIC_DURATION, context)
picnic_point_crs = self.parameterAsPointCrs(parameters, self.PICNIC_POINT, context)
reverse_direction = self.parameterAsBool(parameters, self.REVERSE_DIRECTION, context)
buffer_mode = self.parameterAsBool(parameters, self.BUFFER_MODE, context)
csv_path = self.parameterAsFileOutput(parameters, self.OUTPUT_CSV, context)
if not picnic_point or (picnic_point.x() == 0.0 and picnic_point.y() == 0.0):
picnic_point_crs = None
# Compute the number of steps to display within the progress bar and
# get features from source
total = 100.0 / source.featureCount() if source.featureCount() else 0
features = source.getFeatures()
########################## TRAIL PROCESSING #########################
feedback.pushInfo("Processing trail...")
source_crs = source.sourceCrs()
if not source_crs.isValid():
raise QgsProcessingException(
"Input layer has no valid CRS. Please define the layer CRS before running this algorithm."
)
trail = Trail(
max_sep=10,
speed=speed,
source_crs=source_crs,
transform_context=context.transformContext(),
feedback=feedback
)
trail.process_trail(source_tracks=source,
start_time=departure_dt,
break_point=picnic_point,
picnic_duration=picnic_duration,
reverse=reverse_direction,
buffer=buffer_mode,
project_crs=picnic_point_crs,
adjust_for_slope=adjust_for_slope)
########################## MNT DOWNLOAD (For Z Values) ##################
feedback.pushInfo(f"Download Digital Terrain Model (MNT) from IGN...")
# Generate a temporary file path for the MNT
mnt_path = QgsProcessingUtils.generateTempFilename('mnt_elevation.tif')
target_crs = trail.target_crs
downloader = MNSDownloader(
crs=target_crs,
transform_context=context.transformContext(),
feedback=feedback)
target_resolution = 0.5
# Download MNT (mns=False)
success_mnt = downloader.read_tif(
extent=trail.extent,
resolution=target_resolution*2, # less variation in trail elevation so lower resolution necessary
output_path=mnt_path,
input_crs=target_crs,
is_mns=False
)
if not success_mnt:
raise QgsProcessingException("Failed to download MNT data.")
# Integrate MNT into Trail (Sample Z values)
feedback.pushInfo("Using MNT for trail point elevation values...")
trail.sample_elevation(mnt_path, departure_dt, buffer_mode)
########################## MNS DOWNLOAD (for Shade) ############################
feedback.pushInfo(f"Download Digital Surface Model (MNS) from IGN...")
output_path = self.parameterAsOutputLayer(parameters, self.OUTPUT, context)
low_res_path = self.parameterAsOutputLayer(parameters, self.LOW_RES_MNS, context)
downloader = MNSDownloader(
crs=target_crs,
transform_context=context.transformContext(),
feedback=feedback)
downloader.download_dual_quality_mns(
trail_extent=trail.extent,
high_res_path=output_path,
low_res_path=low_res_path,
trail_lat=trail.center_lat,
input_crs=target_crs,
high_res=target_resolution, # meters per pixel
)
# Open MNS raster with GDAL
ds = gdal.Open(output_path)
if ds is None:
raise QgsProcessingException("Could not open downloaded MNS.")
mns_band = ds.GetRasterBand(1)
mns_array = mns_band.ReadAsArray() # Returns numpy array
geo_transform = ds.GetGeoTransform()
ds = None # Close dataset
########################## CALCULATE SHADOWS ############################
feedback.pushInfo("Calculating shadows...")
calculator = ShadowCalculator(
high_res_path=output_path,
low_res_path=low_res_path
)
shadow_results = calculator.calculate_shadows(
trail_points=trail.trail_points,
max_dist_m=20000
)
########################## CALCULATE STATISTICS ############################
total_points = len(shadow_results)
shady_points = sum(shadow_results)
sunny_points = total_points - shady_points
percent_shady = (shady_points / total_points * 100) if total_points > 0 else 0.0
percent_sunny = (sunny_points / total_points * 100) if total_points > 0 else 0.0
feedback.pushInfo(f"Statistics: {percent_shady:.1f}% Shady, {percent_sunny:.1f}% Sunny")
if csv_path:
try:
with open(csv_path, 'w') as f:
# Header
f.write("Duration (min), % Shady, % Sunny, Time spent in Shadow (min), Time spent in Sun (min), Shady Points, Sunny Points, Total Points\n")
duration_min = 0
if trail.trail_points:
start = trail.trail_points[0].datetime
end = trail.trail_points[-1].datetime
duration_min = start.secsTo(end) / 60
time_shady = int(duration_min*percent_shady/100)
time_sunny = int(duration_min*percent_sunny/100)
f.write(f"{duration_min:.1f},{percent_shady:.2f},{percent_sunny:.2f},{time_shady},{time_sunny},{shady_points},{sunny_points},{total_points}\n")
except Exception as e:
feedback.reportError(f"Failed to write CSV: {e}")
########################## WRITE OUTPUT POINTS ##########################
# Define attribute table columns
fields = QgsFields()
fields.append(QgsField("id", QMetaType.Int))
fields.append(QgsField("status", QMetaType.QString)) # Sunny / Shady
fields.append(QgsField("is_shadow", QMetaType.Int)) # 0 / 1
fields.append(QgsField("x_proj", QMetaType.Double)) # Lambert-93 X
fields.append(QgsField("y_proj", QMetaType.Double)) # Lambert-93 Y
fields.append(QgsField("z_mnt", QMetaType.Double)) # Altitude
fields.append(QgsField("latitude", QMetaType.Double)) # WGS84 Lat
fields.append(QgsField("longitude", QMetaType.Double)) # WGS84 Lon
fields.append(QgsField("arrival_time", QMetaType.QDateTime)) # Time
fields.append(QgsField("elevation_deg", QMetaType.Double)) # Sun elevation angle
fields.append(QgsField("azimuth_deg", QMetaType.Double)) # Sun azimtuh angle
fields.append(QgsField("course", QMetaType.Double))
(point_sink, point_dest_id) = self.parameterAsSink(
parameters,
self.OUTPUT_POINTS,
context,
fields,
QgsWkbTypes.PointZ,
QgsCoordinateReferenceSystem(target_crs)
)
self.point_dest_id = point_dest_id
if point_sink is None:
raise QgsProcessingException("Could not create point sink")
feedback.pushInfo("Writing results...")
for i, tp in enumerate(trail.trail_points):
if feedback.isCanceled(): break
# Calculate direction to next point for visualization
course = 0.0
if i < len(trail.trail_points) - 1:
next_tp = trail.trail_points[i+1]
course = QgsPoint(tp.x, tp.y).azimuth(QgsPoint(next_tp.x, next_tp.y))
elif i > 0:
course = prev_course
prev_course = course
feat = QgsFeature(fields)
geom = QgsGeometry.fromPoint(QgsPoint(tp.x, tp.y, tp.z))
feat.setGeometry(geom)
is_shadow = shadow_results[i]
status_str = "Shadow" if is_shadow == 1 else "Sun"
feat.setAttributes([
i,
status_str,
int(is_shadow),
tp.x,
tp.y,
tp.z,
tp.lat,
tp.lon,
tp.datetime,
math.degrees(tp.solar_pos[0]),
math.degrees(tp.solar_pos[1]),
course
])
point_sink.addFeature(feat, QgsFeatureSink.FastInsert)
feedback.setProgress(int(i * total))
# Return the results of the algorithm. In this case our only result is
# the feature sink which contains the processed features, but some
# algorithms may return multiple feature sinks, calculated numeric
# statistics, etc. These should all be included in the returned
# dictionary, with keys matching the feature corresponding parameter
# or output names.
self.results = {
self.OUTPUT: output_path,
self.OUTPUT_POINTS: point_dest_id,
self.LOW_RES_MNS: low_res_path,
self.OUTPUT_CSV: csv_path,
'detected_crs': target_crs
}
return self.results
[docs]
def postProcessAlgorithm(self, context, feedback):
# Set project CRS to match the detected region
detected_crs_str = self.results.get('detected_crs')
if detected_crs_str:
detected_crs = QgsCoordinateReferenceSystem(detected_crs_str)
project = context.project()
if detected_crs.isValid() and project.crs().authid() != detected_crs.authid():
feedback.pushInfo(f"Changing project CRS from {project.crs().authid()} to {detected_crs.authid()} to match detected region")
project.setCrs(detected_crs)
# style the output trail depending on Sun/Shadow
layer = QgsProcessingUtils.mapLayerFromString(self.point_dest_id, context)
if layer:
categories = []
styles = [
(0, "Sun", "gold"),
(1, "Shadow", "#1f78b4")
]
for val, label, color in styles:
sym_layer = QgsSimpleMarkerSymbolLayer.create({
'name': 'filled_arrowhead',
'color': color,
'size': '2.5',
})
# Direction of arrow based on course field
prop_angle = QgsProperty.fromExpression('"course" - 90')
sym_layer.setDataDefinedProperty(QgsSymbolLayer.PropertyAngle, prop_angle)
sym = QgsSymbol.defaultSymbol(layer.geometryType())
sym.changeSymbolLayer(0, sym_layer)
categories.append(QgsRendererCategory(val, sym, label, True))
renderer = QgsCategorizedSymbolRenderer("is_shadow", categories)
layer.setRenderer(renderer)
layer.triggerRepaint()
csv_path = self.results.get(self.OUTPUT_CSV)
if csv_path and os.path.exists(csv_path):
# Create delimited text layer from csv
path = csv_path.replace('\\', '/')
uri = f"file:///{path}?type=csv&watchFile=no"
vlayer = QgsVectorLayer(uri, "Shadow Statistics", "delimitedtext")
if vlayer.isValid():
context.project().addMapLayer(vlayer)
return self.results
[docs]
def helpUrl(self):
"""
Returns the URL to the help page
"""
cmd_folder = os.path.split(inspect.getfile(inspect.currentframe()))[0]
help_path = os.path.join(cmd_folder, 'help', 'build', 'html', 'index.html')
if os.path.exists(help_path):
return "file://" + help_path
# Fallback if local help isn't built yet
return "https://github.com/yolanda225/qgis-marche-a-lombre"
[docs]
def shortHelpString(self):
"""
Returns a brief description of the algorithm
(appears in the right-hand panel of the dialog)
"""
return self.tr("This algorithm calculates the shady and sunny portions of a trail "
"based on LiDAR HD data (MNS) and the sun's position at the time "
"you are physically at each point on the hike.")
[docs]
def name(self):
"""
Returns the algorithm name, used for identifying the algorithm. This
string should be fixed for the algorithm, and must not be localised.
The name should be unique within each provider. Names should contain
lowercase alphanumeric characters only and no spaces or other
formatting characters.
"""
return "marche_a_lombre"
[docs]
def displayName(self):
"""
Returns the translated algorithm name, which should be used for any
user-visible display of the algorithm name.
"""
return self.tr(self.name())
[docs]
def group(self):
"""
Returns the name of the group this algorithm belongs to. This string
should be localised.
"""
return self.tr(self.groupId())
[docs]
def groupId(self):
"""
Returns the unique ID of the group this algorithm belongs to. This
string should be fixed for the algorithm, and must not be localised.
The group id should be unique within each provider. Group id should
contain lowercase alphanumeric characters only and no spaces or other
formatting characters.
"""
return ''
[docs]
def tr(self, string):
return QCoreApplication.translate('Processing', string)
[docs]
def icon(self):
cmd_folder = os.path.split(inspect.getfile(inspect.currentframe()))[0]
icon = QIcon(os.path.join(os.path.join(cmd_folder, 'logo.png')))
return icon
[docs]
def createInstance(self):
return MarcheALOmbreAlgorithm()