marche_a_lombre.marche_a_lombre_algorithm

  1# -*- coding: utf-8 -*-
  2
  3# /***************************************************************************
  4#  MarcheALOmbre
  5#                                  A QGIS plugin
  6#  This plugin calculates for a given hike the shady and sunny parts
  7#  Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/
  8#                               -------------------
  9#         begin                : 2025-12-11
 10#         copyright            : (C) 2025 by Yolanda Seifert
 11#         email                : yolanda.seifert@gmx.de
 12#  ***************************************************************************/
 13
 14# /***************************************************************************
 15#  *                                                                         *
 16#  *   This program is free software; you can redistribute it and/or modify  *
 17#  *   it under the terms of the GNU General Public License as published by  *
 18#  *   the Free Software Foundation; either version 2 of the License, or     *
 19#  *   (at your option) any later version.                                   *
 20#  *                                                                         *
 21#  ***************************************************************************/
 22
 23__author__ = 'Yolanda Seifert'
 24__date__ = '2025-12-11'
 25__copyright__ = '(C) 2025 by Yolanda Seifert'
 26
 27# This will get replaced with a git SHA1 when you do a git archive
 28
 29__revision__ = '$Format:%H$'
 30
 31import os
 32import inspect
 33import math
 34import processing
 35from osgeo import gdal
 36from qgis.PyQt.QtGui import QIcon, QColor
 37from qgis.PyQt.QtCore import QCoreApplication, QVariant
 38from qgis.core import (QgsProcessing,
 39                        QgsFeatureSink,
 40                        QgsProcessingAlgorithm,
 41                        QgsProcessingParameterFeatureSource,
 42                        QgsProcessingParameterFeatureSink,
 43                        QgsProcessingParameterDateTime,
 44                        QgsProcessingParameterNumber,
 45                        QgsProcessingParameterPoint,
 46                        QgsProcessingParameterBoolean,
 47                        QgsPoint,
 48                        QgsProcessingException,
 49                        QgsProcessingParameterRasterDestination,
 50                        QgsProcessingParameterFileDestination,
 51                        QgsCoordinateReferenceSystem,
 52                        QgsFields,
 53                        QgsWkbTypes,
 54                        QgsFeature,
 55                        QgsGeometry,
 56                        QgsField,
 57                        QgsProcessingUtils,
 58                        QgsCategorizedSymbolRenderer,
 59                        QgsRendererCategory,
 60                        QgsSymbol,
 61                        QgsSimpleMarkerSymbolLayer,
 62                        QgsVectorLayer,
 63                        QgsProperty,
 64                        QgsSymbolLayer)
 65
 66
 67from .mns_downloader import MNSDownloader
 68from .trail import Trail
 69from .shadow_calculator import ShadowCalculator
 70
 71
 72class MarcheALOmbreAlgorithm(QgsProcessingAlgorithm):
 73    """
 74    This is an example algorithm that takes a vector layer and
 75    creates a new identical one.
 76
 77    It is meant to be used as an example of how to create your own
 78    algorithms and explain methods and variables used to do it. An
 79    algorithm like this will be available in all elements, and there
 80    is not need for additional work.
 81
 82    All Processing algorithms should extend the QgsProcessingAlgorithm
 83    class.
 84    """
 85
 86    # Constants used to refer to parameters and outputs. They will be
 87    # used when calling the algorithm from another algorithm, or when
 88    # calling from the QGIS console.
 89
 90    OUTPUT = 'OUTPUT'
 91    INPUT = 'INPUT'
 92    DEPARTURE_TIME = 'DEPARTURE_TIME'
 93    HIKING_SPEED = 'HIKING_SPEED'
 94    PICNIC_POINT = 'PICNIC_POINT'
 95    PICNIC_DURATION = 'PICNIC_DURATION'
 96    REVERSE_DIRECTION = 'REVERSE_DIRECTION'
 97    BUFFER_MODE = 'BUFFER_MODE'
 98    OUTPUT_POINTS = 'OUTPUT_POINTS'
 99    LOW_RES_MNS = 'LOW_RES_MNS'
100    OUTPUT_CSV = 'OUTPUT_CSV'
101
102    def initAlgorithm(self, config):
103        """
104        Here we define the inputs and output of the algorithm, along
105        with some other properties.
106        """
107
108        # We add the input vector features source. It can have any kind of
109        # geometry.
110        self.addParameter(
111            QgsProcessingParameterFeatureSource(
112                self.INPUT,
113                self.tr('Input layer - tracks'),
114                [QgsProcessing.TypeVectorAnyGeometry]
115            )
116        )
117
118        self.addParameter(
119            QgsProcessingParameterDateTime(
120                self.DEPARTURE_TIME,
121                self.tr('Departure Date and Time (Local Time)'),
122                type=QgsProcessingParameterDateTime.DateTime  # Allows selecting both Date and Time
123            )
124        )
125
126        self.addParameter(
127            QgsProcessingParameterNumber(
128                self.HIKING_SPEED,
129                self.tr('Average Hiking Speed (km/h)'),
130                type=QgsProcessingParameterNumber.Double,
131                defaultValue=5.0,  # A standard hiking speed
132                minValue=0.0 
133            )
134        )
135
136        self.addParameter(
137            QgsProcessingParameterPoint(
138                self.PICNIC_POINT,
139                self.tr('Picnic Break Location (1h stop)'),
140                optional=True  # Optional: user might not want a break
141            )
142        )
143
144        self.addParameter(
145            QgsProcessingParameterNumber(
146                self.PICNIC_DURATION,
147                self.tr('Picnic Duration (minutes)'),
148                type=QgsProcessingParameterNumber.Integer,
149                defaultValue=60,
150                minValue=0,
151                optional=True
152            )
153        )
154
155        self.addParameter(
156            QgsProcessingParameterBoolean(
157                self.REVERSE_DIRECTION,
158                self.tr('Reverse Trail Direction (Finish to Start)'),
159                defaultValue=False
160            )
161        )
162
163        self.addParameter(
164            QgsProcessingParameterBoolean(
165                self.BUFFER_MODE,
166                self.tr('Calculate with Buffer (Center, Left 5m, Right 5m)'),
167                defaultValue=False
168            )
169        )
170
171
172        # We add a feature sink in which to store our processed features (this
173        # usually takes the form of a newly created vector layer when the
174        # algorithm is run in QGIS).
175        # self.addParameter(
176        #     QgsProcessingParameterFeatureSink(
177        #         self.OUTPUT,
178        #         self.tr('Output layer')
179        #     )
180        # )
181        self.addParameter(
182            QgsProcessingParameterRasterDestination(
183                self.OUTPUT,
184                self.tr('MNS')
185            )
186        )
187
188        self.addParameter(
189            QgsProcessingParameterFeatureSink(
190                self.OUTPUT_POINTS,
191                self.tr('Densified Trail Points'),
192                type=QgsProcessing.TypeVectorPoint
193            )
194        )
195
196        self.addParameter(
197            QgsProcessingParameterRasterDestination(
198                self.LOW_RES_MNS,
199                self.tr('Low Resolution MNS (Horizon)')
200            )
201        )
202
203        self.addParameter(
204            QgsProcessingParameterFileDestination(
205                self.OUTPUT_CSV,
206                self.tr('Statistics Table'),
207                fileFilter='CSV files (*.csv)'
208            )
209        )
210
211    def processAlgorithm(self, parameters, context, feedback):
212        """
213        Here is where the processing itself takes place.
214        """
215
216        # Retrieve the feature source and sink. The 'dest_id' variable is used
217        # to uniquely identify the feature sink, and must be included in the
218        # dictionary returned by the processAlgorithm function.
219        source = self.parameterAsSource(parameters, self.INPUT, context)
220        departure_dt = self.parameterAsDateTime(parameters, self.DEPARTURE_TIME, context)
221        speed = self.parameterAsDouble(parameters, self.HIKING_SPEED, context)
222        picnic_point = self.parameterAsPoint(parameters, self.PICNIC_POINT, context)
223        picnic_duration = self.parameterAsDouble(parameters, self.PICNIC_DURATION, context)
224        picnic_point_crs = self.parameterAsPointCrs(parameters, self.PICNIC_POINT, context)
225        reverse_direction = self.parameterAsBool(parameters, self.REVERSE_DIRECTION, context)
226        buffer_mode = self.parameterAsBool(parameters, self.BUFFER_MODE, context)
227        csv_path = self.parameterAsFileOutput(parameters, self.OUTPUT_CSV, context)
228
229        if not picnic_point or (picnic_point.x() == 0.0 and picnic_point.y() == 0.0):
230            picnic_point_crs = None
231
232        # Compute the number of steps to display within the progress bar and
233        # get features from source
234        total = 100.0 / source.featureCount() if source.featureCount() else 0
235        features = source.getFeatures()
236
237        ########################## TRAIL PROCESSING #########################
238        feedback.pushInfo("Processing trail...")
239        source_crs = source.sourceCrs()
240        if not source_crs.isValid():
241            raise QgsProcessingException(
242                "Input layer has no valid CRS. Please define the layer CRS before running this algorithm."
243            )
244
245        trail = Trail(
246            max_sep=10,
247            speed=speed, 
248            source_crs=source_crs,
249            transform_context=context.transformContext(),
250            feedback=feedback
251        )
252        trail.process_trail(source_tracks=source, 
253                            start_time=departure_dt, 
254                            break_point=picnic_point, 
255                            picnic_duration=picnic_duration,
256                            reverse=reverse_direction,
257                            buffer=buffer_mode,
258                            project_crs=picnic_point_crs)
259
260        ########################## MNT DOWNLOAD (For Z Values) ##################
261        # Generate a temporary file path for the MNT
262        mnt_path = QgsProcessingUtils.generateTempFilename('mnt_elevation.tif')
263        target_crs = trail.target_crs
264        downloader = MNSDownloader(
265            crs=target_crs, 
266            transform_context=context.transformContext(), 
267            feedback=feedback)
268        target_resolution = 0.5
269        # Download MNT (mns=False)
270        success_mnt = downloader.read_tif(
271            extent=trail.extent,
272            resolution=target_resolution*2, # less variation in trail elevation so lower resolution necessary
273            output_path=mnt_path,
274            input_crs=target_crs,
275            is_mns=False  
276        )
277
278        if not success_mnt:
279            raise QgsProcessingException("Failed to download MNT data.")
280        feedback.pushInfo(f"Downloading MNT for trail elevation data...")
281        
282        # Integrate MNT into Trail (Sample Z values)
283        feedback.pushInfo("Sampling elevation for trail points...")
284        trail.sample_elevation(mnt_path)
285
286        ########################## MNS DOWNLOAD (for Shade) ############################
287        output_path = self.parameterAsOutputLayer(parameters, self.OUTPUT, context)
288        low_res_path = self.parameterAsOutputLayer(parameters, self.LOW_RES_MNS, context)
289        
290        downloader = MNSDownloader(
291            crs=target_crs,
292            transform_context=context.transformContext(), 
293            feedback=feedback)
294
295        downloader.download_dual_quality_mns(
296            trail_extent=trail.extent,
297            high_res_path=output_path,
298            low_res_path=low_res_path,
299            trail_lat=trail.center_lat,
300            input_crs=target_crs,
301            high_res=target_resolution, # meters per pixel
302        )
303        
304        # Open MNS raster with GDAL
305        ds = gdal.Open(output_path)
306        if ds is None:
307            raise QgsProcessingException("Could not open downloaded MNS.")
308            
309        mns_band = ds.GetRasterBand(1)
310        mns_array = mns_band.ReadAsArray() # Returns numpy array
311        geo_transform = ds.GetGeoTransform()
312            
313        ds = None # Close dataset
314
315        ########################## CALCULATE SHADOWS ############################
316        feedback.pushInfo("Calculating shadows...")
317 
318        calculator = ShadowCalculator(
319            high_res_path=output_path,
320            low_res_path=low_res_path
321        )
322        
323        shadow_results = calculator.calculate_shadows(
324            trail_points=trail.trail_points,
325            max_dist_m=20000
326        )
327
328        ########################## CALCULATE STATISTICS ############################
329        total_points = len(shadow_results)
330        shady_points = sum(shadow_results)
331        sunny_points = total_points - shady_points
332        
333        percent_shady = (shady_points / total_points * 100) if total_points > 0 else 0.0
334        percent_sunny = (sunny_points / total_points * 100) if total_points > 0 else 0.0
335
336        feedback.pushInfo(f"Statistics: {percent_shady:.1f}% Shady, {percent_sunny:.1f}% Sunny")
337
338        if csv_path:
339            try:
340                with open(csv_path, 'w') as f:
341                    # Header
342                    f.write("Duration (min), % Shady, % Sunny, Time spent in Shadow (min), Time spent in Sun (min), Shady Points, Sunny Points, Total Points\n")
343                    
344                    duration_min = 0
345                    if trail.trail_points:
346                        start = trail.trail_points[0].datetime
347                        end = trail.trail_points[-1].datetime
348                        duration_min = start.secsTo(end) / 60
349                    time_shady = int(duration_min*percent_shady/100)
350                    time_sunny = int(duration_min*percent_sunny/100)
351
352                    f.write(f"{duration_min:.1f},{percent_shady:.2f},{percent_sunny:.2f},{time_shady},{time_sunny},{shady_points},{sunny_points},{total_points}\n")
353            except Exception as e:
354                feedback.reportError(f"Failed to write CSV: {e}")
355        
356        ########################## WRITE OUTPUT POINTS ##########################
357        # Define attribute table columns
358        fields = QgsFields()
359        fields.append(QgsField("id", QVariant.Int))
360        fields.append(QgsField("status", QVariant.String))     # Sunny / Shady
361        fields.append(QgsField("is_shadow", QVariant.Int))     # 0 / 1
362        fields.append(QgsField("x_proj", QVariant.Double))      # Lambert-93 X
363        fields.append(QgsField("y_proj", QVariant.Double))      # Lambert-93 Y
364        fields.append(QgsField("z_mnt", QVariant.Double))      # Altitude
365        fields.append(QgsField("latitude", QVariant.Double))   # WGS84 Lat
366        fields.append(QgsField("longitude", QVariant.Double))  # WGS84 Lon
367        fields.append(QgsField("arrival_time", QVariant.DateTime)) # Time
368        fields.append(QgsField("elevation_deg", QVariant.Double)) # Sun elevation angle
369        fields.append(QgsField("azimuth_deg", QVariant.Double)) # Sun azimtuh angle
370        fields.append(QgsField("course", QVariant.Double))
371
372        (point_sink, point_dest_id) = self.parameterAsSink(
373            parameters, 
374            self.OUTPUT_POINTS, 
375            context, 
376            fields, 
377            QgsWkbTypes.PointZ,
378            QgsCoordinateReferenceSystem(target_crs)
379        )
380        self.point_dest_id = point_dest_id
381
382        if point_sink is None:
383            raise QgsProcessingException("Could not create point sink")
384        
385        feedback.pushInfo("Writing results...")
386        for i, tp in enumerate(trail.trail_points):
387            if feedback.isCanceled(): break
388
389            # Calculate direction to next point for visualization
390            course = 0.0
391            if i < len(trail.trail_points) - 1:
392                next_tp = trail.trail_points[i+1]
393                course = QgsPoint(tp.x, tp.y).azimuth(QgsPoint(next_tp.x, next_tp.y))
394            elif i > 0:
395                course = prev_course
396            prev_course = course
397
398            feat = QgsFeature(fields)
399            geom = QgsGeometry.fromPoint(QgsPoint(tp.x, tp.y, tp.z))
400            feat.setGeometry(geom)
401            
402            is_shadow = shadow_results[i]
403            status_str = "Shadow" if is_shadow == 1 else "Sun"
404            
405            feat.setAttributes([
406                i,
407                status_str,
408                int(is_shadow),
409                tp.x,
410                tp.y,
411                tp.z,
412                tp.lat,
413                tp.lon,
414                tp.datetime,
415                math.degrees(tp.solar_pos[0]),
416                math.degrees(tp.solar_pos[1]),
417                course
418            ])
419            point_sink.addFeature(feat, QgsFeatureSink.FastInsert)
420            feedback.setProgress(int(i * total))
421
422        # Return the results of the algorithm. In this case our only result is
423        # the feature sink which contains the processed features, but some
424        # algorithms may return multiple feature sinks, calculated numeric
425        # statistics, etc. These should all be included in the returned
426        # dictionary, with keys matching the feature corresponding parameter
427        # or output names.
428        self.results = {
429            self.OUTPUT: output_path,
430            self.OUTPUT_POINTS: point_dest_id,
431            self.LOW_RES_MNS: low_res_path,
432            self.OUTPUT_CSV: csv_path,
433            'detected_crs': target_crs
434        }
435        return self.results
436    
437    def postProcessAlgorithm(self, context, feedback):
438        # Set project CRS to match the detected region
439        detected_crs_str = self.results.get('detected_crs')
440        if detected_crs_str:
441            detected_crs = QgsCoordinateReferenceSystem(detected_crs_str)
442            project = context.project()
443            
444            if detected_crs.isValid() and project.crs().authid() != detected_crs.authid():
445                feedback.pushInfo(f"Changing project CRS from {project.crs().authid()} to {detected_crs.authid()} to match detected region")
446                project.setCrs(detected_crs)
447        # style the output trail depending on Sun/Shadow
448        layer = QgsProcessingUtils.mapLayerFromString(self.point_dest_id, context)
449        
450        if layer:
451            categories = []
452            styles = [
453                (0, "Sun", "gold"), 
454                (1, "Shadow", "#1f78b4") 
455            ]
456
457            for val, label, color in styles:
458                sym_layer = QgsSimpleMarkerSymbolLayer.create({
459                    'name': 'filled_arrowhead', 
460                    'color': color,
461                    'size': '2.5',
462                })
463
464                # Direction of arrow based on course field
465                prop_angle = QgsProperty.fromExpression('"course" - 90')
466                sym_layer.setDataDefinedProperty(QgsSymbolLayer.PropertyAngle, prop_angle)
467
468                sym = QgsSymbol.defaultSymbol(layer.geometryType())
469                sym.changeSymbolLayer(0, sym_layer)
470                categories.append(QgsRendererCategory(val, sym, label, True))
471
472            renderer = QgsCategorizedSymbolRenderer("is_shadow", categories)
473            layer.setRenderer(renderer)
474            layer.triggerRepaint()
475
476        csv_path = self.results.get(self.OUTPUT_CSV)
477        if csv_path and os.path.exists(csv_path):
478            # Create delimited text layer from csv
479            path = csv_path.replace('\\', '/')
480            uri = f"file:///{path}?type=csv&watchFile=no"
481            
482            vlayer = QgsVectorLayer(uri, "Shadow Statistics", "delimitedtext")  
483            if vlayer.isValid():
484                context.project().addMapLayer(vlayer)
485        return self.results
486    
487    def helpUrl(self):
488        """
489        Returns the URL to the help page
490        """
491        cmd_folder = os.path.split(inspect.getfile(inspect.currentframe()))[0]
492        help_path = os.path.join(cmd_folder, 'help', 'build', 'html', 'index.html')
493        
494        if os.path.exists(help_path):
495            return "file://" + help_path
496        
497        # Fallback if local help isn't built yet
498        return "https://github.com/yolanda225/qgis-marche-a-lombre"
499
500    def shortHelpString(self):
501        """
502        Returns a brief description of the algorithm
503        (appears in the right-hand panel of the dialog)
504        """
505        return self.tr("This algorithm calculates the shady and sunny portions of a trail "
506                       "based on LiDAR HD data (MNS) and the sun's position at the time "
507                       "you are physically at each point on the hike.")
508
509    def name(self):
510        """
511        Returns the algorithm name, used for identifying the algorithm. This
512        string should be fixed for the algorithm, and must not be localised.
513        The name should be unique within each provider. Names should contain
514        lowercase alphanumeric characters only and no spaces or other
515        formatting characters.
516        """
517        return "marche_a_lombre"
518
519    def displayName(self):
520        """
521        Returns the translated algorithm name, which should be used for any
522        user-visible display of the algorithm name.
523        """
524        return self.tr(self.name())
525
526    def group(self):
527        """
528        Returns the name of the group this algorithm belongs to. This string
529        should be localised.
530        """
531        return self.tr(self.groupId())
532
533    def groupId(self):
534        """
535        Returns the unique ID of the group this algorithm belongs to. This
536        string should be fixed for the algorithm, and must not be localised.
537        The group id should be unique within each provider. Group id should
538        contain lowercase alphanumeric characters only and no spaces or other
539        formatting characters.
540        """
541        return ''
542
543    def tr(self, string):
544        return QCoreApplication.translate('Processing', string)
545    
546    def icon(self):
547        cmd_folder = os.path.split(inspect.getfile(inspect.currentframe()))[0]
548        icon = QIcon(os.path.join(os.path.join(cmd_folder, 'logo.png')))
549        return icon
550    
551    def createInstance(self):
552        return MarcheALOmbreAlgorithm()
553    
554