From 9f7cf0d83207db94468f6babd2dae52295f37dbf Mon Sep 17 00:00:00 2001 From: ArthurOnnoTerabase Date: Tue, 5 May 2026 15:51:46 -0700 Subject: [PATCH] Fix calc_surface_orientation broadcasting for >1-D tracker_theta The unit-normal-based implementation introduced in v0.15.1 (#2702) used np.column_stack inside _unit_normal, which only handles 1-D inputs correctly. For 2-D (or higher rank) tracker_theta the column-stacked array had the wrong shape, and the subsequent np.where in calc_surface_orientation raised a ValueError. Switch to np.stack(..., axis=-1) and use ellipsis indexing in calc_surface_orientation so the function preserves the input rank for arbitrary-shape tracker_theta. np.atleast_1d preserves the historical (1, 3) return shape of _unit_normal for scalar input. Also adds a parametrised regression test that exercises 2-D and 3-D tracker_theta inputs. Closes #2747 Co-authored-by: Cursor --- docs/sphinx/source/whatsnew/v0.15.2.rst | 5 +++++ pvlib/tracking.py | 9 ++++---- tests/test_tracking.py | 29 +++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.15.2.rst b/docs/sphinx/source/whatsnew/v0.15.2.rst index 37f8692280..3c54d3b0a0 100644 --- a/docs/sphinx/source/whatsnew/v0.15.2.rst +++ b/docs/sphinx/source/whatsnew/v0.15.2.rst @@ -18,6 +18,10 @@ Bug fixes data type integer, users can expect modeled cell temperature values to increase slightly. (:issue:`2608`, :pull:`2745`) +* Fixes a regression in :py:func:`pvlib.tracking.calc_surface_orientation` + introduced in v0.15.1 (:pull:`2702`) that caused a broadcasting + ``ValueError`` when ``tracker_theta`` was a 2-D (or higher rank) array. + (:issue:`2747`, :pull:`2749`) Enhancements ~~~~~~~~~~~~ @@ -53,3 +57,4 @@ Contributors ~~~~~~~~~~~~ * :ghuser:`Omesh37` * Cliff Hansen (:ghuser:`cwhanse`) +* Arthur Onno (:ghuser:`ArthurOnnoTerabase`) diff --git a/pvlib/tracking.py b/pvlib/tracking.py index de82f44ffa..69c679ef79 100644 --- a/pvlib/tracking.py +++ b/pvlib/tracking.py @@ -233,10 +233,11 @@ def _unit_normal(axis_azimuth, axis_tilt, theta): Returns ------- ndarray - Shape (N,3) where theta has length N + Shape ``theta.shape + (3,)``, with a minimum rank of 2. For 1-D + ``theta`` of length N this is ``(N, 3)``. """ - theta = np.asarray(theta) + theta = np.atleast_1d(np.asarray(theta)) cA, sA = cosd(-axis_azimuth), sind(-axis_azimuth) cT, sT = cosd(-axis_tilt), sind(-axis_tilt) @@ -248,7 +249,7 @@ def _unit_normal(axis_azimuth, axis_tilt, theta): y = sA * sTh - cA * sT * cTh z = cT * cTh - result = np.column_stack((x, y, z)) + result = np.stack((x, y, z), axis=-1) return result @@ -296,7 +297,7 @@ def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0): # project unit_normal to x-y plane to calculate azimuth surface_azimuth = np.degrees( - np.arctan2(unit_normal[:, 0], unit_normal[:, 1])) + np.arctan2(unit_normal[..., 0], unit_normal[..., 1])) surface_azimuth = np.where(surface_tilt == 0., axis_azimuth - 90., surface_azimuth) diff --git a/tests/test_tracking.py b/tests/test_tracking.py index c1f89c2f25..cfb1c4fb98 100644 --- a/tests/test_tracking.py +++ b/tests/test_tracking.py @@ -557,3 +557,32 @@ def test_calc_surface_orientation_special(): # in a modulo-360 sense. np.testing.assert_allclose(np.round(out['surface_azimuth'], 4) % 360, expected_azimuths, rtol=1e-5, atol=1e-5) + + +@pytest.mark.parametrize('shape', [(3, 5), (1, 7), (4, 1), (2, 3, 4)]) +def test_calc_surface_orientation_2d(shape): + # Regression test for GH#2747: calc_surface_orientation must accept + # tracker_theta of arbitrary rank, not just 1-D. Compare the >1-D result + # to the 1-D result computed on the flattened input. + rng = np.random.default_rng(0) + rotations_flat = rng.uniform(-90, 90, size=int(np.prod(shape))) + rotations_nd = rotations_flat.reshape(shape) + + out_1d = tracking.calc_surface_orientation(rotations_flat, + axis_tilt=20, + axis_azimuth=180) + out_nd = tracking.calc_surface_orientation(rotations_nd, + axis_tilt=20, + axis_azimuth=180) + + assert out_nd['surface_tilt'].shape == shape + assert out_nd['surface_azimuth'].shape == shape + np.testing.assert_allclose(out_nd['surface_tilt'].reshape(-1), + out_1d['surface_tilt']) + np.testing.assert_allclose(out_nd['surface_azimuth'].reshape(-1), + out_1d['surface_azimuth']) + + # _unit_normal must preserve the input rank, appending a trailing axis + # of length 3. + unorm = tracking._unit_normal(180., 20., rotations_nd) + assert unorm.shape == shape + (3,)