Source code for marche_a_lombre.marche_a_lombre_algorithm

# -*- 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()