diff --git a/.gitignore b/.gitignore index 5e5f5ea..868a60f 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,4 @@ cython_debug/ .DS_Store +.vscode diff --git a/makefile b/makefile new file mode 100644 index 0000000..cd5394e --- /dev/null +++ b/makefile @@ -0,0 +1,59 @@ +PACKAGE_NAME = solar-apparent-time +ENVIRONMENT_NAME = $(PACKAGE_NAME) +DOCKER_IMAGE_NAME = $(PACKAGE_NAME) + +clean: + rm -rf *.o *.out *.log + rm -rf build/ + rm -rf dist/ + rm -rf *.egg-info + rm -rf .pytest_cache + find . -type d -name "__pycache__" -exec rm -rf {} + + +test: + pytest + +build: + python -m build + +twine-upload: + twine upload dist/* + +dist: + make clean + make build + make twine-upload + +remove-environment: + mamba env remove -y -n $(ENVIRONMENT_NAME) + +install: + pip install -e .[dev] + +uninstall: + pip uninstall $(PACKAGE_NAME) + +reinstall: + make uninstall + make install + +environment: + mamba create -y -n $(ENVIRONMENT_NAME) -c conda-forge python=3.10 + +colima-start: + colima start -m 16 -a x86_64 -d 100 + +docker-build: + docker build -t $(DOCKER_IMAGE_NAME):latest . + +docker-build-environment: + docker build --target environment -t $(DOCKER_IMAGE_NAME):latest . + +docker-build-installation: + docker build --target installation -t $(DOCKER_IMAGE_NAME):latest . + +docker-interactive: + docker run -it $(DOCKER_IMAGE_NAME) fish + +docker-remove: + docker rmi -f $(DOCKER_IMAGE_NAME) diff --git a/pyproject.toml b/pyproject.toml index 22ce6f2..c112a35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=60", "setuptools-scm>=8.0", "wheel"] [project] name = "solar-apparent-time" -version = "1.3.2" +version = "1.4.0" description = "methods to translate Python datetime between solar apparent time and Coordinate Universal Time (UTC)" readme = "README.md" authors = [ diff --git a/solar_apparent_time/__init__.py b/solar_apparent_time/__init__.py index d415915..af09c05 100644 --- a/solar_apparent_time/__init__.py +++ b/solar_apparent_time/__init__.py @@ -1,9 +1,4 @@ from .solar_apparent_time import * +from .version import __version__ -from os.path import join, dirname, abspath - -with open(join(abspath(dirname(__file__)), "version.txt")) as f: - version = f.read() - -__version__ = version __author__ = "Gregory H. Halverson" diff --git a/solar_apparent_time/solar_apparent_time.py b/solar_apparent_time/solar_apparent_time.py index d4fe946..7793e8e 100644 --- a/solar_apparent_time/solar_apparent_time.py +++ b/solar_apparent_time/solar_apparent_time.py @@ -1,70 +1,261 @@ -from typing import Union +from typing import Union from datetime import datetime, timedelta import numpy as np +import pandas as pd import rasters as rt +from rasters import SpatialGeometry + + +def _parse_time(time_UTC: Union[datetime, str, list, np.ndarray]) -> np.ndarray: + """ + Convert a time or list/array of times to a numpy array of datetime64 objects. + Accepts a single datetime, string, or a list/array of either. + + Parameters + ---------- + time_UTC : datetime, str, list, or np.ndarray + The UTC time(s) as datetime object(s), string(s), or array-like. + + Returns + ------- + np.ndarray + Array of datetime64 objects. + """ + if isinstance(time_UTC, (str, datetime)): + return np.array([pd.to_datetime(time_UTC)]) + + # If already array-like + arr = np.array(time_UTC) + + if np.issubdtype(arr.dtype, np.datetime64): + return pd.to_datetime(arr) + + return pd.to_datetime(arr) + +def _broadcast_time_and_space(times: np.ndarray, lons: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """ + Broadcast time and longitude arrays to compatible shapes for element-wise operations. + + Parameters + ---------- + times : np.ndarray + Array of times (datetime64). + lons : np.ndarray + Array of longitudes (degrees). + + Returns + ------- + tuple[np.ndarray, np.ndarray] + Broadcasted arrays of times and longitudes. + """ + times = np.asarray(times) + lons = np.asarray(lons) + + if times.shape == (): + times = times[None] + + if lons.shape == (): + lons = lons[None] + + return np.broadcast_arrays(times[..., None], lons) + +def calculate_solar_hour_of_day( + time_UTC: Union[datetime, str, list, np.ndarray], + geometry: SpatialGeometry = None, + lat: Union[np.ndarray, float] = None, + lon: Union[np.ndarray, float] = None +) -> np.ndarray: + """ + Calculate the solar hour of day for given UTC time(s) and spatial information. + + Parameters + ---------- + time_UTC : datetime, str, list, or np.ndarray + UTC time(s) as datetime object(s), string(s), or array-like. + geometry : SpatialGeometry, optional + SpatialGeometry or RasterGeometry object with longitude attribute. + lat : float or np.ndarray, optional + Latitude(s) in degrees (not used, included for API compatibility). + lon : float or np.ndarray, optional + Longitude(s) in degrees. Required if geometry is not provided. + + Returns + ------- + np.ndarray + Array of solar hour of day values, same shape as broadcasted input. + + Notes + ----- + The solar hour of day is the local solar time in hours, accounting for longitude offset. + """ + times = _parse_time(time_UTC) + + if geometry is not None: + lon = geometry.lon + elif lon is not None: + lon = np.asarray(lon) + else: + raise ValueError('Must provide either spatial or lon.') + + # Broadcast times and lons + times_b, lons_b = _broadcast_time_and_space(times, lon) + + # Calculate hour_UTC + hour_UTC = ( + times_b.astype('datetime64[h]').astype(int) % 24 + + (times_b.astype('datetime64[m]').astype(int) % 60) / 60 + + (times_b.astype('datetime64[s]').astype(int) % 60) / 3600 + ) + + offset = np.radians(lons_b) / np.pi * 12 + hour_of_day = hour_UTC + offset + hour_of_day = np.where(hour_of_day < 0, hour_of_day + 24, hour_of_day) + hour_of_day = np.where(hour_of_day > 24, hour_of_day - 24, hour_of_day) + + return hour_of_day + +def calculate_solar_day_of_year( + time_UTC: Union[datetime, str, list, np.ndarray], + geometry: SpatialGeometry = None, + lat: Union[np.ndarray, float] = None, + lon: Union[np.ndarray, float] = None +) -> np.ndarray: + """ + Calculate the solar day of year for given UTC time(s) and spatial information. + + Parameters + ---------- + time_UTC : datetime, str, list, or np.ndarray + UTC time(s) as datetime object(s), string(s), or array-like. + geometry : SpatialGeometry, optional + SpatialGeometry or RasterGeometry object with longitude attribute. + lat : float or np.ndarray, optional + Latitude(s) in degrees (not used, included for API compatibility). + lon : float or np.ndarray, optional + Longitude(s) in degrees. Required if geometry is not provided. + + Returns + ------- + np.ndarray + Array of solar day of year values, same shape as broadcasted input. + + Notes + ----- + The solar day of year is the day of year at the local solar time, accounting for longitude offset. + """ + times = _parse_time(time_UTC) + + if geometry is not None: + lon = geometry.lon + elif lon is not None: + lon = np.asarray(lon) + else: + raise ValueError('Must provide either spatial or lon.') + + # Broadcast times and lons + times_b, lons_b = _broadcast_time_and_space(times, lon) + # Calculate DOY_UTC + doy_UTC = np.array([t.timetuple().tm_yday for t in times_b.flat]).reshape(times_b.shape) + + hour_UTC = ( + times_b.astype('datetime64[h]').astype(int) % 24 + + (times_b.astype('datetime64[m]').astype(int) % 60) / 60 + + (times_b.astype('datetime64[s]').astype(int) % 60) / 3600 + ) + + offset = np.radians(lons_b) / np.pi * 12 + hour_of_day = hour_UTC + offset + day_of_year = doy_UTC.copy() + day_of_year = np.where(hour_of_day < 0, day_of_year - 1, day_of_year) + day_of_year = np.where(hour_of_day > 24, day_of_year + 1, day_of_year) + + return day_of_year def UTC_to_solar(time_UTC: datetime, lon: float) -> datetime: """ - Converts Coordinated Universal Time (UTC) to solar time. + Convert Coordinated Universal Time (UTC) to solar apparent time at a given longitude. - Parameters: - time_UTC (datetime): The UTC time. - lon (float): The longitude in degrees. + Parameters + ---------- + time_UTC : datetime + The UTC time. + lon : float + The longitude in degrees. - Returns: - datetime: The solar time at the given longitude. + Returns + ------- + datetime + The solar time at the given longitude. """ return time_UTC + timedelta(hours=(np.radians(lon) / np.pi * 12)) def solar_to_UTC(time_solar: datetime, lon: float) -> datetime: """ - Converts solar time to Coordinated Universal Time (UTC). + Convert solar apparent time to Coordinated Universal Time (UTC) at a given longitude. - Parameters: - time_solar (datetime): The solar time. - lon (float): The longitude in degrees. + Parameters + ---------- + time_solar : datetime + The solar time. + lon : float + The longitude in degrees. - Returns: - datetime: The UTC time at the given longitude. + Returns + ------- + datetime + The UTC time at the given longitude. """ return time_solar - timedelta(hours=(np.radians(lon) / np.pi * 12)) def UTC_offset_hours_for_longitude(lon: Union[float, np.ndarray]) -> Union[float, np.ndarray]: """ - Calculates the offset in hours from UTC based on the given longitude. + Calculate the offset in hours from UTC based on longitude. - Args: - lon (Union[float, np.ndarray]): The longitude in degrees. + Parameters + ---------- + lon : float or np.ndarray + Longitude(s) in degrees. - Returns: - Union[float, np.ndarray]: The calculated offset in hours from UTC. + Returns + ------- + float or np.ndarray + The calculated offset in hours from UTC. """ # Convert longitude to radians and calculate the offset in hours from UTC return np.radians(lon) / np.pi * 12 def UTC_offset_hours_for_area(geometry: rt.RasterGeometry) -> rt.Raster: """ - Calculates the UTC offset in hours for a given raster geometry. + Calculate the UTC offset in hours for a given raster geometry. - Parameters: - geometry (rt.RasterGeometry): The raster geometry. + Parameters + ---------- + geometry : rt.RasterGeometry + The raster geometry object with longitude information. - Returns: - rt.Raster: The UTC offset in hours. + Returns + ------- + rt.Raster + The UTC offset in hours as a raster. """ return rt.Raster(np.radians(geometry.lon) / np.pi * 12, geometry=geometry) def solar_day_of_year_for_area(time_UTC: datetime, geometry: rt.RasterGeometry) -> rt.Raster: """ - Calculates the day of the year for a given UTC time and raster geometry. + Calculate the solar day of year for a given UTC time and raster geometry. - Parameters: - time_UTC (datetime): The UTC time. - geometry (rt.RasterGeometry): The raster geometry. + Parameters + ---------- + time_UTC : datetime + The UTC time. + geometry : rt.RasterGeometry + The raster geometry object with longitude information. - Returns: - rt.Raster: The day of the year. + Returns + ------- + rt.Raster + The day of the year as a raster. """ doy_UTC = time_UTC.timetuple().tm_yday hour_UTC = time_UTC.hour + time_UTC.minute / 60 + time_UTC.second / 3600 @@ -76,16 +267,23 @@ def solar_day_of_year_for_area(time_UTC: datetime, geometry: rt.RasterGeometry) return doy -def solar_day_of_year_for_longitude(time_UTC: datetime, lon: Union[float, np.ndarray]) -> Union[float, np.ndarray]: +def solar_day_of_year_for_longitude( + time_UTC: datetime, + lon: Union[float, np.ndarray]) -> Union[float, np.ndarray]: """ - Calculates the day of year based on the given UTC time and longitude. + Calculate the solar day of year for a given UTC time and longitude(s). - Args: - time_UTC (datetime.datetime): The UTC time to calculate the day of year for. - lon (Union[float, np.ndarray]): The longitude in degrees. + Parameters + ---------- + time_UTC : datetime + The UTC time to calculate the day of year for. + lon : float or np.ndarray + Longitude(s) in degrees. - Returns: - Union[float, np.ndarray]: The calculated day of year. + Returns + ------- + float or np.ndarray + The calculated day of year. """ # Calculate the day of year at the given longitude DOY_UTC = time_UTC.timetuple().tm_yday @@ -101,14 +299,19 @@ def solar_day_of_year_for_longitude(time_UTC: datetime, lon: Union[float, np.nda def solar_hour_of_day_for_area(time_UTC: datetime, geometry: rt.RasterGeometry) -> rt.Raster: """ - Calculates the hour of the day for a given UTC time and raster geometry. + Calculate the solar hour of day for a given UTC time and raster geometry. - Parameters: - time_UTC (datetime): The UTC time. - geometry (rt.RasterGeometry): The raster geometry. + Parameters + ---------- + time_UTC : datetime + The UTC time. + geometry : rt.RasterGeometry + The raster geometry object with longitude information. - Returns: - rt.Raster: The hour of the day. + Returns + ------- + rt.Raster + The hour of the day as a raster. """ hour_UTC = time_UTC.hour + time_UTC.minute / 60 + time_UTC.second / 3600 UTC_offset_hours = UTC_offset_hours_for_area(geometry=geometry) diff --git a/solar_apparent_time/version.py b/solar_apparent_time/version.py new file mode 100644 index 0000000..1559726 --- /dev/null +++ b/solar_apparent_time/version.py @@ -0,0 +1,4 @@ +from os.path import join, abspath, dirname +from importlib.metadata import version + +__version__ = version("sun-angles") diff --git a/solar_apparent_time/version.txt b/solar_apparent_time/version.txt deleted file mode 100644 index 6261a05..0000000 --- a/solar_apparent_time/version.txt +++ /dev/null @@ -1 +0,0 @@ -1.3.1 \ No newline at end of file diff --git a/tests/test_import_sun_angles.py b/tests/test_import_solar_apparent_time.py similarity index 100% rename from tests/test_import_sun_angles.py rename to tests/test_import_solar_apparent_time.py