Source code for marche_a_lombre.trail_point

# -*- coding: utf-8 -*-
"""
Part of MarcheALOmbre QGIS Plugin
Copyright (C) 2025 Yolanda Seifert
Licensed under GPL v2+
"""
import math
from datetime import datetime, timezone
try:
    import pvlib
    HAS_PVLIB = True
except (ImportError, ValueError, RuntimeError, OSError) as e:
    print(f"PVLib import failed ({e}). Using manual Solar Position Calculation")
    HAS_PVLIB = False

[docs] class TrailPoint: def __init__(self, lon, lat, x, y, z, datetime, convergence): """ Initializes a TrailPoint and automatically calculates the solar position Args: lon (float): Longitude (WGS84) lat (float): Latitude (WGS84) x (float): Projected X coordinate y (float): Projected Y coordinate z (float): Elevation datetime (datetime): Time of arrival convergence (float): meridian convergence """ self.lon = lon self.lat = lat self.x = x self.y = y self.z = z self.datetime = datetime self.convergence = convergence elev, az_true, self.azimuth_grid = self.calc_solar_pos(self.datetime) self.solar_pos = (elev, az_true)
[docs] def calc_solar_pos(self, dt): """ Calculates Solar Azimuth and Elevation for a given place and time. Uses pvlib if installed Args: dt (datetime): A datetime object Returns: tuple (float): (elevation, azimuth) in radians """ dt = dt.toUTC().toPyDateTime() if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) if HAS_PVLIB: try: sp = pvlib.solarposition.get_solarposition(dt, self.lat, self.lon, altitude=None, pressure=None, method='nrel_numpy') azimuth_pvlib = sp['azimuth'].iloc[0] elevation_pvlib = 90-sp['zenith'].iloc[0] az_rad = math.radians(elevation_pvlib) # Apply Convergence (True Azimuth -> Grid Azimuth) az_grid= (az_rad + self.convergence + 2 * math.pi) % (2 * math.pi) return az_rad, math.radians(azimuth_pvlib), az_grid except Exception as e: print(f"PVLib failed, falling back to manual: {e}") # Calculate time variables start_of_year = datetime(dt.year, 1, 1, tzinfo=timezone.utc) day_of_year = (dt - start_of_year).days + 1 hour_decimal = dt.hour + dt.minute / 60.0 + dt.second / 3600.0 # Fractional year (gamma) in radians gamma = (2 * math.pi / 365.0) * (day_of_year - 1 + (hour_decimal - 12) / 24) # Equation of time describes offset between mean and true solar time for given day # is due to elliptic orbit and inclined axis of earth eqtime = 229.18 * (0.000075 + 0.001868 * math.cos(gamma) - 0.032077 * math.sin(gamma) \ - 0.014615 * math.cos(2 * gamma) - 0.040849 * math.sin(2 * gamma)) # Solar Declination (angle between solar radiation and equatorial plane) Spencer's Method decl = 0.006918 - 0.399912 * math.cos(gamma) + 0.070257 * math.sin(gamma) \ - 0.006758 * math.cos(2 * gamma) + 0.000907 * math.sin(2 * gamma) \ - 0.002697 * math.cos(3 * gamma) + 0.00148 * math.sin(3 * gamma) # True Solar Time # Earth rotates 1 degree every 4 minutes time_offset = eqtime + 4 * self.lon tst = hour_decimal * 60 + time_offset # Hour Angle in radians - angle between local lon and lon with solar noon # (tst / 4) - 180 converts minutes to degrees so that 12:00 PM becomes 0 ha_deg = (tst / 4) - 180 ha_rad = math.radians(ha_deg) # more efficient to compute cos(decl) and sin(decl) earlier lat_rad = math.radians(self.lat) sin_dec, cos_dec = math.sin(decl), math.cos(decl) sin_lat, cos_lat = math.sin(lat_rad), math.cos(lat_rad) sin_ha, cos_ha = math.sin(ha_rad), math.cos(ha_rad) # Caluclate Elevation angle sin_elev = sin_lat * sin_dec + cos_lat * cos_dec * cos_ha elevation_rad = math.asin(max(-1.0, min(1.0, sin_elev))) # Calculate Azimuth angle x = -cos_ha * sin_lat * cos_dec + sin_dec * cos_lat y = -sin_ha * cos_dec azimuth_rad = math.atan2(y, x) # Normalize to 0-2pi azimuth_rad = (azimuth_rad + 2 * math.pi) % (2 * math.pi) az_grid = (azimuth_rad + self.convergence + 2 * math.pi) % (2 * math.pi) return elevation_rad, azimuth_rad, az_grid