diff --git a/docs/sphinx/source/whatsnew/v0.10.2.rst b/docs/sphinx/source/whatsnew/v0.10.2.rst index 6288b077fc..9a0a4f9145 100644 --- a/docs/sphinx/source/whatsnew/v0.10.2.rst +++ b/docs/sphinx/source/whatsnew/v0.10.2.rst @@ -15,6 +15,8 @@ Enhancements :py:func:`pvlib.iotools.get_pvgis_hourly`, :py:func:`pvlib.iotools.get_cams`, :py:func:`pvlib.iotools.get_bsrn`, and :py:func:`pvlib.iotools.read_midc_raw_data_from_nrel`. (:pull:`1800`) +* Added support for asymmetric limiting angles in :py:func:`pvlib.tracking.singleaxis` + and :py:class:`~pvlib.pvsystem.SingleAxisTrackerMount. (:issue:`1777`, :pull:`1809`, :pull:`1852`) * Added option to infer threshold values for :py:func:`pvlib.clearsky.detect_clearsky` (:issue:`1808`, :pull:`1784`) * Added a continuous version of the Erbs diffuse-fraction/decomposition model. @@ -56,6 +58,7 @@ Requirements Contributors ~~~~~~~~~~~~ * Adam R. Jensen (:ghuser:`AdamRJensen`) +* Michal Arieli (:ghuser:`MichalArieli`) * Abigail Jones (:ghuser:`ajonesr`) * Taos Transue (:ghuser:`reepoi`) * Echedey Luis (:ghuser:`echedey-ls`) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 2dcf8f5ee3..2743340d04 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -15,7 +15,7 @@ import pandas as pd from dataclasses import dataclass from abc import ABC, abstractmethod -from typing import Optional +from typing import Optional, Union from pvlib._deprecation import deprecated, warn_deprecated @@ -1411,12 +1411,21 @@ class SingleAxisTrackerMount(AbstractMount): A value denoting the compass direction along which the axis of rotation lies, measured east of north. [degrees] - max_angle : float, default 90 - A value denoting the maximum rotation angle + max_angle : float or tuple, default 90 + A value denoting the maximum rotation angle, in decimal degrees, of the one-axis tracker from its horizontal position (horizontal - if axis_tilt = 0). A max_angle of 90 degrees allows the tracker - to rotate to a vertical position to point the panel towards a - horizon. max_angle of 180 degrees allows for full rotation. [degrees] + if axis_tilt = 0). If a float is provided, it represents the maximum + rotation angle, and the minimum rotation angle is assumed to be the + opposite of the maximum angle. If a tuple of (min_angle, max_angle) is + provided, it represents both the minimum and maximum rotation angles. + + A rotation to 'max_angle' is a counter-clockwise rotation about the + y-axis of the tracker coordinate system. For example, for a tracker + with 'axis_azimuth' oriented to the south, a rotation to 'max_angle' + is towards the west, and a rotation toward 'min_angle' is in the + opposite direction, toward the east. Hence a max_angle of 180 degrees + (equivalent to max_angle = (-180, 180)) allows the tracker to achieve + its full rotation capability. backtrack : bool, default True Controls whether the tracker has the capability to "backtrack" @@ -1452,7 +1461,7 @@ class SingleAxisTrackerMount(AbstractMount): """ axis_tilt: float = 0.0 axis_azimuth: float = 0.0 - max_angle: float = 90.0 + max_angle: Union[float, tuple] = 90.0 backtrack: bool = True gcr: float = 2.0/7.0 cross_axis_tilt: float = 0.0 diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py index b379dd41bc..862d082775 100644 --- a/pvlib/tests/test_pvsystem.py +++ b/pvlib/tests/test_pvsystem.py @@ -2422,6 +2422,15 @@ def test_SingleAxisTrackerMount_get_orientation(single_axis_tracker_mount): assert actual[key] == pytest.approx(expected_value), err_msg +def test_SingleAxisTrackerMount_get_orientation_asymmetric_max(): + mount = pvsystem.SingleAxisTrackerMount(max_angle=(-30, 45)) + expected = {'surface_tilt': [45, 30], 'surface_azimuth': [90, 270]} + actual = mount.get_orientation([60, 60], [90, 270]) + for key, expected_value in expected.items(): + err_msg = f"{key} value incorrect" + assert actual[key] == pytest.approx(expected_value), err_msg + + def test_dc_ohms_from_percent(): expected = .1425 out = pvsystem.dc_ohms_from_percent(38, 8, 3, 1, 1) diff --git a/pvlib/tests/test_tracking.py b/pvlib/tests/test_tracking.py index 95c05c8df7..4febc73389 100644 --- a/pvlib/tests/test_tracking.py +++ b/pvlib/tests/test_tracking.py @@ -151,6 +151,22 @@ def test_max_angle(): assert_frame_equal(expect, tracker_data) +def test_min_angle(): + apparent_zenith = pd.Series([60]) + apparent_azimuth = pd.Series([270]) + tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth, + axis_tilt=0, axis_azimuth=0, + max_angle=(-45, 50), backtrack=True, + gcr=2.0/7.0) + + expect = pd.DataFrame({'aoi': 15, 'surface_azimuth': 270, + 'surface_tilt': 45, 'tracker_theta': -45}, + index=[0], dtype=np.float64) + expect = expect[SINGLEAXIS_COL_ORDER] + + assert_frame_equal(expect, tracker_data) + + def test_backtrack(): apparent_zenith = pd.Series([80]) apparent_azimuth = pd.Series([90]) diff --git a/pvlib/tracking.py b/pvlib/tracking.py index 4e027665ed..04ed5f8506 100644 --- a/pvlib/tracking.py +++ b/pvlib/tracking.py @@ -44,12 +44,21 @@ def singleaxis(apparent_zenith, apparent_azimuth, A value denoting the compass direction along which the axis of rotation lies. Measured in decimal degrees east of north. - max_angle : float, default 90 + max_angle : float or tuple, default 90 A value denoting the maximum rotation angle, in decimal degrees, of the one-axis tracker from its horizontal position (horizontal - if axis_tilt = 0). A max_angle of 90 degrees allows the tracker - to rotate to a vertical position to point the panel towards a - horizon. max_angle of 180 degrees allows for full rotation. + if axis_tilt = 0). If a float is provided, it represents the maximum + rotation angle, and the minimum rotation angle is assumed to be the + opposite of the maximum angle. If a tuple of (min_angle, max_angle) is + provided, it represents both the minimum and maximum rotation angles. + + A rotation to 'max_angle' is a counter-clockwise rotation about the + y-axis of the tracker coordinate system. For example, for a tracker + with 'axis_azimuth' oriented to the south, a rotation to 'max_angle' + is towards the west, and a rotation toward 'min_angle' is in the + opposite direction, toward the east. Hence a max_angle of 180 degrees + (equivalent to max_angle = (-180, 180)) allows the tracker to achieve + its full rotation capability. backtrack : bool, default True Controls whether the tracker has the capability to "backtrack" @@ -190,7 +199,16 @@ def singleaxis(apparent_zenith, apparent_azimuth, # NOTE: max_angle defined relative to zero-point rotation, not the # system-plane normal - tracker_theta = np.clip(tracker_theta, -max_angle, max_angle) + + # Determine minimum and maximum rotation angles based on max_angle. + # If max_angle is a single value, assume min_angle is the negative. + if np.isscalar(max_angle): + min_angle = -max_angle + else: + min_angle, max_angle = max_angle + + # Clip tracker_theta between the minimum and maximum angles. + tracker_theta = np.clip(tracker_theta, min_angle, max_angle) # Calculate auxiliary angles surface = calc_surface_orientation(tracker_theta, axis_tilt, axis_azimuth)