From d6d6f31e026a0673ed746df0d150b764871861a2 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 29 Sep 2025 14:15:47 -0400 Subject: [PATCH 01/78] ants2d and new VF code --- pvlib/bifacial/__init__.py | 4 +- pvlib/bifacial/ants2d.py | 563 +++++++++++++++++++++++++++++++++++++ pvlib/bifacial/utils.py | 331 +++++++++++++++++++--- 3 files changed, 855 insertions(+), 43 deletions(-) create mode 100644 pvlib/bifacial/ants2d.py diff --git a/pvlib/bifacial/__init__.py b/pvlib/bifacial/__init__.py index e166c55108..ec05f69da3 100644 --- a/pvlib/bifacial/__init__.py +++ b/pvlib/bifacial/__init__.py @@ -3,7 +3,9 @@ """ from pvlib._deprecation import deprecated -from pvlib.bifacial import pvfactors, infinite_sheds, utils # noqa: F401 +from pvlib.bifacial import ( # noqa: F401 + pvfactors, infinite_sheds, ants2d, utils +) from .loss_models import power_mismatch_deline # noqa: F401 pvfactors_timeseries = deprecated( diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py new file mode 100644 index 0000000000..ad7b78b0c3 --- /dev/null +++ b/pvlib/bifacial/ants2d.py @@ -0,0 +1,563 @@ +r""" +Functions for the ANTS 2D bifacial irradiance model. +""" + +import numpy as np +import pandas as pd +from pvlib.tools import cosd, sind, tand, acosd +from pvlib.bifacial import utils +from pvlib.irradiance import aoi_projection, haydavies, perez +from pvlib.shading import projected_solar_zenith_angle, shaded_fraction1d +from pvlib.tracking import calc_surface_orientation + + +def _shaded_fraction(tracker_rotation, phi, gcr, x0=0, x1=1): + """ + Calculate fraction (from the bottom) of row slant height that is shaded + from direct irradiance by the row in front toward the sun. + + See [1], Eq. 14 and also [2], Eq. 32. + + .. math:: + F_x = \\max \\left( 0, \\min \\left(\\frac{\\text{GCR} \\cos \\theta + + \\left( \\text{GCR} \\sin \\theta - \\tan \\beta_{c} \\right) + \\tan Z - 1} + {\\text{GCR} \\left( \\cos \\theta + \\sin \\theta \\tan Z \\right)}, + 1 \\right) \\right) + + Parameters + ---------- TODO fix + solar_zenith : numeric + Apparent (refraction-corrected) solar zenith. [degrees] + solar_azimuth : numeric + Solar azimuth. [degrees] + surface_tilt : numeric + Row tilt from horizontal, e.g. surface facing up = 0, surface facing + horizon = 90. [degrees] + surface_azimuth : numeric + Azimuth angle of the row surface. North=0, East=90, South=180, + West=270. [degrees] + gcr : numeric + Ground coverage ratio, which is the ratio of row slant length to row + spacing (pitch). [unitless] + x0, x1 : TODO + + Returns + ------- + f_x : numeric + Fraction of row slant height from the bottom that is shaded from + direct irradiance. + + References + ---------- + .. [1] Mikofski, M., Darawali, R., Hamer, M., Neubert, A., and Newmiller, + J. "Bifacial Performance Modeling in Large Arrays". 2019 IEEE 46th + Photovoltaic Specialists Conference (PVSC), 2019, pp. 1282-1287. + :doi:`10.1109/PVSC40753.2019.8980572`. + .. [2] Kevin Anderson and Mark Mikofski, "Slope-Aware Backtracking for + Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020. + https://www.nrel.gov/docs/fy20osti/76626.pdf + """ + + # note: ground slope is already accounted for in phi and gcr, so don't + # apply it here. + # also, we have PSZA instead of solar position, so use fake azimuths to + # trick shaded_fraction1d into accepting it as-is. + # direction of positive phi by right-hand rule: + f_s = shaded_fraction1d(phi, solar_azimuth=90, + axis_azimuth=0, + shaded_row_rotation=tracker_rotation, + collector_width=1, pitch=1/gcr) + + # dimensions: row segment, time + f_s = np.atleast_1d(f_s)[np.newaxis, :] + x0 = np.atleast_1d(x0)[:, np.newaxis] + x1 = np.atleast_1d(x1)[:, np.newaxis] + + swap = tracker_rotation < 0 + x0, x1 = np.where(swap, 1 - x1, x0), np.where(swap, 1 - x0, x1) + + f_s = np.clip((f_s - x0) / (x1 - x0), a_min=0, a_max=1) + + return f_s + + +def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, + gcr, height, pitch, ghi, dhi, dni, + albedo, x0, x1, g0, g1, max_rows): + r""" + Calculate plane-of-array (POA) irradiance on one side of a row of modules. + + The infinite sheds model [1] assumes the PV system comprises parallel, + evenly spaced rows on a level, horizontal surface. Rows can be on fixed + racking or single axis trackers. The model calculates irradiance at a + location far from the ends of any rows, in effect, assuming that the + rows (sheds) are infinitely long. + + POA irradiance components include direct, diffuse and global (total). + Irradiance values are reduced to account for reflection of direct light, + but are not adjusted for solar spectrum or reduced by a module's + bifaciality factor. + + Parameters TODO fix these + ---------- + surface_tilt : numeric + Tilt of the surface from horizontal. Must be between 0 and 180. For + example, for a fixed tilt module mounted at 30 degrees from + horizontal, use ``surface_tilt=30`` to get front-side irradiance and + ``surface_tilt=150`` to get rear-side irradiance. [degree] + + surface_azimuth : numeric + Surface azimuth in decimal degrees east of north + (e.g. North = 0, South = 180, East = 90, West = 270). [degree] + + solar_zenith : numeric + Refraction-corrected solar zenith. [degree] + + solar_azimuth : numeric + Solar azimuth. [degree] + + gcr : float + Ground coverage ratio, ratio of row slant length to row spacing. + [unitless] + + height : float + Height of the center point of the row above the ground; must be in the + same units as ``pitch``. + + pitch : float + Distance between two rows; must be in the same units as ``height``. + + ghi : numeric + Global horizontal irradiance. [W/m2] + + dhi : numeric + Diffuse horizontal irradiance. [W/m2] + + dni : numeric + Direct normal irradiance. [W/m2] + + albedo : numeric + Surface albedo. [unitless] + + model : str, default 'isotropic' + Irradiance model - can be one of 'isotropic' or 'haydavies'. + + dni_extra : numeric, optional + Extraterrestrial direct normal irradiance. Required when + ``model='haydavies'``. [W/m2] + + iam : numeric, default 1.0 + Incidence angle modifier, the fraction of direct irradiance incident + on the surface that is not reflected away. [unitless] + + npoints : int, default 100 + + .. deprecated:: v0.11.2 + + This parameter has no effect; integrated view factors are now + calculated exactly instead of with discretized approximations. + + vectorize : bool, default False + + .. deprecated:: v0.11.2 + + This parameter has no effect; calculations are now vectorized + with no memory usage penality. + + + Returns + ------- + output : dict or DataFrame + Output is a ``pandas.DataFrame`` when ``ghi`` is a Series. + Otherwise it is a dict of ``numpy.ndarray`` + See Notes for descriptions of content. + + Notes + ----- + Input parameters ``height`` and ``pitch`` must have the same unit. + + ``output`` always includes: + + - ``poa_global`` : total POA irradiance. [W/m^2] + - ``poa_diffuse`` : total diffuse POA irradiance from all sources. [W/m^2] + - ``poa_direct`` : total direct POA irradiance. [W/m^2] + - ``poa_sky_diffuse`` : total sky diffuse irradiance on the plane of array. + [W/m^2] + - ``poa_ground_diffuse`` : total ground-reflected diffuse irradiance on the + plane of array. [W/m^2] + - ``shaded_fraction`` : fraction of row slant height from the bottom that + is shaded from direct irradiance by adjacent rows. [unitless] + + References + ---------- + .. [1] Mikofski, M., Darawali, R., Hamer, M., Neubert, A., and Newmiller, + J. "Bifacial Performance Modeling in Large Arrays". 2019 IEEE 46th + Photovoltaic Specialists Conference (PVSC), 2019, pp. 1282-1287. + :doi:`10.1109/PVSC40753.2019.8980572`. + + See also + -------- + get_irradiance + """ + + # in-plane beam component + projection = np.array(np.clip(cos_aoi, a_min=0, a_max=None)) + projection = projection[np.newaxis, np.newaxis, :] + row_shaded_fraction = _shaded_fraction(tracker_rotation, phi, gcr, x0, x1) + row_shaded_fraction = row_shaded_fraction[np.newaxis, :, :] + poa_direct = dni * projection * (1 - row_shaded_fraction) + poa_direct = poa_direct[0] # drop unnecessary first dimension + + + # in-plane sky diffuse component + vf_row_sky = utils.vf_row_sky_2d_integ(tracker_rotation, gcr, x0, x1) + poa_sky_diffuse = vf_row_sky * dhi + poa_sky_diffuse = poa_sky_diffuse[0] # drop unnecesary first dimension + + + # in-plane ground-reflected component + ground_unshaded_fraction = utils._unshaded_ground_fraction( + tracker_rotation, phi, gcr, + pitch=pitch, height=height, g0=g0, g1=g1, max_rows=max_rows) + + ground_shaded_fraction = 1 - ground_unshaded_fraction + ground_shaded_fraction = ground_shaded_fraction[:, np.newaxis, :] + + vf_row_ground = utils.vf_row_ground_2d_integ(surface_tilt=tracker_rotation, + gcr=gcr, height=height, + pitch=pitch, + x0=x0, x1=x1, g0=g0, g1=g1, + max_rows=max_rows) + poa_ground_diffuse = vf_row_ground * albedo * ( + (1-ground_shaded_fraction) * (ghi - dhi) # reflected beam + + vf_gnd_sky * dhi # reflected diffuse + ) + poa_ground_diffuse = np.sum(poa_ground_diffuse, axis=0) # sum over ground segments + + + # add sky and ground-reflected irradiance on the row by irradiance + # component + poa_diffuse = poa_ground_diffuse + poa_sky_diffuse + poa_global = poa_direct + poa_diffuse + + output = { + 'poa_global': poa_global, 'poa_direct': poa_direct, + 'poa_diffuse': poa_diffuse, 'poa_ground_diffuse': poa_ground_diffuse, + 'poa_sky_diffuse': poa_sky_diffuse, + 'shaded_fraction': row_shaded_fraction + } + if isinstance(ghi, pd.Series): + output = pd.DataFrame(output) + return output + + +def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, + gcr, height, pitch, ghi, dhi, dni, + albedo, model='isotropic', dni_extra=None, airmass=None, + n_row_segments=1, n_ground_segments=1, axis_tilt=0, + cross_axis_slope=0): + """ + Get front and rear irradiance using the infinite sheds model. + + The infinite sheds model [1] assumes the PV system comprises parallel, + evenly spaced rows on a level, horizontal surface. Rows can be on fixed + racking or single axis trackers. The model calculates irradiance at a + location far from the ends of any rows, in effect, assuming that the + rows (sheds) are infinitely long. + + The model accounts for the following effects: + + - restricted view of the sky from module surfaces due to the nearby rows. + - restricted view of the ground from module surfaces due to nearby rows. + - restricted view of the sky from the ground due to rows. + - shading of module surfaces by nearby rows. + - shading of rear cells of a module by mounting structure and by + module features. + + The model implicitly assumes that diffuse irradiance from the sky is + isotropic, and that module surfaces do not allow irradiance to transmit + through the module to the ground through gaps between cells. + + Parameters + ---------- TODO fix + surface_tilt : numeric + Tilt from horizontal of the front-side surface. [degree] + + surface_azimuth : numeric + Surface azimuth in decimal degrees east of north + (e.g. North = 0, South = 180, East = 90, West = 270). [degree] + + solar_zenith : numeric + Refraction-corrected solar zenith. [degree] + + solar_azimuth : numeric + Solar azimuth. [degree] + + gcr : float + Ground coverage ratio, ratio of row slant length to row spacing. + [unitless] + + height : float + Height of the center point of the row above the ground; must be in the + same units as ``pitch``. + + pitch : float + Distance between two rows; must be in the same units as ``height``. + + ghi : numeric + Global horizontal irradiance. [W/m2] + + dhi : numeric + Diffuse horizontal irradiance. [W/m2] + + dni : numeric + Direct normal irradiance. [W/m2] + + albedo : numeric + Surface albedo. [unitless] + + model : str, default 'isotropic' + Irradiance model - can be one of 'isotropic' or 'haydavies'. + + dni_extra : numeric, optional + Extraterrestrial direct normal irradiance. Required when + ``model='haydavies'``. [W/m2] + + iam_front : numeric, default 1.0 + Incidence angle modifier, the fraction of direct irradiance incident + on the front surface that is not reflected away. [unitless] + + iam_back : numeric, default 1.0 + Incidence angle modifier, the fraction of direct irradiance incident + on the back surface that is not reflected away. [unitless] + + bifaciality : numeric, default 0.8 + Ratio of the efficiency of the module's rear surface to the efficiency + of the front surface. [unitless] + + shade_factor : numeric, default -0.02 + Fraction of back surface irradiance that is blocked by array mounting + structures. Negative value is a reduction in back irradiance. + [unitless] + + transmission_factor : numeric, default 0.0 + Fraction of irradiance on the back surface that does not reach the + module's cells due to module features such as busbars, junction box, + etc. A negative value is a reduction in back irradiance. [unitless] + + npoints : int, default 100 + + .. deprecated:: v0.11.2 + + This parameter has no effect; integrated view factors are now + calculated exactly instead of with discretized approximations. + + vectorize : bool, default False + + .. deprecated:: v0.11.2 + + This parameter has no effect; calculations are now vectorized + with no memory usage penality. + + Returns + ------- + output : dict or DataFrame + Output is a DataFrame when input ghi is a Series. See Notes for + descriptions of content. + + Notes + ----- + + ``output`` includes: + + - ``poa_global`` : total irradiance reaching the module cells from both + front and back surfaces. [W/m^2] + - ``poa_front`` : total irradiance reaching the module cells from the front + surface. [W/m^2] + - ``poa_back`` : total irradiance reaching the module cells from the back + surface. [W/m^2] + - ``poa_front_direct`` : direct irradiance reaching the module cells from + the front surface. [W/m^2] + - ``poa_front_diffuse`` : total diffuse irradiance reaching the module + cells from the front surface. [W/m^2] + - ``poa_front_sky_diffuse`` : sky diffuse irradiance reaching the module + cells from the front surface. [W/m^2] + - ``poa_front_ground_diffuse`` : ground-reflected diffuse irradiance + reaching the module cells from the front surface. [W/m^2] + - ``shaded_fraction_front`` : fraction of row slant height from the bottom + that is shaded from direct irradiance on the front surface by adjacent + rows. [unitless] + - ``poa_back_direct`` : direct irradiance reaching the module cells from + the back surface. [W/m^2] + - ``poa_back_diffuse`` : total diffuse irradiance reaching the module + cells from the back surface. [W/m^2] + - ``poa_back_sky_diffuse`` : sky diffuse irradiance reaching the module + cells from the back surface. [W/m^2] + - ``poa_back_ground_diffuse`` : ground-reflected diffuse irradiance + reaching the module cells from the back surface. [W/m^2] + - ``shaded_fraction_back`` : fraction of row slant height from the bottom + that is shaded from direct irradiance on the back surface by adjacent + rows. [unitless] + + References + ---------- + .. [1] Mikofski, M., Darawali, R., Hamer, M., Neubert, A., and Newmiller, + J. "Bifacial Performance Modeling in Large Arrays". 2019 IEEE 46th + Photovoltaic Specialists Conference (PVSC), 2019, pp. 1282-1287. + :doi:`10.1109/PVSC40753.2019.8980572`. + + See also + -------- + get_irradiance_poa + """ + + # preparation steps + if model in ['haydavies', 'perez']: + # determine circumsolar irradiance, add it to DNI + + if model == 'haydavies': + if dni_extra is None: + raise ValueError(f'must supply dni_extra for {model} model') + diffuse_model_func = haydavies + extra_kwargs = {} + + elif model == 'perez': + # note: horizon brightening is ignored + if dni_extra is None or airmass is None: + raise ValueError( + f'must supply dni_extra and airmass for {model} model') + diffuse_model_func = perez + extra_kwargs = {'airmass': airmass} + + kwargs = dict( + dhi=dhi, dni=dni, dni_extra=dni_extra, + solar_zenith=solar_zenith, solar_azimuth=solar_azimuth, + return_components=True + ) + # Call the model first time within the horizontal plane - to subtract + # circumsolar_horizontal from DHI + sky_diffuse_comps_horizontal = diffuse_model_func( + surface_tilt=0, surface_azimuth=180, **kwargs, **extra_kwargs) + circumsolar_horizontal = sky_diffuse_comps_horizontal['circumsolar'] + + # Call the model a second time where circumsolar_normal is facing + # directly towards sun, and can be added to DNI + sky_diffuse_comps_normal = diffuse_model_func( + surface_tilt=solar_zenith, surface_azimuth=solar_azimuth, + **kwargs, **extra_kwargs) + circumsolar_normal = sky_diffuse_comps_normal['circumsolar'] + + dhi = dhi - circumsolar_horizontal + dni = dni + circumsolar_normal + + + true_tracker_rotation = tracker_rotation + if axis_tilt != 0 or cross_axis_slope != 0: + slope_azimuth = axis_azimuth + np.degrees(np.arctan2(sind(cross_axis_slope), cosd(cross_axis_slope) * sind(axis_tilt))) + slope_tilt = acosd(cosd(axis_tilt) * cosd(cross_axis_slope)) + + height = height * cosd(slope_tilt) + pitch = pitch / cosd(cross_axis_slope) + gcr = gcr * cosd(cross_axis_slope) + tracker_rotation = tracker_rotation - cross_axis_slope + tracker_rotation = ((tracker_rotation + 180) % 360) - 180 # put back to [-180, 180] + + + ghi = dhi + dni * np.maximum( + aoi_projection(slope_tilt, slope_azimuth, + solar_zenith, solar_azimuth), + 0) + # + dhi: maybe no need to adjust, since the blocked view is only near the + # the horizon, and that part of the sky is blocked by rows anyway? + # + dni: no adjustment needed; the measurement plane is not affected + #dhi = dhi + #dni = dni + + x_row = np.linspace(0, 1, n_row_segments+1) + x0 = x_row[:-1] + x1 = x_row[1:] + + x_ground = np.linspace(0, 1, n_ground_segments+1) + g0 = x_ground[:-1] + g1 = x_ground[1:] + + # dimensions: ground segment, row segment, time + albedo = np.atleast_2d(albedo)[:, np.newaxis, :] + ghi = np.atleast_1d(ghi)[np.newaxis, np.newaxis, :] + dhi = np.atleast_1d(dhi)[np.newaxis, np.newaxis, :] + dni = np.atleast_1d(dni)[np.newaxis, np.newaxis, :] + + # Calculate some geometric quantities + # rows to consider in front and behind current row + # ensures that view factors to the sky are computed to within 4 degrees + # of the horizon + max_rows = np.ceil(height / (pitch * tand(4))) + + phi = projected_solar_zenith_angle(solar_zenith, solar_azimuth, + axis_tilt, axis_azimuth) + phi = phi - cross_axis_slope + + # compute this here, as it is expensive and does not differ between the + # front and rear sides + vf_gnd_sky = utils.vf_ground_sky_2d_integ( + tracker_rotation, gcr, height, pitch, g0=g0, g1=g1, max_rows=max_rows) + vf_gnd_sky = vf_gnd_sky[:, np.newaxis, :] + + # front + front_orientation = calc_surface_orientation(true_tracker_rotation, + axis_tilt, axis_azimuth) + cos_aoi_front = aoi_projection(**front_orientation, + solar_zenith=solar_zenith, + solar_azimuth=solar_azimuth) + poa_front = _ants2d_singleside(tracker_rotation, cos_aoi_front, phi, + vf_gnd_sky, gcr, height, pitch, ghi, dhi, + dni, albedo, x0, x1, g0, g1, max_rows) + + # rear + tracker_rotation_rear = true_tracker_rotation + 180 + tracker_rotation_rear = ((tracker_rotation_rear + 180) % 360) - 180 + rear_orientation = calc_surface_orientation(tracker_rotation_rear, + axis_tilt, axis_azimuth) + cos_aoi_rear = aoi_projection(**rear_orientation, + solar_zenith=solar_zenith, + solar_azimuth=solar_azimuth) + tracker_rotation_rear = tracker_rotation + 180 + tracker_rotation_rear = ((tracker_rotation_rear + 180) % 360) - 180 + poa_rear = _ants2d_singleside(tracker_rotation_rear, cos_aoi_rear, phi, + vf_gnd_sky, gcr, height, pitch, ghi, dhi, + dni, albedo, x0, x1, g0, g1, max_rows) + + for key, value in poa_rear.items(): + poa_rear[key] = value[::-1, :] # invert x0/x1 dimension + + colmap_front = { + 'poa_global': 'poa_front', + 'poa_direct': 'poa_front_direct', + 'poa_diffuse': 'poa_front_diffuse', + 'poa_sky_diffuse': 'poa_front_sky_diffuse', + 'poa_ground_diffuse': 'poa_front_ground_diffuse', + 'shaded_fraction': 'shaded_fraction_front', + } + colmap_rear = { + 'poa_global': 'poa_back', + 'poa_direct': 'poa_back_direct', + 'poa_diffuse': 'poa_back_diffuse', + 'poa_sky_diffuse': 'poa_back_sky_diffuse', + 'poa_ground_diffuse': 'poa_back_ground_diffuse', + 'shaded_fraction': 'shaded_fraction_back', + } + + if isinstance(ghi, pd.Series): + poa_front = poa_front.rename(columns=colmap_front) + poa_rear = poa_rear.rename(columns=colmap_rear) + output = pd.concat([poa_front, poa_rear], axis=1) + else: + for old_key, new_key in colmap_front.items(): + poa_front[new_key] = poa_front.pop(old_key) + for old_key, new_key in colmap_rear.items(): + poa_rear[new_key] = poa_rear.pop(old_key) + poa_front.update(poa_rear) + output = poa_front + + return output diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index a69a3e18d4..fba090b8fb 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -4,7 +4,8 @@ """ import numpy as np from pvlib.tools import sind, cosd, tand -from scipy.integrate import trapezoid +import warnings +from pvlib._deprecation import pvlibDeprecationWarning def _solar_projection_tangent(solar_zenith, solar_azimuth, surface_azimuth): @@ -37,8 +38,8 @@ def _solar_projection_tangent(solar_zenith, solar_azimuth, surface_azimuth): return tan_phi -def _unshaded_ground_fraction(surface_tilt, surface_azimuth, solar_zenith, - solar_azimuth, gcr, max_zenith=87): +def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch, height, + g0=0, g1=1, max_rows=10, max_zenith=85): r""" Calculate the fraction of the ground with incident direct irradiance. @@ -50,7 +51,7 @@ def _unshaded_ground_fraction(surface_tilt, surface_azimuth, solar_zenith, from vertical of the sun vector projected to a vertical plane that contains the row azimuth `surface_azimuth`. - Parameters + Parameters # TODO fix ---------- surface_tilt : numeric Surface tilt angle. The tilt angle is defined as @@ -66,7 +67,14 @@ def _unshaded_ground_fraction(surface_tilt, surface_azimuth, solar_zenith, gcr : float Ground coverage ratio, which is the ratio of row slant length to row spacing (pitch). [unitless] - max_zenith : numeric, default 87 + height : float + Height of the center point of the row above the ground; must be in the + same units as ``pitch``. + pitch : float + Distance between two rows; must be in the same units as ``height``. + g0, g1 : TODO + max_rows : TODO + max_zenith : numeric, default 85 Maximum zenith angle. For solar_zenith > max_zenith, unshaded ground fraction is set to 0. [degree] @@ -83,13 +91,57 @@ def _unshaded_ground_fraction(surface_tilt, surface_azimuth, solar_zenith, Photovoltaic Specialists Conference (PVSC), 2019, pp. 1282-1287. :doi:`10.1109/PVSC40753.2019.8980572`. """ - tan_phi = _solar_projection_tangent(solar_zenith, solar_azimuth, - surface_azimuth) - f_gnd_beam = 1.0 - np.minimum( - 1.0, gcr * np.abs(cosd(surface_tilt) + sind(surface_tilt) * tan_phi)) - # [1], Eq. 4 - f_gnd_beam = np.where(solar_zenith > max_zenith, 0., f_gnd_beam) - return f_gnd_beam # 1 - min(1, abs()) < 1 always + + swap = (tracker_rotation > 90) | (tracker_rotation <= -90) + tracker_rotation = np.where(swap, tracker_rotation + 180, tracker_rotation) + + # dimensions: k/max_rows, ground segment, time + + tracker_rotation = np.atleast_1d(tracker_rotation)[np.newaxis, np.newaxis, :] + phi = np.atleast_1d(phi)[np.newaxis, np.newaxis, :] + + g0 = np.atleast_1d(g0)[np.newaxis, :, np.newaxis] + g1 = np.atleast_1d(g1)[np.newaxis, :, np.newaxis] + + # TODO seems like this should be np.arange(-max_rows, max_rows+1)? + # see GH #1867 + k = np.arange(-max_rows, max_rows)[:, np.newaxis, np.newaxis] + + collector_width = pitch * gcr + Lcostheta = collector_width * cosd(tracker_rotation) + Lsintheta = collector_width * sind(tracker_rotation) + tanphi = tand(phi) + + # a, b: boundaries of ground segment + # d, c: left/right shading module edges + c = (k*pitch + 0.5 * Lcostheta, height + 0.5 * Lsintheta) + d = (k*pitch - 0.5 * Lcostheta, height - 0.5 * Lsintheta) + + cp = c[0] + c[1] * tanphi + dp = d[0] + d[1] * tanphi + a = g0*pitch + b = g1*pitch + + # individual contributions from all k rows + # TODO bug with zenith=0, fix these < > <= >= + fs = np.full_like(cp, 1.0) + # fs = np.where((dp < a) & (cp > b), 1.0, fs) # initial value already 1.0 + fs = np.where((dp < a) & (a < cp) & (cp < b), (cp - a) / (b - a), fs) + fs = np.where((dp < a) & (cp < a), 0.0, fs) + fs = np.where((a < dp) & (dp < b) & (cp > b), (b - dp) / (b - a), fs) + fs = np.where((a < dp) & (dp < b) & (a < cp) & (cp < b), + (cp - dp) / (b - a), fs) + fs = np.where((dp > b) & (cp > b), 0.0, fs) + + # total shaded fraction is sum of individuals; note that shadows + # never overlap in this model, except when shaded fraction is 100% anyway + f_gnd_beam = 1 - np.clip(np.sum(fs, axis=0), 0, 1) # sum along k dimension + + # using phi is more convenient, and I think better, than using zenith + phi = phi[0, :, :] # drop k dimension for the next line + f_gnd_beam = np.where(np.abs(phi) > max_zenith, 0., f_gnd_beam) + + return f_gnd_beam def vf_ground_sky_2d(rotation, gcr, x, pitch, height, max_rows=10): @@ -174,13 +226,13 @@ def vf_ground_sky_2d(rotation, gcr, x, pitch, height, max_rows=10): return vf -def vf_ground_sky_2d_integ(surface_tilt, gcr, height, pitch, max_rows=10, - npoints=100, vectorize=False): +def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, + max_rows=10, npoints=None, vectorize=None): """ Integrated view factor to the sky from the ground underneath interior rows of the array. - Parameters + Parameters TODO Fix ---------- surface_tilt : numeric Surface tilt angle in degrees from horizontal, e.g., surface facing up @@ -192,6 +244,7 @@ def vf_ground_sky_2d_integ(surface_tilt, gcr, height, pitch, max_rows=10, same units as ``pitch``. pitch : float Distance between two rows. Must be in the same units as ``height``. + g0, g1 : TODO max_rows : int, default 10 Maximum number of rows to consider in front and behind the current row. npoints : int, default 100 @@ -206,23 +259,71 @@ def vf_ground_sky_2d_integ(surface_tilt, gcr, height, pitch, max_rows=10, Integration of view factor over the length between adjacent, interior rows. Shape matches that of ``surface_tilt``. [unitless] """ - # Abuse vf_ground_sky_2d by supplying surface_tilt in place - # of a signed rotation. This is OK because - # 1) z span the full distance between 2 rows, and - # 2) max_rows is set to be large upstream, and - # 3) _vf_ground_sky_2d considers [-max_rows, +max_rows] - # The VFs to the sky will thus be symmetric around z=0.5 - z = np.linspace(0, 1, npoints) - rotation = np.atleast_1d(surface_tilt) - if vectorize: - fz_sky = vf_ground_sky_2d(rotation, gcr, z, pitch, height, max_rows) - else: - fz_sky = np.zeros((npoints, len(rotation))) - for k, r in enumerate(rotation): - vf = vf_ground_sky_2d(r, gcr, z, pitch, height, max_rows) - fz_sky[:, k] = vf[:, 0] # remove spurious rotation dimension - # calculate the integrated view factor for all of the ground between rows - return trapezoid(fz_sky, z, axis=0) + if npoints is not None or vectorize is not None: + msg = ( + "The `npoints` and `vectorize` parameters have no effect and will " + "be removed in a future version." # TODO make this better + ) + warnings.warn(msg, pvlibDeprecationWarning) + + # dimensions: k/max_rows, ground segment, time + + tracker_rotation = np.atleast_1d(tracker_rotation)[np.newaxis, np.newaxis, :] + + g0 = np.atleast_1d(g0)[np.newaxis, :, np.newaxis] + g1 = np.atleast_1d(g1)[np.newaxis, :, np.newaxis] + + # TODO seems like this should be np.arange(-max_rows, max_rows+1)? + # see GH #1867 + k = np.arange(-max_rows, max_rows)[:, np.newaxis, np.newaxis] + + collector_width = pitch * gcr + Lcostheta = collector_width * cosd(tracker_rotation) + Lsintheta = collector_width * sind(tracker_rotation) + + # primary crossed string points: + # a, b: boundaries of ground segment + # c, d: upper module edges + a = (g0*pitch, 0) + b = (g1*pitch, 0) + sign = np.sign(tracker_rotation) + c = ((k+1)*pitch + sign * 0.5 * Lcostheta, height + sign * 0.5 * Lsintheta) + d = (c[0] - pitch, c[1]) + + # view obstruction points (module edges, but need to figure out which ones) + + # first decide whether the left obstruction is the left or right mod edge + left = (k*pitch - 0.5 * Lcostheta, height - 0.5 * Lsintheta) + right = (k*pitch + 0.5 * Lcostheta, height + 0.5 * Lsintheta) + angle_left = _angle(a, left) + angle_right = _angle(a, right) + ob_left = ( + np.where(angle_left > angle_right, right[0], left[0]), + np.where(angle_left > angle_right, right[1], left[1]) + ) + + # now for the right obstruction + left = (left[0] + pitch, left[1]) + right = (right[0] + pitch, right[1]) + angle_left = _angle(b, left) + angle_right = _angle(b, right) + ob_right = ( + np.where(angle_left > angle_right, left[0], right[0]), + np.where(angle_left > angle_right, left[1], right[1]) + ) + + # hottel string lengths, considering obstructions + #ac = _obstructed_string_length(a, c, ob_left, ob_right) + #ad = _obstructed_string_length(a, d, ob_left, ob_right) + #bc = _obstructed_string_length(b, c, ob_left, ob_right) + #bd = _obstructed_string_length(b, d, ob_left, ob_right) + ac, ad, bc, bd = _obstructed_string_lengths(a, b, c, d, ob_left, ob_right) + + # crossed string formula for VF + vf_slats = 0.5 * (1/((g1 - g0) * pitch)) * ((ac + bd) - (bc + ad)) + vf_total = np.sum(np.maximum(vf_slats, 0), axis=0) # sum along k dimension + + return vf_total def _vf_poly(surface_tilt, gcr, x, delta): @@ -311,6 +412,16 @@ def vf_row_sky_2d_integ(surface_tilt, gcr, x0=0, x1=1): from x0 to x1. [unitless] ''' + # dimensions: row segment, time + + surface_tilt = np.atleast_1d(surface_tilt)[np.newaxis, :] + + x0 = np.atleast_1d(x0)[:, np.newaxis] + x1 = np.atleast_1d(x1)[:, np.newaxis] + + swap = surface_tilt < 0 + x0, x1 = np.where(swap, 1 - x1, x0), np.where(swap, 1 - x0, x1) + u = np.abs(x1 - x0) p0 = _vf_poly(surface_tilt, gcr, 1 - x0, -1) p1 = _vf_poly(surface_tilt, gcr, 1 - x1, -1) @@ -351,7 +462,8 @@ def vf_row_ground_2d(surface_tilt, gcr, x): return 0.5 * (1 - (1/gcr * cosd(surface_tilt) + x)/p) -def vf_row_ground_2d_integ(surface_tilt, gcr, x0=0, x1=1): +def vf_row_ground_2d_integ(surface_tilt, gcr, height, pitch, + x0=0, x1=1, g0=0, g1=1, max_rows=20): r''' Calculate the average view factor to the ground from a segment of the row surface between x0 and x1. @@ -367,6 +479,10 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, x0=0, x1=1): = 0, surface facing horizon = 90. [degree] gcr : numeric Ratio of the row slant length to the row spacing (pitch). [unitless] + height : float + TODO, make optional if x0=g0=0 and x1=g1=1? + pitch : float + TODO, make optional if x0=g0=0 and x1=g1=1? x0 : numeric, default 0. Position on the row's slant length, as a fraction of the slant length. x0=0 corresponds to the bottom of the row. x0 should be less than x1. @@ -374,6 +490,8 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, x0=0, x1=1): x1 : numeric, default 1. Position on the row's slant length, as a fraction of the slant length. x1 should be greater than x0. [unitless] + g0, g1 : TODO + max_rows : TODO Returns ------- @@ -382,12 +500,141 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, x0=0, x1=1): [unitless] ''' - u = np.abs(x1 - x0) - p0 = _vf_poly(surface_tilt, gcr, x0, 1) - p1 = _vf_poly(surface_tilt, gcr, x1, 1) - with np.errstate(divide='ignore'): - result = np.where(u < 1e-6, - vf_row_ground_2d(surface_tilt, gcr, x0), - 0.5*(1 - 1/u * (p1 - p0)) - ) - return result + + # dimensions: k/max_rows, ground segment, row segment, time + + surface_tilt = np.atleast_1d(surface_tilt)[np.newaxis, np.newaxis, np.newaxis, :] + + x0 = np.atleast_1d(x0)[np.newaxis, np.newaxis, :, np.newaxis] + x1 = np.atleast_1d(x1)[np.newaxis, np.newaxis, :, np.newaxis] + g0 = np.atleast_1d(g0)[np.newaxis, :, np.newaxis, np.newaxis] + g1 = np.atleast_1d(g1)[np.newaxis, :, np.newaxis, np.newaxis] + + # TODO seems like this should be np.arange(-max_rows, max_rows+1)? + # see GH #1867 + k = np.arange(-max_rows, max_rows)[:, np.newaxis, np.newaxis, np.newaxis] + + collector_width = pitch * gcr + Lcostheta = collector_width * cosd(surface_tilt) + Lsintheta = collector_width * sind(surface_tilt) + + # view obstruction points (lower module edges) + # use a number slightly larger than 0.5 because the obstruction must + # be a nonzero distance from all points the VF could be calculated from + ob_right = (-pitch - 0.5001 * Lcostheta, height - 0.5001 * abs(Lsintheta)) + ob_left = (ob_right[0] + pitch, ob_right[1]) + + invert = surface_tilt < 0 + temp = ob_right[0] + ob_right = (np.where(invert, -ob_left[0], ob_right[0]), ob_right[1]) + ob_left = (np.where(invert, -temp, ob_left[0]), ob_left[1]) + + # primary crossed string points: + # a, b: positions on module + # c, d: boundaries of ground segment + + a = ((x0-0.5) * Lcostheta, height + (x0-0.5) * Lsintheta) + b = ((x1-0.5) * Lcostheta, height + (x1-0.5) * Lsintheta) + c = ((k+g0)*pitch, 0) + d = ((k+g1)*pitch, 0) + + # hottel string lengths, considering obstructions + #ac = _obstructed_string_length(a, c, ob_left, ob_right) + #ad = _obstructed_string_length(a, d, ob_left, ob_right) + #bc = _obstructed_string_length(b, c, ob_left, ob_right) + #bd = _obstructed_string_length(b, d, ob_left, ob_right) + ac, ad, bc, bd = _obstructed_string_lengths(a, b, c, d, ob_left, ob_right) + + # crossed string formula for VF + vf_slats = 0.5 * (1/((x1 - x0) * collector_width)) * ((ac + bd) - (bc + ad)) + vf_total = np.sum(np.maximum(vf_slats, 0), axis=0) # sum along k dimension + + return vf_total + + +def _obstructed_string_lengths(a, b, c, d, ob_left, ob_right): + # string length calculations for Hottel's crossed strings method, + # considering view obstructions from the left and right. + # all inputs are (x, y) points. + + # unobstructed (straight-line) distances + dist_ac = _dist(a, c) + dist_ad = _dist(a, d) + dist_bc = _dist(b, c) + dist_bd = _dist(b, d) + dist_a_ob_left = _dist(a, ob_left) + dist_a_ob_right = _dist(a, ob_right) + dist_b_ob_left = _dist(b, ob_left) + dist_b_ob_right = _dist(b, ob_right) + dist_ob_left_c = _dist(ob_left, c) + dist_ob_right_c = _dist(ob_right, c) + dist_ob_left_d = _dist(ob_left, d) + dist_ob_right_d = _dist(ob_right, d) + + # angles + ang_ac = _angle(a, c) + ang_ad = _angle(a, d) + ang_bc = _angle(b, c) + ang_bd = _angle(b, d) + ang_a_ob_left = _angle(a, ob_left) + ang_a_ob_right = _angle(a, ob_right) + ang_b_ob_left = _angle(b, ob_left) + ang_b_ob_right = _angle(b, ob_right) + + # obstructed distances + ac = np.where(ang_ac - ang_a_ob_left > 1e-6, + dist_a_ob_left + dist_ob_left_c, + dist_ac) + ac = np.where(ang_a_ob_right - ang_ac > 1e-6, + dist_a_ob_right + dist_ob_right_c, + ac) + + ad = np.where(ang_ad - ang_a_ob_left > 1e-6, + dist_a_ob_left + dist_ob_left_d, + dist_ad) + ad = np.where(ang_a_ob_right - ang_ad > 1e-6, + dist_a_ob_right + dist_ob_right_d, + ad) + + bc = np.where(ang_bc - ang_b_ob_left > 1e-6, + dist_b_ob_left + dist_ob_left_c, + dist_bc) + bc = np.where(ang_b_ob_right - ang_bc > 1e-6, + dist_b_ob_right + dist_ob_right_c, + bc) + + bd = np.where(ang_bd - ang_b_ob_left > 1e-6, + dist_b_ob_left + dist_ob_left_d, + dist_bd) + bd = np.where(ang_b_ob_right - ang_bd > 1e-6, + dist_b_ob_right + dist_ob_right_d, + bd) + + return ac, ad, bc, bd + + +def _dist(p1, p2): + return ((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2)**0.5 + + +def _angle(p1, p2): + return np.arctan2(p2[1] - p1[1], p2[0] - p1[0]) + + +# def _obstructed_string_length(p1, p2, ob_left, ob_right): +# # string length calculations for Hottel's crossed strings method, +# # considering view obstructions from the left and right. +# # all inputs are (x, y) points. + +# # unobstructed length +# d = _dist(p1, p2) +# angle_p1_p2 = _angle(p1, p2) +# # obstructed on the left +# d = np.where(angle_p1_p2 - _angle(p1, ob_left) > 1e-6, +# _dist(p1, ob_left) + _dist(ob_left, p2), +# d) +# # obstructed on the right +# d = np.where(_angle(p1, ob_right) - angle_p1_p2 > 1e-6, +# _dist(p1, ob_right) + _dist(ob_right, p2), +# d) +# return d From 99a7a813154dddb43f30ca495109930bd696f55f Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 29 Sep 2025 14:48:44 -0400 Subject: [PATCH 02/78] remove some cruft --- pvlib/bifacial/utils.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index fba090b8fb..1aa951de70 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -313,10 +313,6 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, ) # hottel string lengths, considering obstructions - #ac = _obstructed_string_length(a, c, ob_left, ob_right) - #ad = _obstructed_string_length(a, d, ob_left, ob_right) - #bc = _obstructed_string_length(b, c, ob_left, ob_right) - #bd = _obstructed_string_length(b, d, ob_left, ob_right) ac, ad, bc, bd = _obstructed_string_lengths(a, b, c, d, ob_left, ob_right) # crossed string formula for VF @@ -539,10 +535,6 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height, pitch, d = ((k+g1)*pitch, 0) # hottel string lengths, considering obstructions - #ac = _obstructed_string_length(a, c, ob_left, ob_right) - #ad = _obstructed_string_length(a, d, ob_left, ob_right) - #bc = _obstructed_string_length(b, c, ob_left, ob_right) - #bd = _obstructed_string_length(b, d, ob_left, ob_right) ac, ad, bc, bd = _obstructed_string_lengths(a, b, c, d, ob_left, ob_right) # crossed string formula for VF @@ -619,22 +611,3 @@ def _dist(p1, p2): def _angle(p1, p2): return np.arctan2(p2[1] - p1[1], p2[0] - p1[0]) - - -# def _obstructed_string_length(p1, p2, ob_left, ob_right): -# # string length calculations for Hottel's crossed strings method, -# # considering view obstructions from the left and right. -# # all inputs are (x, y) points. - -# # unobstructed length -# d = _dist(p1, p2) -# angle_p1_p2 = _angle(p1, p2) -# # obstructed on the left -# d = np.where(angle_p1_p2 - _angle(p1, ob_left) > 1e-6, -# _dist(p1, ob_left) + _dist(ob_left, p2), -# d) -# # obstructed on the right -# d = np.where(_angle(p1, ob_right) - angle_p1_p2 > 1e-6, -# _dist(p1, ob_right) + _dist(ob_right, p2), -# d) -# return d From a6f22bbf4d5b390f610853e582187996abcc7fb1 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Tue, 7 Oct 2025 17:32:45 -0400 Subject: [PATCH 03/78] add tests for ants2d._shaded_fraction --- tests/bifacial/test_ants2d.py | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/bifacial/test_ants2d.py diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py new file mode 100644 index 0000000000..09d3bd0961 --- /dev/null +++ b/tests/bifacial/test_ants2d.py @@ -0,0 +1,41 @@ +""" +test ants2d +""" + +import numpy as np +import pandas as pd +from pvlib.bifacial import ants2d + +import pytest + + +def test__shaded_fraction(): + + # special angles + tracker_rotation = np.array([60, 60, 60, 60]) + phi = np.array([60, 60, 60, 60]) + gcr = np.array([1, 0.75, 2/3, 0.5]) + expected = np.array([[0.5, 1/3, 0.25, 0]]) + fs = ants2d._shaded_fraction(tracker_rotation, phi, gcr) + np.testing.assert_allclose(fs, expected) + fs = ants2d._shaded_fraction(-tracker_rotation, -phi, gcr) + np.testing.assert_allclose(fs, expected) + + # sun too high for shade + assert 0 == ants2d._shaded_fraction(10, 20, 0.5) + assert 0 == ants2d._shaded_fraction(-10, -20, 0.5) + + # sun behind the modules (AOI > 90) + # (debatable whether this should be zero or one) + assert 0 == ants2d._shaded_fraction(45, -50, 0.5) + assert 0 == ants2d._shaded_fraction(-45, 50, 0.5) + + # edge cases + tracker_rotation = np.array([0, 0, 0, 90, 90, 90, -90, -90, -90]) + phi = np.array([0, 90, -90, 0, 90, -90, 0, 90, -90]) + gcr = 0.5 + # (some of these are debatable as well) + expected = np.array([[0, 0, 0, 0, 1, 1, 0, 1, 1]]) + fs = ants2d._shaded_fraction(tracker_rotation, phi, gcr) + np.testing.assert_allclose(fs, expected) + From 02686aa0611bbe1545c267bc2ef1dde634603325 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Tue, 7 Oct 2025 17:52:36 -0400 Subject: [PATCH 04/78] refactoring and more tests --- pvlib/bifacial/ants2d.py | 140 ++++++++++++++++++++-------------- tests/bifacial/test_ants2d.py | 25 ++++++ 2 files changed, 107 insertions(+), 58 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index ad7b78b0c3..4d29ab863b 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -209,7 +209,7 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, poa_direct = dni * projection * (1 - row_shaded_fraction) poa_direct = poa_direct[0] # drop unnecessary first dimension - + # in-plane sky diffuse component vf_row_sky = utils.vf_row_sky_2d_integ(tracker_rotation, gcr, x0, x1) poa_sky_diffuse = vf_row_sky * dhi @@ -223,7 +223,7 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, ground_shaded_fraction = 1 - ground_unshaded_fraction ground_shaded_fraction = ground_shaded_fraction[:, np.newaxis, :] - + vf_row_ground = utils.vf_row_ground_2d_integ(surface_tilt=tracker_rotation, gcr=gcr, height=height, pitch=pitch, @@ -252,6 +252,78 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, return output +def _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, solar_azimuth, + dni_extra, airmass): + + if model in ['haydavies', 'perez']: + # determine circumsolar irradiance, add it to DNI + + if model == 'haydavies': + if dni_extra is None: + raise ValueError(f'Must supply dni_extra for {model} model') + diffuse_model_func = haydavies + extra_kwargs = {} + + elif model == 'perez': + # note: horizon brightening is ignored + if dni_extra is None or airmass is None: + raise ValueError( + f'Must supply dni_extra and airmass for {model} model') + diffuse_model_func = perez + extra_kwargs = {'airmass': airmass} + + kwargs = dict( + dhi=dhi, dni=dni, dni_extra=dni_extra, + solar_zenith=solar_zenith, solar_azimuth=solar_azimuth, + return_components=True + ) + # Call the model first time within the horizontal plane - to subtract + # circumsolar_horizontal from DHI + sky_diffuse_comps_horizontal = diffuse_model_func( + surface_tilt=0, surface_azimuth=180, **kwargs, **extra_kwargs) + circumsolar_horizontal = sky_diffuse_comps_horizontal['circumsolar'] + + # Call the model a second time where circumsolar_normal is facing + # directly towards sun, and can be added to DNI + sky_diffuse_comps_normal = diffuse_model_func( + surface_tilt=solar_zenith, surface_azimuth=solar_azimuth, + **kwargs, **extra_kwargs) + circumsolar_normal = sky_diffuse_comps_normal['circumsolar'] + + dhi = dhi - circumsolar_horizontal + dni = dni + circumsolar_normal + elif model != 'isotropic': + raise ValueError(f"Invalid model: {model}") + + +def _apply_ground_slope(height, pitch, gcr, tracker_rotation, ghi, dni, dhi, + solar_zenith, solar_azimuth, axis_tilt, axis_azimuth, + cross_axis_slope): + slope_azimuth = axis_azimuth + np.degrees( + np.arctan2(sind(cross_axis_slope), + cosd(cross_axis_slope) * sind(axis_tilt)) + ) + slope_tilt = acosd(cosd(axis_tilt) * cosd(cross_axis_slope)) + + height = height * cosd(slope_tilt) + pitch = pitch / cosd(cross_axis_slope) + gcr = gcr * cosd(cross_axis_slope) + tracker_rotation = tracker_rotation - cross_axis_slope + tracker_rotation = ((tracker_rotation + 180) % 360) - 180 # put back to [-180, 180] + + ghi = dhi + dni * np.maximum( + aoi_projection(slope_tilt, slope_azimuth, + solar_zenith, solar_azimuth), + 0) + # dhi: no need to adjust; the blocked view is only near the + # the horizon, and that part of the sky is blocked by rows anyway + # dni: no adjustment needed; the measurement plane is not affected + #dhi = dhi + #dni = dni + return height, pitch, gcr, tracker_rotation, ghi + + + def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, gcr, height, pitch, ghi, dhi, dni, albedo, model='isotropic', dni_extra=None, airmass=None, @@ -413,66 +485,18 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, """ # preparation steps - if model in ['haydavies', 'perez']: - # determine circumsolar irradiance, add it to DNI - - if model == 'haydavies': - if dni_extra is None: - raise ValueError(f'must supply dni_extra for {model} model') - diffuse_model_func = haydavies - extra_kwargs = {} - - elif model == 'perez': - # note: horizon brightening is ignored - if dni_extra is None or airmass is None: - raise ValueError( - f'must supply dni_extra and airmass for {model} model') - diffuse_model_func = perez - extra_kwargs = {'airmass': airmass} - - kwargs = dict( - dhi=dhi, dni=dni, dni_extra=dni_extra, - solar_zenith=solar_zenith, solar_azimuth=solar_azimuth, - return_components=True - ) - # Call the model first time within the horizontal plane - to subtract - # circumsolar_horizontal from DHI - sky_diffuse_comps_horizontal = diffuse_model_func( - surface_tilt=0, surface_azimuth=180, **kwargs, **extra_kwargs) - circumsolar_horizontal = sky_diffuse_comps_horizontal['circumsolar'] - - # Call the model a second time where circumsolar_normal is facing - # directly towards sun, and can be added to DNI - sky_diffuse_comps_normal = diffuse_model_func( - surface_tilt=solar_zenith, surface_azimuth=solar_azimuth, - **kwargs, **extra_kwargs) - circumsolar_normal = sky_diffuse_comps_normal['circumsolar'] - - dhi = dhi - circumsolar_horizontal - dni = dni + circumsolar_normal + dni, dhi = _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, + solar_azimuth, dni_extra, airmass) true_tracker_rotation = tracker_rotation + if axis_tilt != 0 or cross_axis_slope != 0: - slope_azimuth = axis_azimuth + np.degrees(np.arctan2(sind(cross_axis_slope), cosd(cross_axis_slope) * sind(axis_tilt))) - slope_tilt = acosd(cosd(axis_tilt) * cosd(cross_axis_slope)) - - height = height * cosd(slope_tilt) - pitch = pitch / cosd(cross_axis_slope) - gcr = gcr * cosd(cross_axis_slope) - tracker_rotation = tracker_rotation - cross_axis_slope - tracker_rotation = ((tracker_rotation + 180) % 360) - 180 # put back to [-180, 180] - - - ghi = dhi + dni * np.maximum( - aoi_projection(slope_tilt, slope_azimuth, - solar_zenith, solar_azimuth), - 0) - # + dhi: maybe no need to adjust, since the blocked view is only near the - # the horizon, and that part of the sky is blocked by rows anyway? - # + dni: no adjustment needed; the measurement plane is not affected - #dhi = dhi - #dni = dni + height, pitch, gcr, tracker_rotation, ghi = _apply_ground_slope( + height, pitch, gcr, tracker_rotation, ghi, dni, dhi, + solar_zenith, solar_azimuth, axis_tilt, axis_azimuth, + cross_axis_slope + ) x_row = np.linspace(0, 1, n_row_segments+1) x0 = x_row[:-1] diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 09d3bd0961..5473a5a249 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -39,3 +39,28 @@ def test__shaded_fraction(): fs = ants2d._shaded_fraction(tracker_rotation, phi, gcr) np.testing.assert_allclose(fs, expected) + +def test__shaded_fraction_x0x1(): + fs = ants2d._shaded_fraction(np.array([60, -60]), np.array([60, -60]), + 2/3, x0=[0, 0.5], x1=[0.5, 1]) + np.testing.assert_allclose(fs, np.array([[0.5, 0.0], [0.0, 0.5]])) + + +def test__ants2d_singleside(): + pass + + +def test__apply_sky_diffuse_model(): + pass + + +def test__apply_sky_diffuse_model_errors(): + with pytest.raises(ValueError, match='Must supply dni_extra'): + ants2d._apply_sky_diffuse_model(0, 0, 'haydavies', None, + None, None, None) + with pytest.raises(ValueError, match='Must supply dni_extra and airmass'): + ants2d._apply_sky_diffuse_model(0, 0, 'perez', None, + None, None, None) + with pytest.raises(ValueError, match='Invalid model: not_a_model'): + ants2d._apply_sky_diffuse_model(0, 0, 'not_a_model', None, + None, None, None) From 034efd9e7a7351a80c8623170ae95b9fe5345ce6 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 8 Oct 2025 09:19:56 -0400 Subject: [PATCH 05/78] tests for _apply_ground_slope --- pvlib/bifacial/ants2d.py | 1 - tests/bifacial/test_ants2d.py | 85 +++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 4d29ab863b..e75a5dab79 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -323,7 +323,6 @@ def _apply_ground_slope(height, pitch, gcr, tracker_rotation, ghi, dni, dhi, return height, pitch, gcr, tracker_rotation, ghi - def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, gcr, height, pitch, ghi, dhi, dni, albedo, model='isotropic', dni_extra=None, airmass=None, diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 5473a5a249..d4a817d069 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -64,3 +64,88 @@ def test__apply_sky_diffuse_model_errors(): with pytest.raises(ValueError, match='Invalid model: not_a_model'): ants2d._apply_sky_diffuse_model(0, 0, 'not_a_model', None, None, None, None) + + +def test__apply_ground_slope_cross_axis_slope(): + # use an outrageous cross_axis_slope, just for testing + inputs = { + 'height': 2, 'pitch': 4, 'gcr': 0.5, 'tracker_rotation': 50, + 'ghi': 600, 'dni': 900, 'dhi': 150, 'solar_zenith': 60, + 'solar_azimuth': 270, + } + outputs = ants2d._apply_ground_slope(axis_tilt=0, axis_azimuth=180, + cross_axis_slope=60, **inputs) + # cosd(60) gives a factor of 2 difference in various inputs + expected = {'height': inputs['height'] / 2, 'pitch': inputs['pitch'] * 2, + 'gcr': inputs['gcr'] / 2, 'tracker_rotation': -10, + # zenith aligns with cross_axis_slope: + 'ghi': inputs['dni'] + inputs['dhi']} + for (key, exp), actual in zip(expected.items(), outputs): + assert actual == pytest.approx(exp, abs=1e-10), key + + # now flip it around and check negative cross-axis slope + outputs = ants2d._apply_ground_slope(axis_tilt=0, axis_azimuth=180, + cross_axis_slope=-60, **inputs) + expected = {'height': inputs['height'] / 2, 'pitch': inputs['pitch'] * 2, + 'gcr': inputs['gcr'] / 2, 'tracker_rotation': 110, + 'ghi': inputs['dhi']} + for (key, exp), actual in zip(expected.items(), outputs): + assert actual == pytest.approx(exp, abs=1e-10), key + + +def test__apply_ground_slope_axis_tilt(): + # use an outrageous axis_tilt, just for testing + inputs = { + 'height': 2, 'pitch': 4, 'gcr': 0.5, 'tracker_rotation': 50, + 'ghi': 600, 'dni': 900, 'dhi': 150, 'solar_zenith': 60, + 'solar_azimuth': 180, + } + outputs = ants2d._apply_ground_slope(axis_tilt=60, axis_azimuth=180, + cross_axis_slope=0, **inputs) + expected = {'height': inputs['height'] / 2, 'pitch': inputs['pitch'], + 'gcr': inputs['gcr'], + 'tracker_rotation': inputs['tracker_rotation'], + # zenith aligns with axis_tilt: + 'ghi': inputs['dni'] + inputs['dhi']} + for (key, exp), actual in zip(expected.items(), outputs): + assert actual == pytest.approx(exp, abs=1e-10), key + + # now flip it around and check negative axis tilt + outputs = ants2d._apply_ground_slope(axis_tilt=-60, axis_azimuth=180, + cross_axis_slope=0, **inputs) + + expected = {'height': inputs['height'] / 2, 'pitch': inputs['pitch'], + 'gcr': inputs['gcr'], + 'tracker_rotation': inputs['tracker_rotation'], + 'ghi': inputs['dhi']} + for (key, exp), actual in zip(expected.items(), outputs): + assert actual == pytest.approx(exp, abs=1e-10), key + + +def test__apply_ground_slope_both(): + inputs = { + 'height': 2, 'pitch': 4, 'gcr': 0.5, 'tracker_rotation': 50, + 'ghi': 600, 'dni': 900, 'dhi': 150, 'solar_zenith': 15, + # the azimuth that results from the tilt/slope parameters below + 'solar_azimuth': 234.73561031724535, + } + outputs = ants2d._apply_ground_slope(axis_tilt=45, axis_azimuth=180, + cross_axis_slope=45, **inputs) + expected = {'height': inputs['height'] / 2, + 'pitch': inputs['pitch'] * 2**0.5, + 'gcr': inputs['gcr'] / 2**0.5, + 'tracker_rotation': inputs['tracker_rotation'] - 45, + # zenith aligns with axis_tilt: + 'ghi': inputs['dni'] / 2**0.5 + inputs['dhi']} + for (key, exp), actual in zip(expected.items(), outputs): + assert actual == pytest.approx(exp, abs=1e-10), key + + +def test__apply_ground_slope_zero(): + inputs = [2, 4, 0.5, 45, 600, 900, 150, 60, 210] + outputs = ants2d._apply_ground_slope(*inputs, axis_tilt=0, + axis_azimuth=180, + cross_axis_slope=0) + for input, output in zip(inputs, outputs): + assert pytest.approx(input, abs=1e-10) == output + From 47489155ae1e1483a7da1a2b12af2b6137b79cb5 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 8 Oct 2025 09:51:22 -0400 Subject: [PATCH 06/78] tests for _apply_sky_diffuse_model --- pvlib/bifacial/ants2d.py | 2 ++ tests/bifacial/test_ants2d.py | 38 +++++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index e75a5dab79..05db8551fd 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -295,6 +295,8 @@ def _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, solar_azimuth, elif model != 'isotropic': raise ValueError(f"Invalid model: {model}") + return dni, dhi + def _apply_ground_slope(height, pitch, gcr, tracker_rotation, ghi, dni, dhi, solar_zenith, solar_azimuth, axis_tilt, axis_azimuth, diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index d4a817d069..e9ee32ff88 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -4,6 +4,7 @@ import numpy as np import pandas as pd +import pvlib from pvlib.bifacial import ants2d import pytest @@ -50,8 +51,41 @@ def test__ants2d_singleside(): pass -def test__apply_sky_diffuse_model(): - pass +@pytest.mark.parametrize('model', ['perez', 'haydavies']) +def test__apply_sky_diffuse_model(model): + inputs = {'dni': 900, 'dhi': 150, 'solar_zenith': 41, 'solar_azimuth': 67, + 'dni_extra': 1360, 'airmass': 1.324} + dni_adj, dhi_adj = ants2d._apply_sky_diffuse_model(**inputs, model=model) + # ensure that the adjusted values+isotropic yield the same poa_global as + # the target model + kwargs = inputs.copy() + kwargs.pop('dni') + kwargs.pop('dhi') + if model != 'perez': + kwargs.pop('airmass') + kwargs['surface_tilt'] = 20 + kwargs['surface_azimuth'] = 180 + adj = pvlib.irradiance.get_total_irradiance(dni=dni_adj, dhi=dhi_adj, + ghi=1000, # doesn't matter + model='isotropic', **kwargs) + func = {'perez': pvlib.irradiance.perez, + 'haydavies': pvlib.irradiance.haydavies}[model] + diffuse = func(dni=inputs['dni'], dhi=inputs['dhi'], **kwargs, + return_components=True) + aoi_proj = pvlib.irradiance.aoi_projection(kwargs['surface_tilt'], + kwargs['surface_azimuth'], + kwargs['solar_zenith'], + kwargs['solar_azimuth']) + poa_direct = inputs['dni'] * aoi_proj + diffuse['circumsolar'] + poa_sky_diffuse = diffuse['isotropic'] + poa_ground = 1000 * 0.25 * (1 - pvlib.tools.cosd(20)) / 2 + assert adj['poa_direct'] == pytest.approx(poa_direct, abs=1e-10) + assert adj['poa_sky_diffuse'] == pytest.approx(poa_sky_diffuse, abs=1e-10) + assert adj['poa_ground_diffuse'] == pytest.approx(poa_ground, abs=1e-10) + # sum of components, ignoring horizon brightening per ANTS-2D assumption + assert adj['poa_global'] == pytest.approx( + poa_direct + poa_sky_diffuse + poa_ground, abs=1e-10) + def test__apply_sky_diffuse_model_errors(): From d2bf3adbfdd83809278a7b324cc5a1f412567dca Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 8 Oct 2025 11:59:00 -0400 Subject: [PATCH 07/78] fix return shape and tests for vf_row_ground_2d_integ --- pvlib/bifacial/utils.py | 13 ++++++++++++- tests/bifacial/test_utils.py | 14 +++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 1aa951de70..b47973331e 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -496,7 +496,15 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height, pitch, [unitless] ''' - + # keep track of scalar inputs so that we can have output match at the end + squeeze = [] + if np.isscalar(g0) and np.isscalar(g1): + squeeze.append(0) + if np.isscalar(x0) and np.isscalar(x1): + squeeze.append(1) + if np.isscalar(surface_tilt): + squeeze.append(2) + # dimensions: k/max_rows, ground segment, row segment, time surface_tilt = np.atleast_1d(surface_tilt)[np.newaxis, np.newaxis, np.newaxis, :] @@ -541,6 +549,9 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height, pitch, vf_slats = 0.5 * (1/((x1 - x0) * collector_width)) * ((ac + bd) - (bc + ad)) vf_total = np.sum(np.maximum(vf_slats, 0), axis=0) # sum along k dimension + # dimensions are now ground_segment, row_segment, time + vf_total = vf_total.squeeze(axis=tuple(squeeze)) + return vf_total diff --git a/tests/bifacial/test_utils.py b/tests/bifacial/test_utils.py index e24af42b3e..ad6af8301d 100644 --- a/tests/bifacial/test_utils.py +++ b/tests/bifacial/test_utils.py @@ -166,15 +166,17 @@ def test_vf_ground_2d_integ(test_system_fixed_tilt): # with float input, check end position with np.errstate(invalid='ignore'): vf = utils.vf_row_ground_2d_integ(ts['surface_tilt'], ts['gcr'], - 0., 0.) + ts['height'], ts['pitch'], + x0=0., x1=0.00001, max_rows=1000) expected = utils.vf_row_ground_2d(ts['surface_tilt'], ts['gcr'], 0.) - assert np.isclose(vf, expected) + assert np.isclose(vf, expected, atol=1e-4) # with array input fx0 = np.array([0., 0.5]) - fx1 = np.array([0., 0.8]) + fx1 = np.array([0.00001, 0.8]) with np.errstate(invalid='ignore'): vf = utils.vf_row_ground_2d_integ(ts['surface_tilt'], ts['gcr'], - fx0, fx1) + ts['height'], ts['pitch'], + x0=fx0, x1=fx1, max_rows=1000) phi = ground_angle(ts['surface_tilt'], ts['gcr'], fx0[0]) y0 = 0.5 * (1 - cosd(phi - ts['surface_tilt'])) x = np.arange(fx0[1], fx1[1], 1e-4) @@ -184,7 +186,9 @@ def test_vf_ground_2d_integ(test_system_fixed_tilt): expected = np.array([y0, y1]) assert np.allclose(vf, expected, rtol=1e-2) # with defaults (0, 1) - vf = utils.vf_row_ground_2d_integ(ts['surface_tilt'], ts['gcr'], 0, 1) + vf = utils.vf_row_ground_2d_integ(ts['surface_tilt'], ts['gcr'], + ts['height'], ts['pitch'], x0=0, x1=1, + max_rows=1000) x = np.arange(0, 1, 1e-4) phi_y = ground_angle(ts['surface_tilt'], ts['gcr'], x) y = 0.5 * (1 - cosd(phi_y - ts['surface_tilt'])) From 405649b1c40d1c4c130743ec256d439a94eea205 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 8 Oct 2025 12:39:05 -0400 Subject: [PATCH 08/78] fix return shape and tests for vf_row_sky_2d_integ --- pvlib/bifacial/utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index b47973331e..4654049e85 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -408,6 +408,13 @@ def vf_row_sky_2d_integ(surface_tilt, gcr, x0=0, x1=1): from x0 to x1. [unitless] ''' + # keep track of scalar inputs so that we can have output match at the end + squeeze = [] + if np.isscalar(x0) and np.isscalar(x1): + squeeze.append(0) + if np.isscalar(surface_tilt): + squeeze.append(1) + # dimensions: row segment, time surface_tilt = np.atleast_1d(surface_tilt)[np.newaxis, :] @@ -426,6 +433,7 @@ def vf_row_sky_2d_integ(surface_tilt, gcr, x0=0, x1=1): vf_row_sky_2d(surface_tilt, gcr, x0), 0.5*(1 + 1/u * (p1 - p0)) ) + result = result.squeeze(axis=tuple(squeeze)) return result From 7dd6853171f2c632291f64ca78bcd75a7f01692c Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 8 Oct 2025 15:18:21 -0400 Subject: [PATCH 09/78] major docstring cleanup --- pvlib/bifacial/ants2d.py | 371 ++++++++++++++++----------------------- pvlib/bifacial/utils.py | 27 ++- 2 files changed, 168 insertions(+), 230 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 05db8551fd..ef0040f909 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -86,119 +86,87 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, gcr, height, pitch, ghi, dhi, dni, albedo, x0, x1, g0, g1, max_rows): r""" - Calculate plane-of-array (POA) irradiance on one side of a row of modules. + Calculate plane-of-array irradiance components on one side of a row + of modules. - The infinite sheds model [1] assumes the PV system comprises parallel, - evenly spaced rows on a level, horizontal surface. Rows can be on fixed - racking or single axis trackers. The model calculates irradiance at a - location far from the ends of any rows, in effect, assuming that the - rows (sheds) are infinitely long. - - POA irradiance components include direct, diffuse and global (total). - Irradiance values are reduced to account for reflection of direct light, - but are not adjusted for solar spectrum or reduced by a module's - bifaciality factor. - - Parameters TODO fix these + Parameters ---------- - surface_tilt : numeric - Tilt of the surface from horizontal. Must be between 0 and 180. For - example, for a fixed tilt module mounted at 30 degrees from - horizontal, use ``surface_tilt=30`` to get front-side irradiance and - ``surface_tilt=150`` to get rear-side irradiance. [degree] - - surface_azimuth : numeric - Surface azimuth in decimal degrees east of north - (e.g. North = 0, South = 180, East = 90, West = 270). [degree] - - solar_zenith : numeric - Refraction-corrected solar zenith. [degree] - - solar_azimuth : numeric - Solar azimuth. [degree] - + tracker_rotation : numeric + Tracker rotation angle as a right-handed rotation around + the axis defined by ``axis_tilt`` and ``axis_azimuth``. For example, + with ``axis_tilt=0`` and ``axis_azimuth=180``, ``tracker_theta > 0`` + results in ``surface_azimuth`` to the West while ``tracker_theta < 0`` + results in ``surface_azimuth`` to the East. [degree] + cos_aoi : numeric + Cosine of the angle of incidence of beam irradiance; can be + calculated using :py:func:`pvlib.irradiance.aoi_projection`. [unitless] + phi : numeric + Project solar zenith angle; calculate with + :py:func:`pvlib.shading.projected_solar_zenith_angle`. [degree] + vf_gnd_sky : numeric + View factors from the ground surface to the sky. Dimensions are + TODO,TODO,TODO. [unitless] gcr : float Ground coverage ratio, ratio of row slant length to row spacing. [unitless] - height : float Height of the center point of the row above the ground; must be in the same units as ``pitch``. - pitch : float Distance between two rows; must be in the same units as ``height``. - ghi : numeric - Global horizontal irradiance. [W/m2] - + Global horizontal irradiance. [Wm⁻²] dhi : numeric - Diffuse horizontal irradiance. [W/m2] - + Diffuse horizontal irradiance. [Wm⁻²] dni : numeric - Direct normal irradiance. [W/m2] - + Direct normal irradiance. [Wm⁻²] albedo : numeric - Surface albedo. [unitless] - - model : str, default 'isotropic' - Irradiance model - can be one of 'isotropic' or 'haydavies'. - - dni_extra : numeric, optional - Extraterrestrial direct normal irradiance. Required when - ``model='haydavies'``. [W/m2] - - iam : numeric, default 1.0 - Incidence angle modifier, the fraction of direct irradiance incident - on the surface that is not reflected away. [unitless] - - npoints : int, default 100 - - .. deprecated:: v0.11.2 - - This parameter has no effect; integrated view factors are now - calculated exactly instead of with discretized approximations. - - vectorize : bool, default False + Surface albedo. TODO shape [unitless] + x0 : numeric, default 0 + Position on the row's slant length, as a fraction of the slant length. + ``x0=0`` corresponds to the left side of the row. + ``x0`` should be less than ``x1``. [unitless] + x1 : numeric, default 1 + Position on the row's slant length, as a fraction of the slant length. + ``x1=1`` corresponds to the right side of the row. + ``x1`` should be greater than ``x0``. [unitless] + g0 : numeric + Position on the ground surface, as a fraction of the row-to-row + spacing. ``g0=0`` corresponds to ground underneath the middle of the + left row. ``g0`` should be less than ``g1``. [unitless] + g1 : numeric + Position on the ground surface, as a fraction of the row-to-row + spacing. ``g1=0`` corresponds to ground underneath the middle of the + right row. ``g1`` should be greater than ``g0``. [unitless] + max_rows : int + Number of array units (sky wedges, ground segments, etc) to consider. + [unitless] - .. deprecated:: v0.11.2 + Returns + ------- + output : dict of ``numpy.ndarray`` - This parameter has no effect; calculations are now vectorized - with no memory usage penality. + ``output`` includes the following quantities: + - ``poa_global``: total POA irradiance. [Wm⁻²] + - ``poa_diffuse``: total diffuse POA irradiance from all sources. + [Wm⁻²] + - ``poa_direct``: direct POA irradiance. [Wm⁻²] + - ``poa_sky_diffuse``: sky diffuse POA irradiance. [Wm⁻²] + - ``poa_ground_diffuse``: ground-reflected diffuse POA irradiance. + [Wm⁻²] + - ``shaded_fraction``: fraction of row slant height from the bottom + that is shaded from direct irradiance by adjacent rows. [unitless] - Returns - ------- - output : dict or DataFrame - Output is a ``pandas.DataFrame`` when ``ghi`` is a Series. - Otherwise it is a dict of ``numpy.ndarray`` - See Notes for descriptions of content. + Shape of each quantity depends on TODO x0/x1 g0/g1 Notes ----- Input parameters ``height`` and ``pitch`` must have the same unit. - ``output`` always includes: - - - ``poa_global`` : total POA irradiance. [W/m^2] - - ``poa_diffuse`` : total diffuse POA irradiance from all sources. [W/m^2] - - ``poa_direct`` : total direct POA irradiance. [W/m^2] - - ``poa_sky_diffuse`` : total sky diffuse irradiance on the plane of array. - [W/m^2] - - ``poa_ground_diffuse`` : total ground-reflected diffuse irradiance on the - plane of array. [W/m^2] - - ``shaded_fraction`` : fraction of row slant height from the bottom that - is shaded from direct irradiance by adjacent rows. [unitless] - References ---------- - .. [1] Mikofski, M., Darawali, R., Hamer, M., Neubert, A., and Newmiller, - J. "Bifacial Performance Modeling in Large Arrays". 2019 IEEE 46th - Photovoltaic Specialists Conference (PVSC), 2019, pp. 1282-1287. - :doi:`10.1109/PVSC40753.2019.8980572`. - - See also - -------- - get_irradiance + .. [1] TODO """ # in-plane beam component @@ -242,13 +210,13 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, poa_global = poa_direct + poa_diffuse output = { - 'poa_global': poa_global, 'poa_direct': poa_direct, - 'poa_diffuse': poa_diffuse, 'poa_ground_diffuse': poa_ground_diffuse, + 'poa_global': poa_global, + 'poa_direct': poa_direct, + 'poa_diffuse': poa_diffuse, 'poa_sky_diffuse': poa_sky_diffuse, + 'poa_ground_diffuse': poa_ground_diffuse, 'shaded_fraction': row_shaded_fraction } - if isinstance(ghi, pd.Series): - output = pd.DataFrame(output) return output @@ -331,10 +299,10 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, n_row_segments=1, n_ground_segments=1, axis_tilt=0, cross_axis_slope=0): """ - Get front and rear irradiance using the infinite sheds model. + Get front and rear irradiance using the ANTS-2D bifacial irradiance model. - The infinite sheds model [1] assumes the PV system comprises parallel, - evenly spaced rows on a level, horizontal surface. Rows can be on fixed + The ANTS-2D model [1] assumes the PV system comprises parallel, + evenly spaced rows on flat or uniformly sloped ground. Rows can be on fixed racking or single axis trackers. The model calculates irradiance at a location far from the ends of any rows, in effect, assuming that the rows (sheds) are infinitely long. @@ -345,144 +313,108 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, - restricted view of the ground from module surfaces due to nearby rows. - restricted view of the sky from the ground due to rows. - shading of module surfaces by nearby rows. - - shading of rear cells of a module by mounting structure and by - module features. - - The model implicitly assumes that diffuse irradiance from the sky is - isotropic, and that module surfaces do not allow irradiance to transmit - through the module to the ground through gaps between cells. + - nonuniform ground albedo. + - sloped ground surface. Parameters - ---------- TODO fix - surface_tilt : numeric - Tilt from horizontal of the front-side surface. [degree] - - surface_azimuth : numeric - Surface azimuth in decimal degrees east of north - (e.g. North = 0, South = 180, East = 90, West = 270). [degree] - + ---------- + tracker_rotation : numeric + Tracker rotation angle as a right-handed rotation around + the axis defined by ``axis_tilt`` and ``axis_azimuth``. For example, + with ``axis_tilt=0`` and ``axis_azimuth=180``, ``tracker_theta > 0`` + results in ``surface_azimuth`` to the West while ``tracker_theta < 0`` + results in ``surface_azimuth`` to the East. [degree] + axis_azimuth : numeric + Axis azimuth angle in degrees. + North = 0°; East = 90°; South = 180°; West = 270° solar_zenith : numeric Refraction-corrected solar zenith. [degree] - solar_azimuth : numeric Solar azimuth. [degree] - gcr : float Ground coverage ratio, ratio of row slant length to row spacing. [unitless] - height : float Height of the center point of the row above the ground; must be in the same units as ``pitch``. - pitch : float Distance between two rows; must be in the same units as ``height``. - ghi : numeric - Global horizontal irradiance. [W/m2] - + Global horizontal irradiance. [Wm⁻²] dhi : numeric - Diffuse horizontal irradiance. [W/m2] - + Diffuse horizontal irradiance. [Wm⁻²] dni : numeric - Direct normal irradiance. [W/m2] - + Direct normal irradiance. [Wm⁻²] albedo : numeric - Surface albedo. [unitless] - + Surface albedo. TODO shape [unitless] model : str, default 'isotropic' - Irradiance model - can be one of 'isotropic' or 'haydavies'. - + Irradiance model - can be one of 'isotropic', 'haydavies', or 'perez'. dni_extra : numeric, optional Extraterrestrial direct normal irradiance. Required when - ``model='haydavies'``. [W/m2] - - iam_front : numeric, default 1.0 - Incidence angle modifier, the fraction of direct irradiance incident - on the front surface that is not reflected away. [unitless] - - iam_back : numeric, default 1.0 - Incidence angle modifier, the fraction of direct irradiance incident - on the back surface that is not reflected away. [unitless] - - bifaciality : numeric, default 0.8 - Ratio of the efficiency of the module's rear surface to the efficiency - of the front surface. [unitless] - - shade_factor : numeric, default -0.02 - Fraction of back surface irradiance that is blocked by array mounting - structures. Negative value is a reduction in back irradiance. - [unitless] - - transmission_factor : numeric, default 0.0 - Fraction of irradiance on the back surface that does not reach the - module's cells due to module features such as busbars, junction box, - etc. A negative value is a reduction in back irradiance. [unitless] - - npoints : int, default 100 - - .. deprecated:: v0.11.2 - - This parameter has no effect; integrated view factors are now - calculated exactly instead of with discretized approximations. - - vectorize : bool, default False - - .. deprecated:: v0.11.2 - - This parameter has no effect; calculations are now vectorized - with no memory usage penality. + ``model='haydavies'`` or ``model='perez'``. [Wm⁻²] + airmass : numeric, optional + Relative airmass. Required when ``model='perez'``. [unitless] + n_row_segments : int, default 1 + Number of segments to partition the row surface into. Irradiance + will be computed and returned for each segment. + n_ground_segments : int, default 1 + Number of segments to partition the ground surface into. If specified, + ``albedo`` must be specified for each segment. + axis_tilt : numeric, default 0 + Tilt of the axis of rotation with respect to horizontal. [degree] + cross_axis_slope : numeric, default 0 + The angle, relative to horizontal, of the line formed by the + intersection between the slope containing the tracker axes and a plane + perpendicular to the tracker axes. The cross-axis slope should be + specified using a right-handed convention. For example, trackers with + axis azimuth of 180 degrees (heading south) will have a negative + cross-axis tilt if the tracker axes plane slopes down to the east and + positive cross-axis slope if the tracker axes plane slopes down to the + west. Use :func:`~pvlib.tracking.calc_cross_axis_tilt` to calculate + ``cross_axis_slope``. [degrees] Returns ------- output : dict or DataFrame - Output is a DataFrame when input ghi is a Series. See Notes for - descriptions of content. + ``output`` is a DataFrame when input ghi is a Series and + ``n_row_segments=1`` and a dict of ``np.ndarray`` otherwise. + + ``output`` includes the following quantities: + + - ``poa_global``: sum of front- and back-side incident irradiance. + [Wm⁻²] + - ``poa_front``: total incident irradiance on the front surface. [Wm⁻²] + - ``poa_back``: total incident irradiance on the back surface. [Wm⁻²] + - ``poa_front_direct``: direct irradiance incident on the front + surface. [Wm⁻²] + - ``poa_front_diffuse``: total diffuse irradiance incident on the front + surface. [Wm⁻²] + - ``poa_front_sky_diffuse``: sky diffuse irradiance incident on the + front surface. [Wm⁻²] + - ``poa_front_ground_diffuse``: ground-reflected diffuse irradiance + incident on the front surface. [Wm⁻²] + - ``shaded_fraction_front``: fraction of row slant height that is + shaded from direct irradiance on the front surface by adjacent + rows. [unitless] + - ``poa_back_direct``: direct irradiance incident on the back + surface. [Wm⁻²] + - ``poa_back_diffuse``: total diffuse irradiance incident on the back + surface. [Wm⁻²] + - ``poa_back_sky_diffuse``: sky diffuse irradiance incident on the + back surface. [Wm⁻²] + - ``poa_back_ground_diffuse``: ground-reflected diffuse irradiance + incident on the back surface. [Wm⁻²] + - ``shaded_fraction_back``: fraction of row slant height that is + shaded from direct irradiance on the back surface by adjacent + rows. [unitless] Notes ----- - - ``output`` includes: - - - ``poa_global`` : total irradiance reaching the module cells from both - front and back surfaces. [W/m^2] - - ``poa_front`` : total irradiance reaching the module cells from the front - surface. [W/m^2] - - ``poa_back`` : total irradiance reaching the module cells from the back - surface. [W/m^2] - - ``poa_front_direct`` : direct irradiance reaching the module cells from - the front surface. [W/m^2] - - ``poa_front_diffuse`` : total diffuse irradiance reaching the module - cells from the front surface. [W/m^2] - - ``poa_front_sky_diffuse`` : sky diffuse irradiance reaching the module - cells from the front surface. [W/m^2] - - ``poa_front_ground_diffuse`` : ground-reflected diffuse irradiance - reaching the module cells from the front surface. [W/m^2] - - ``shaded_fraction_front`` : fraction of row slant height from the bottom - that is shaded from direct irradiance on the front surface by adjacent - rows. [unitless] - - ``poa_back_direct`` : direct irradiance reaching the module cells from - the back surface. [W/m^2] - - ``poa_back_diffuse`` : total diffuse irradiance reaching the module - cells from the back surface. [W/m^2] - - ``poa_back_sky_diffuse`` : sky diffuse irradiance reaching the module - cells from the back surface. [W/m^2] - - ``poa_back_ground_diffuse`` : ground-reflected diffuse irradiance - reaching the module cells from the back surface. [W/m^2] - - ``shaded_fraction_back`` : fraction of row slant height from the bottom - that is shaded from direct irradiance on the back surface by adjacent - rows. [unitless] + Input parameters ``height`` and ``pitch`` must have the same unit. References ---------- - .. [1] Mikofski, M., Darawali, R., Hamer, M., Neubert, A., and Newmiller, - J. "Bifacial Performance Modeling in Large Arrays". 2019 IEEE 46th - Photovoltaic Specialists Conference (PVSC), 2019, pp. 1282-1287. - :doi:`10.1109/PVSC40753.2019.8980572`. - - See also - -------- - get_irradiance_poa + .. [1] TODO """ # preparation steps @@ -549,12 +481,12 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, solar_azimuth=solar_azimuth) tracker_rotation_rear = tracker_rotation + 180 tracker_rotation_rear = ((tracker_rotation_rear + 180) % 360) - 180 - poa_rear = _ants2d_singleside(tracker_rotation_rear, cos_aoi_rear, phi, + poa_back = _ants2d_singleside(tracker_rotation_rear, cos_aoi_rear, phi, vf_gnd_sky, gcr, height, pitch, ghi, dhi, dni, albedo, x0, x1, g0, g1, max_rows) - for key, value in poa_rear.items(): - poa_rear[key] = value[::-1, :] # invert x0/x1 dimension + for key, value in poa_back.items(): + poa_back[key] = value[::-1, :] # invert x0/x1 dimension colmap_front = { 'poa_global': 'poa_front', @@ -564,25 +496,16 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, 'poa_ground_diffuse': 'poa_front_ground_diffuse', 'shaded_fraction': 'shaded_fraction_front', } - colmap_rear = { - 'poa_global': 'poa_back', - 'poa_direct': 'poa_back_direct', - 'poa_diffuse': 'poa_back_diffuse', - 'poa_sky_diffuse': 'poa_back_sky_diffuse', - 'poa_ground_diffuse': 'poa_back_ground_diffuse', - 'shaded_fraction': 'shaded_fraction_back', + colmap_back = { + k: v.replace("front", "back") for k, v in colmap_front.items() } + for old_key, new_key in colmap_front.items(): + poa_front[new_key] = poa_front.pop(old_key) + for old_key, new_key in colmap_back.items(): + poa_back[new_key] = poa_back.pop(old_key) + poa_front.update(poa_back) - if isinstance(ghi, pd.Series): - poa_front = poa_front.rename(columns=colmap_front) - poa_rear = poa_rear.rename(columns=colmap_rear) - output = pd.concat([poa_front, poa_rear], axis=1) - else: - for old_key, new_key in colmap_front.items(): - poa_front[new_key] = poa_front.pop(old_key) - for old_key, new_key in colmap_rear.items(): - poa_rear[new_key] = poa_rear.pop(old_key) - poa_front.update(poa_rear) - output = poa_front + if n_row_segments == 1 and isinstance(ghi, pd.Series): + poa_front = pd.DataFrame(poa_front, index=ghi.index) - return output + return poa_front diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 4654049e85..c5843a48e1 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -244,14 +244,29 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, same units as ``pitch``. pitch : float Distance between two rows. Must be in the same units as ``height``. - g0, g1 : TODO + g0 : numeric + Position on the ground surface, as a fraction of the row-to-row + spacing. ``g0=0`` corresponds to ground underneath the middle of the + left row. ``g0`` should be less than ``g1``. [unitless] + g1 : numeric + Position on the ground surface, as a fraction of the row-to-row + spacing. ``g1=0`` corresponds to ground underneath the middle of the + right row. ``g1`` should be greater than ``g0``. [unitless] max_rows : int, default 10 Maximum number of rows to consider in front and behind the current row. - npoints : int, default 100 - Number of points used to discretize distance along the ground. - vectorize : bool, default False - If True, vectorize the view factor calculation across ``surface_tilt``. - This increases speed with the cost of increased memory usage. + npoints : int, optional + + .. deprecated:: TODO + + This parameter has no effect; integrated view factors are now + calculated exactly instead of with discretized approximations. + + vectorize : bool, optional + + .. deprecated:: TODO + + This parameter has no effect; calculations are now vectorized + with no memory usage penality. Returns ------- From 505d8d4274f233d59f0f7d6731cea970ed6560b0 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 8 Oct 2025 16:15:37 -0400 Subject: [PATCH 10/78] standardize to "back" instead of "rear" --- pvlib/bifacial/ants2d.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index ef0040f909..17f17e0684 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -456,7 +456,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, phi = phi - cross_axis_slope # compute this here, as it is expensive and does not differ between the - # front and rear sides + # front and back sides vf_gnd_sky = utils.vf_ground_sky_2d_integ( tracker_rotation, gcr, height, pitch, g0=g0, g1=g1, max_rows=max_rows) vf_gnd_sky = vf_gnd_sky[:, np.newaxis, :] @@ -471,17 +471,17 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, vf_gnd_sky, gcr, height, pitch, ghi, dhi, dni, albedo, x0, x1, g0, g1, max_rows) - # rear - tracker_rotation_rear = true_tracker_rotation + 180 - tracker_rotation_rear = ((tracker_rotation_rear + 180) % 360) - 180 - rear_orientation = calc_surface_orientation(tracker_rotation_rear, + # back + tracker_rotation_back = true_tracker_rotation + 180 + tracker_rotation_back = ((tracker_rotation_back + 180) % 360) - 180 + back_orientation = calc_surface_orientation(tracker_rotation_back, axis_tilt, axis_azimuth) - cos_aoi_rear = aoi_projection(**rear_orientation, + cos_aoi_back = aoi_projection(**back_orientation, solar_zenith=solar_zenith, solar_azimuth=solar_azimuth) - tracker_rotation_rear = tracker_rotation + 180 - tracker_rotation_rear = ((tracker_rotation_rear + 180) % 360) - 180 - poa_back = _ants2d_singleside(tracker_rotation_rear, cos_aoi_rear, phi, + tracker_rotation_back = tracker_rotation + 180 + tracker_rotation_back = ((tracker_rotation_back + 180) % 360) - 180 + poa_back = _ants2d_singleside(tracker_rotation_back, cos_aoi_back, phi, vf_gnd_sky, gcr, height, pitch, ghi, dhi, dni, albedo, x0, x1, g0, g1, max_rows) From 8811daab67b3f7029c7c7ed31953865998606a54 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 9 Oct 2025 16:06:44 -0400 Subject: [PATCH 11/78] shape fixes, pandas out --- pvlib/bifacial/ants2d.py | 41 ++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 17f17e0684..4df165c1e7 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -58,6 +58,12 @@ def _shaded_fraction(tracker_rotation, phi, gcr, x0=0, x1=1): Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020. https://www.nrel.gov/docs/fy20osti/76626.pdf """ + # keep track of scalar inputs so that we can have output match at the end + squeeze = [] + if np.isscalar(x0) and np.isscalar(x1): + squeeze.append(0) + if np.isscalar(tracker_rotation): + squeeze.append(1) # note: ground slope is already accounted for in phi and gcr, so don't # apply it here. @@ -78,7 +84,8 @@ def _shaded_fraction(tracker_rotation, phi, gcr, x0=0, x1=1): x0, x1 = np.where(swap, 1 - x1, x0), np.where(swap, 1 - x0, x1) f_s = np.clip((f_s - x0) / (x1 - x0), a_min=0, a_max=1) - + f_s = f_s.squeeze(axis=tuple(squeeze)) + return f_s @@ -158,7 +165,7 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, - ``shaded_fraction``: fraction of row slant height from the bottom that is shaded from direct irradiance by adjacent rows. [unitless] - Shape of each quantity depends on TODO x0/x1 g0/g1 + Each array has shape (len(x0), len(tracker_rotation)). Notes ----- @@ -168,20 +175,19 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, ---------- .. [1] TODO """ + # reminder of base dimensions: ground segment, row segment, time # in-plane beam component projection = np.array(np.clip(cos_aoi, a_min=0, a_max=None)) - projection = projection[np.newaxis, np.newaxis, :] row_shaded_fraction = _shaded_fraction(tracker_rotation, phi, gcr, x0, x1) - row_shaded_fraction = row_shaded_fraction[np.newaxis, :, :] poa_direct = dni * projection * (1 - row_shaded_fraction) - poa_direct = poa_direct[0] # drop unnecessary first dimension + poa_direct = poa_direct[0] # drop ground segment dimension # in-plane sky diffuse component vf_row_sky = utils.vf_row_sky_2d_integ(tracker_rotation, gcr, x0, x1) poa_sky_diffuse = vf_row_sky * dhi - poa_sky_diffuse = poa_sky_diffuse[0] # drop unnecesary first dimension + poa_sky_diffuse = poa_sky_diffuse[0] # drop ground segment dimension # in-plane ground-reflected component @@ -201,7 +207,8 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, (1-ground_shaded_fraction) * (ghi - dhi) # reflected beam + vf_gnd_sky * dhi # reflected diffuse ) - poa_ground_diffuse = np.sum(poa_ground_diffuse, axis=0) # sum over ground segments + # sum over ground segments + poa_ground_diffuse = np.sum(poa_ground_diffuse, axis=0) # add sky and ground-reflected irradiance on the row by irradiance @@ -209,6 +216,7 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, poa_diffuse = poa_ground_diffuse + poa_sky_diffuse poa_global = poa_direct + poa_diffuse + # all arrays are now 2D with shape (n_row_segments, len(tracker_rotation)) output = { 'poa_global': poa_global, 'poa_direct': poa_direct, @@ -296,7 +304,7 @@ def _apply_ground_slope(height, pitch, gcr, tracker_rotation, ghi, dni, dhi, def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, gcr, height, pitch, ghi, dhi, dni, albedo, model='isotropic', dni_extra=None, airmass=None, - n_row_segments=1, n_ground_segments=1, axis_tilt=0, + n_row_segments=1, n_ground_segments=10, axis_tilt=0, cross_axis_slope=0): """ Get front and rear irradiance using the ANTS-2D bifacial irradiance model. @@ -357,9 +365,9 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, n_row_segments : int, default 1 Number of segments to partition the row surface into. Irradiance will be computed and returned for each segment. - n_ground_segments : int, default 1 - Number of segments to partition the ground surface into. If specified, - ``albedo`` must be specified for each segment. + n_ground_segments : int, default 10 + Number of segments to partition the ground surface into. ``albedo`` + can be specified for each segment. axis_tilt : numeric, default 0 Tilt of the axis of rotation with respect to horizontal. [degree] cross_axis_slope : numeric, default 0 @@ -416,6 +424,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, ---------- .. [1] TODO """ + pandas_index = ghi.index if isinstance(ghi, pd.Series) else None # preparation steps @@ -423,7 +432,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, solar_azimuth, dni_extra, airmass) true_tracker_rotation = tracker_rotation - + if axis_tilt != 0 or cross_axis_slope != 0: height, pitch, gcr, tracker_rotation, ghi = _apply_ground_slope( height, pitch, gcr, tracker_rotation, ghi, dni, dhi, @@ -505,7 +514,11 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, poa_back[new_key] = poa_back.pop(old_key) poa_front.update(poa_back) - if n_row_segments == 1 and isinstance(ghi, pd.Series): - poa_front = pd.DataFrame(poa_front, index=ghi.index) + if n_row_segments == 1: + for k, v in poa_front.items(): + poa_front[k] = v[0] # drop row segment dimension + + if pandas_index is not None: + poa_front = pd.DataFrame(poa_front, index=pandas_index) return poa_front From 0907682fc658147955750d8fb268866a32511155 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 9 Oct 2025 16:07:16 -0400 Subject: [PATCH 12/78] bugfix in unshaded ground fraction --- pvlib/bifacial/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index c5843a48e1..f3f9915cb8 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -92,9 +92,6 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch, height, :doi:`10.1109/PVSC40753.2019.8980572`. """ - swap = (tracker_rotation > 90) | (tracker_rotation <= -90) - tracker_rotation = np.where(swap, tracker_rotation + 180, tracker_rotation) - # dimensions: k/max_rows, ground segment, time tracker_rotation = np.atleast_1d(tracker_rotation)[np.newaxis, np.newaxis, :] @@ -119,6 +116,9 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch, height, cp = c[0] + c[1] * tanphi dp = d[0] + d[1] * tanphi + swap = dp > cp + cp, dp = np.where(swap, dp, cp), np.where(swap, cp, dp) + a = g0*pitch b = g1*pitch From 8c792ecc3a15fb00cbb04831a6663a882cc494f3 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 9 Oct 2025 16:14:42 -0400 Subject: [PATCH 13/78] add several tests for main function --- tests/bifacial/test_ants2d.py | 127 +++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 2 deletions(-) diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index e9ee32ff88..22b725a5a9 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -16,7 +16,7 @@ def test__shaded_fraction(): tracker_rotation = np.array([60, 60, 60, 60]) phi = np.array([60, 60, 60, 60]) gcr = np.array([1, 0.75, 2/3, 0.5]) - expected = np.array([[0.5, 1/3, 0.25, 0]]) + expected = np.array([0.5, 1/3, 0.25, 0]) fs = ants2d._shaded_fraction(tracker_rotation, phi, gcr) np.testing.assert_allclose(fs, expected) fs = ants2d._shaded_fraction(-tracker_rotation, -phi, gcr) @@ -36,7 +36,7 @@ def test__shaded_fraction(): phi = np.array([0, 90, -90, 0, 90, -90, 0, 90, -90]) gcr = 0.5 # (some of these are debatable as well) - expected = np.array([[0, 0, 0, 0, 1, 1, 0, 1, 1]]) + expected = np.array([0, 0, 0, 0, 1, 1, 0, 1, 1]) fs = ants2d._shaded_fraction(tracker_rotation, phi, gcr) np.testing.assert_allclose(fs, expected) @@ -183,3 +183,126 @@ def test__apply_ground_slope_zero(): for input, output in zip(inputs, outputs): assert pytest.approx(input, abs=1e-10) == output + +@pytest.fixture +def ants_params(): + # parameters for get_irradiance + times = pd.date_range("2019-06-01 11:30", freq="h", periods=2) + inputs = { + 'tracker_rotation': [45, -45], + 'axis_azimuth': 180, + 'solar_zenith': [60, 60], + 'solar_azimuth': [225, 135], + 'gcr': 0.5, 'height': 2.5, 'pitch': 4.0, + 'ghi': [700, 700], + 'dni': [1000, 1000], + 'dhi': [200, 200], + 'albedo': 0.2, + 'dni_extra': [1360, 1360], + 'airmass': [2, 2], + } + for k, v in inputs.items(): + if isinstance(v, list): + inputs[k] = pd.Series(v, index=times) + return inputs + + +def test_get_irradiance_return_type(ants_params): + # verify pandas in -> pandas out, and shapes of numpy outputs + out = ants2d.get_irradiance(**ants_params, n_row_segments=1) + assert isinstance(out, pd.DataFrame) # DataFrame, since n_row_segments=1 + expected_keys = ['poa_front', 'poa_front_direct', 'poa_front_diffuse', + 'poa_front_sky_diffuse', 'poa_front_ground_diffuse', + 'shaded_fraction_front', 'poa_back', 'poa_back_direct', + 'poa_back_diffuse', 'poa_back_sky_diffuse', 'poa_back_ground_diffuse', + 'shaded_fraction_back'] + assert set(out.columns) == set(expected_keys) + assert len(out) == 2 # 2 timestamps + + out = ants2d.get_irradiance(**ants_params, n_row_segments=3) + assert isinstance(out, dict) # dict, since n_row_segments>1 + assert set(out.keys()) == set(expected_keys) + for k, v in out.items(): + assert v.shape == (3, 2), k # 3 row segments, 2 timestamps + + +def test_get_irradiance_symmetry(ants_params): + # check symmetries for normal tracker + out = ants2d.get_irradiance(**ants_params, n_row_segments=1) + # symmetrical/mirrored inputs should produce equal outputs + pd.testing.assert_series_equal(out.iloc[0, :], out.iloc[1, :], + check_names=False) + + +@pytest.mark.parametrize('solar_zenith', [ + 60, # partial ground shading, no module shading + 80, # full ground shading, partial module shading +]) +def test_get_irradiance_vertical(ants_params, solar_zenith): + # check symmetries for vertical panels (tilt=90) + ants_params['solar_zenith'] = pd.Series(solar_zenith, + index=ants_params['ghi'].index) + + ants_params['tracker_rotation'] = pd.Series([90, 90], + index=ants_params['ghi'].index) + out = ants2d.get_irradiance(**ants_params, n_row_segments=1) + # inputs are symmetrical morning/afternoon, so morning front should equal + # afternoon back, and vice versa + front_keys = ['poa_front', 'poa_front_direct', 'poa_front_diffuse', + 'poa_front_sky_diffuse', 'poa_front_ground_diffuse', + 'shaded_fraction_front'] + for front_key in front_keys: + back_key = front_key.replace("front", "back") + assert np.isclose(out.iloc[0][front_key], out.iloc[1][back_key]) + assert np.isclose(out.iloc[1][front_key], out.iloc[0][back_key]) + + # now same, but for rotation=-90 + ants_params['tracker_rotation'] *= -1 + out = ants2d.get_irradiance(**ants_params, n_row_segments=1) + for front_key in front_keys: + back_key = front_key.replace("front", "back") + assert np.isclose(out.iloc[0][front_key], out.iloc[1][back_key]) + assert np.isclose(out.iloc[1][front_key], out.iloc[0][back_key]) + + # now back to +90, but with >1 row segment + ants_params['tracker_rotation'] = pd.Series([90, 90], + index=ants_params['ghi'].index) + out = ants2d.get_irradiance(**ants_params, n_row_segments=2) + lower_half = 0 + upper_half = 1 + morning = 0 + afternoon = 1 + for front_key in front_keys: + back_key = front_key.replace("front", "back") + assert np.isclose(out[front_key][lower_half, morning], + out[back_key][lower_half, afternoon]) + assert np.isclose(out[front_key][upper_half, morning], + out[back_key][upper_half, afternoon]) + assert np.isclose(out[back_key][lower_half, morning], + out[front_key][lower_half, afternoon]) + assert np.isclose(out[back_key][upper_half, morning], + out[front_key][upper_half, afternoon]) + + +def test_get_irradiance_limit(ants_params): + # check that as pitch->infinity, front-side irradiance converges to + # output of get_total_irradiance + ants_params['pitch'] *= 1000 + ants_params['gcr'] /= 1000 + ants = ants2d.get_irradiance(**ants_params, n_row_segments=1) + + surface_tilt = ants_params['tracker_rotation'].abs() + surface_azimuth = np.where(ants_params['tracker_rotation'] > 0, 270, 90) + total_irrad = pvlib.irradiance.get_total_irradiance( + surface_tilt, surface_azimuth, + ants_params['solar_zenith'], ants_params['solar_azimuth'], + ants_params['dni'], ants_params['ghi'], ants_params['dhi'], + albedo=ants_params['albedo'], model='isotropic') + + colmap = {'poa_front': 'poa_global', 'poa_front_direct': 'poa_direct', + 'poa_front_diffuse': 'poa_diffuse', + 'poa_front_sky_diffuse': 'poa_sky_diffuse', + 'poa_front_ground_diffuse': 'poa_ground_diffuse'} + ants_front = ants[list(colmap)].rename(columns=colmap) + pd.testing.assert_frame_equal(ants_front, total_irrad, atol=0.1) + From 9d28b75f4cbfdba408d7116355a271b63fb4621a Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 9 Oct 2025 16:40:48 -0400 Subject: [PATCH 14/78] test updates --- tests/bifacial/test_ants2d.py | 44 +++++++++++++++++------------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 22b725a5a9..a4e5c68cbd 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -238,12 +238,12 @@ def test_get_irradiance_symmetry(ants_params): 60, # partial ground shading, no module shading 80, # full ground shading, partial module shading ]) -def test_get_irradiance_vertical(ants_params, solar_zenith): +@pytest.mark.parametrize('tracker_rotation', [+90, -90]) +def test_get_irradiance_vertical(ants_params, solar_zenith, tracker_rotation): # check symmetries for vertical panels (tilt=90) ants_params['solar_zenith'] = pd.Series(solar_zenith, index=ants_params['ghi'].index) - - ants_params['tracker_rotation'] = pd.Series([90, 90], + ants_params['tracker_rotation'] = pd.Series(tracker_rotation, index=ants_params['ghi'].index) out = ants2d.get_irradiance(**ants_params, n_row_segments=1) # inputs are symmetrical morning/afternoon, so morning front should equal @@ -256,17 +256,7 @@ def test_get_irradiance_vertical(ants_params, solar_zenith): assert np.isclose(out.iloc[0][front_key], out.iloc[1][back_key]) assert np.isclose(out.iloc[1][front_key], out.iloc[0][back_key]) - # now same, but for rotation=-90 - ants_params['tracker_rotation'] *= -1 - out = ants2d.get_irradiance(**ants_params, n_row_segments=1) - for front_key in front_keys: - back_key = front_key.replace("front", "back") - assert np.isclose(out.iloc[0][front_key], out.iloc[1][back_key]) - assert np.isclose(out.iloc[1][front_key], out.iloc[0][back_key]) - - # now back to +90, but with >1 row segment - ants_params['tracker_rotation'] = pd.Series([90, 90], - index=ants_params['ghi'].index) + # now with >1 row segment out = ants2d.get_irradiance(**ants_params, n_row_segments=2) lower_half = 0 upper_half = 1 @@ -285,24 +275,34 @@ def test_get_irradiance_vertical(ants_params, solar_zenith): def test_get_irradiance_limit(ants_params): - # check that as pitch->infinity, front-side irradiance converges to - # output of get_total_irradiance - ants_params['pitch'] *= 1000 - ants_params['gcr'] /= 1000 - ants = ants2d.get_irradiance(**ants_params, n_row_segments=1) - + # check that diffuse components of front-side irradiance are lower + # than what get_total_irradiance predicts surface_tilt = ants_params['tracker_rotation'].abs() surface_azimuth = np.where(ants_params['tracker_rotation'] > 0, 270, 90) - total_irrad = pvlib.irradiance.get_total_irradiance( + irrad = pvlib.irradiance.get_total_irradiance( surface_tilt, surface_azimuth, ants_params['solar_zenith'], ants_params['solar_azimuth'], ants_params['dni'], ants_params['ghi'], ants_params['dhi'], albedo=ants_params['albedo'], model='isotropic') + ants = ants2d.get_irradiance(**ants_params, n_row_segments=1) + # 15 W/m2 happens to be just below the difference (determined empirically) + diff_sky = irrad['poa_sky_diffuse'] - ants['poa_front_sky_diffuse'] + diff_ground = irrad['poa_ground_diffuse'] - ants['poa_front_ground_diffuse'] + assert all(diff_sky > 15) + assert all(diff_ground > 15) + + # but as pitch->infinity, front-side irradiance converges to + # output of get_total_irradiance + ants_params['pitch'] *= 1000 + ants_params['gcr'] /= 1000 + ants = ants2d.get_irradiance(**ants_params, n_row_segments=1) + colmap = {'poa_front': 'poa_global', 'poa_front_direct': 'poa_direct', 'poa_front_diffuse': 'poa_diffuse', 'poa_front_sky_diffuse': 'poa_sky_diffuse', 'poa_front_ground_diffuse': 'poa_ground_diffuse'} ants_front = ants[list(colmap)].rename(columns=colmap) - pd.testing.assert_frame_equal(ants_front, total_irrad, atol=0.1) + pd.testing.assert_frame_equal(ants_front, irrad, atol=0.1) + From 233102386f17dfe317beb49e280598bb7e469800 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 9 Oct 2025 17:59:27 -0400 Subject: [PATCH 15/78] better return type logic; more get_irradiance tests --- pvlib/bifacial/ants2d.py | 24 ++++++++++----- tests/bifacial/test_ants2d.py | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 4df165c1e7..1fa01f7a09 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -74,12 +74,12 @@ def _shaded_fraction(tracker_rotation, phi, gcr, x0=0, x1=1): axis_azimuth=0, shaded_row_rotation=tracker_rotation, collector_width=1, pitch=1/gcr) - + # dimensions: row segment, time f_s = np.atleast_1d(f_s)[np.newaxis, :] x0 = np.atleast_1d(x0)[:, np.newaxis] x1 = np.atleast_1d(x1)[:, np.newaxis] - + swap = tracker_rotation < 0 x0, x1 = np.where(swap, 1 - x1, x0), np.where(swap, 1 - x0, x1) @@ -384,11 +384,13 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, Returns ------- output : dict or DataFrame - ``output`` is a DataFrame when input ghi is a Series and - ``n_row_segments=1`` and a dict of ``np.ndarray`` otherwise. + ``output`` is a DataFrame when input ``tracker_rotation`` is a Series + and ``n_row_segments=1``, a dict of scalars when ``tracker_rotation`` + is a scalar and ``n_row_segments=1``, and a dict of ``np.ndarray`` + otherwise. ``output`` includes the following quantities: - + - ``poa_global``: sum of front- and back-side incident irradiance. [Wm⁻²] - ``poa_front``: total incident irradiance on the front surface. [Wm⁻²] @@ -424,7 +426,6 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, ---------- .. [1] TODO """ - pandas_index = ghi.index if isinstance(ghi, pd.Series) else None # preparation steps @@ -453,6 +454,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, ghi = np.atleast_1d(ghi)[np.newaxis, np.newaxis, :] dhi = np.atleast_1d(dhi)[np.newaxis, np.newaxis, :] dni = np.atleast_1d(dni)[np.newaxis, np.newaxis, :] + tracker_rotation = np.atleast_1d(tracker_rotation) # Calculate some geometric quantities # rows to consider in front and behind current row @@ -518,7 +520,13 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, for k, v in poa_front.items(): poa_front[k] = v[0] # drop row segment dimension - if pandas_index is not None: - poa_front = pd.DataFrame(poa_front, index=pandas_index) + if np.isscalar(true_tracker_rotation): + # drop the second dimension too, so scalars are returned + for k, v in poa_front.items(): + poa_front[k] = float(v[0]) + + elif isinstance(true_tracker_rotation, pd.Series): + poa_front = pd.DataFrame(poa_front, + index=true_tracker_rotation.index) return poa_front diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index a4e5c68cbd..4a823b6eeb 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -306,3 +306,58 @@ def test_get_irradiance_limit(ants_params): pd.testing.assert_frame_equal(ants_front, irrad, atol=0.1) +@pytest.fixture +def ants_params_fixed(): + # parameters for get_irradiance, for a fixed-tilt system + inputs = { + 'tracker_rotation': 30, + 'axis_azimuth': 90, + 'solar_zenith': 60, + 'solar_azimuth': 175, + 'gcr': 0.6, 'height': 1.5, 'pitch': 3.5, + 'ghi': 700, + 'dni': 1000, + 'dhi': 200, + 'albedo': 0.2, + 'dni_extra': 1360, + 'airmass': 2, + } + return inputs + + +def test_get_irradiance_direct_shading(ants_params_fixed): + # check that direct shading increases as sun approaches horizon + ants_params_fixed.pop('solar_zenith') + out60 = ants2d.get_irradiance(solar_zenith=60, **ants_params_fixed) + out80 = ants2d.get_irradiance(solar_zenith=80, **ants_params_fixed) + assert out80['poa_front_direct'] < out60['poa_front_direct'] + + +def test_get_irradiance_multiple_row_segments(ants_params_fixed): + # check that granular sims average to the same value as n_row_segments=1 + out4 = ants2d.get_irradiance(**ants_params_fixed, n_row_segments=4) + out2 = ants2d.get_irradiance(**ants_params_fixed, n_row_segments=2) + out1 = ants2d.get_irradiance(**ants_params_fixed, n_row_segments=1) + + for k in out4: + # check two bottom quarters average to the bottom half, and top + # two quarters average to the top half + assert np.isclose(np.mean(out4[k][0:2, 0]), out2[k][0, 0]) + assert np.isclose(np.mean(out4[k][2:4, 0]), out2[k][1, 0]) + + # check that two halves average to the whole + assert np.isclose(np.mean(out2[k][:, 0]), out1[k]) + + +def test_get_irradiance_slope(ants_params_fixed): + # check the slope affects direct & diffuse shading + flat = ants2d.get_irradiance(cross_axis_slope=0, **ants_params_fixed) + # negative slope with axis_azimuth=90 means sloping down to the north + tilt = ants2d.get_irradiance(cross_axis_slope=-10, **ants_params_fixed) + assert tilt['poa_front_direct'] < flat['poa_front_direct'] + assert tilt['poa_front_sky_diffuse'] < flat['poa_front_sky_diffuse'] + + +def test_get_irradiance_nonuniform_albedo(): + # check that specifying albedo for each ground segment works + From 56fe82bbd92361639ca3cbd54190a783beb4956a Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Fri, 10 Oct 2025 10:45:47 -0400 Subject: [PATCH 16/78] expose max_rows, more get_irradiance tests --- pvlib/bifacial/ants2d.py | 11 ++++++++--- tests/bifacial/test_ants2d.py | 25 ++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 1fa01f7a09..a05af94e61 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -305,7 +305,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, gcr, height, pitch, ghi, dhi, dni, albedo, model='isotropic', dni_extra=None, airmass=None, n_row_segments=1, n_ground_segments=10, axis_tilt=0, - cross_axis_slope=0): + cross_axis_slope=0, max_rows=None): """ Get front and rear irradiance using the ANTS-2D bifacial irradiance model. @@ -380,6 +380,10 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, positive cross-axis slope if the tracker axes plane slopes down to the west. Use :func:`~pvlib.tracking.calc_cross_axis_tilt` to calculate ``cross_axis_slope``. [degrees] + max_rows : int, optional + Number of array units (sky wedges, ground segments, etc) to consider. + If not specified, units out to within 4 degrees of the horizon will + be considered. [unitless] Returns ------- @@ -460,8 +464,9 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, # rows to consider in front and behind current row # ensures that view factors to the sky are computed to within 4 degrees # of the horizon - max_rows = np.ceil(height / (pitch * tand(4))) - + if max_rows is None: + max_rows = np.ceil(height / (pitch * tand(4))) + phi = projected_solar_zenith_angle(solar_zenith, solar_azimuth, axis_tilt, axis_azimuth) phi = phi - cross_axis_slope diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 4a823b6eeb..2f5bbbadf1 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -354,10 +354,33 @@ def test_get_irradiance_slope(ants_params_fixed): flat = ants2d.get_irradiance(cross_axis_slope=0, **ants_params_fixed) # negative slope with axis_azimuth=90 means sloping down to the north tilt = ants2d.get_irradiance(cross_axis_slope=-10, **ants_params_fixed) + assert tilt['shaded_fraction_front'] > flat['shaded_fraction_front'] assert tilt['poa_front_direct'] < flat['poa_front_direct'] assert tilt['poa_front_sky_diffuse'] < flat['poa_front_sky_diffuse'] def test_get_irradiance_nonuniform_albedo(): # check that specifying albedo for each ground segment works - + + # horizontal array, very close to the ground, with different albedo + # on left and right sides. check that ground-reflected irradiance at + # the edges of the module match the corresponding albedos + inputs = { + 'tracker_rotation': 0.0001, + 'axis_azimuth': 180, + 'solar_zenith': 0.001, + 'solar_azimuth': 180.001, + 'gcr': 0.1, 'height': 0.05, 'pitch': 20, + 'ghi': 1000, + 'dni': 1000, + 'dhi': 0, + 'albedo': np.array([[0.5]*10 + [0.1]*10]).T, + } + out = ants2d.get_irradiance(n_ground_segments=20, + n_row_segments=10000, + max_rows=2, + **inputs) + left, right = out['poa_back_ground_diffuse'][[0, -1], 0] + # divide by two because ~half the visible ground is fully shaded + np.testing.assert_allclose(left, 0.1 * 1000 / 2, rtol=0.002) + np.testing.assert_allclose(right, 0.5 * 1000 / 2, rtol=0.002) From 920ec30547dd78972b97001b1645e03aa2565383 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 13 Oct 2025 12:46:50 -0400 Subject: [PATCH 17/78] fix issue with horizontal array and large max_rows --- pvlib/bifacial/utils.py | 16 ++++++++-------- tests/bifacial/test_utils.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index f3f9915cb8..22269dd152 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -608,31 +608,31 @@ def _obstructed_string_lengths(a, b, c, d, ob_left, ob_right): ang_b_ob_right = _angle(b, ob_right) # obstructed distances - ac = np.where(ang_ac - ang_a_ob_left > 1e-6, + ac = np.where(ang_ac - ang_a_ob_left > 1e-8, dist_a_ob_left + dist_ob_left_c, dist_ac) - ac = np.where(ang_a_ob_right - ang_ac > 1e-6, + ac = np.where((ang_a_ob_right - ang_ac > 1e-8), dist_a_ob_right + dist_ob_right_c, ac) - ad = np.where(ang_ad - ang_a_ob_left > 1e-6, + ad = np.where(ang_ad - ang_a_ob_left > 1e-8, dist_a_ob_left + dist_ob_left_d, dist_ad) - ad = np.where(ang_a_ob_right - ang_ad > 1e-6, + ad = np.where(ang_a_ob_right - ang_ad > 1e-8, dist_a_ob_right + dist_ob_right_d, ad) - bc = np.where(ang_bc - ang_b_ob_left > 1e-6, + bc = np.where(ang_bc - ang_b_ob_left > 1e-8, dist_b_ob_left + dist_ob_left_c, dist_bc) - bc = np.where(ang_b_ob_right - ang_bc > 1e-6, + bc = np.where(ang_b_ob_right - ang_bc > 1e-8, dist_b_ob_right + dist_ob_right_c, bc) - bd = np.where(ang_bd - ang_b_ob_left > 1e-6, + bd = np.where(ang_bd - ang_b_ob_left > 1e-8, dist_b_ob_left + dist_ob_left_d, dist_bd) - bd = np.where(ang_b_ob_right - ang_bd > 1e-6, + bd = np.where(ang_b_ob_right - ang_bd > 1e-8, dist_b_ob_right + dist_ob_right_d, bd) diff --git a/tests/bifacial/test_utils.py b/tests/bifacial/test_utils.py index ad6af8301d..79ac274e4b 100644 --- a/tests/bifacial/test_utils.py +++ b/tests/bifacial/test_utils.py @@ -147,6 +147,19 @@ def test_vf_row_sky_2d_integ(test_system_fixed_tilt): assert np.allclose(vf, y1, rtol=1e-3) +def test_vf_ground_sky_2d_integ_horizontal_large_max_rows(): + # with large max_rows, rows far out towards the horizon are considered. + # obstructed string lengths are then calculated using angles very close + # to zero. however, numerical roundoff requires some amount of slop being + # allowed in the comparison. this case previously failed when the slop + # allowance was too large and prevented accurate resolution of those + # far-away rows. + inputs = dict(tracker_rotation=0, gcr=0.518, height=1.5, pitch=3.5, + g0=0, g1=0.05, max_rows=1000) + vf_gnd_sky = utils.vf_ground_sky_2d_integ(**inputs) + assert np.max(vf_gnd_sky) < 1 + + def test_vf_row_ground_2d(test_system_fixed_tilt): ts, _, _ = test_system_fixed_tilt # with float input, fx at bottom of row From 403c0a4d2015c3f03acce3c39c3c2c90c03e92c1 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 13 Oct 2025 12:49:20 -0400 Subject: [PATCH 18/78] fix unshaded_ground_fraction for horizontal special case --- pvlib/bifacial/utils.py | 9 ++++--- tests/bifacial/test_ants2d.py | 45 +++++++++++++++++++++++++++++++++++ tests/bifacial/test_utils.py | 13 ++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 22269dd152..25a6fdb46c 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -123,12 +123,11 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch, height, b = g1*pitch # individual contributions from all k rows - # TODO bug with zenith=0, fix these < > <= >= fs = np.full_like(cp, 1.0) - # fs = np.where((dp < a) & (cp > b), 1.0, fs) # initial value already 1.0 - fs = np.where((dp < a) & (a < cp) & (cp < b), (cp - a) / (b - a), fs) + # fs = np.where((dp <= a) & (cp >= b), 1.0, fs) # fill value already 1.0 + fs = np.where((dp <= a) & (a <= cp) & (cp < b), (cp - a) / (b - a), fs) fs = np.where((dp < a) & (cp < a), 0.0, fs) - fs = np.where((a < dp) & (dp < b) & (cp > b), (b - dp) / (b - a), fs) + fs = np.where((a < dp) & (dp <= b) & (cp >= b), (b - dp) / (b - a), fs) fs = np.where((a < dp) & (dp < b) & (a < cp) & (cp < b), (cp - dp) / (b - a), fs) fs = np.where((dp > b) & (cp > b), 0.0, fs) @@ -571,7 +570,7 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height, pitch, # crossed string formula for VF vf_slats = 0.5 * (1/((x1 - x0) * collector_width)) * ((ac + bd) - (bc + ad)) vf_total = np.sum(np.maximum(vf_slats, 0), axis=0) # sum along k dimension - + # dimensions are now ground_segment, row_segment, time vf_total = vf_total.squeeze(axis=tuple(squeeze)) diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 2f5bbbadf1..8b15d8c29f 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -325,6 +325,28 @@ def ants_params_fixed(): return inputs +def test_get_irradiance_horizontal(ants_params_fixed): + # check that no issues pop up with tracker_rotation=solar_zenith=0 + ants_params_fixed['tracker_rotation'] = 0 + ants_params_fixed['solar_zenith'] = 0 + ants_params_fixed['solar_azimuth'] = 180 + zero = ants2d.get_irradiance(**ants_params_fixed) + + ants_params_fixed['tracker_rotation'] = 0.0001 + ants_params_fixed['solar_zenith'] = 0.0001 + ants_params_fixed['solar_azimuth'] = 180.0001 + pos_epsilon = ants2d.get_irradiance(**ants_params_fixed) + + ants_params_fixed['tracker_rotation'] = -0.0001 + ants_params_fixed['solar_zenith'] = -0.0001 + ants_params_fixed['solar_azimuth'] = 179.9999 + neg_epsilon = ants2d.get_irradiance(**ants_params_fixed) + + for key in zero: + np.testing.assert_allclose(zero[key], pos_epsilon[key], atol=0.01) + np.testing.assert_allclose(zero[key], neg_epsilon[key], atol=0.01) + + def test_get_irradiance_direct_shading(ants_params_fixed): # check that direct shading increases as sun approaches horizon ants_params_fixed.pop('solar_zenith') @@ -384,3 +406,26 @@ def test_get_irradiance_nonuniform_albedo(): # divide by two because ~half the visible ground is fully shaded np.testing.assert_allclose(left, 0.1 * 1000 / 2, rtol=0.002) np.testing.assert_allclose(right, 0.5 * 1000 / 2, rtol=0.002) + + +def test_get_irradiance_nonuniform_albedo_limit(): + # nonuniform albedo averages to uniform albedo, when sufficiently far away + base_inputs = { + 'tracker_rotation': 45, + 'axis_azimuth': 180, + 'solar_zenith': 10, + 'solar_azimuth': 215, + 'gcr': 0.5, 'height': 1000, 'pitch': 4, + 'ghi': 300, + 'dni': 0, # set dni to zero so that shadows don't confound results + 'dhi': 300, + 'n_ground_segments': 2, + 'max_rows': 100000, + } + out_uni = ants2d.get_irradiance(albedo=0.3, + **base_inputs) + out_non = ants2d.get_irradiance(albedo=np.array([[0.5, 0.1]]).T, + **base_inputs) + for key in out_non: + np.testing.assert_allclose(out_non[key], out_uni[key], atol=1e-6) + diff --git a/tests/bifacial/test_utils.py b/tests/bifacial/test_utils.py index 79ac274e4b..7e89bddb4f 100644 --- a/tests/bifacial/test_utils.py +++ b/tests/bifacial/test_utils.py @@ -79,6 +79,19 @@ def test__unshaded_ground_fraction( assert np.allclose(f_sky_beam_b, expected) +def test__unshaded_ground_fraction_horizontal(): + # test that zero angles don't mess things up. specifically, check + # for continuity with small positive and negative angles + params = dict(gcr=0.5, pitch=4, height=2.5, + g0=[0, 0.25, 0.5, 0.75], g1=[0.25, 0.5, 0.75, 1]) + zero = utils._unshaded_ground_fraction(0.0, 0.0, **params) + pos = utils._unshaded_ground_fraction(0.01, 0.01, **params) + neg = utils._unshaded_ground_fraction(-0.01, -0.01, **params) + np.testing.assert_allclose(pos, neg, atol=0.01) + np.testing.assert_allclose(pos, zero, atol=0.01) + np.testing.assert_allclose(neg, zero, atol=0.01) + + def test__vf_ground_sky_2d(test_system_fixed_tilt): # vector input ts, pts, vfs_gnd_sky = test_system_fixed_tilt From 24c8f32c6cb2764601cc66eba55f5395f606d27e Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 13 Oct 2025 13:26:16 -0400 Subject: [PATCH 19/78] fix row->ground VF edge case --- pvlib/bifacial/utils.py | 4 ++++ tests/bifacial/test_ants2d.py | 6 +++--- tests/bifacial/test_utils.py | 12 ++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 25a6fdb46c..6a79bd7b1e 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -529,6 +529,10 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height, pitch, # dimensions: k/max_rows, ground segment, row segment, time + # cheat a little to prevent numerical issues with surface_tilt==180, -180 + surface_tilt = np.where(surface_tilt == 180, 179.9999, surface_tilt) + surface_tilt = np.where(surface_tilt == -180, -179.9999, surface_tilt) + surface_tilt = np.atleast_1d(surface_tilt)[np.newaxis, np.newaxis, np.newaxis, :] x0 = np.atleast_1d(x0)[np.newaxis, np.newaxis, :, np.newaxis] diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 8b15d8c29f..a21e617c7d 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -388,10 +388,10 @@ def test_get_irradiance_nonuniform_albedo(): # on left and right sides. check that ground-reflected irradiance at # the edges of the module match the corresponding albedos inputs = { - 'tracker_rotation': 0.0001, + 'tracker_rotation': 0, 'axis_azimuth': 180, - 'solar_zenith': 0.001, - 'solar_azimuth': 180.001, + 'solar_zenith': 0, + 'solar_azimuth': 180, 'gcr': 0.1, 'height': 0.05, 'pitch': 20, 'ghi': 1000, 'dni': 1000, diff --git a/tests/bifacial/test_utils.py b/tests/bifacial/test_utils.py index 7e89bddb4f..864e115362 100644 --- a/tests/bifacial/test_utils.py +++ b/tests/bifacial/test_utils.py @@ -220,3 +220,15 @@ def test_vf_ground_2d_integ(test_system_fixed_tilt): y = 0.5 * (1 - cosd(phi_y - ts['surface_tilt'])) y1 = trapezoid(y, x) / (1 - 0) assert np.allclose(vf, y1, rtol=1e-2) + + +def test_vf_row_ground_2d_integ(): + # horizontal, rear-side + inputs = {'surface_tilt': np.array([-180]), + 'gcr': 0.6, 'height': 1.5, 'pitch': 3.5, + 'x0': np.array([0.]), 'x1': np.array([1.]), + 'g0': np.array([0., 0.5]), + 'g1': np.array([0.5, 1.]), + 'max_rows': 7} + vf_row_ground = utils.vf_row_ground_2d_integ(**inputs) + assert all(vf_row_ground > 1e-3) From 06f2a97cf44973615a5c4324ee31601be0a4c145 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 13 Oct 2025 13:53:13 -0400 Subject: [PATCH 20/78] switch default model from isotropic to perez --- pvlib/bifacial/ants2d.py | 4 ++-- tests/bifacial/test_ants2d.py | 18 ++++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index a05af94e61..2fc4ccf348 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -303,7 +303,7 @@ def _apply_ground_slope(height, pitch, gcr, tracker_rotation, ghi, dni, dhi, def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, gcr, height, pitch, ghi, dhi, dni, - albedo, model='isotropic', dni_extra=None, airmass=None, + albedo, model='perez', dni_extra=None, airmass=None, n_row_segments=1, n_ground_segments=10, axis_tilt=0, cross_axis_slope=0, max_rows=None): """ @@ -355,7 +355,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, Direct normal irradiance. [Wm⁻²] albedo : numeric Surface albedo. TODO shape [unitless] - model : str, default 'isotropic' + model : str, default 'perez' Irradiance model - can be one of 'isotropic', 'haydavies', or 'perez'. dni_extra : numeric, optional Extraterrestrial direct normal irradiance. Required when diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index a21e617c7d..5c0c1e4a02 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -47,10 +47,6 @@ def test__shaded_fraction_x0x1(): np.testing.assert_allclose(fs, np.array([[0.5, 0.0], [0.0, 0.5]])) -def test__ants2d_singleside(): - pass - - @pytest.mark.parametrize('model', ['perez', 'haydavies']) def test__apply_sky_diffuse_model(model): inputs = {'dni': 900, 'dhi': 150, 'solar_zenith': 41, 'solar_azimuth': 67, @@ -285,7 +281,8 @@ def test_get_irradiance_limit(ants_params): ants_params['dni'], ants_params['ghi'], ants_params['dhi'], albedo=ants_params['albedo'], model='isotropic') - ants = ants2d.get_irradiance(**ants_params, n_row_segments=1) + ants = ants2d.get_irradiance(**ants_params, n_row_segments=1, + model='isotropic') # 15 W/m2 happens to be just below the difference (determined empirically) diff_sky = irrad['poa_sky_diffuse'] - ants['poa_front_sky_diffuse'] diff_ground = irrad['poa_ground_diffuse'] - ants['poa_front_ground_diffuse'] @@ -296,7 +293,8 @@ def test_get_irradiance_limit(ants_params): # output of get_total_irradiance ants_params['pitch'] *= 1000 ants_params['gcr'] /= 1000 - ants = ants2d.get_irradiance(**ants_params, n_row_segments=1) + ants = ants2d.get_irradiance(**ants_params, n_row_segments=1, + model='isotropic') colmap = {'poa_front': 'poa_global', 'poa_front_direct': 'poa_direct', 'poa_front_diffuse': 'poa_diffuse', @@ -397,11 +395,14 @@ def test_get_irradiance_nonuniform_albedo(): 'dni': 1000, 'dhi': 0, 'albedo': np.array([[0.5]*10 + [0.1]*10]).T, + 'model': 'isotropic' } out = ants2d.get_irradiance(n_ground_segments=20, n_row_segments=10000, max_rows=2, **inputs) + # check far left and right segments, on the edge of the module. + # need a large n_row_segments so that these segments are very thin left, right = out['poa_back_ground_diffuse'][[0, -1], 0] # divide by two because ~half the visible ground is fully shaded np.testing.assert_allclose(left, 0.1 * 1000 / 2, rtol=0.002) @@ -420,8 +421,9 @@ def test_get_irradiance_nonuniform_albedo_limit(): 'dni': 0, # set dni to zero so that shadows don't confound results 'dhi': 300, 'n_ground_segments': 2, - 'max_rows': 100000, - } + 'max_rows': 10000, + 'model': 'isotropic', + } out_uni = ants2d.get_irradiance(albedo=0.3, **base_inputs) out_non = ants2d.get_irradiance(albedo=np.array([[0.5, 0.1]]).T, From 69105fd569ac2d5a1671cf6717d99a0e504b1aae Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 13 Oct 2025 13:53:33 -0400 Subject: [PATCH 21/78] add hardcoded regression test --- tests/bifacial/test_ants2d.py | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 5c0c1e4a02..937146a091 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -431,3 +431,48 @@ def test_get_irradiance_nonuniform_albedo_limit(): for key in out_non: np.testing.assert_allclose(out_non[key], out_uni[key], atol=1e-6) + +@pytest.mark.parametrize('model,expected', [ + ('isotropic', {'poa_front': 1006.3548761345762, + 'poa_front_direct': 833.3333333333335, + 'poa_front_diffuse': 173.0215428012428, + 'poa_front_sky_diffuse': 172.27247024391784, + 'poa_front_ground_diffuse': 0.7490725573249604, + 'shaded_fraction_front': 0.035915234551783914, + 'poa_back': 23.626216052516494, + 'poa_back_direct': 0.0, + 'poa_back_diffuse': 23.626216052516494, + 'poa_back_sky_diffuse': 8.509173579096064, + 'poa_back_ground_diffuse': 15.11704247342043, + 'shaded_fraction_back': 0.035915234551784025}), + ('haydavies', {'poa_front': 1124.2311927022897, + 'poa_front_direct': 1078.4313725490197, + 'poa_front_diffuse': 45.79982015327015, + 'poa_front_sky_diffuse': 45.60153624103707, + 'poa_front_ground_diffuse': 0.19828391223307773, + 'shaded_fraction_front': 0.035915234551783914, + 'poa_back': 6.2539983668426, + 'poa_back_direct': 0.0, + 'poa_back_diffuse': 6.2539983668426, + 'poa_back_sky_diffuse': 2.252428300348958, + 'poa_back_ground_diffuse': 4.001570066493642, + 'shaded_fraction_back': 0.035915234551784025}), + ('perez', {'poa_front': 1060.3368384162613, + 'poa_front_direct': 945.5770264984124, + 'poa_front_diffuse': 114.75981191784896, + 'poa_front_sky_diffuse': 114.26297537137229, + 'poa_front_ground_diffuse': 0.4968365464766687, + 'shaded_fraction_front': 0.035915234551783914, + 'poa_back': 15.670534816764919, + 'poa_back_direct': 0.0, + 'poa_back_diffuse': 15.670534816764919, + 'poa_back_sky_diffuse': 5.643870374194696, + 'poa_back_ground_diffuse': 10.026664442570222, + 'shaded_fraction_back': 0.035915234551784025}) + ]) +def test_get_irradiance_regression(model, expected, ants_params_fixed): + # values computed for typical but arbitrary inputs, to verify that output + # is stable over time + out = ants2d.get_irradiance(**ants_params_fixed, model=model) + for key in expected: + np.testing.assert_allclose(out[key], expected[key], atol=1e-10) From eaa8fd1cb33e59f0e2be67758fafa5fe3f342bf6 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 13 Oct 2025 13:55:29 -0400 Subject: [PATCH 22/78] alphabetize module imports --- pvlib/bifacial/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/bifacial/__init__.py b/pvlib/bifacial/__init__.py index ec05f69da3..256a93b99f 100644 --- a/pvlib/bifacial/__init__.py +++ b/pvlib/bifacial/__init__.py @@ -4,7 +4,7 @@ from pvlib._deprecation import deprecated from pvlib.bifacial import ( # noqa: F401 - pvfactors, infinite_sheds, ants2d, utils + ants2d, infinite_sheds, pvfactors, utils ) from .loss_models import power_mismatch_deline # noqa: F401 From 98d1664e253e48f25e011546df6e0228ab0375cb Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 13 Oct 2025 15:52:07 -0400 Subject: [PATCH 23/78] docstring work --- pvlib/bifacial/ants2d.py | 2 +- pvlib/bifacial/utils.py | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 2fc4ccf348..4052babe45 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -143,7 +143,7 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, left row. ``g0`` should be less than ``g1``. [unitless] g1 : numeric Position on the ground surface, as a fraction of the row-to-row - spacing. ``g1=0`` corresponds to ground underneath the middle of the + spacing. ``g1=1`` corresponds to ground underneath the middle of the right row. ``g1`` should be greater than ``g0``. [unitless] max_rows : int Number of array units (sky wedges, ground segments, etc) to consider. diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 6a79bd7b1e..382ec551d9 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -249,7 +249,7 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, left row. ``g0`` should be less than ``g1``. [unitless] g1 : numeric Position on the ground surface, as a fraction of the row-to-row - spacing. ``g1=0`` corresponds to ground underneath the middle of the + spacing. ``g1=1`` corresponds to ground underneath the middle of the right row. ``g1`` should be greater than ``g0``. [unitless] max_rows : int, default 10 Maximum number of rows to consider in front and behind the current row. @@ -503,12 +503,19 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height, pitch, TODO, make optional if x0=g0=0 and x1=g1=1? x0 : numeric, default 0. Position on the row's slant length, as a fraction of the slant length. - x0=0 corresponds to the bottom of the row. x0 should be less than x1. - [unitless] + x0=0 corresponds to the bottom of the row. ``x0`` should be less than + ``x1``. [unitless] x1 : numeric, default 1. Position on the row's slant length, as a fraction of the slant length. - x1 should be greater than x0. [unitless] - g0, g1 : TODO + ``x1`` should be greater than ``x0``. [unitless] + g0 : numeric + Position on the ground surface, as a fraction of the row-to-row + spacing. ``g0=0`` corresponds to ground underneath the middle of the + left row. ``g0`` should be less than ``g1``. [unitless] + g1 : numeric + Position on the ground surface, as a fraction of the row-to-row + spacing. ``g1=0`` corresponds to ground underneath the middle of the + right row. ``g1`` should be greater than ``g0``. [unitless] max_rows : TODO Returns From 784877f9167423187325d780bed773c8eb3ee67b Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 13 Oct 2025 15:53:49 -0400 Subject: [PATCH 24/78] utils functions: make height and pitch optional --- pvlib/bifacial/utils.py | 45 +++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 382ec551d9..9ca082c96d 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -38,8 +38,9 @@ def _solar_projection_tangent(solar_zenith, solar_azimuth, surface_azimuth): return tan_phi -def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch, height, - g0=0, g1=1, max_rows=10, max_zenith=85): +def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch=None, + height=None, g0=0, g1=1, max_rows=10, + max_zenith=85): r""" Calculate the fraction of the ground with incident direct irradiance. @@ -67,12 +68,21 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch, height, gcr : float Ground coverage ratio, which is the ratio of row slant length to row spacing (pitch). [unitless] - height : float + height : float, optional Height of the center point of the row above the ground; must be in the - same units as ``pitch``. - pitch : float + same units as ``pitch``. Required if ``g0`` is not zero or ``g1`` is + not one. + pitch : float, optional Distance between two rows; must be in the same units as ``height``. - g0, g1 : TODO + Required if ``g0`` is not zero or ``g1`` is not one. + g0 : numeric + Position on the ground surface, as a fraction of the row-to-row + spacing. ``g0=0`` corresponds to ground underneath the middle of the + left row. ``g0`` should be less than ``g1``. [unitless] + g1 : numeric + Position on the ground surface, as a fraction of the row-to-row + spacing. ``g1=1`` corresponds to ground underneath the middle of the + right row. ``g1`` should be greater than ``g0``. [unitless] max_rows : TODO max_zenith : numeric, default 85 Maximum zenith angle. For solar_zenith > max_zenith, unshaded ground @@ -92,6 +102,11 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch, height, :doi:`10.1109/PVSC40753.2019.8980572`. """ + if np.isscalar(g0) and g0 == 0 and np.isscalar(g1) and g1 == 1: + # height and pitch have no effect, so set to arbitrary values so + # that they can be optional parameters + height = 1 + pitch = 1 / gcr # dimensions: k/max_rows, ground segment, time tracker_rotation = np.atleast_1d(tracker_rotation)[np.newaxis, np.newaxis, :] @@ -480,7 +495,6 @@ def vf_row_ground_2d(surface_tilt, gcr, x): return 0.5 * (1 - (1/gcr * cosd(surface_tilt) + x)/p) -def vf_row_ground_2d_integ(surface_tilt, gcr, height, pitch, x0=0, x1=1, g0=0, g1=1, max_rows=20): r''' Calculate the average view factor to the ground from a segment of the row @@ -497,10 +511,13 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height, pitch, = 0, surface facing horizon = 90. [degree] gcr : numeric Ratio of the row slant length to the row spacing (pitch). [unitless] - height : float - TODO, make optional if x0=g0=0 and x1=g1=1? - pitch : float - TODO, make optional if x0=g0=0 and x1=g1=1? + height : float, optional + Height of the center point of the row above the ground; must be in the + same units as ``pitch``. Required if ``g0`` or ``x0` is not zero or + if ``g1`` or ``x1`` is not one. + pitch : float, optional + Distance between two rows; must be in the same units as ``height``. + Required if ``g0`` or ``x0` is not zero or if ``g1`` or ``x1`` x0 : numeric, default 0. Position on the row's slant length, as a fraction of the slant length. x0=0 corresponds to the bottom of the row. ``x0`` should be less than @@ -525,6 +542,12 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height, pitch, [unitless] ''' + if all(np.isscalar(x) for x in [x0, x1, g0, g1]) and ( + g0 == 0 and g1 == 1 and x0 == 0 and x1 == 1): + # height and pitch have no effect, so set to arbitrary values so + # that they can be optional parameters + height = 1 + pitch = 1 / gcr # keep track of scalar inputs so that we can have output match at the end squeeze = [] if np.isscalar(g0) and np.isscalar(g1): From 2cbfb0d40329b814438c88fa1df0cbf2fc27a611 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 13 Oct 2025 15:54:55 -0400 Subject: [PATCH 25/78] get infinite_sheds working again --- pvlib/bifacial/infinite_sheds.py | 18 +++--- pvlib/bifacial/utils.py | 26 +++++++- tests/bifacial/test_infinite_sheds.py | 90 ++++++++++++++++----------- tests/bifacial/test_utils.py | 51 ++++++++------- 4 files changed, 115 insertions(+), 70 deletions(-) diff --git a/pvlib/bifacial/infinite_sheds.py b/pvlib/bifacial/infinite_sheds.py index 2418fd71bb..ed937ac6f2 100644 --- a/pvlib/bifacial/infinite_sheds.py +++ b/pvlib/bifacial/infinite_sheds.py @@ -4,7 +4,7 @@ import numpy as np import pandas as pd -from pvlib.tools import cosd, sind, tand +from pvlib.tools import cosd, sind, tand, atand from pvlib.bifacial import utils from pvlib.irradiance import beam_component, aoi, haydavies @@ -90,7 +90,7 @@ def _poa_sky_diffuse_pv(dhi, gcr, surface_tilt): poa_sky_diffuse_pv : numeric Total sky diffuse irradiance incident on the PV surface. [W/m^2] """ - vf_integ = utils.vf_row_sky_2d_integ(surface_tilt, gcr, 0., 1.) + vf_integ = utils.vf_row_sky_2d_integ(surface_tilt, gcr, x0=0., x1=1.) return dhi * vf_integ @@ -117,7 +117,8 @@ def _poa_ground_pv(poa_ground, gcr, surface_tilt): numeric Ground diffuse irradiance on the row plane. [W/m^2] """ - vf_integ = utils.vf_row_ground_2d_integ(surface_tilt, gcr, 0., 1.) + vf_integ = utils.vf_row_ground_2d_integ(surface_tilt, gcr, x0=0., x1=1., + g0=0., g1=1.) return poa_ground * vf_integ @@ -321,15 +322,18 @@ def get_irradiance_poa(surface_tilt, surface_azimuth, solar_zenith, max_rows = np.ceil(height / (pitch * tand(5))) # fraction of ground between rows that is illuminated accounting for # shade from panels. [1], Eq. 4 - f_gnd_beam = utils._unshaded_ground_fraction( - surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, gcr) + tanphi = utils._solar_projection_tangent(solar_zenith, solar_azimuth, + surface_azimuth) + phi = atand(tanphi) + f_gnd_beam = utils._unshaded_ground_fraction(surface_tilt, phi, gcr) + # integrated view factor from the ground to the sky, integrated between # adjacent rows interior to the array # method differs from [1], Eq. 7 and Eq. 8; height is defined at row # center rather than at row lower edge as in [1]. vf_gnd_sky = utils.vf_ground_sky_2d_integ( - surface_tilt, gcr, height, pitch, max_rows, npoints, - vectorize) + surface_tilt, gcr, height, pitch, g0=0, g1=1, max_rows=max_rows, + npoints=npoints, vectorize=vectorize) # fraction of row slant height that is shaded from direct irradiance f_x = _shaded_fraction(solar_zenith, solar_azimuth, surface_tilt, surface_azimuth, gcr) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 9ca082c96d..00ba17a143 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -101,12 +101,19 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch=None, Photovoltaic Specialists Conference (PVSC), 2019, pp. 1282-1287. :doi:`10.1109/PVSC40753.2019.8980572`. """ - if np.isscalar(g0) and g0 == 0 and np.isscalar(g1) and g1 == 1: # height and pitch have no effect, so set to arbitrary values so # that they can be optional parameters height = 1 pitch = 1 / gcr + + # keep track of scalar inputs so that we can have output match at the end + squeeze = [] + if np.isscalar(g0) and np.isscalar(g1): + squeeze.append(0) + if np.isscalar(tracker_rotation): + squeeze.append(1) + # dimensions: k/max_rows, ground segment, time tracker_rotation = np.atleast_1d(tracker_rotation)[np.newaxis, np.newaxis, :] @@ -155,6 +162,8 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch=None, phi = phi[0, :, :] # drop k dimension for the next line f_gnd_beam = np.where(np.abs(phi) > max_zenith, 0., f_gnd_beam) + # dimensions are now ground_segment, time + f_gnd_beam = f_gnd_beam.squeeze(axis=tuple(squeeze)) return f_gnd_beam @@ -295,6 +304,13 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, ) warnings.warn(msg, pvlibDeprecationWarning) + # keep track of scalar inputs so that we can have output match at the end + squeeze = [] + if np.isscalar(g0) and np.isscalar(g1): + squeeze.append(0) + if np.isscalar(tracker_rotation): + squeeze.append(1) + # dimensions: k/max_rows, ground segment, time tracker_rotation = np.atleast_1d(tracker_rotation)[np.newaxis, np.newaxis, :] @@ -347,7 +363,10 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, # crossed string formula for VF vf_slats = 0.5 * (1/((g1 - g0) * pitch)) * ((ac + bd) - (bc + ad)) vf_total = np.sum(np.maximum(vf_slats, 0), axis=0) # sum along k dimension - + + # dimensions are now ground_segment, row_segment, time + vf_total = vf_total.squeeze(axis=tuple(squeeze)) + return vf_total @@ -495,6 +514,7 @@ def vf_row_ground_2d(surface_tilt, gcr, x): return 0.5 * (1 - (1/gcr * cosd(surface_tilt) + x)/p) +def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, x0=0, x1=1, g0=0, g1=1, max_rows=20): r''' Calculate the average view factor to the ground from a segment of the row @@ -518,6 +538,7 @@ def vf_row_ground_2d(surface_tilt, gcr, x): pitch : float, optional Distance between two rows; must be in the same units as ``height``. Required if ``g0`` or ``x0` is not zero or if ``g1`` or ``x1`` + is not one. x0 : numeric, default 0. Position on the row's slant length, as a fraction of the slant length. x0=0 corresponds to the bottom of the row. ``x0`` should be less than @@ -548,6 +569,7 @@ def vf_row_ground_2d(surface_tilt, gcr, x): # that they can be optional parameters height = 1 pitch = 1 / gcr + # keep track of scalar inputs so that we can have output match at the end squeeze = [] if np.isscalar(g0) and np.isscalar(g1): diff --git a/tests/bifacial/test_infinite_sheds.py b/tests/bifacial/test_infinite_sheds.py index 5459b9d575..d91ef322e0 100644 --- a/tests/bifacial/test_infinite_sheds.py +++ b/tests/bifacial/test_infinite_sheds.py @@ -9,6 +9,8 @@ import pytest +from pvlib._deprecation import pvlibDeprecationWarning + @pytest.fixture def test_system(): @@ -94,10 +96,12 @@ def test_get_irradiance_poa(): albedo = 0 iam = 1.0 npoints = 100 - res = infinite_sheds.get_irradiance_poa( - surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, - gcr, height, pitch, ghi, dhi, dni, - albedo, iam=iam, npoints=npoints) + match = "`npoints` and `vectorize` parameters have no effect" + with pytest.warns(pvlibDeprecationWarning, match=match): + res = infinite_sheds.get_irradiance_poa( + surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, + gcr, height, pitch, ghi, dhi, dni, + albedo, iam=iam, npoints=npoints) expected_diffuse = np.array([300.]) expected_direct = np.array([700.]) expected_global = expected_diffuse + expected_direct @@ -120,10 +124,12 @@ def test_get_irradiance_poa(): expected_global = expected_diffuse + expected_direct expected_shaded_fraction = np.array( [0., 0., 0., 0.]) - res = infinite_sheds.get_irradiance_poa( - surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, - gcr, height, pitch, ghi, dhi, dni, - albedo, iam=iam, npoints=npoints) + match = "`npoints` and `vectorize` parameters have no effect" + with pytest.warns(pvlibDeprecationWarning, match=match): + res = infinite_sheds.get_irradiance_poa( + surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, + gcr, height, pitch, ghi, dhi, dni, + albedo, iam=iam, npoints=npoints) assert np.allclose(res['poa_global'], expected_global) assert np.allclose(res['poa_diffuse'], expected_diffuse) assert np.allclose(res['poa_direct'], expected_direct) @@ -143,10 +149,12 @@ def test_get_irradiance_poa(): expected_shaded_fraction = pd.Series( data=expected_shaded_fraction, index=surface_tilt.index) expected_shaded_fraction.name = 'shaded_fraction' # to match output Series - res = infinite_sheds.get_irradiance_poa( - surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, - gcr, height, pitch, ghi, dhi, dni, - albedo, iam=iam, npoints=npoints) + match = "`npoints` and `vectorize` parameters have no effect" + with pytest.warns(pvlibDeprecationWarning, match=match): + res = infinite_sheds.get_irradiance_poa( + surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, + gcr, height, pitch, ghi, dhi, dni, + albedo, iam=iam, npoints=npoints) assert isinstance(res, pd.DataFrame) assert_series_equal(res['poa_global'], expected_global) assert_series_equal(res['shaded_fraction'], expected_shaded_fraction) @@ -180,11 +188,13 @@ def test_get_irradiance(vectorize): iam_front = 1.0 iam_back = 1.0 npoints = 100 - result = infinite_sheds.get_irradiance( - surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, - gcr, height, pitch, ghi, dhi, dni, albedo, iam_front, iam_back, - bifaciality=0.8, shade_factor=-0.02, transmission_factor=0, - npoints=npoints, vectorize=vectorize) + match = "`npoints` and `vectorize` parameters have no effect" + with pytest.warns(pvlibDeprecationWarning, match=match): + result = infinite_sheds.get_irradiance( + surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, + gcr, height, pitch, ghi, dhi, dni, albedo, iam_front, iam_back, + bifaciality=0.8, shade_factor=-0.02, transmission_factor=0, + npoints=npoints, vectorize=vectorize) expected_front_diffuse = np.array([300.]) expected_front_direct = np.array([700.]) expected_front_global = expected_front_diffuse + expected_front_direct @@ -204,15 +214,17 @@ def test_get_irradiance(vectorize): dni = pd.Series([700., 0., 0., 700.], index=ghi.index) solar_zenith = pd.Series([0., 0., 0., 135.], index=ghi.index) surface_tilt = pd.Series([0., 0., 90., 0.], index=ghi.index) - result = infinite_sheds.get_irradiance( - surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, - gcr, height, pitch, ghi, dhi, dni, albedo, iam_front, iam_back, - bifaciality=0.8, shade_factor=-0.02, transmission_factor=0, - npoints=npoints, vectorize=vectorize) - result_front = infinite_sheds.get_irradiance_poa( - surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, - gcr, height, pitch, ghi, dhi, dni, - albedo, iam=iam_front, vectorize=vectorize) + match = "`npoints` and `vectorize` parameters have no effect" + with pytest.warns(pvlibDeprecationWarning, match=match): + result = infinite_sheds.get_irradiance( + surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, + gcr, height, pitch, ghi, dhi, dni, albedo, iam_front, iam_back, + bifaciality=0.8, shade_factor=-0.02, transmission_factor=0, + npoints=npoints, vectorize=vectorize) + result_front = infinite_sheds.get_irradiance_poa( + surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, + gcr, height, pitch, ghi, dhi, dni, + albedo, iam=iam_front, vectorize=vectorize) assert isinstance(result, pd.DataFrame) expected_poa_global = pd.Series( [1000., 500., result_front['poa_global'][2] * (1 + 0.8 * 0.98), @@ -234,7 +246,7 @@ def test_get_irradiance_limiting_gcr(): surface_azimuth = 180. gcr = 0.00001 height = 1. - pitch = 100. + pitch = 1 / gcr ghi = 1000. dhi = 300. dni = 700. @@ -242,11 +254,13 @@ def test_get_irradiance_limiting_gcr(): iam_front = 1.0 iam_back = 1.0 npoints = 100 - result = infinite_sheds.get_irradiance( - surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, - gcr, height, pitch, ghi, dhi, dni, albedo, iam_front, iam_back, - bifaciality=1., shade_factor=-0.00, transmission_factor=0., - npoints=npoints) + match = "`npoints` and `vectorize` parameters have no effect" + with pytest.warns(pvlibDeprecationWarning, match=match): + result = infinite_sheds.get_irradiance( + surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, + gcr, height, pitch, ghi, dhi, dni, albedo, iam_front, iam_back, + bifaciality=1., shade_factor=-0.00, transmission_factor=0., + npoints=npoints) expected_ground_diffuse = np.array([500.]) expected_sky_diffuse = np.array([150.]) expected_direct = np.array([0.]) @@ -292,11 +306,13 @@ def test_get_irradiance_with_haydavies(): iam_front = 1.0 iam_back = 1.0 npoints = 100 - result = infinite_sheds.get_irradiance( - surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, - gcr, height, pitch, ghi, dhi, dni, albedo, model, dni_extra, - iam_front, iam_back, bifaciality=0.8, shade_factor=-0.02, - transmission_factor=0, npoints=npoints) + match = "`npoints` and `vectorize` parameters have no effect" + with pytest.warns(pvlibDeprecationWarning, match=match): + result = infinite_sheds.get_irradiance( + surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, + gcr, height, pitch, ghi, dhi, dni, albedo, model, dni_extra, + iam_front, iam_back, bifaciality=0.8, shade_factor=-0.02, + transmission_factor=0, npoints=npoints) expected_front_diffuse = np.array([151.38]) expected_front_direct = np.array([848.62]) expected_front_global = expected_front_diffuse + expected_front_direct diff --git a/tests/bifacial/test_utils.py b/tests/bifacial/test_utils.py index 864e115362..e02036883f 100644 --- a/tests/bifacial/test_utils.py +++ b/tests/bifacial/test_utils.py @@ -8,6 +8,8 @@ from pvlib.tools import cosd from scipy.integrate import trapezoid +from pvlib._deprecation import pvlibDeprecationWarning + @pytest.fixture def test_system_fixed_tilt(): @@ -53,29 +55,25 @@ def test__solar_projection_tangent(): @pytest.mark.parametrize( - "gcr,surface_tilt,surface_azimuth,solar_zenith,solar_azimuth,expected", - [(0.5, 0., 180., 0., 180., 0.5), - (1.0, 0., 180., 0., 180., 0.0), - (1.0, 90., 180., 0., 180., 1.0), - (0.5, 45., 180., 45., 270., 1.0 - np.sqrt(2) / 4), - (0.5, 45., 180., 90., 180., 0.), - (np.sqrt(2) / 2, 45, 180, 0, 180, 0.5), - (np.sqrt(2) / 2, 45, 180, 45, 180, 0.0), - (np.sqrt(2) / 2, 45, 180, 45, 90, 0.5), - (np.sqrt(2) / 2, 45, 180, 45, 0, 1.0), - (np.sqrt(2) / 2, 45, 180, 45, 135, 0.5 * (1 - np.sqrt(2) / 2)), + "gcr,surface_tilt,phi,expected", + [(0.5, 0., 0., 0.5), + (1.0, 0., 0., 0.0), + (1.0, 90., 0., 1.0), + (0.5, 45., 0., 1.0 - np.sqrt(2) / 4), + (0.5, 45., 90., 0.), + (np.sqrt(2) / 2, 45, 0, 0.5), + (np.sqrt(2) / 2, 45, 45, 0.0), + (np.sqrt(2) / 2, 45, 0, 0.5), + (np.sqrt(2) / 2, 45, -45, 1.0), + (np.sqrt(2) / 2, 45, 35.264389682754654, 0.5 * (1 - np.sqrt(2) / 2)), ]) -def test__unshaded_ground_fraction( - surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, gcr, - expected): +def test__unshaded_ground_fraction(surface_tilt, phi, gcr, expected): # frontside, same for both sides - f_sky_beam_f = utils._unshaded_ground_fraction( - surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, gcr) + f_sky_beam_f = utils._unshaded_ground_fraction(surface_tilt, phi, gcr) assert np.allclose(f_sky_beam_f, expected) # backside, should be the same as frontside - f_sky_beam_b = utils._unshaded_ground_fraction( - 180. - surface_tilt, surface_azimuth - 180., solar_zenith, - solar_azimuth, gcr) + f_sky_beam_b = utils._unshaded_ground_fraction(surface_tilt - 180., phi, + gcr) assert np.allclose(f_sky_beam_b, expected) @@ -110,9 +108,11 @@ def test_vf_ground_sky_2d_integ(test_system_fixed_tilt, vectorize): # pass rotation here since max_rows=1 for the hand-solved case in # the fixture test_system, which means the ground-to-sky view factor # isn't summed over enough rows for symmetry to hold. - vf_integ = utils.vf_ground_sky_2d_integ( - ts['rotation'], ts['gcr'], ts['height'], ts['pitch'], - max_rows=1, npoints=3, vectorize=vectorize) + match = '`npoints` and `vectorize` parameters have no effect' + with pytest.warns(pvlibDeprecationWarning, match=match): + vf_integ = utils.vf_ground_sky_2d_integ( + ts['rotation'], ts['gcr'], ts['height'], ts['pitch'], + max_rows=1, npoints=3, vectorize=vectorize) expected_vf_integ = trapezoid(vfs_gnd_sky, pts, axis=0) assert np.isclose(vf_integ, expected_vf_integ, rtol=0.1) @@ -187,7 +187,7 @@ def test_vf_row_ground_2d(test_system_fixed_tilt): assert np.allclose(vf, expected) -def test_vf_ground_2d_integ(test_system_fixed_tilt): +def test_vf_row_ground_2d_integ(test_system_fixed_tilt): ts, _, _ = test_system_fixed_tilt # with float input, check end position with np.errstate(invalid='ignore'): @@ -222,7 +222,7 @@ def test_vf_ground_2d_integ(test_system_fixed_tilt): assert np.allclose(vf, y1, rtol=1e-2) -def test_vf_row_ground_2d_integ(): +def test_vf_row_ground_2d_integ_upsidedown(): # horizontal, rear-side inputs = {'surface_tilt': np.array([-180]), 'gcr': 0.6, 'height': 1.5, 'pitch': 3.5, @@ -232,3 +232,6 @@ def test_vf_row_ground_2d_integ(): 'max_rows': 7} vf_row_ground = utils.vf_row_ground_2d_integ(**inputs) assert all(vf_row_ground > 1e-3) + inputs['surface_tilt'] = +180 + vf_row_ground = utils.vf_row_ground_2d_integ(**inputs) + assert all(vf_row_ground > 1e-3) From 79274220cc1563c64e96e23257c897c3cb649335 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 13 Oct 2025 16:17:41 -0400 Subject: [PATCH 26/78] polishing --- pvlib/bifacial/ants2d.py | 85 ++++++++++++++++-------------------- pvlib/bifacial/utils.py | 66 ++++++++++++---------------- tests/bifacial/test_utils.py | 2 +- 3 files changed, 66 insertions(+), 87 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 4052babe45..7d993e7009 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -16,31 +16,26 @@ def _shaded_fraction(tracker_rotation, phi, gcr, x0=0, x1=1): Calculate fraction (from the bottom) of row slant height that is shaded from direct irradiance by the row in front toward the sun. - See [1], Eq. 14 and also [2], Eq. 32. - - .. math:: - F_x = \\max \\left( 0, \\min \\left(\\frac{\\text{GCR} \\cos \\theta - + \\left( \\text{GCR} \\sin \\theta - \\tan \\beta_{c} \\right) - \\tan Z - 1} - {\\text{GCR} \\left( \\cos \\theta + \\sin \\theta \\tan Z \\right)}, - 1 \\right) \\right) - Parameters - ---------- TODO fix - solar_zenith : numeric - Apparent (refraction-corrected) solar zenith. [degrees] - solar_azimuth : numeric - Solar azimuth. [degrees] - surface_tilt : numeric - Row tilt from horizontal, e.g. surface facing up = 0, surface facing - horizon = 90. [degrees] - surface_azimuth : numeric - Azimuth angle of the row surface. North=0, East=90, South=180, - West=270. [degrees] + ---------- + tracker_rotation : numeric + Tracker rotation angle as a right-handed rotation around + the axis defined by ``axis_tilt`` and ``axis_azimuth``. For example, + with ``axis_tilt=0`` and ``axis_azimuth=180``, ``tracker_theta > 0`` + results in ``surface_azimuth`` to the West while ``tracker_theta < 0`` + results in ``surface_azimuth`` to the East. [degree] + phi : numeric + Projected solar zenith angle. [degrees] gcr : numeric Ground coverage ratio, which is the ratio of row slant length to row spacing (pitch). [unitless] - x0, x1 : TODO + x0 : numeric, default 0. + Position on the row's slant length, as a fraction of the slant length. + ``x0=0`` corresponds to the bottom of the row. ``x0`` should be less + than ``x1``. [unitless] + x1 : numeric, default 1. + Position on the row's slant length, as a fraction of the slant length. + ``x1`` should be greater than ``x0``. [unitless] Returns ------- @@ -50,11 +45,7 @@ def _shaded_fraction(tracker_rotation, phi, gcr, x0=0, x1=1): References ---------- - .. [1] Mikofski, M., Darawali, R., Hamer, M., Neubert, A., and Newmiller, - J. "Bifacial Performance Modeling in Large Arrays". 2019 IEEE 46th - Photovoltaic Specialists Conference (PVSC), 2019, pp. 1282-1287. - :doi:`10.1109/PVSC40753.2019.8980572`. - .. [2] Kevin Anderson and Mark Mikofski, "Slope-Aware Backtracking for + .. [1] Kevin Anderson and Mark Mikofski, "Slope-Aware Backtracking for Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020. https://www.nrel.gov/docs/fy20osti/76626.pdf """ @@ -112,7 +103,7 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, :py:func:`pvlib.shading.projected_solar_zenith_angle`. [degree] vf_gnd_sky : numeric View factors from the ground surface to the sky. Dimensions are - TODO,TODO,TODO. [unitless] + (ground segment, row segment, timestamp). [unitless] gcr : float Ground coverage ratio, ratio of row slant length to row spacing. [unitless] @@ -128,23 +119,29 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, dni : numeric Direct normal irradiance. [Wm⁻²] albedo : numeric - Surface albedo. TODO shape [unitless] + Surface albedo. If a scalar, it is applied to all ground segments and + timestamps. Otherwise, must be specified as an array with shape + (n_ground_segments, n_timestamps). [unitless] x0 : numeric, default 0 Position on the row's slant length, as a fraction of the slant length. ``x0=0`` corresponds to the left side of the row. - ``x0`` should be less than ``x1``. [unitless] + ``x0`` should be less than ``x1``. If specified as array, it + must have the same length as ``x1``. [unitless] x1 : numeric, default 1 Position on the row's slant length, as a fraction of the slant length. ``x1=1`` corresponds to the right side of the row. - ``x1`` should be greater than ``x0``. [unitless] + ``x1`` should be greater than ``x0``. If specified as array, it + must have the same length as ``x0``.[unitless] g0 : numeric Position on the ground surface, as a fraction of the row-to-row spacing. ``g0=0`` corresponds to ground underneath the middle of the - left row. ``g0`` should be less than ``g1``. [unitless] + left row. ``g0`` should be less than ``g1``. If specified as array, it + must have the same length as ``g1``.[unitless] g1 : numeric Position on the ground surface, as a fraction of the row-to-row spacing. ``g1=1`` corresponds to ground underneath the middle of the - right row. ``g1`` should be greater than ``g0``. [unitless] + right row. ``g1`` should be greater than ``g0``. If specified as array, it + must have the same length as ``g0``.[unitless] max_rows : int Number of array units (sky wedges, ground segments, etc) to consider. [unitless] @@ -167,10 +164,6 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, Each array has shape (len(x0), len(tracker_rotation)). - Notes - ----- - Input parameters ``height`` and ``pitch`` must have the same unit. - References ---------- .. [1] TODO @@ -336,9 +329,9 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, Axis azimuth angle in degrees. North = 0°; East = 90°; South = 180°; West = 270° solar_zenith : numeric - Refraction-corrected solar zenith. [degree] + Refraction-corrected solar zenith angle. [degree] solar_azimuth : numeric - Solar azimuth. [degree] + Solar azimuth angle. [degree] gcr : float Ground coverage ratio, ratio of row slant length to row spacing. [unitless] @@ -354,7 +347,9 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, dni : numeric Direct normal irradiance. [Wm⁻²] albedo : numeric - Surface albedo. TODO shape [unitless] + Surface albedo. If a scalar, it is applied to all ground segments and + timestamps. Otherwise, must be specified as an array with shape + (``n_ground_segments``, ``len(tracker_rotation)``). [unitless] model : str, default 'perez' Irradiance model - can be one of 'isotropic', 'haydavies', or 'perez'. dni_extra : numeric, optional @@ -382,8 +377,8 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, ``cross_axis_slope``. [degrees] max_rows : int, optional Number of array units (sky wedges, ground segments, etc) to consider. - If not specified, units out to within 4 degrees of the horizon will - be considered. [unitless] + If not specified, units will be considered to within 4 degrees of the + horizon. [unitless] Returns ------- @@ -391,9 +386,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, ``output`` is a DataFrame when input ``tracker_rotation`` is a Series and ``n_row_segments=1``, a dict of scalars when ``tracker_rotation`` is a scalar and ``n_row_segments=1``, and a dict of ``np.ndarray`` - otherwise. - - ``output`` includes the following quantities: + otherwise. The following quantities are included: - ``poa_global``: sum of front- and back-side incident irradiance. [Wm⁻²] @@ -422,10 +415,6 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, shaded from direct irradiance on the back surface by adjacent rows. [unitless] - Notes - ----- - Input parameters ``height`` and ``pitch`` must have the same unit. - References ---------- .. [1] TODO diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 00ba17a143..1eb2e7818c 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -38,33 +38,20 @@ def _solar_projection_tangent(solar_zenith, solar_azimuth, surface_azimuth): return tan_phi -def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch=None, - height=None, g0=0, g1=1, max_rows=10, +def _unshaded_ground_fraction(surface_tilt, phi, gcr, height=None, + pitch=None, g0=0, g1=1, max_rows=10, max_zenith=85): r""" Calculate the fraction of the ground with incident direct irradiance. - .. math:: - F_{gnd,sky} = 1 - \min{\left(1, \text{GCR} \left|\cos \beta + - \sin \beta \tan \phi \right|\right)} - - where :math:`\beta` is the surface tilt and :math:`\phi` is the angle - from vertical of the sun vector projected to a vertical plane that - contains the row azimuth `surface_azimuth`. - - Parameters # TODO fix + Parameters ---------- surface_tilt : numeric Surface tilt angle. The tilt angle is defined as degrees from horizontal, e.g., surface facing up = 0, surface facing horizon = 90. [degree] - surface_azimuth : numeric - Azimuth of the module surface, i.e., North=0, East=90, South=180, - West=270. [degree] - solar_zenith : numeric - Solar zenith angle. [degree]. - solar_azimuth : numeric - Solar azimuth. [degree]. + phi : numeric + Projected solar zenith angle. [degree]. gcr : float Ground coverage ratio, which is the ratio of row slant length to row spacing (pitch). [unitless] @@ -75,15 +62,17 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch=None, pitch : float, optional Distance between two rows; must be in the same units as ``height``. Required if ``g0`` is not zero or ``g1`` is not one. - g0 : numeric + g0 : numeric, default 0 Position on the ground surface, as a fraction of the row-to-row spacing. ``g0=0`` corresponds to ground underneath the middle of the left row. ``g0`` should be less than ``g1``. [unitless] - g1 : numeric + g1 : numeric, default 1 Position on the ground surface, as a fraction of the row-to-row spacing. ``g1=1`` corresponds to ground underneath the middle of the right row. ``g1`` should be greater than ``g0``. [unitless] - max_rows : TODO + max_rows : int, default 10 + Maximum number of rows to consider on either side of the current + row. [unitless] max_zenith : numeric, default 85 Maximum zenith angle. For solar_zenith > max_zenith, unshaded ground fraction is set to 0. [degree] @@ -111,12 +100,12 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch=None, squeeze = [] if np.isscalar(g0) and np.isscalar(g1): squeeze.append(0) - if np.isscalar(tracker_rotation): + if np.isscalar(surface_tilt): squeeze.append(1) # dimensions: k/max_rows, ground segment, time - tracker_rotation = np.atleast_1d(tracker_rotation)[np.newaxis, np.newaxis, :] + surface_tilt = np.atleast_1d(surface_tilt)[np.newaxis, np.newaxis, :] phi = np.atleast_1d(phi)[np.newaxis, np.newaxis, :] g0 = np.atleast_1d(g0)[np.newaxis, :, np.newaxis] @@ -127,8 +116,8 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, pitch=None, k = np.arange(-max_rows, max_rows)[:, np.newaxis, np.newaxis] collector_width = pitch * gcr - Lcostheta = collector_width * cosd(tracker_rotation) - Lsintheta = collector_width * sind(tracker_rotation) + Lcostheta = collector_width * cosd(surface_tilt) + Lsintheta = collector_width * sind(surface_tilt) tanphi = tand(phi) # a, b: boundaries of ground segment @@ -186,11 +175,11 @@ def vf_ground_sky_2d(rotation, gcr, x, pitch, height, max_rows=10): Position on the ground between two rows, as a fraction of the pitch. x = 0 corresponds to the point on the ground directly below the center point of a row. Positive x is towards the right. [unitless] + pitch : float + Distance between two rows; must be in the same units as ``height``. height : float Height of the center point of the row above the ground; must be in the same units as ``pitch``. - pitch : float - Distance between two rows; must be in the same units as ``height``. max_rows : int, default 10 Maximum number of rows to consider on either side of the current row. [unitless] @@ -255,7 +244,7 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, Integrated view factor to the sky from the ground underneath interior rows of the array. - Parameters TODO Fix + Parameters ---------- surface_tilt : numeric Surface tilt angle in degrees from horizontal, e.g., surface facing up @@ -267,11 +256,11 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, same units as ``pitch``. pitch : float Distance between two rows. Must be in the same units as ``height``. - g0 : numeric + g0 : numeric, default 0 Position on the ground surface, as a fraction of the row-to-row spacing. ``g0=0`` corresponds to ground underneath the middle of the left row. ``g0`` should be less than ``g1``. [unitless] - g1 : numeric + g1 : numeric, default 1 Position on the ground surface, as a fraction of the row-to-row spacing. ``g1=1`` corresponds to ground underneath the middle of the right row. ``g1`` should be greater than ``g0``. [unitless] @@ -443,11 +432,11 @@ def vf_row_sky_2d_integ(surface_tilt, gcr, x0=0, x1=1): Ratio of the row slant length to the row spacing (pitch). [unitless] x0 : numeric, default 0 Position on the row's slant length, as a fraction of the slant length. - x0=0 corresponds to the bottom of the row. x0 should be less than x1. - [unitless] + ``x0=0`` corresponds to the bottom of the row. ``x0`` should be less than + ``x1``. [unitless] x1 : numeric, default 1 Position on the row's slant length, as a fraction of the slant length. - x1 should be greater than x0. [unitless] + ``x1`` should be greater than ``x0``. [unitless] Returns ------- @@ -541,20 +530,21 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, is not one. x0 : numeric, default 0. Position on the row's slant length, as a fraction of the slant length. - x0=0 corresponds to the bottom of the row. ``x0`` should be less than - ``x1``. [unitless] + ``x0=0`` corresponds to the bottom of the row. ``x0`` should be less + than ``x1``. [unitless] x1 : numeric, default 1. Position on the row's slant length, as a fraction of the slant length. ``x1`` should be greater than ``x0``. [unitless] - g0 : numeric + g0 : numeric, default 0 Position on the ground surface, as a fraction of the row-to-row spacing. ``g0=0`` corresponds to ground underneath the middle of the left row. ``g0`` should be less than ``g1``. [unitless] - g1 : numeric + g1 : numeric, default 1 Position on the ground surface, as a fraction of the row-to-row spacing. ``g1=0`` corresponds to ground underneath the middle of the right row. ``g1`` should be greater than ``g0``. [unitless] - max_rows : TODO + max_rows : int, default 20 + Maximum number of rows to consider in front and behind the current row. Returns ------- diff --git a/tests/bifacial/test_utils.py b/tests/bifacial/test_utils.py index e02036883f..c0a6830167 100644 --- a/tests/bifacial/test_utils.py +++ b/tests/bifacial/test_utils.py @@ -90,7 +90,7 @@ def test__unshaded_ground_fraction_horizontal(): np.testing.assert_allclose(neg, zero, atol=0.01) -def test__vf_ground_sky_2d(test_system_fixed_tilt): +def test_vf_ground_sky_2d(test_system_fixed_tilt): # vector input ts, pts, vfs_gnd_sky = test_system_fixed_tilt vfs = utils.vf_ground_sky_2d(ts['rotation'], ts['gcr'], pts, From 52d4fc2fae06a0961819aa88189ae5e2a8a93aaf Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 1 Dec 2025 15:35:20 -0500 Subject: [PATCH 27/78] fix output when surface angle is scalar but irrad is array --- pvlib/bifacial/ants2d.py | 11 ++++++++++- tests/bifacial/test_ants2d.py | 10 ++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 7d993e7009..29c40faaed 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -420,6 +420,15 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, .. [1] TODO """ + # so we can return scalars out if needed + maybe_array_inputs = [ + tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, + gcr, height, pitch, ghi, dhi, dni, + albedo, dni_extra, airmass, axis_tilt, cross_axis_slope] + all_scalar_inputs = all([ + np.isscalar(x) or x is None for x in maybe_array_inputs + ]) + # preparation steps dni, dhi = _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, @@ -514,7 +523,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, for k, v in poa_front.items(): poa_front[k] = v[0] # drop row segment dimension - if np.isscalar(true_tracker_rotation): + if all_scalar_inputs: # drop the second dimension too, so scalars are returned for k, v in poa_front.items(): poa_front[k] = float(v[0]) diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 937146a091..36b38f6fcb 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -476,3 +476,13 @@ def test_get_irradiance_regression(model, expected, ants_params_fixed): out = ants2d.get_irradiance(**ants_params_fixed, model=model) for key in expected: np.testing.assert_allclose(out[key], expected[key], atol=1e-10) + + +def test_scalar_surface_angles(ants_params): + # scalar surface angles but array irradiance inputs + ants_params['tracker_rotation'] = 30 + ants_params['axis_azimuth'] = 90 + out = ants2d.get_irradiance(**ants_params) + for key in out: + assert isinstance(out[key], np.ndarray) + assert out[key].shape == (2,) From 39663ea9486bc4520861fd5b28a518fc02bf886d Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 1 Dec 2025 17:03:35 -0500 Subject: [PATCH 28/78] simplify numpy import --- tests/bifacial/test_ants2d.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 36b38f6fcb..7b0367589d 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -8,6 +8,7 @@ from pvlib.bifacial import ants2d import pytest +from numpy.testing import assert_allclose def test__shaded_fraction(): @@ -18,9 +19,9 @@ def test__shaded_fraction(): gcr = np.array([1, 0.75, 2/3, 0.5]) expected = np.array([0.5, 1/3, 0.25, 0]) fs = ants2d._shaded_fraction(tracker_rotation, phi, gcr) - np.testing.assert_allclose(fs, expected) + assert_allclose(fs, expected) fs = ants2d._shaded_fraction(-tracker_rotation, -phi, gcr) - np.testing.assert_allclose(fs, expected) + assert_allclose(fs, expected) # sun too high for shade assert 0 == ants2d._shaded_fraction(10, 20, 0.5) @@ -38,13 +39,13 @@ def test__shaded_fraction(): # (some of these are debatable as well) expected = np.array([0, 0, 0, 0, 1, 1, 0, 1, 1]) fs = ants2d._shaded_fraction(tracker_rotation, phi, gcr) - np.testing.assert_allclose(fs, expected) + assert_allclose(fs, expected) def test__shaded_fraction_x0x1(): fs = ants2d._shaded_fraction(np.array([60, -60]), np.array([60, -60]), 2/3, x0=[0, 0.5], x1=[0.5, 1]) - np.testing.assert_allclose(fs, np.array([[0.5, 0.0], [0.0, 0.5]])) + assert_allclose(fs, np.array([[0.5, 0.0], [0.0, 0.5]])) @pytest.mark.parametrize('model', ['perez', 'haydavies']) @@ -341,8 +342,8 @@ def test_get_irradiance_horizontal(ants_params_fixed): neg_epsilon = ants2d.get_irradiance(**ants_params_fixed) for key in zero: - np.testing.assert_allclose(zero[key], pos_epsilon[key], atol=0.01) - np.testing.assert_allclose(zero[key], neg_epsilon[key], atol=0.01) + assert_allclose(zero[key], pos_epsilon[key], atol=0.01) + assert_allclose(zero[key], neg_epsilon[key], atol=0.01) def test_get_irradiance_direct_shading(ants_params_fixed): @@ -405,8 +406,8 @@ def test_get_irradiance_nonuniform_albedo(): # need a large n_row_segments so that these segments are very thin left, right = out['poa_back_ground_diffuse'][[0, -1], 0] # divide by two because ~half the visible ground is fully shaded - np.testing.assert_allclose(left, 0.1 * 1000 / 2, rtol=0.002) - np.testing.assert_allclose(right, 0.5 * 1000 / 2, rtol=0.002) + assert_allclose(left, 0.1 * 1000 / 2, rtol=0.002) + assert_allclose(right, 0.5 * 1000 / 2, rtol=0.002) def test_get_irradiance_nonuniform_albedo_limit(): @@ -423,13 +424,13 @@ def test_get_irradiance_nonuniform_albedo_limit(): 'n_ground_segments': 2, 'max_rows': 10000, 'model': 'isotropic', - } + } out_uni = ants2d.get_irradiance(albedo=0.3, **base_inputs) out_non = ants2d.get_irradiance(albedo=np.array([[0.5, 0.1]]).T, **base_inputs) for key in out_non: - np.testing.assert_allclose(out_non[key], out_uni[key], atol=1e-6) + assert_allclose(out_non[key], out_uni[key], atol=1e-6) @pytest.mark.parametrize('model,expected', [ @@ -475,11 +476,11 @@ def test_get_irradiance_regression(model, expected, ants_params_fixed): # is stable over time out = ants2d.get_irradiance(**ants_params_fixed, model=model) for key in expected: - np.testing.assert_allclose(out[key], expected[key], atol=1e-10) + assert_allclose(out[key], expected[key], atol=1e-10) def test_scalar_surface_angles(ants_params): - # scalar surface angles but array irradiance inputs + # scalar surface angles but Series irradiance inputs ants_params['tracker_rotation'] = 30 ants_params['axis_azimuth'] = 90 out = ants2d.get_irradiance(**ants_params) From 5a650e4c1f4ae588acaef1d7807363a63569530f Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 1 Dec 2025 17:04:06 -0500 Subject: [PATCH 29/78] add option to return irradiance incident on ground surface --- pvlib/bifacial/ants2d.py | 126 +++++++++++++++++++++++----------- tests/bifacial/test_ants2d.py | 84 ++++++++++++++++++++++- 2 files changed, 167 insertions(+), 43 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 29c40faaed..1bbb6267e5 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -80,9 +80,9 @@ def _shaded_fraction(tracker_rotation, phi, gcr, x0=0, x1=1): return f_s -def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, - gcr, height, pitch, ghi, dhi, dni, - albedo, x0, x1, g0, g1, max_rows): +def _ants2d_singleside(tracker_rotation, cos_aoi, phi, gcr, height, pitch, + dni, dhi, ground_irradiance, albedo, x0, x1, g0, g1, + max_rows): r""" Calculate plane-of-array irradiance components on one side of a row of modules. @@ -101,9 +101,6 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, phi : numeric Project solar zenith angle; calculate with :py:func:`pvlib.shading.projected_solar_zenith_angle`. [degree] - vf_gnd_sky : numeric - View factors from the ground surface to the sky. Dimensions are - (ground segment, row segment, timestamp). [unitless] gcr : float Ground coverage ratio, ratio of row slant length to row spacing. [unitless] @@ -112,12 +109,13 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, same units as ``pitch``. pitch : float Distance between two rows; must be in the same units as ``height``. - ghi : numeric - Global horizontal irradiance. [Wm⁻²] - dhi : numeric - Diffuse horizontal irradiance. [Wm⁻²] dni : numeric Direct normal irradiance. [Wm⁻²] + dhi : numeric + Diffuse horizontal irradiance. [Wm⁻²] + ground_irradiance : numeric + Irradiance incident on the ground surface, partitioned according + to ``x0`` and ``x1``. Sum of direct and diffuse components. [Wm⁻²] albedo : numeric Surface albedo. If a scalar, it is applied to all ground segments and timestamps. Otherwise, must be specified as an array with shape @@ -184,22 +182,12 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, vf_gnd_sky, # in-plane ground-reflected component - ground_unshaded_fraction = utils._unshaded_ground_fraction( - tracker_rotation, phi, gcr, - pitch=pitch, height=height, g0=g0, g1=g1, max_rows=max_rows) - - ground_shaded_fraction = 1 - ground_unshaded_fraction - ground_shaded_fraction = ground_shaded_fraction[:, np.newaxis, :] - vf_row_ground = utils.vf_row_ground_2d_integ(surface_tilt=tracker_rotation, gcr=gcr, height=height, pitch=pitch, x0=x0, x1=x1, g0=g0, g1=g1, max_rows=max_rows) - poa_ground_diffuse = vf_row_ground * albedo * ( - (1-ground_shaded_fraction) * (ghi - dhi) # reflected beam - + vf_gnd_sky * dhi # reflected diffuse - ) + poa_ground_diffuse = vf_row_ground * albedo * ground_irradiance # sum over ground segments poa_ground_diffuse = np.sum(poa_ground_diffuse, axis=0) @@ -298,7 +286,8 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, gcr, height, pitch, ghi, dhi, dni, albedo, model='perez', dni_extra=None, airmass=None, n_row_segments=1, n_ground_segments=10, axis_tilt=0, - cross_axis_slope=0, max_rows=None): + cross_axis_slope=0, max_rows=None, + return_ground_components=False): """ Get front and rear irradiance using the ANTS-2D bifacial irradiance model. @@ -379,13 +368,16 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, Number of array units (sky wedges, ground segments, etc) to consider. If not specified, units will be considered to within 4 degrees of the horizon. [unitless] + return_ground_components : bool, default False + If True, also return the direct and diffuse irradiance incident on the + ground. These values are returned in a second dict. Returns ------- output : dict or DataFrame - ``output`` is a DataFrame when input ``tracker_rotation`` is a Series - and ``n_row_segments=1``, a dict of scalars when ``tracker_rotation`` - is a scalar and ``n_row_segments=1``, and a dict of ``np.ndarray`` + ``output`` is a DataFrame when inputs are Series + and ``n_row_segments=1``, a dict of scalars when inputs are scalars + and ``n_row_segments=1``, and a dict of ``np.ndarray`` otherwise. The following quantities are included: - ``poa_global``: sum of front- and back-side incident irradiance. @@ -415,6 +407,18 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, shaded from direct irradiance on the back surface by adjacent rows. [unitless] + ground_irradiance : dict or DataFrame + ``ground_irradiance`` is a DataFrame when inputs are Series + and ``n_ground_segments=1``, a dict of scalars when inputs are scalars + and ``n_ground_segments=1``, and a dict of ``np.ndarray`` + otherwise. Only returned when ``return_ground_components=True``. + The following quantities are included: + + - ``ground_direct``: direct irradiance incident on the ground surface. + [Wm⁻²] + - ``ground_diffuse``: diffuse irradiance incident on the ground + surface. [Wm⁻²] + References ---------- .. [1] TODO @@ -428,6 +432,12 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, all_scalar_inputs = all([ np.isscalar(x) or x is None for x in maybe_array_inputs ]) + try: + pd_index = next( + x.index for x in maybe_array_inputs if isinstance(x, pd.Series) + ) + except StopIteration: + pd_index = None # no pandas inputs # preparation steps @@ -475,15 +485,30 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, tracker_rotation, gcr, height, pitch, g0=g0, g1=g1, max_rows=max_rows) vf_gnd_sky = vf_gnd_sky[:, np.newaxis, :] + # irradiance components incident on ground surface + ground_unshaded_fraction = utils._unshaded_ground_fraction( + tracker_rotation, phi, gcr, + pitch=pitch, height=height, g0=g0, g1=g1, max_rows=max_rows) + + ground_shaded_fraction = 1 - ground_unshaded_fraction + ground_shaded_fraction = ground_shaded_fraction[:, np.newaxis, :] + + ground_direct = (1-ground_shaded_fraction) * (ghi - dhi) + ground_diffuse = vf_gnd_sky * dhi + ground_total = ground_direct + ground_diffuse + + # inputs shared between front and back calculations + params = dict(phi=phi, gcr=gcr, height=height, pitch=pitch, dni=dni, + dhi=dhi, ground_irradiance=ground_total, albedo=albedo, + x0=x0, x1=x1, g0=g0, g1=g1, max_rows=max_rows) + # front front_orientation = calc_surface_orientation(true_tracker_rotation, axis_tilt, axis_azimuth) cos_aoi_front = aoi_projection(**front_orientation, solar_zenith=solar_zenith, solar_azimuth=solar_azimuth) - poa_front = _ants2d_singleside(tracker_rotation, cos_aoi_front, phi, - vf_gnd_sky, gcr, height, pitch, ghi, dhi, - dni, albedo, x0, x1, g0, g1, max_rows) + poa_front = _ants2d_singleside(tracker_rotation, cos_aoi_front, **params) # back tracker_rotation_back = true_tracker_rotation + 180 @@ -495,9 +520,8 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, solar_azimuth=solar_azimuth) tracker_rotation_back = tracker_rotation + 180 tracker_rotation_back = ((tracker_rotation_back + 180) % 360) - 180 - poa_back = _ants2d_singleside(tracker_rotation_back, cos_aoi_back, phi, - vf_gnd_sky, gcr, height, pitch, ghi, dhi, - dni, albedo, x0, x1, g0, g1, max_rows) + poa_back = _ants2d_singleside(tracker_rotation_back, cos_aoi_back, + **params) for key, value in poa_back.items(): poa_back[key] = value[::-1, :] # invert x0/x1 dimension @@ -517,19 +541,39 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, poa_front[new_key] = poa_front.pop(old_key) for old_key, new_key in colmap_back.items(): poa_back[new_key] = poa_back.pop(old_key) - poa_front.update(poa_back) + out = {**poa_front, **poa_back} - if n_row_segments == 1: - for k, v in poa_front.items(): - poa_front[k] = v[0] # drop row segment dimension + if n_row_segments == 1 : + for k, v in out.items(): + out[k] = v[0] # drop row segment dimension if all_scalar_inputs: # drop the second dimension too, so scalars are returned - for k, v in poa_front.items(): - poa_front[k] = float(v[0]) + for k, v in out.items(): + out[k] = float(v[0]) - elif isinstance(true_tracker_rotation, pd.Series): - poa_front = pd.DataFrame(poa_front, - index=true_tracker_rotation.index) + elif pd_index is not None: + out = pd.DataFrame(out, index=pd_index) - return poa_front + if return_ground_components: + squeeze = [] + if n_ground_segments == 1: + squeeze.append(0) # drop ground segment dimension + squeeze.append(1) # always drop the row segment dimension + if all_scalar_inputs: + squeeze.append(2) # drop time dimension + squeeze = tuple(squeeze) + out_ground = { + 'ground_direct': ground_direct.squeeze(axis=squeeze), + 'ground_diffuse': ground_diffuse.squeeze(axis=squeeze), + } + if squeeze == (0, 1, 2): + for k, v in out_ground.items(): + out_ground[k] = float(v) + + if pd_index is not None and squeeze == (0, 1): + out_ground = pd.DataFrame(out_ground, index=pd_index) + + return out, out_ground + + return out diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 7b0367589d..908d802aac 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -485,5 +485,85 @@ def test_scalar_surface_angles(ants_params): ants_params['axis_azimuth'] = 90 out = ants2d.get_irradiance(**ants_params) for key in out: - assert isinstance(out[key], np.ndarray) - assert out[key].shape == (2,) + assert isinstance(out[key], pd.Series), key + assert len(out[key]) == 2, key + + # array inputs + ants_params = { + k: (v.values if isinstance(v, pd.Series) else v) + for k, v in ants_params.items() + } + out = ants2d.get_irradiance(**ants_params) + for key in out: + assert isinstance(out[key], np.ndarray), key + assert out[key].shape == (2,), key + + +@pytest.fixture +def ground_base_params(): + inputs = { + 'solar_zenith': 60, + 'solar_azimuth': 90, + 'gcr': 0.5, 'height': 2.5, 'pitch': 4, + 'ghi': 800, + 'dni': 1000, + 'dhi': 300, + 'albedo': 0.2, + 'n_ground_segments': 2, + 'model': 'isotropic', + } + return inputs + + +def test_ground_components(ground_base_params): + # test ground incident irradiance values + # sun is edge-on to modules; ground is fully illuminated + _, out = ants2d.get_irradiance(tracker_rotation=60, axis_azimuth=180, + **ground_base_params, + return_ground_components=True) + assert_allclose(out['ground_direct_irradiance'], 500) # dni * cos(zenith) + assert out['ground_diffuse_irradiance'] < 300 # some dhi blocked by rows + + # sun is normal to modules; ground is fully shaded + _, out = ants2d.get_irradiance(tracker_rotation=-60, axis_azimuth=180, + **ground_base_params, + return_ground_components=True) + assert_allclose(out['ground_direct_irradiance'], 0) + assert out['ground_diffuse_irradiance'] < 300 + + +def test_ground_components_types(ants_params, ants_params_fixed): + # test second return value type/shape when return_ground_components=True + + # scalar inputs, single ground segment + _, out = ants2d.get_irradiance(**ants_params_fixed, n_ground_segments=1, + return_ground_components=True) + assert isinstance(out, dict) + assert set(out.keys()) == {'ground_direct', 'ground_diffuse'} + for key, value in out.items(): + assert isinstance(value, float), key + + # scalar inputs, multiple ground segments + _, out = ants2d.get_irradiance(**ants_params_fixed, n_ground_segments=10, + return_ground_components=True) + assert isinstance(out, dict) + assert set(out.keys()) == {'ground_direct', 'ground_diffuse'} + for key, value in out.items(): + assert isinstance(value, np.ndarray), key + assert value.shape == (10,), key + + # series inputs, single ground segment + _, out = ants2d.get_irradiance(**ants_params, n_ground_segments=1, + return_ground_components=True) + assert isinstance(out, pd.DataFrame) + assert set(out.columns) == {'ground_direct', 'ground_diffuse'} + assert len(out) == 2 + + # series inputs, multiple ground segments + _, out = ants2d.get_irradiance(**ants_params, n_ground_segments=10, + return_ground_components=True) + assert isinstance(out, dict) + assert set(out.keys()) == {'ground_direct', 'ground_diffuse'} + for key, value in out.items(): + assert isinstance(value, np.ndarray), key + assert value.shape == (10, 2), key From 28480f71c71e0ae2ed79f07ae9d27e7262853173 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 1 Dec 2025 17:55:48 -0500 Subject: [PATCH 30/78] more tests for ground irradiance --- tests/bifacial/test_ants2d.py | 43 +++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 908d802aac..ffab1ab11a 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -499,8 +499,8 @@ def test_scalar_surface_angles(ants_params): assert out[key].shape == (2,), key -@pytest.fixture -def ground_base_params(): +def test_ground_components(): + # test ground incident irradiance values inputs = { 'solar_zenith': 60, 'solar_azimuth': 90, @@ -509,27 +509,42 @@ def ground_base_params(): 'dni': 1000, 'dhi': 300, 'albedo': 0.2, - 'n_ground_segments': 2, + 'n_ground_segments': 1, 'model': 'isotropic', } - return inputs - -def test_ground_components(ground_base_params): - # test ground incident irradiance values # sun is edge-on to modules; ground is fully illuminated - _, out = ants2d.get_irradiance(tracker_rotation=60, axis_azimuth=180, - **ground_base_params, + _, out = ants2d.get_irradiance(tracker_rotation=30, axis_azimuth=180, + **inputs, return_ground_components=True) - assert_allclose(out['ground_direct_irradiance'], 500) # dni * cos(zenith) - assert out['ground_diffuse_irradiance'] < 300 # some dhi blocked by rows + assert_allclose(out['ground_direct'], 500) # dni * cos(zenith) + assert out['ground_diffuse'] < 300 # some dhi blocked by rows # sun is normal to modules; ground is fully shaded _, out = ants2d.get_irradiance(tracker_rotation=-60, axis_azimuth=180, - **ground_base_params, + **inputs, + return_ground_components=True) + assert_allclose(out['ground_direct'], 0, atol=1e-10) + assert out['ground_diffuse'] < 300 + + # flat array + _, out = ants2d.get_irradiance(tracker_rotation=0, axis_azimuth=180, + **inputs, + return_ground_components=True, + max_rows=500) + assert_allclose(out['ground_direct'], 250) # gcr of 0.5 + assert_allclose(out['ground_diffuse'], 150, atol=1e-3) + + # flat array, four segments + inputs['n_ground_segments'] = 4 + inputs['solar_zenith'] = 0 + _, out = ants2d.get_irradiance(tracker_rotation=0, axis_azimuth=180, + **inputs, return_ground_components=True) - assert_allclose(out['ground_direct_irradiance'], 0) - assert out['ground_diffuse_irradiance'] < 300 + assert_allclose(out['ground_direct'], [0, 500, 500, 0]) + diffuse = out['ground_diffuse'] + assert_allclose(diffuse[0], diffuse[3], atol=0.1) + assert_allclose(diffuse[1], diffuse[2], atol=0.1) def test_ground_components_types(ants_params, ants_params_fixed): From 0fa4bc91269ceda3b0ac0f5e4001b2c4ce23c24e Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 1 Dec 2025 18:41:40 -0500 Subject: [PATCH 31/78] one more test --- tests/bifacial/test_ants2d.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index ffab1ab11a..83841443e0 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -513,21 +513,21 @@ def test_ground_components(): 'model': 'isotropic', } - # sun is edge-on to modules; ground is fully illuminated + # sun is edge-on to modules: ground is fully illuminated _, out = ants2d.get_irradiance(tracker_rotation=30, axis_azimuth=180, **inputs, return_ground_components=True) assert_allclose(out['ground_direct'], 500) # dni * cos(zenith) assert out['ground_diffuse'] < 300 # some dhi blocked by rows - # sun is normal to modules; ground is fully shaded + # sun is normal to modules: ground is fully shaded _, out = ants2d.get_irradiance(tracker_rotation=-60, axis_azimuth=180, **inputs, return_ground_components=True) assert_allclose(out['ground_direct'], 0, atol=1e-10) assert out['ground_diffuse'] < 300 - # flat array + # flat array: ground_direct -> dni * cos(zenith) * gcr _, out = ants2d.get_irradiance(tracker_rotation=0, axis_azimuth=180, **inputs, return_ground_components=True, @@ -535,7 +535,7 @@ def test_ground_components(): assert_allclose(out['ground_direct'], 250) # gcr of 0.5 assert_allclose(out['ground_diffuse'], 150, atol=1e-3) - # flat array, four segments + # flat array, four segments: perfect direct shading, diffuse symmetry inputs['n_ground_segments'] = 4 inputs['solar_zenith'] = 0 _, out = ants2d.get_irradiance(tracker_rotation=0, axis_azimuth=180, @@ -546,6 +546,15 @@ def test_ground_components(): assert_allclose(diffuse[0], diffuse[3], atol=0.1) assert_allclose(diffuse[1], diffuse[2], atol=0.1) + # flat array, many rows, very high: ground_diffuse -> dhi * gcr + inputs['height'] = 200 + inputs['n_ground_segments'] = 4 + _, out = ants2d.get_irradiance(tracker_rotation=0, axis_azimuth=180, + **inputs, + max_rows=5000, + return_ground_components=True) + assert_allclose(out['ground_diffuse'], 150, atol=0.01) + def test_ground_components_types(ants_params, ants_params_fixed): # test second return value type/shape when return_ground_components=True From 258d5a604bcb68ee72d37226e688a05fda8ae0d3 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 4 Dec 2025 09:07:44 -0500 Subject: [PATCH 32/78] use `assert_allclose`, not `assert np.isclose` --- tests/bifacial/test_ants2d.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 83841443e0..49cc6113f7 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -250,8 +250,10 @@ def test_get_irradiance_vertical(ants_params, solar_zenith, tracker_rotation): 'shaded_fraction_front'] for front_key in front_keys: back_key = front_key.replace("front", "back") - assert np.isclose(out.iloc[0][front_key], out.iloc[1][back_key]) - assert np.isclose(out.iloc[1][front_key], out.iloc[0][back_key]) + np.testing.assert_allclose(out.iloc[0][front_key], + out.iloc[1][back_key]) + np.testing.assert_allclose(out.iloc[1][front_key], + out.iloc[0][back_key]) # now with >1 row segment out = ants2d.get_irradiance(**ants_params, n_row_segments=2) @@ -261,14 +263,14 @@ def test_get_irradiance_vertical(ants_params, solar_zenith, tracker_rotation): afternoon = 1 for front_key in front_keys: back_key = front_key.replace("front", "back") - assert np.isclose(out[front_key][lower_half, morning], - out[back_key][lower_half, afternoon]) - assert np.isclose(out[front_key][upper_half, morning], - out[back_key][upper_half, afternoon]) - assert np.isclose(out[back_key][lower_half, morning], - out[front_key][lower_half, afternoon]) - assert np.isclose(out[back_key][upper_half, morning], - out[front_key][upper_half, afternoon]) + np.testing.assert_allclose(out[front_key][lower_half, morning], + out[back_key][lower_half, afternoon]) + np.testing.assert_allclose(out[front_key][upper_half, morning], + out[back_key][upper_half, afternoon]) + np.testing.assert_allclose(out[back_key][lower_half, morning], + out[front_key][lower_half, afternoon]) + np.testing.assert_allclose(out[back_key][upper_half, morning], + out[front_key][upper_half, afternoon]) def test_get_irradiance_limit(ants_params): @@ -363,11 +365,11 @@ def test_get_irradiance_multiple_row_segments(ants_params_fixed): for k in out4: # check two bottom quarters average to the bottom half, and top # two quarters average to the top half - assert np.isclose(np.mean(out4[k][0:2, 0]), out2[k][0, 0]) - assert np.isclose(np.mean(out4[k][2:4, 0]), out2[k][1, 0]) + np.testing.assert_allclose(np.mean(out4[k][0:2, 0]), out2[k][0, 0]) + np.testing.assert_allclose(np.mean(out4[k][2:4, 0]), out2[k][1, 0]) # check that two halves average to the whole - assert np.isclose(np.mean(out2[k][:, 0]), out1[k]) + np.testing.assert_allclose(np.mean(out2[k][:, 0]), out1[k]) def test_get_irradiance_slope(ants_params_fixed): From 015500d6d42dc305c85502baf96983f018b806a5 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 4 Dec 2025 09:09:27 -0500 Subject: [PATCH 33/78] allow specifying custom row/ground subsets --- pvlib/bifacial/ants2d.py | 58 +++++++++++++++++++------------- tests/bifacial/test_ants2d.py | 62 ++++++++++++++++++++++------------- 2 files changed, 74 insertions(+), 46 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 1bbb6267e5..7b6a00b627 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -285,7 +285,7 @@ def _apply_ground_slope(height, pitch, gcr, tracker_rotation, ghi, dni, dhi, def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, gcr, height, pitch, ghi, dhi, dni, albedo, model='perez', dni_extra=None, airmass=None, - n_row_segments=1, n_ground_segments=10, axis_tilt=0, + row_segments=1, ground_segments=10, axis_tilt=0, cross_axis_slope=0, max_rows=None, return_ground_components=False): """ @@ -346,12 +346,19 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, ``model='haydavies'`` or ``model='perez'``. [Wm⁻²] airmass : numeric, optional Relative airmass. Required when ``model='perez'``. [unitless] - n_row_segments : int, default 1 - Number of segments to partition the row surface into. Irradiance - will be computed and returned for each segment. - n_ground_segments : int, default 10 - Number of segments to partition the ground surface into. ``albedo`` - can be specified for each segment. + row_segments : int or list of pairs, default 1 + If ``row_segments`` is an int, it defines the number of equal-length + segments the row width is divided into. Otherwise, it must be a list + of pairs ``(x0, x1)`` where ``x0`` and ``x1`` are fractions of the + row width and ``x0 < x1``. Irradiance will be computed and returned + for each segment. + ground_segments : int or list of pairs, default 10 + If ``ground_segments`` is an int, it defines the number of equal-length + segments the ground surface is divided into. Otherwise, it must be + a list of pairs ``(g0, g1)`` where ``g0`` and ``g1`` are fractions of + the pitch and ``g0 < g1``. The pairs must be non-overlapping and must + cover the entire ground surface. ``albedo`` can be specified for + each segment. axis_tilt : numeric, default 0 Tilt of the axis of rotation with respect to horizontal. [degree] cross_axis_slope : numeric, default 0 @@ -376,8 +383,8 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, ------- output : dict or DataFrame ``output`` is a DataFrame when inputs are Series - and ``n_row_segments=1``, a dict of scalars when inputs are scalars - and ``n_row_segments=1``, and a dict of ``np.ndarray`` + and ``row_segments=1``, a dict of scalars when inputs are scalars + and ``row_segments=1``, and a dict of ``np.ndarray`` otherwise. The following quantities are included: - ``poa_global``: sum of front- and back-side incident irradiance. @@ -433,6 +440,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, np.isscalar(x) or x is None for x in maybe_array_inputs ]) try: + # get the index of the first pandas input, if there is one pd_index = next( x.index for x in maybe_array_inputs if isinstance(x, pd.Series) ) @@ -453,13 +461,19 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, cross_axis_slope ) - x_row = np.linspace(0, 1, n_row_segments+1) - x0 = x_row[:-1] - x1 = x_row[1:] + if np.isscalar(row_segments): + x_row = np.linspace(0, 1, row_segments+1) + x0, x1 = x_row[:-1], x_row[1:] + else: + x0 = np.array([pair[0] for pair in row_segments]) + x1 = np.array([pair[1] for pair in row_segments]) - x_ground = np.linspace(0, 1, n_ground_segments+1) - g0 = x_ground[:-1] - g1 = x_ground[1:] + if np.isscalar(ground_segments): + x_ground = np.linspace(0, 1, ground_segments+1) + g0, g1 = x_ground[:-1], x_ground[1:] + else: + g0 = np.array([pair[0] for pair in ground_segments]) + g1 = np.array([pair[1] for pair in ground_segments]) # dimensions: ground segment, row segment, time albedo = np.atleast_2d(albedo)[:, np.newaxis, :] @@ -500,7 +514,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, # inputs shared between front and back calculations params = dict(phi=phi, gcr=gcr, height=height, pitch=pitch, dni=dni, dhi=dhi, ground_irradiance=ground_total, albedo=albedo, - x0=x0, x1=x1, g0=g0, g1=g1, max_rows=max_rows) + g0=g0, g1=g1, max_rows=max_rows) # front front_orientation = calc_surface_orientation(true_tracker_rotation, @@ -508,7 +522,8 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, cos_aoi_front = aoi_projection(**front_orientation, solar_zenith=solar_zenith, solar_azimuth=solar_azimuth) - poa_front = _ants2d_singleside(tracker_rotation, cos_aoi_front, **params) + poa_front = _ants2d_singleside(tracker_rotation, cos_aoi_front, + x0=x0, x1=x1, **params) # back tracker_rotation_back = true_tracker_rotation + 180 @@ -521,10 +536,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, tracker_rotation_back = tracker_rotation + 180 tracker_rotation_back = ((tracker_rotation_back + 180) % 360) - 180 poa_back = _ants2d_singleside(tracker_rotation_back, cos_aoi_back, - **params) - - for key, value in poa_back.items(): - poa_back[key] = value[::-1, :] # invert x0/x1 dimension + x0=1-x1, x1=1-x0, **params) colmap_front = { 'poa_global': 'poa_front', @@ -543,7 +555,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, poa_back[new_key] = poa_back.pop(old_key) out = {**poa_front, **poa_back} - if n_row_segments == 1 : + if row_segments == 1: for k, v in out.items(): out[k] = v[0] # drop row segment dimension @@ -557,7 +569,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, if return_ground_components: squeeze = [] - if n_ground_segments == 1: + if ground_segments == 1: squeeze.append(0) # drop ground segment dimension squeeze.append(1) # always drop the row segment dimension if all_scalar_inputs: diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 49cc6113f7..54250ba99d 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -206,7 +206,7 @@ def ants_params(): def test_get_irradiance_return_type(ants_params): # verify pandas in -> pandas out, and shapes of numpy outputs - out = ants2d.get_irradiance(**ants_params, n_row_segments=1) + out = ants2d.get_irradiance(**ants_params, row_segments=1) assert isinstance(out, pd.DataFrame) # DataFrame, since n_row_segments=1 expected_keys = ['poa_front', 'poa_front_direct', 'poa_front_diffuse', 'poa_front_sky_diffuse', 'poa_front_ground_diffuse', @@ -216,8 +216,8 @@ def test_get_irradiance_return_type(ants_params): assert set(out.columns) == set(expected_keys) assert len(out) == 2 # 2 timestamps - out = ants2d.get_irradiance(**ants_params, n_row_segments=3) - assert isinstance(out, dict) # dict, since n_row_segments>1 + out = ants2d.get_irradiance(**ants_params, row_segments=3) + assert isinstance(out, dict) # dict, since row_segments>1 assert set(out.keys()) == set(expected_keys) for k, v in out.items(): assert v.shape == (3, 2), k # 3 row segments, 2 timestamps @@ -225,7 +225,7 @@ def test_get_irradiance_return_type(ants_params): def test_get_irradiance_symmetry(ants_params): # check symmetries for normal tracker - out = ants2d.get_irradiance(**ants_params, n_row_segments=1) + out = ants2d.get_irradiance(**ants_params, row_segments=1) # symmetrical/mirrored inputs should produce equal outputs pd.testing.assert_series_equal(out.iloc[0, :], out.iloc[1, :], check_names=False) @@ -242,7 +242,7 @@ def test_get_irradiance_vertical(ants_params, solar_zenith, tracker_rotation): index=ants_params['ghi'].index) ants_params['tracker_rotation'] = pd.Series(tracker_rotation, index=ants_params['ghi'].index) - out = ants2d.get_irradiance(**ants_params, n_row_segments=1) + out = ants2d.get_irradiance(**ants_params, row_segments=1) # inputs are symmetrical morning/afternoon, so morning front should equal # afternoon back, and vice versa front_keys = ['poa_front', 'poa_front_direct', 'poa_front_diffuse', @@ -256,7 +256,7 @@ def test_get_irradiance_vertical(ants_params, solar_zenith, tracker_rotation): out.iloc[0][back_key]) # now with >1 row segment - out = ants2d.get_irradiance(**ants_params, n_row_segments=2) + out = ants2d.get_irradiance(**ants_params, row_segments=2) lower_half = 0 upper_half = 1 morning = 0 @@ -284,7 +284,7 @@ def test_get_irradiance_limit(ants_params): ants_params['dni'], ants_params['ghi'], ants_params['dhi'], albedo=ants_params['albedo'], model='isotropic') - ants = ants2d.get_irradiance(**ants_params, n_row_segments=1, + ants = ants2d.get_irradiance(**ants_params, row_segments=1, model='isotropic') # 15 W/m2 happens to be just below the difference (determined empirically) diff_sky = irrad['poa_sky_diffuse'] - ants['poa_front_sky_diffuse'] @@ -296,7 +296,7 @@ def test_get_irradiance_limit(ants_params): # output of get_total_irradiance ants_params['pitch'] *= 1000 ants_params['gcr'] /= 1000 - ants = ants2d.get_irradiance(**ants_params, n_row_segments=1, + ants = ants2d.get_irradiance(**ants_params, row_segments=1, model='isotropic') colmap = {'poa_front': 'poa_global', 'poa_front_direct': 'poa_direct', @@ -357,10 +357,10 @@ def test_get_irradiance_direct_shading(ants_params_fixed): def test_get_irradiance_multiple_row_segments(ants_params_fixed): - # check that granular sims average to the same value as n_row_segments=1 - out4 = ants2d.get_irradiance(**ants_params_fixed, n_row_segments=4) - out2 = ants2d.get_irradiance(**ants_params_fixed, n_row_segments=2) - out1 = ants2d.get_irradiance(**ants_params_fixed, n_row_segments=1) + # check that granular sims average to the same value as row_segments=1 + out4 = ants2d.get_irradiance(**ants_params_fixed, row_segments=4) + out2 = ants2d.get_irradiance(**ants_params_fixed, row_segments=2) + out1 = ants2d.get_irradiance(**ants_params_fixed, row_segments=1) for k in out4: # check two bottom quarters average to the bottom half, and top @@ -372,6 +372,22 @@ def test_get_irradiance_multiple_row_segments(ants_params_fixed): np.testing.assert_allclose(np.mean(out2[k][:, 0]), out1[k]) +def test_get_irradiance_custom_x0x1(ants_params_fixed): + # different ways of specifying the lower and upper halves + expected = ants2d.get_irradiance(**ants_params_fixed, row_segments=2) + actual = ants2d.get_irradiance(**ants_params_fixed, + row_segments=[(0.0, 0.5), (0.5, 1.0)]) + for key in expected: + np.testing.assert_allclose(expected[key], actual[key]) + + # specify only one part of the module + expected = ants2d.get_irradiance(**ants_params_fixed, row_segments=4) + actual = ants2d.get_irradiance(**ants_params_fixed, + row_segments=[(0.25, 0.5)]) + for key in expected: + np.testing.assert_allclose(expected[key][1], actual[key][0]) + + def test_get_irradiance_slope(ants_params_fixed): # check the slope affects direct & diffuse shading flat = ants2d.get_irradiance(cross_axis_slope=0, **ants_params_fixed) @@ -400,12 +416,12 @@ def test_get_irradiance_nonuniform_albedo(): 'albedo': np.array([[0.5]*10 + [0.1]*10]).T, 'model': 'isotropic' } - out = ants2d.get_irradiance(n_ground_segments=20, - n_row_segments=10000, + out = ants2d.get_irradiance(ground_segments=20, + row_segments=10000, max_rows=2, **inputs) # check far left and right segments, on the edge of the module. - # need a large n_row_segments so that these segments are very thin + # need a large row_segments so that these segments are very thin left, right = out['poa_back_ground_diffuse'][[0, -1], 0] # divide by two because ~half the visible ground is fully shaded assert_allclose(left, 0.1 * 1000 / 2, rtol=0.002) @@ -423,7 +439,7 @@ def test_get_irradiance_nonuniform_albedo_limit(): 'ghi': 300, 'dni': 0, # set dni to zero so that shadows don't confound results 'dhi': 300, - 'n_ground_segments': 2, + 'ground_segments': 2, 'max_rows': 10000, 'model': 'isotropic', } @@ -511,7 +527,7 @@ def test_ground_components(): 'dni': 1000, 'dhi': 300, 'albedo': 0.2, - 'n_ground_segments': 1, + 'ground_segments': 1, 'model': 'isotropic', } @@ -538,7 +554,7 @@ def test_ground_components(): assert_allclose(out['ground_diffuse'], 150, atol=1e-3) # flat array, four segments: perfect direct shading, diffuse symmetry - inputs['n_ground_segments'] = 4 + inputs['ground_segments'] = 4 inputs['solar_zenith'] = 0 _, out = ants2d.get_irradiance(tracker_rotation=0, axis_azimuth=180, **inputs, @@ -550,7 +566,7 @@ def test_ground_components(): # flat array, many rows, very high: ground_diffuse -> dhi * gcr inputs['height'] = 200 - inputs['n_ground_segments'] = 4 + inputs['ground_segments'] = 4 _, out = ants2d.get_irradiance(tracker_rotation=0, axis_azimuth=180, **inputs, max_rows=5000, @@ -562,7 +578,7 @@ def test_ground_components_types(ants_params, ants_params_fixed): # test second return value type/shape when return_ground_components=True # scalar inputs, single ground segment - _, out = ants2d.get_irradiance(**ants_params_fixed, n_ground_segments=1, + _, out = ants2d.get_irradiance(**ants_params_fixed, ground_segments=1, return_ground_components=True) assert isinstance(out, dict) assert set(out.keys()) == {'ground_direct', 'ground_diffuse'} @@ -570,7 +586,7 @@ def test_ground_components_types(ants_params, ants_params_fixed): assert isinstance(value, float), key # scalar inputs, multiple ground segments - _, out = ants2d.get_irradiance(**ants_params_fixed, n_ground_segments=10, + _, out = ants2d.get_irradiance(**ants_params_fixed, ground_segments=10, return_ground_components=True) assert isinstance(out, dict) assert set(out.keys()) == {'ground_direct', 'ground_diffuse'} @@ -579,14 +595,14 @@ def test_ground_components_types(ants_params, ants_params_fixed): assert value.shape == (10,), key # series inputs, single ground segment - _, out = ants2d.get_irradiance(**ants_params, n_ground_segments=1, + _, out = ants2d.get_irradiance(**ants_params, ground_segments=1, return_ground_components=True) assert isinstance(out, pd.DataFrame) assert set(out.columns) == {'ground_direct', 'ground_diffuse'} assert len(out) == 2 # series inputs, multiple ground segments - _, out = ants2d.get_irradiance(**ants_params, n_ground_segments=10, + _, out = ants2d.get_irradiance(**ants_params, ground_segments=10, return_ground_components=True) assert isinstance(out, dict) assert set(out.keys()) == {'ground_direct', 'ground_diffuse'} From 2b5750a731bedbe600b313371fc9097223a71b2f Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 4 Dec 2025 09:38:35 -0500 Subject: [PATCH 34/78] lint --- pvlib/bifacial/ants2d.py | 18 +++--- pvlib/bifacial/utils.py | 46 +++++++-------- tests/bifacial/test_ants2d.py | 103 +++++++++++++++++----------------- 3 files changed, 84 insertions(+), 83 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 7b6a00b627..6566a95f66 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -60,7 +60,7 @@ def _shaded_fraction(tracker_rotation, phi, gcr, x0=0, x1=1): # apply it here. # also, we have PSZA instead of solar position, so use fake azimuths to # trick shaded_fraction1d into accepting it as-is. - # direction of positive phi by right-hand rule: + # direction of positive phi by right-hand rule: f_s = shaded_fraction1d(phi, solar_azimuth=90, axis_azimuth=0, shaded_row_rotation=tracker_rotation, @@ -129,17 +129,17 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, gcr, height, pitch, Position on the row's slant length, as a fraction of the slant length. ``x1=1`` corresponds to the right side of the row. ``x1`` should be greater than ``x0``. If specified as array, it - must have the same length as ``x0``.[unitless] + must have the same length as ``x0``. [unitless] g0 : numeric Position on the ground surface, as a fraction of the row-to-row spacing. ``g0=0`` corresponds to ground underneath the middle of the left row. ``g0`` should be less than ``g1``. If specified as array, it - must have the same length as ``g1``.[unitless] + must have the same length as ``g1``. [unitless] g1 : numeric Position on the ground surface, as a fraction of the row-to-row spacing. ``g1=1`` corresponds to ground underneath the middle of the - right row. ``g1`` should be greater than ``g0``. If specified as array, it - must have the same length as ``g0``.[unitless] + right row. ``g1`` should be greater than ``g0``. If specified as + array, it must have the same length as ``g0``. [unitless] max_rows : int Number of array units (sky wedges, ground segments, etc) to consider. [unitless] @@ -174,13 +174,11 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, gcr, height, pitch, poa_direct = dni * projection * (1 - row_shaded_fraction) poa_direct = poa_direct[0] # drop ground segment dimension - # in-plane sky diffuse component vf_row_sky = utils.vf_row_sky_2d_integ(tracker_rotation, gcr, x0, x1) poa_sky_diffuse = vf_row_sky * dhi poa_sky_diffuse = poa_sky_diffuse[0] # drop ground segment dimension - # in-plane ground-reflected component vf_row_ground = utils.vf_row_ground_2d_integ(surface_tilt=tracker_rotation, gcr=gcr, height=height, @@ -191,7 +189,6 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, gcr, height, pitch, # sum over ground segments poa_ground_diffuse = np.sum(poa_ground_diffuse, axis=0) - # add sky and ground-reflected irradiance on the row by irradiance # component poa_diffuse = poa_ground_diffuse + poa_sky_diffuse @@ -268,7 +265,8 @@ def _apply_ground_slope(height, pitch, gcr, tracker_rotation, ghi, dni, dhi, pitch = pitch / cosd(cross_axis_slope) gcr = gcr * cosd(cross_axis_slope) tracker_rotation = tracker_rotation - cross_axis_slope - tracker_rotation = ((tracker_rotation + 180) % 360) - 180 # put back to [-180, 180] + # put back to [-180, 180]: + tracker_rotation = ((tracker_rotation + 180) % 360) - 180 ghi = dhi + dni * np.maximum( aoi_projection(slope_tilt, slope_azimuth, @@ -277,8 +275,6 @@ def _apply_ground_slope(height, pitch, gcr, tracker_rotation, ghi, dni, dhi, # dhi: no need to adjust; the blocked view is only near the # the horizon, and that part of the sky is blocked by rows anyway # dni: no adjustment needed; the measurement plane is not affected - #dhi = dhi - #dni = dni return height, pitch, gcr, tracker_rotation, ghi diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 1eb2e7818c..eb6a5ec7de 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -110,7 +110,7 @@ def _unshaded_ground_fraction(surface_tilt, phi, gcr, height=None, g0 = np.atleast_1d(g0)[np.newaxis, :, np.newaxis] g1 = np.atleast_1d(g1)[np.newaxis, :, np.newaxis] - + # TODO seems like this should be np.arange(-max_rows, max_rows+1)? # see GH #1867 k = np.arange(-max_rows, max_rows)[:, np.newaxis, np.newaxis] @@ -124,12 +124,12 @@ def _unshaded_ground_fraction(surface_tilt, phi, gcr, height=None, # d, c: left/right shading module edges c = (k*pitch + 0.5 * Lcostheta, height + 0.5 * Lsintheta) d = (k*pitch - 0.5 * Lcostheta, height - 0.5 * Lsintheta) - + cp = c[0] + c[1] * tanphi dp = d[0] + d[1] * tanphi swap = dp > cp cp, dp = np.where(swap, dp, cp), np.where(swap, cp, dp) - + a = g0*pitch b = g1*pitch @@ -142,11 +142,11 @@ def _unshaded_ground_fraction(surface_tilt, phi, gcr, height=None, fs = np.where((a < dp) & (dp < b) & (a < cp) & (cp < b), (cp - dp) / (b - a), fs) fs = np.where((dp > b) & (cp > b), 0.0, fs) - + # total shaded fraction is sum of individuals; note that shadows # never overlap in this model, except when shaded fraction is 100% anyway f_gnd_beam = 1 - np.clip(np.sum(fs, axis=0), 0, 1) # sum along k dimension - + # using phi is more convenient, and I think better, than using zenith phi = phi[0, :, :] # drop k dimension for the next line f_gnd_beam = np.where(np.abs(phi) > max_zenith, 0., f_gnd_beam) @@ -302,11 +302,12 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, # dimensions: k/max_rows, ground segment, time - tracker_rotation = np.atleast_1d(tracker_rotation)[np.newaxis, np.newaxis, :] - + tracker_rotation = \ + np.atleast_1d(tracker_rotation)[np.newaxis, np.newaxis, :] + g0 = np.atleast_1d(g0)[np.newaxis, :, np.newaxis] g1 = np.atleast_1d(g1)[np.newaxis, :, np.newaxis] - + # TODO seems like this should be np.arange(-max_rows, max_rows+1)? # see GH #1867 k = np.arange(-max_rows, max_rows)[:, np.newaxis, np.newaxis] @@ -325,7 +326,7 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, d = (c[0] - pitch, c[1]) # view obstruction points (module edges, but need to figure out which ones) - + # first decide whether the left obstruction is the left or right mod edge left = (k*pitch - 0.5 * Lcostheta, height - 0.5 * Lsintheta) right = (k*pitch + 0.5 * Lcostheta, height + 0.5 * Lsintheta) @@ -335,7 +336,7 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, np.where(angle_left > angle_right, right[0], left[0]), np.where(angle_left > angle_right, right[1], left[1]) ) - + # now for the right obstruction left = (left[0] + pitch, left[1]) right = (right[0] + pitch, right[1]) @@ -432,8 +433,8 @@ def vf_row_sky_2d_integ(surface_tilt, gcr, x0=0, x1=1): Ratio of the row slant length to the row spacing (pitch). [unitless] x0 : numeric, default 0 Position on the row's slant length, as a fraction of the slant length. - ``x0=0`` corresponds to the bottom of the row. ``x0`` should be less than - ``x1``. [unitless] + ``x0=0`` corresponds to the bottom of the row. ``x0`` should be less + than ``x1``. [unitless] x1 : numeric, default 1 Position on the row's slant length, as a fraction of the slant length. ``x1`` should be greater than ``x0``. [unitless] @@ -453,9 +454,9 @@ def vf_row_sky_2d_integ(surface_tilt, gcr, x0=0, x1=1): squeeze.append(1) # dimensions: row segment, time - + surface_tilt = np.atleast_1d(surface_tilt)[np.newaxis, :] - + x0 = np.atleast_1d(x0)[:, np.newaxis] x1 = np.atleast_1d(x1)[:, np.newaxis] @@ -574,19 +575,20 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, # cheat a little to prevent numerical issues with surface_tilt==180, -180 surface_tilt = np.where(surface_tilt == 180, 179.9999, surface_tilt) surface_tilt = np.where(surface_tilt == -180, -179.9999, surface_tilt) - - surface_tilt = np.atleast_1d(surface_tilt)[np.newaxis, np.newaxis, np.newaxis, :] - + + surface_tilt = \ + np.atleast_1d(surface_tilt)[np.newaxis, np.newaxis, np.newaxis, :] + x0 = np.atleast_1d(x0)[np.newaxis, np.newaxis, :, np.newaxis] x1 = np.atleast_1d(x1)[np.newaxis, np.newaxis, :, np.newaxis] g0 = np.atleast_1d(g0)[np.newaxis, :, np.newaxis, np.newaxis] g1 = np.atleast_1d(g1)[np.newaxis, :, np.newaxis, np.newaxis] - + # TODO seems like this should be np.arange(-max_rows, max_rows+1)? # see GH #1867 k = np.arange(-max_rows, max_rows)[:, np.newaxis, np.newaxis, np.newaxis] - - collector_width = pitch * gcr + + collector_width = pitch * gcr Lcostheta = collector_width * cosd(surface_tilt) Lsintheta = collector_width * sind(surface_tilt) @@ -595,7 +597,7 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, # be a nonzero distance from all points the VF could be calculated from ob_right = (-pitch - 0.5001 * Lcostheta, height - 0.5001 * abs(Lsintheta)) ob_left = (ob_right[0] + pitch, ob_right[1]) - + invert = surface_tilt < 0 temp = ob_right[0] ob_right = (np.where(invert, -ob_left[0], ob_right[0]), ob_right[1]) @@ -614,7 +616,7 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, ac, ad, bc, bd = _obstructed_string_lengths(a, b, c, d, ob_left, ob_right) # crossed string formula for VF - vf_slats = 0.5 * (1/((x1 - x0) * collector_width)) * ((ac + bd) - (bc + ad)) + vf_slats = 1 / (2 * (x1 - x0) * collector_width) * ((ac + bd) - (bc + ad)) vf_total = np.sum(np.maximum(vf_slats, 0), axis=0) # sum along k dimension # dimensions are now ground_segment, row_segment, time diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 54250ba99d..a0318de1d3 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -12,7 +12,6 @@ def test__shaded_fraction(): - # special angles tracker_rotation = np.array([60, 60, 60, 60]) phi = np.array([60, 60, 60, 60]) @@ -84,7 +83,6 @@ def test__apply_sky_diffuse_model(model): poa_direct + poa_sky_diffuse + poa_ground, abs=1e-10) - def test__apply_sky_diffuse_model_errors(): with pytest.raises(ValueError, match='Must supply dni_extra'): ants2d._apply_sky_diffuse_model(0, 0, 'haydavies', None, @@ -208,11 +206,12 @@ def test_get_irradiance_return_type(ants_params): # verify pandas in -> pandas out, and shapes of numpy outputs out = ants2d.get_irradiance(**ants_params, row_segments=1) assert isinstance(out, pd.DataFrame) # DataFrame, since n_row_segments=1 - expected_keys = ['poa_front', 'poa_front_direct', 'poa_front_diffuse', - 'poa_front_sky_diffuse', 'poa_front_ground_diffuse', - 'shaded_fraction_front', 'poa_back', 'poa_back_direct', - 'poa_back_diffuse', 'poa_back_sky_diffuse', 'poa_back_ground_diffuse', - 'shaded_fraction_back'] + expected_keys = [ + 'poa_front', 'poa_front_direct', 'poa_front_diffuse', + 'poa_front_sky_diffuse', 'poa_front_ground_diffuse', + 'shaded_fraction_front', 'poa_back', 'poa_back_direct', + 'poa_back_diffuse', 'poa_back_sky_diffuse', 'poa_back_ground_diffuse', + 'shaded_fraction_back'] assert set(out.columns) == set(expected_keys) assert len(out) == 2 # 2 timestamps @@ -245,9 +244,10 @@ def test_get_irradiance_vertical(ants_params, solar_zenith, tracker_rotation): out = ants2d.get_irradiance(**ants_params, row_segments=1) # inputs are symmetrical morning/afternoon, so morning front should equal # afternoon back, and vice versa - front_keys = ['poa_front', 'poa_front_direct', 'poa_front_diffuse', - 'poa_front_sky_diffuse', 'poa_front_ground_diffuse', - 'shaded_fraction_front'] + front_keys = [ + 'poa_front', 'poa_front_direct', 'poa_front_diffuse', + 'poa_front_sky_diffuse', 'poa_front_ground_diffuse', + 'shaded_fraction_front'] for front_key in front_keys: back_key = front_key.replace("front", "back") np.testing.assert_allclose(out.iloc[0][front_key], @@ -288,9 +288,9 @@ def test_get_irradiance_limit(ants_params): model='isotropic') # 15 W/m2 happens to be just below the difference (determined empirically) diff_sky = irrad['poa_sky_diffuse'] - ants['poa_front_sky_diffuse'] - diff_ground = irrad['poa_ground_diffuse'] - ants['poa_front_ground_diffuse'] + diff_grd = irrad['poa_ground_diffuse'] - ants['poa_front_ground_diffuse'] assert all(diff_sky > 15) - assert all(diff_ground > 15) + assert all(diff_grd > 15) # but as pitch->infinity, front-side irradiance converges to # output of get_total_irradiance @@ -305,7 +305,7 @@ def test_get_irradiance_limit(ants_params): 'poa_front_ground_diffuse': 'poa_ground_diffuse'} ants_front = ants[list(colmap)].rename(columns=colmap) pd.testing.assert_frame_equal(ants_front, irrad, atol=0.1) - + @pytest.fixture def ants_params_fixed(): @@ -452,43 +452,46 @@ def test_get_irradiance_nonuniform_albedo_limit(): @pytest.mark.parametrize('model,expected', [ - ('isotropic', {'poa_front': 1006.3548761345762, - 'poa_front_direct': 833.3333333333335, - 'poa_front_diffuse': 173.0215428012428, - 'poa_front_sky_diffuse': 172.27247024391784, - 'poa_front_ground_diffuse': 0.7490725573249604, - 'shaded_fraction_front': 0.035915234551783914, - 'poa_back': 23.626216052516494, - 'poa_back_direct': 0.0, - 'poa_back_diffuse': 23.626216052516494, - 'poa_back_sky_diffuse': 8.509173579096064, - 'poa_back_ground_diffuse': 15.11704247342043, - 'shaded_fraction_back': 0.035915234551784025}), - ('haydavies', {'poa_front': 1124.2311927022897, - 'poa_front_direct': 1078.4313725490197, - 'poa_front_diffuse': 45.79982015327015, - 'poa_front_sky_diffuse': 45.60153624103707, - 'poa_front_ground_diffuse': 0.19828391223307773, - 'shaded_fraction_front': 0.035915234551783914, - 'poa_back': 6.2539983668426, - 'poa_back_direct': 0.0, - 'poa_back_diffuse': 6.2539983668426, - 'poa_back_sky_diffuse': 2.252428300348958, - 'poa_back_ground_diffuse': 4.001570066493642, - 'shaded_fraction_back': 0.035915234551784025}), - ('perez', {'poa_front': 1060.3368384162613, - 'poa_front_direct': 945.5770264984124, - 'poa_front_diffuse': 114.75981191784896, - 'poa_front_sky_diffuse': 114.26297537137229, - 'poa_front_ground_diffuse': 0.4968365464766687, - 'shaded_fraction_front': 0.035915234551783914, - 'poa_back': 15.670534816764919, - 'poa_back_direct': 0.0, - 'poa_back_diffuse': 15.670534816764919, - 'poa_back_sky_diffuse': 5.643870374194696, - 'poa_back_ground_diffuse': 10.026664442570222, - 'shaded_fraction_back': 0.035915234551784025}) - ]) + ('isotropic', { + 'poa_front': 1006.3548761345762, + 'poa_front_direct': 833.3333333333335, + 'poa_front_diffuse': 173.0215428012428, + 'poa_front_sky_diffuse': 172.27247024391784, + 'poa_front_ground_diffuse': 0.7490725573249604, + 'shaded_fraction_front': 0.035915234551783914, + 'poa_back': 23.626216052516494, + 'poa_back_direct': 0.0, + 'poa_back_diffuse': 23.626216052516494, + 'poa_back_sky_diffuse': 8.509173579096064, + 'poa_back_ground_diffuse': 15.11704247342043, + 'shaded_fraction_back': 0.035915234551784025}), + ('haydavies', { + 'poa_front': 1124.2311927022897, + 'poa_front_direct': 1078.4313725490197, + 'poa_front_diffuse': 45.79982015327015, + 'poa_front_sky_diffuse': 45.60153624103707, + 'poa_front_ground_diffuse': 0.19828391223307773, + 'shaded_fraction_front': 0.035915234551783914, + 'poa_back': 6.2539983668426, + 'poa_back_direct': 0.0, + 'poa_back_diffuse': 6.2539983668426, + 'poa_back_sky_diffuse': 2.252428300348958, + 'poa_back_ground_diffuse': 4.001570066493642, + 'shaded_fraction_back': 0.035915234551784025}), + ('perez', { + 'poa_front': 1060.3368384162613, + 'poa_front_direct': 945.5770264984124, + 'poa_front_diffuse': 114.75981191784896, + 'poa_front_sky_diffuse': 114.26297537137229, + 'poa_front_ground_diffuse': 0.4968365464766687, + 'shaded_fraction_front': 0.035915234551783914, + 'poa_back': 15.670534816764919, + 'poa_back_direct': 0.0, + 'poa_back_diffuse': 15.670534816764919, + 'poa_back_sky_diffuse': 5.643870374194696, + 'poa_back_ground_diffuse': 10.026664442570222, + 'shaded_fraction_back': 0.035915234551784025}) +]) def test_get_irradiance_regression(model, expected, ants_params_fixed): # values computed for typical but arbitrary inputs, to verify that output # is stable over time From cf1ce339ec9456ea7f3715f7dfae87d866c168af Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Tue, 21 Apr 2026 16:02:07 -0400 Subject: [PATCH 35/78] use `poa_` for `return_components=True` after #2627 --- pvlib/bifacial/ants2d.py | 12 ++++++++---- tests/bifacial/test_ants2d.py | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 6566a95f66..d06e181e98 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -164,7 +164,9 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, gcr, height, pitch, References ---------- - .. [1] TODO + .. [1] K. S. Anderson, A. R. Jensen, and C. W. Hansen, "A Bifacial View + Factor Model Considering Terrain Slope and Nonuniform Albedo," + IEEE JPV, 2026. :doi:`10.1109/JPHOTOV.2026.3677506` """ # reminder of base dimensions: ground segment, row segment, time @@ -235,14 +237,14 @@ def _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, solar_azimuth, # circumsolar_horizontal from DHI sky_diffuse_comps_horizontal = diffuse_model_func( surface_tilt=0, surface_azimuth=180, **kwargs, **extra_kwargs) - circumsolar_horizontal = sky_diffuse_comps_horizontal['circumsolar'] + circumsolar_horizontal = sky_diffuse_comps_horizontal['poa_circumsolar'] # Call the model a second time where circumsolar_normal is facing # directly towards sun, and can be added to DNI sky_diffuse_comps_normal = diffuse_model_func( surface_tilt=solar_zenith, surface_azimuth=solar_azimuth, **kwargs, **extra_kwargs) - circumsolar_normal = sky_diffuse_comps_normal['circumsolar'] + circumsolar_normal = sky_diffuse_comps_normal['poa_circumsolar'] dhi = dhi - circumsolar_horizontal dni = dni + circumsolar_normal @@ -424,7 +426,9 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, References ---------- - .. [1] TODO + .. [1] K. S. Anderson, A. R. Jensen, and C. W. Hansen, "A Bifacial View + Factor Model Considering Terrain Slope and Nonuniform Albedo," + IEEE JPV, 2026. :doi:`10.1109/JPHOTOV.2026.3677506` """ # so we can return scalars out if needed diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index a0318de1d3..280c3e4d6d 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -72,8 +72,8 @@ def test__apply_sky_diffuse_model(model): kwargs['surface_azimuth'], kwargs['solar_zenith'], kwargs['solar_azimuth']) - poa_direct = inputs['dni'] * aoi_proj + diffuse['circumsolar'] - poa_sky_diffuse = diffuse['isotropic'] + poa_direct = inputs['dni'] * aoi_proj + diffuse['poa_circumsolar'] + poa_sky_diffuse = diffuse['poa_isotropic'] poa_ground = 1000 * 0.25 * (1 - pvlib.tools.cosd(20)) / 2 assert adj['poa_direct'] == pytest.approx(poa_direct, abs=1e-10) assert adj['poa_sky_diffuse'] == pytest.approx(poa_sky_diffuse, abs=1e-10) From 92a95b22d07b9864954cb339c78950ae95c52eda Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Fri, 24 Apr 2026 14:41:45 -0400 Subject: [PATCH 36/78] misc cleanup --- pvlib/bifacial/ants2d.py | 6 +++--- pvlib/bifacial/utils.py | 12 ++++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index d06e181e98..5d0804b160 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -234,10 +234,10 @@ def _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, solar_azimuth, return_components=True ) # Call the model first time within the horizontal plane - to subtract - # circumsolar_horizontal from DHI + # circumsolar_horiz from DHI sky_diffuse_comps_horizontal = diffuse_model_func( surface_tilt=0, surface_azimuth=180, **kwargs, **extra_kwargs) - circumsolar_horizontal = sky_diffuse_comps_horizontal['poa_circumsolar'] + circumsolar_horiz = sky_diffuse_comps_horizontal['poa_circumsolar'] # Call the model a second time where circumsolar_normal is facing # directly towards sun, and can be added to DNI @@ -246,7 +246,7 @@ def _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, solar_azimuth, **kwargs, **extra_kwargs) circumsolar_normal = sky_diffuse_comps_normal['poa_circumsolar'] - dhi = dhi - circumsolar_horizontal + dhi = dhi - circumsolar_horiz dni = dni + circumsolar_normal elif model != 'isotropic': raise ValueError(f"Invalid model: {model}") diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index eb6a5ec7de..c019283a29 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -5,7 +5,7 @@ import numpy as np from pvlib.tools import sind, cosd, tand import warnings -from pvlib._deprecation import pvlibDeprecationWarning +from pvlib._deprecation import pvlibDeprecationWarning, renamed_kwarg_warning def _solar_projection_tangent(solar_zenith, solar_azimuth, surface_azimuth): @@ -238,6 +238,7 @@ def vf_ground_sky_2d(rotation, gcr, x, pitch, height, max_rows=10): return vf +@renamed_kwarg_warning("1.4.0", "surface_tilt", "tracker_rotation") def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, max_rows=10, npoints=None, vectorize=None): """ @@ -246,9 +247,12 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, Parameters ---------- - surface_tilt : numeric - Surface tilt angle in degrees from horizontal, e.g., surface facing up - = 0, surface facing horizon = 90. [degree] + tracker_rotation : numeric + Tracker rotation angle as a right-handed rotation around + the axis defined by ``axis_tilt`` and ``axis_azimuth``. For example, + with ``axis_tilt=0`` and ``axis_azimuth=180``, ``tracker_theta > 0`` + results in ``surface_azimuth`` to the West while ``tracker_theta < 0`` + results in ``surface_azimuth`` to the East. [degree] gcr : float Ratio of row slant length to row spacing (pitch). [unitless] height : float From e651baf145960ca987eadcb1bf10d2a66e228ea6 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 30 Apr 2026 09:45:38 -0400 Subject: [PATCH 37/78] fix squeeze bug --- pvlib/bifacial/utils.py | 10 ++++++---- tests/bifacial/test_infinite_sheds.py | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index c019283a29..6a745a2a19 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -100,7 +100,7 @@ def _unshaded_ground_fraction(surface_tilt, phi, gcr, height=None, squeeze = [] if np.isscalar(g0) and np.isscalar(g1): squeeze.append(0) - if np.isscalar(surface_tilt): + if np.isscalar(surface_tilt) and np.isscalar(phi): squeeze.append(1) # dimensions: k/max_rows, ground segment, time @@ -272,17 +272,19 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, Maximum number of rows to consider in front and behind the current row. npoints : int, optional - .. deprecated:: TODO + .. deprecated:: 0.15.2 This parameter has no effect; integrated view factors are now calculated exactly instead of with discretized approximations. + This parameter will be removed in the future. vectorize : bool, optional - .. deprecated:: TODO + .. deprecated:: 0.15.2 This parameter has no effect; calculations are now vectorized with no memory usage penality. + This parameter will be removed in the future. Returns ------- @@ -293,7 +295,7 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, if npoints is not None or vectorize is not None: msg = ( "The `npoints` and `vectorize` parameters have no effect and will " - "be removed in a future version." # TODO make this better + "be removed in a future version." ) warnings.warn(msg, pvlibDeprecationWarning) diff --git a/tests/bifacial/test_infinite_sheds.py b/tests/bifacial/test_infinite_sheds.py index d91ef322e0..24973a6ed8 100644 --- a/tests/bifacial/test_infinite_sheds.py +++ b/tests/bifacial/test_infinite_sheds.py @@ -333,3 +333,26 @@ def test_get_irradiance_with_haydavies(): gcr, height, pitch, ghi, dhi, dni, albedo, model, None, iam_front, iam_back, bifaciality=0.8, shade_factor=-0.02, transmission_factor=0, npoints=npoints) + + +def test_get_irradiance_mixed_inputs(): + # check that scalar surface_tilt, surface_azimuth work with + # arrays for other inputs + ts_inputs = dict(solar_zenith=0, solar_azimuth=180, ghi=1000, dhi=300, + dni=700, albedo=0) + kwargs = dict(gcr=0.5, height=1, pitch=1, bifaciality=0.8, iam_front=1.0, + iam_back=1.0, shade_factor=-0.02, transmission_factor=0) + + surface_tilt = 0 + surface_azimuth = 180 + + result_scalars = infinite_sheds.get_irradiance(surface_tilt, + surface_azimuth, + **ts_inputs, **kwargs) + + ts_inputs = {k: np.array([v, v]) for k, v in ts_inputs.items()} + result_arrays = infinite_sheds.get_irradiance(surface_tilt, + surface_azimuth, + **ts_inputs, **kwargs) + for key in result_scalars: + np.testing.assert_allclose(result_scalars[key], result_arrays[key][0]) From c8be1262f212e2ed7baba7786a55453153ddde01 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 30 Apr 2026 09:46:25 -0400 Subject: [PATCH 38/78] deprecate `npoints`, `vectorize` --- pvlib/bifacial/infinite_sheds.py | 34 ++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/pvlib/bifacial/infinite_sheds.py b/pvlib/bifacial/infinite_sheds.py index 15ef4a89bf..399bfae3cd 100644 --- a/pvlib/bifacial/infinite_sheds.py +++ b/pvlib/bifacial/infinite_sheds.py @@ -185,7 +185,7 @@ def _shaded_fraction(solar_zenith, solar_azimuth, surface_tilt, def get_irradiance_poa(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, gcr, height, pitch, ghi, dhi, dni, albedo, model='isotropic', dni_extra=None, iam=1.0, - npoints=100, vectorize=False): + npoints=None, vectorize=None): r""" Calculate plane-of-array (POA) irradiance on one side of a row of modules. @@ -252,14 +252,25 @@ def get_irradiance_poa(surface_tilt, surface_azimuth, solar_zenith, Incidence angle modifier, the fraction of direct irradiance incident on the surface that is not reflected away. [unitless] - npoints : int, default 100 + npoints : int, optional Number of discretization points for calculating integrated view factors. - vectorize : bool, default False + .. deprecated:: 0.15.2 + Integrated view factors are now calculated analytically, so + this parameter now has no effect and will be removed in the + future. + + vectorize : bool, optional If True, vectorize the view factor calculation across ``surface_tilt``. This increases speed with the cost of increased memory usage. + .. deprecated:: 0.15.2 + Integrated view factors are now calculated more efficiently, so + there is no need to disable vectorization to save memory. + This parameter now has no effect and will be removed in the + future. + Returns ------- output : dict or DataFrame @@ -382,7 +393,7 @@ def get_irradiance(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, gcr, height, pitch, ghi, dhi, dni, albedo, model='isotropic', dni_extra=None, iam_front=1.0, iam_back=1.0, bifaciality=0.8, shade_factor=-0.02, - transmission_factor=0, npoints=100, vectorize=False): + transmission_factor=0, npoints=None, vectorize=None): """ Get front and rear irradiance using the infinite sheds model. @@ -472,14 +483,25 @@ def get_irradiance(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, module's cells due to module features such as busbars, junction box, etc. A negative value is a reduction in back irradiance. [unitless] - npoints : int, default 100 + npoints : int, optional Number of discretization points for calculating integrated view factors. - vectorize : bool, default False + .. deprecated:: 0.15.2 + Integrated view factors are now calculated analytically, so + this parameter now has no effect and will be removed in the + future. + + vectorize : bool, optional If True, vectorize the view factor calculation across ``surface_tilt``. This increases speed with the cost of increased memory usage. + .. deprecated:: 0.15.2 + Integrated view factors are now calculated more efficiently, so + there is no need to disable vectorization to save memory. + This parameter now has no effect and will be removed in the + future. + Returns ------- output : dict or DataFrame From 69eaf67fcce66fca390e5d2439b558550a34d1d2 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 30 Apr 2026 09:49:45 -0400 Subject: [PATCH 39/78] fix deprecation version --- pvlib/bifacial/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 6a745a2a19..32d32180dc 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -238,7 +238,7 @@ def vf_ground_sky_2d(rotation, gcr, x, pitch, height, max_rows=10): return vf -@renamed_kwarg_warning("1.4.0", "surface_tilt", "tracker_rotation") +@renamed_kwarg_warning("0.15.2", "surface_tilt", "tracker_rotation") def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, max_rows=10, npoints=None, vectorize=None): """ From 485eb0c877aa65d1858f5268890b4f0082384a93 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 30 Apr 2026 09:52:38 -0400 Subject: [PATCH 40/78] lint --- tests/bifacial/test_infinite_sheds.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bifacial/test_infinite_sheds.py b/tests/bifacial/test_infinite_sheds.py index 24973a6ed8..e9d45bcbf4 100644 --- a/tests/bifacial/test_infinite_sheds.py +++ b/tests/bifacial/test_infinite_sheds.py @@ -339,7 +339,7 @@ def test_get_irradiance_mixed_inputs(): # check that scalar surface_tilt, surface_azimuth work with # arrays for other inputs ts_inputs = dict(solar_zenith=0, solar_azimuth=180, ghi=1000, dhi=300, - dni=700, albedo=0) + dni=700, albedo=0) kwargs = dict(gcr=0.5, height=1, pitch=1, bifaciality=0.8, iam_front=1.0, iam_back=1.0, shade_factor=-0.02, transmission_factor=0) @@ -349,7 +349,7 @@ def test_get_irradiance_mixed_inputs(): result_scalars = infinite_sheds.get_irradiance(surface_tilt, surface_azimuth, **ts_inputs, **kwargs) - + ts_inputs = {k: np.array([v, v]) for k, v in ts_inputs.items()} result_arrays = infinite_sheds.get_irradiance(surface_tilt, surface_azimuth, From e4c0c51a69c5a0ec1cd79fb9d41291037bb40459 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 30 Apr 2026 10:12:37 -0400 Subject: [PATCH 41/78] docstring cleanup --- pvlib/bifacial/infinite_sheds.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pvlib/bifacial/infinite_sheds.py b/pvlib/bifacial/infinite_sheds.py index 399bfae3cd..bf8fadd7d2 100644 --- a/pvlib/bifacial/infinite_sheds.py +++ b/pvlib/bifacial/infinite_sheds.py @@ -55,8 +55,6 @@ def _poa_sky_diffuse_pv(dhi, gcr, surface_tilt): = 0, surface facing horizon = 90. [degree] gcr : float Ratio of row slant length to row spacing (pitch). [unitless] - npoints : int, default 100 - Number of points for integration. [unitless] A detailed calculation would be @@ -488,19 +486,20 @@ def get_irradiance(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, factors. .. deprecated:: 0.15.2 - Integrated view factors are now calculated analytically, so - this parameter now has no effect and will be removed in the - future. + + This parameter has no effect; integrated view factors are now + calculated exactly instead of with discretized approximations. + This parameter will be removed in the future. vectorize : bool, optional If True, vectorize the view factor calculation across ``surface_tilt``. This increases speed with the cost of increased memory usage. .. deprecated:: 0.15.2 - Integrated view factors are now calculated more efficiently, so - there is no need to disable vectorization to save memory. - This parameter now has no effect and will be removed in the - future. + + This parameter has no effect; calculations are now vectorized + with no memory usage penality. + This parameter will be removed in the future. Returns ------- From 30257a730e7dbbe4ef2735e61e19e93c96c93765 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 30 Apr 2026 10:33:10 -0400 Subject: [PATCH 42/78] whatsnew entries --- docs/sphinx/source/whatsnew/v0.15.2.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/sphinx/source/whatsnew/v0.15.2.rst b/docs/sphinx/source/whatsnew/v0.15.2.rst index d0fd0f5b25..d9f1b87e9c 100644 --- a/docs/sphinx/source/whatsnew/v0.15.2.rst +++ b/docs/sphinx/source/whatsnew/v0.15.2.rst @@ -10,6 +10,15 @@ Breaking Changes Deprecations ~~~~~~~~~~~~ +* Parameters ``npoints`` and ``vectorize`` in the following functions + are now unnecessary and are deprecated (:pull:`2740`): + + - :py:func:`pvlib.bifacial.infinite_sheds.get_irradiance` + - :py:func:`pvlib.bifacial.infinite_sheds.get_irradiance_poa` + - :py:func:`pvlib.bifacial.utils.vf_ground_sky_2d_integ` + +* Parameter ``surface_tilt`` is deprecated and renamed to ``tracker_rotation`` + in :py:func:`pvlib.bifacial.utils.vf_ground_sky_2d_integ` (:pull:`2740`). Bug fixes @@ -18,6 +27,13 @@ Bug fixes Enhancements ~~~~~~~~~~~~ +* Implement the ANTS-2D bifacial irradiance model in + :py:func:`pvlib.bifacial.ants2d.get_irradiance`. (:pull:`2740`) +* Add parameters ``g0`` and ``g1`` to allow segmented ground surfaces in + :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` and + :py:func:`~pvlib.bifacial.utils.vf_row_ground_2d_integ`. (:pull:`2740`) +* Accelerate :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` by one or + two orders of magnitude. (:pull:`2740`) Documentation From a36841d4f5062933cb7d4daae556f785a2d6b6dc Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 30 Apr 2026 11:02:57 -0400 Subject: [PATCH 43/78] fix broken gallery example --- .../plot_agrivoltaics_ground_irradiance.py | 12 +++++++---- pvlib/bifacial/utils.py | 21 ++++++++++--------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/docs/examples/agrivoltaics/plot_agrivoltaics_ground_irradiance.py b/docs/examples/agrivoltaics/plot_agrivoltaics_ground_irradiance.py index 521c68cb55..e22759f019 100644 --- a/docs/examples/agrivoltaics/plot_agrivoltaics_ground_irradiance.py +++ b/docs/examples/agrivoltaics/plot_agrivoltaics_ground_irradiance.py @@ -136,17 +136,21 @@ # (i.e., receives DNI). vf_ground_sky = pvlib.bifacial.utils.vf_ground_sky_2d_integ( - surface_tilt=tracking_orientations['surface_tilt'], + tracker_rotation=tracking_orientations['tracker_theta'], gcr=gcr, height=height, pitch=pitch, ) -unshaded_ground_fraction = pvlib.bifacial.utils._unshaded_ground_fraction( - surface_tilt=tracking_orientations['surface_tilt'], - surface_azimuth=tracking_orientations['surface_azimuth'], +phi = pvlib.shading.projected_solar_zenith_angle( solar_zenith=solpos['apparent_zenith'], solar_azimuth=solpos['azimuth'], + axis_tilt=0, + axis_azimuth=axis_azimuth, +) +unshaded_ground_fraction = pvlib.bifacial.utils._unshaded_ground_fraction( + tracker_rotation=tracking_orientations['tracker_theta'], + phi=phi, gcr=gcr, ) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 32d32180dc..e857e585b7 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -38,7 +38,7 @@ def _solar_projection_tangent(solar_zenith, solar_azimuth, surface_azimuth): return tan_phi -def _unshaded_ground_fraction(surface_tilt, phi, gcr, height=None, +def _unshaded_ground_fraction(tracker_rotation, phi, gcr, height=None, pitch=None, g0=0, g1=1, max_rows=10, max_zenith=85): r""" @@ -46,12 +46,12 @@ def _unshaded_ground_fraction(surface_tilt, phi, gcr, height=None, Parameters ---------- - surface_tilt : numeric - Surface tilt angle. The tilt angle is defined as - degrees from horizontal, e.g., surface facing up = 0, surface facing - horizon = 90. [degree] + tracker_rotation : numeric + Tracker rotation angle as a right-handed rotation around + the same axis as ``phi``. [degree] phi : numeric - Projected solar zenith angle. [degree]. + Projected solar zenith angle, defined around the same axis as + ``tracker_rotation``. [degree]. gcr : float Ground coverage ratio, which is the ratio of row slant length to row spacing (pitch). [unitless] @@ -100,12 +100,13 @@ def _unshaded_ground_fraction(surface_tilt, phi, gcr, height=None, squeeze = [] if np.isscalar(g0) and np.isscalar(g1): squeeze.append(0) - if np.isscalar(surface_tilt) and np.isscalar(phi): + if np.isscalar(tracker_rotation) and np.isscalar(phi): squeeze.append(1) # dimensions: k/max_rows, ground segment, time - surface_tilt = np.atleast_1d(surface_tilt)[np.newaxis, np.newaxis, :] + tracker_rotation = np.atleast_1d(tracker_rotation)[np.newaxis, np.newaxis, + :] phi = np.atleast_1d(phi)[np.newaxis, np.newaxis, :] g0 = np.atleast_1d(g0)[np.newaxis, :, np.newaxis] @@ -116,8 +117,8 @@ def _unshaded_ground_fraction(surface_tilt, phi, gcr, height=None, k = np.arange(-max_rows, max_rows)[:, np.newaxis, np.newaxis] collector_width = pitch * gcr - Lcostheta = collector_width * cosd(surface_tilt) - Lsintheta = collector_width * sind(surface_tilt) + Lcostheta = collector_width * cosd(tracker_rotation) + Lsintheta = collector_width * sind(tracker_rotation) tanphi = tand(phi) # a, b: boundaries of ground segment From 611f9dac8e199de93cc16de0c857f060afd27d1b Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 30 Apr 2026 11:19:25 -0400 Subject: [PATCH 44/78] add `reference` docs entry and user guide description --- docs/sphinx/source/reference/bifacial.rst | 1 + .../source/user_guide/modeling_topics/bifacial.rst | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/docs/sphinx/source/reference/bifacial.rst b/docs/sphinx/source/reference/bifacial.rst index a107062fd2..2f6366a1af 100644 --- a/docs/sphinx/source/reference/bifacial.rst +++ b/docs/sphinx/source/reference/bifacial.rst @@ -11,6 +11,7 @@ Functions for calculating front and back surface irradiance bifacial.pvfactors.pvfactors_timeseries bifacial.infinite_sheds.get_irradiance bifacial.infinite_sheds.get_irradiance_poa + bifacial.ants2d.get_irradiance Loss models that are specific to bifacial PV systems diff --git a/docs/sphinx/source/user_guide/modeling_topics/bifacial.rst b/docs/sphinx/source/user_guide/modeling_topics/bifacial.rst index e7e943e1ad..40b92c24c5 100644 --- a/docs/sphinx/source/user_guide/modeling_topics/bifacial.rst +++ b/docs/sphinx/source/user_guide/modeling_topics/bifacial.rst @@ -114,6 +114,19 @@ as additional ``dni`` for transposition and shading purposes. This model is influenced by the 2D model published by Marion, *et al.* in [2]. +ANTS-2D +------- + +The ANTS-2D model is a 2-D model similar to the Infinite Sheds and pvfactors +models. However, it has additional inputs to allow modeling of arrays on +sloped terrain and ground surfaces with nonuniform albedo. It partitions the +module and ground surfaces into a user-specified number of segments to better +capture irradiance nonuniformity effects. It is also computationally efficient +relative to models of comparable capability like pvfactors. Finally, it can +compute and return irradiance components at the ground level, which may be +useful for agriPV modeling. + + References ---------- .. [1] Mikofski, M., Darawali, R., Hamer, M., Neubert, A., and Newmiller, From e81e250082ad2e17d0159a98f1ab4dba8b31ee8e Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 30 Apr 2026 11:25:24 -0400 Subject: [PATCH 45/78] add test to cover untested branch --- tests/bifacial/test_ants2d.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 280c3e4d6d..156c6a8e03 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -388,6 +388,15 @@ def test_get_irradiance_custom_x0x1(ants_params_fixed): np.testing.assert_allclose(expected[key][1], actual[key][0]) +def test_get_irradiance_custom_g0g1(ants_params_fixed): + # different ways of specifying the ground surface + expected = ants2d.get_irradiance(**ants_params_fixed, ground_segments=2) + actual = ants2d.get_irradiance(**ants_params_fixed, + ground_segments=[(0.0, 0.5), (0.5, 1.0)]) + for key in expected: + np.testing.assert_allclose(expected[key], actual[key]) + + def test_get_irradiance_slope(ants_params_fixed): # check the slope affects direct & diffuse shading flat = ants2d.get_irradiance(cross_axis_slope=0, **ants_params_fixed) From 5a4d792b174f3d3a6314a0c57634764116866728 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 6 May 2026 14:24:06 -0400 Subject: [PATCH 46/78] feedback from review Co-Authored-By: Cliff Hansen <5393711+cwhanse@users.noreply.github.com> --- docs/sphinx/source/whatsnew/v0.15.2.rst | 7 +++-- pvlib/bifacial/utils.py | 38 +++++++++++-------------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.15.2.rst b/docs/sphinx/source/whatsnew/v0.15.2.rst index d9f1b87e9c..57a72057c0 100644 --- a/docs/sphinx/source/whatsnew/v0.15.2.rst +++ b/docs/sphinx/source/whatsnew/v0.15.2.rst @@ -23,7 +23,9 @@ Deprecations Bug fixes ~~~~~~~~~ - +* Fix asymmetry in how ``max_rows`` is interpreted in + :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d`. The difference + should be negligible for typical simulations. (:issue:`1867`, :pull:`2740`) Enhancements ~~~~~~~~~~~~ @@ -33,7 +35,8 @@ Enhancements :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` and :py:func:`~pvlib.bifacial.utils.vf_row_ground_2d_integ`. (:pull:`2740`) * Accelerate :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` by one or - two orders of magnitude. (:pull:`2740`) + two orders of magnitude. This also makes :py:mod:`pvlib.bifacial.infinite_sheds` faster. + (:pull:`2740`) Documentation diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index e857e585b7..bdb0cde285 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -50,29 +50,29 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, height=None, Tracker rotation angle as a right-handed rotation around the same axis as ``phi``. [degree] phi : numeric - Projected solar zenith angle, defined around the same axis as - ``tracker_rotation``. [degree]. + Projected solar zenith angle, defined as a right-handed rotation + around the same axis as ``tracker_rotation``. [degree]. gcr : float Ground coverage ratio, which is the ratio of row slant length to row spacing (pitch). [unitless] height : float, optional - Height of the center point of the row above the ground; must be in the - same units as ``pitch``. Required if ``g0`` is not zero or ``g1`` is - not one. + Height above ground of the center point of the row slant length; must + be in the same units as ``pitch``. Required if ``g0`` is not zero or + ``g1`` is not one. pitch : float, optional - Distance between two rows; must be in the same units as ``height``. - Required if ``g0`` is not zero or ``g1`` is not one. + Distance between centers of adjacent rows; must be in the same units + as ``height``. Required if ``g0`` is not zero or ``g1`` is not one. g0 : numeric, default 0 - Position on the ground surface, as a fraction of the row-to-row - spacing. ``g0=0`` corresponds to ground underneath the middle of the + Left position on the ground surface, as a fraction of the row-to-row + spacing. ``g0=0`` corresponds to ground underneath the center of the left row. ``g0`` should be less than ``g1``. [unitless] g1 : numeric, default 1 - Position on the ground surface, as a fraction of the row-to-row - spacing. ``g1=1`` corresponds to ground underneath the middle of the + Right position on the ground surface, as a fraction of the row-to-row + spacing. ``g1=1`` corresponds to ground underneath the center of the right row. ``g1`` should be greater than ``g0``. [unitless] max_rows : int, default 10 - Maximum number of rows to consider on either side of the current - row. [unitless] + Maximum number of rows to consider on either side of the left and + right rows. [unitless] max_zenith : numeric, default 85 Maximum zenith angle. For solar_zenith > max_zenith, unshaded ground fraction is set to 0. [degree] @@ -112,9 +112,7 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, height=None, g0 = np.atleast_1d(g0)[np.newaxis, :, np.newaxis] g1 = np.atleast_1d(g1)[np.newaxis, :, np.newaxis] - # TODO seems like this should be np.arange(-max_rows, max_rows+1)? - # see GH #1867 - k = np.arange(-max_rows, max_rows)[:, np.newaxis, np.newaxis] + k = np.arange(-max_rows, max_rows+2)[:, np.newaxis, np.newaxis] collector_width = pitch * gcr Lcostheta = collector_width * cosd(tracker_rotation) @@ -202,7 +200,7 @@ def vf_ground_sky_2d(rotation, gcr, x, pitch, height, max_rows=10): # handle floats: x = np.atleast_1d(x)[:, np.newaxis, np.newaxis] rotation = np.atleast_1d(rotation)[np.newaxis, :, np.newaxis] - all_k = np.arange(-max_rows, max_rows + 1) + all_k = np.arange(-max_rows, max_rows + 2) width = gcr * pitch / 2. distance_to_row_centers = (all_k - x) * pitch dy = width * sind(rotation) @@ -315,9 +313,7 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, g0 = np.atleast_1d(g0)[np.newaxis, :, np.newaxis] g1 = np.atleast_1d(g1)[np.newaxis, :, np.newaxis] - # TODO seems like this should be np.arange(-max_rows, max_rows+1)? - # see GH #1867 - k = np.arange(-max_rows, max_rows)[:, np.newaxis, np.newaxis] + k = np.arange(-max_rows, max_rows+1)[:, np.newaxis, np.newaxis] collector_width = pitch * gcr Lcostheta = collector_width * cosd(tracker_rotation) @@ -591,8 +587,6 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, g0 = np.atleast_1d(g0)[np.newaxis, :, np.newaxis, np.newaxis] g1 = np.atleast_1d(g1)[np.newaxis, :, np.newaxis, np.newaxis] - # TODO seems like this should be np.arange(-max_rows, max_rows+1)? - # see GH #1867 k = np.arange(-max_rows, max_rows)[:, np.newaxis, np.newaxis, np.newaxis] collector_width = pitch * gcr From 04583d8826fc3e9757699c271c9c2b60ee606c09 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 20 May 2026 11:59:05 -0400 Subject: [PATCH 47/78] Update pvlib/bifacial/utils.py Co-authored-by: Cliff Hansen --- pvlib/bifacial/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index bdb0cde285..d446b52e35 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -282,7 +282,7 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, .. deprecated:: 0.15.2 This parameter has no effect; calculations are now vectorized - with no memory usage penality. + with no memory usage penalty. This parameter will be removed in the future. Returns From fa944123fd11ac1f527af3051a3ab4351e144dc9 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 20 May 2026 12:47:11 -0400 Subject: [PATCH 48/78] adopt new description for `phi` --- pvlib/bifacial/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index d446b52e35..e18a617a30 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -50,8 +50,10 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, height=None, Tracker rotation angle as a right-handed rotation around the same axis as ``phi``. [degree] phi : numeric - Projected solar zenith angle, defined as a right-handed rotation - around the same axis as ``tracker_rotation``. [degree]. + The angle between (1) the sun vector projected onto the plane + perpendicular to the PV rows and (2) the vector normal to the plane + containing the PV rows. + See :py:func:`pvlib.shading.projected_solar_zenith_angle`. [degree] gcr : float Ground coverage ratio, which is the ratio of row slant length to row spacing (pitch). [unitless] From 6dbcd22ba7e6ed4283a85106c5a72b043bcb88d9 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 20 May 2026 16:23:44 -0400 Subject: [PATCH 49/78] Update pvlib/bifacial/utils.py Co-authored-by: Cliff Hansen --- pvlib/bifacial/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index e18a617a30..af802a9000 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -694,4 +694,5 @@ def _dist(p1, p2): def _angle(p1, p2): + # polar angle of the ray from p1 to p2 return np.arctan2(p2[1] - p1[1], p2[0] - p1[0]) From d3679fe54a465dd868ea177875a9428b40db3962 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 1 Jun 2026 08:28:19 -0400 Subject: [PATCH 50/78] changes from review --- pvlib/bifacial/ants2d.py | 3 ++- pvlib/bifacial/utils.py | 12 +++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 5d0804b160..5bae124382 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -293,7 +293,8 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, evenly spaced rows on flat or uniformly sloped ground. Rows can be on fixed racking or single axis trackers. The model calculates irradiance at a location far from the ends of any rows, in effect, assuming that the - rows (sheds) are infinitely long. + rows (sheds) are infinitely long, and parallel to the (potentially sloped) + ground surface. The model accounts for the following effects: diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index e18a617a30..9c84f64592 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -244,16 +244,14 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, max_rows=10, npoints=None, vectorize=None): """ Integrated view factor to the sky from the ground underneath - interior rows of the array. + interior rows of the array. Row height above the ground is assumed + to be uniform along the row's length. Parameters ---------- tracker_rotation : numeric - Tracker rotation angle as a right-handed rotation around - the axis defined by ``axis_tilt`` and ``axis_azimuth``. For example, - with ``axis_tilt=0`` and ``axis_azimuth=180``, ``tracker_theta > 0`` - results in ``surface_azimuth`` to the West while ``tracker_theta < 0`` - results in ``surface_azimuth`` to the East. [degree] + Tracker rotation angle. Positive rotations indicate raising the + row's right edge. [degree] gcr : float Ratio of row slant length to row spacing (pitch). [unitless] height : float @@ -291,7 +289,7 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, ------- fgnd_sky : numeric Integration of view factor over the length between adjacent, interior - rows. Shape matches that of ``surface_tilt``. [unitless] + rows. Shape matches that of ``tracker_rotation``. [unitless] """ if npoints is not None or vectorize is not None: msg = ( From ce481ded10f284e7d0ccb150920ad72a42d888ff Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 1 Jun 2026 08:55:17 -0400 Subject: [PATCH 51/78] allow perez_driesse --- pvlib/bifacial/ants2d.py | 25 ++++++++++++++++++------- tests/bifacial/test_ants2d.py | 23 ++++++++++++++++++++--- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 5bae124382..8dff4082b4 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -6,7 +6,7 @@ import pandas as pd from pvlib.tools import cosd, sind, tand, acosd from pvlib.bifacial import utils -from pvlib.irradiance import aoi_projection, haydavies, perez +from pvlib.irradiance import aoi_projection, haydavies, perez, perez_driesse from pvlib.shading import projected_solar_zenith_angle, shaded_fraction1d from pvlib.tracking import calc_surface_orientation @@ -211,7 +211,7 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, gcr, height, pitch, def _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, solar_azimuth, dni_extra, airmass): - if model in ['haydavies', 'perez']: + if model in ['haydavies', 'perez', 'perez_driesse']: # determine circumsolar irradiance, add it to DNI if model == 'haydavies': @@ -220,12 +220,15 @@ def _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, solar_azimuth, diffuse_model_func = haydavies extra_kwargs = {} - elif model == 'perez': + elif model in ['perez', 'perez_driesse']: # note: horizon brightening is ignored if dni_extra is None or airmass is None: raise ValueError( f'Must supply dni_extra and airmass for {model} model') - diffuse_model_func = perez + if model == 'perez': + diffuse_model_func = perez + else: + diffuse_model_func = perez_driesse extra_kwargs = {'airmass': airmass} kwargs = dict( @@ -339,12 +342,15 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, timestamps. Otherwise, must be specified as an array with shape (``n_ground_segments``, ``len(tracker_rotation)``). [unitless] model : str, default 'perez' - Irradiance model - can be one of 'isotropic', 'haydavies', or 'perez'. + Irradiance model - can be one of 'isotropic', 'haydavies', 'perez', + or 'perez_driesse'. dni_extra : numeric, optional Extraterrestrial direct normal irradiance. Required when - ``model='haydavies'`` or ``model='perez'``. [Wm⁻²] + ``model='haydavies'``, ``model='perez'``, or ``model='perez_driese'``. + [Wm⁻²] airmass : numeric, optional - Relative airmass. Required when ``model='perez'``. [unitless] + Relative airmass. Required when ``model='perez'`` or + ``model='perez_driese'``. [unitless] row_segments : int or list of pairs, default 1 If ``row_segments`` is an int, it defines the number of equal-length segments the row width is divided into. Otherwise, it must be a list @@ -425,6 +431,11 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, - ``ground_diffuse``: diffuse irradiance incident on the ground surface. [Wm⁻²] + Notes + ----- + - ``model='perez_driesse'`` was not evaluated in [1]_ and is included + in this implementation as an extension. + References ---------- .. [1] K. S. Anderson, A. R. Jensen, and C. W. Hansen, "A Bifacial View diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 156c6a8e03..aaab72ca3a 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -47,7 +47,7 @@ def test__shaded_fraction_x0x1(): assert_allclose(fs, np.array([[0.5, 0.0], [0.0, 0.5]])) -@pytest.mark.parametrize('model', ['perez', 'haydavies']) +@pytest.mark.parametrize('model', ['perez', 'haydavies', 'perez_driesse']) def test__apply_sky_diffuse_model(model): inputs = {'dni': 900, 'dhi': 150, 'solar_zenith': 41, 'solar_azimuth': 67, 'dni_extra': 1360, 'airmass': 1.324} @@ -57,7 +57,7 @@ def test__apply_sky_diffuse_model(model): kwargs = inputs.copy() kwargs.pop('dni') kwargs.pop('dhi') - if model != 'perez': + if model not in ['perez', 'perez_driesse']: kwargs.pop('airmass') kwargs['surface_tilt'] = 20 kwargs['surface_azimuth'] = 180 @@ -65,7 +65,8 @@ def test__apply_sky_diffuse_model(model): ghi=1000, # doesn't matter model='isotropic', **kwargs) func = {'perez': pvlib.irradiance.perez, - 'haydavies': pvlib.irradiance.haydavies}[model] + 'haydavies': pvlib.irradiance.haydavies, + 'perez_driesse': pvlib.irradiance.perez_driesse}[model] diffuse = func(dni=inputs['dni'], dhi=inputs['dhi'], **kwargs, return_components=True) aoi_proj = pvlib.irradiance.aoi_projection(kwargs['surface_tilt'], @@ -90,6 +91,9 @@ def test__apply_sky_diffuse_model_errors(): with pytest.raises(ValueError, match='Must supply dni_extra and airmass'): ants2d._apply_sky_diffuse_model(0, 0, 'perez', None, None, None, None) + with pytest.raises(ValueError, match='Must supply dni_extra and airmass'): + ants2d._apply_sky_diffuse_model(0, 0, 'perez_driesse', None, + None, None, None) with pytest.raises(ValueError, match='Invalid model: not_a_model'): ants2d._apply_sky_diffuse_model(0, 0, 'not_a_model', None, None, None, None) @@ -499,6 +503,19 @@ def test_get_irradiance_nonuniform_albedo_limit(): 'poa_back_diffuse': 15.670534816764919, 'poa_back_sky_diffuse': 5.643870374194696, 'poa_back_ground_diffuse': 10.026664442570222, + 'shaded_fraction_back': 0.035915234551784025}), + ('perez_driesse', { + 'poa_front': 1064.6127452995743, + 'poa_front_direct': 954.4678403911828, + 'poa_front_diffuse': 110.14490490839144, + 'poa_front_sky_diffuse': 109.66804795601284, + 'poa_front_ground_diffuse': 0.47685695237859554, + 'shaded_fraction_front': 0.035915234551783914, + 'poa_back': 15.040365947024998, + 'poa_back_direct': 0.0, + 'poa_back_diffuse': 15.040365947024998, + 'poa_back_sky_diffuse': 5.416909938175634, + 'poa_back_ground_diffuse': 9.623456008849365, 'shaded_fraction_back': 0.035915234551784025}) ]) def test_get_irradiance_regression(model, expected, ants_params_fixed): From 043453a26470568e41944b12329569c169c692df Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 1 Jun 2026 08:55:36 -0400 Subject: [PATCH 52/78] add reference to utils functions --- pvlib/bifacial/utils.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 9c84f64592..d149686935 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -87,10 +87,9 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, height=None, References ---------- - .. [1] Mikofski, M., Darawali, R., Hamer, M., Neubert, A., and Newmiller, - J. "Bifacial Performance Modeling in Large Arrays". 2019 IEEE 46th - Photovoltaic Specialists Conference (PVSC), 2019, pp. 1282-1287. - :doi:`10.1109/PVSC40753.2019.8980572`. + .. [1] K. S. Anderson, A. R. Jensen, and C. W. Hansen, "A Bifacial View + Factor Model Considering Terrain Slope and Nonuniform Albedo," + IEEE JPV, 2026. :doi:`10.1109/JPHOTOV.2026.3677506` """ if np.isscalar(g0) and g0 == 0 and np.isscalar(g1) and g1 == 1: # height and pitch have no effect, so set to arbitrary values so @@ -290,6 +289,12 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, fgnd_sky : numeric Integration of view factor over the length between adjacent, interior rows. Shape matches that of ``tracker_rotation``. [unitless] + + References + ---------- + .. [1] K. S. Anderson, A. R. Jensen, and C. W. Hansen, "A Bifacial View + Factor Model Considering Terrain Slope and Nonuniform Albedo," + IEEE JPV, 2026. :doi:`10.1109/JPHOTOV.2026.3677506` """ if npoints is not None or vectorize is not None: msg = ( @@ -448,6 +453,11 @@ def vf_row_sky_2d_integ(surface_tilt, gcr, x0=0, x1=1): Average fraction of the sky dome visible from points in the segment from x0 to x1. [unitless] + References + ---------- + .. [1] K. S. Anderson, A. R. Jensen, and C. W. Hansen, "A Bifacial View + Factor Model Considering Terrain Slope and Nonuniform Albedo," + IEEE JPV, 2026. :doi:`10.1109/JPHOTOV.2026.3677506` ''' # keep track of scalar inputs so that we can have output match at the end squeeze = [] @@ -556,6 +566,11 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, Integrated view factor to the visible ground on the interval (x0, x1). [unitless] + References + ---------- + .. [1] K. S. Anderson, A. R. Jensen, and C. W. Hansen, "A Bifacial View + Factor Model Considering Terrain Slope and Nonuniform Albedo," + IEEE JPV, 2026. :doi:`10.1109/JPHOTOV.2026.3677506` ''' if all(np.isscalar(x) for x in [x0, x1, g0, g1]) and ( g0 == 0 and g1 == 1 and x0 == 0 and x1 == 1): From ac2424b2d042f1d9fc853f39ec6261f3c82cdaee Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 1 Jun 2026 09:23:33 -0400 Subject: [PATCH 53/78] move g0, g1 to end of function signatures --- pvlib/bifacial/utils.py | 43 +++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index d149686935..631d1189b4 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -39,8 +39,8 @@ def _solar_projection_tangent(solar_zenith, solar_azimuth, surface_azimuth): def _unshaded_ground_fraction(tracker_rotation, phi, gcr, height=None, - pitch=None, g0=0, g1=1, max_rows=10, - max_zenith=85): + pitch=None, max_rows=10, max_zenith=85, + g0=0, g1=1): r""" Calculate the fraction of the ground with incident direct irradiance. @@ -64,6 +64,12 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, height=None, pitch : float, optional Distance between centers of adjacent rows; must be in the same units as ``height``. Required if ``g0`` is not zero or ``g1`` is not one. + max_rows : int, default 10 + Maximum number of rows to consider on either side of the left and + right rows. [unitless] + max_zenith : numeric, default 85 + Maximum zenith angle. For solar_zenith > max_zenith, unshaded ground + fraction is set to 0. [degree] g0 : numeric, default 0 Left position on the ground surface, as a fraction of the row-to-row spacing. ``g0=0`` corresponds to ground underneath the center of the @@ -72,12 +78,6 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, height=None, Right position on the ground surface, as a fraction of the row-to-row spacing. ``g1=1`` corresponds to ground underneath the center of the right row. ``g1`` should be greater than ``g0``. [unitless] - max_rows : int, default 10 - Maximum number of rows to consider on either side of the left and - right rows. [unitless] - max_zenith : numeric, default 85 - Maximum zenith angle. For solar_zenith > max_zenith, unshaded ground - fraction is set to 0. [degree] Returns ------- @@ -239,8 +239,8 @@ def vf_ground_sky_2d(rotation, gcr, x, pitch, height, max_rows=10): @renamed_kwarg_warning("0.15.2", "surface_tilt", "tracker_rotation") -def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, - max_rows=10, npoints=None, vectorize=None): +def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, max_rows=10, + npoints=None, vectorize=None, g0=0, g1=1): """ Integrated view factor to the sky from the ground underneath interior rows of the array. Row height above the ground is assumed @@ -258,14 +258,6 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, same units as ``pitch``. pitch : float Distance between two rows. Must be in the same units as ``height``. - g0 : numeric, default 0 - Position on the ground surface, as a fraction of the row-to-row - spacing. ``g0=0`` corresponds to ground underneath the middle of the - left row. ``g0`` should be less than ``g1``. [unitless] - g1 : numeric, default 1 - Position on the ground surface, as a fraction of the row-to-row - spacing. ``g1=1`` corresponds to ground underneath the middle of the - right row. ``g1`` should be greater than ``g0``. [unitless] max_rows : int, default 10 Maximum number of rows to consider in front and behind the current row. npoints : int, optional @@ -284,6 +276,15 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, g0=0, g1=1, with no memory usage penalty. This parameter will be removed in the future. + g0 : numeric, default 0 + Position on the ground surface, as a fraction of the row-to-row + spacing. ``g0=0`` corresponds to ground underneath the middle of the + left row. ``g0`` should be less than ``g1``. [unitless] + g1 : numeric, default 1 + Position on the ground surface, as a fraction of the row-to-row + spacing. ``g1=1`` corresponds to ground underneath the middle of the + right row. ``g1`` should be greater than ``g0``. [unitless] + Returns ------- fgnd_sky : numeric @@ -518,7 +519,7 @@ def vf_row_ground_2d(surface_tilt, gcr, x): def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, - x0=0, x1=1, g0=0, g1=1, max_rows=20): + x0=0, x1=1, max_rows=20, g0=0, g1=1): r''' Calculate the average view factor to the ground from a segment of the row surface between x0 and x1. @@ -549,6 +550,8 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, x1 : numeric, default 1. Position on the row's slant length, as a fraction of the slant length. ``x1`` should be greater than ``x0``. [unitless] + max_rows : int, default 20 + Maximum number of rows to consider in front and behind the current row. g0 : numeric, default 0 Position on the ground surface, as a fraction of the row-to-row spacing. ``g0=0`` corresponds to ground underneath the middle of the @@ -557,8 +560,6 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, Position on the ground surface, as a fraction of the row-to-row spacing. ``g1=0`` corresponds to ground underneath the middle of the right row. ``g1`` should be greater than ``g0``. [unitless] - max_rows : int, default 20 - Maximum number of rows to consider in front and behind the current row. Returns ------- From fe8728fd889857aeccd4f12f316ca6811aa709c0 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 1 Jun 2026 09:25:25 -0400 Subject: [PATCH 54/78] fix whatsnew merge --- docs/sphinx/source/whatsnew/v0.15.2.rst | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.15.2.rst b/docs/sphinx/source/whatsnew/v0.15.2.rst index 67d64f9c42..58f49830eb 100644 --- a/docs/sphinx/source/whatsnew/v0.15.2.rst +++ b/docs/sphinx/source/whatsnew/v0.15.2.rst @@ -30,18 +30,6 @@ Bug fixes data type integer, users can expect modeled cell temperature values to increase slightly. (:issue:`2608`, :pull:`2745`) - - -Enhancements -~~~~~~~~~~~~ -* Implement the ANTS-2D bifacial irradiance model in - :py:func:`pvlib.bifacial.ants2d.get_irradiance`. (:pull:`2740`) -* Add parameters ``g0`` and ``g1`` to allow segmented ground surfaces in - :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` and - :py:func:`~pvlib.bifacial.utils.vf_row_ground_2d_integ`. (:pull:`2740`) -* Accelerate :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` by one or - two orders of magnitude. This also makes :py:mod:`pvlib.bifacial.infinite_sheds` faster. - (:pull:`2740`) * 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. @@ -52,10 +40,17 @@ Enhancements * Add the ``front_side_fraction`` parameter to :py:func:`pvlib.snow.loss_townsend` to support Townsend snow-loss workflows for bifacial systems. (:issue:`2755`, :pull:`2756`) - * Added mapping of the parameter ``"albedo"`` in :py:func:`~pvlib.iotools.get_nasa_power` when ``map_variables=True`` (:pull:`2753`) +* Implement the ANTS-2D bifacial irradiance model in + :py:func:`pvlib.bifacial.ants2d.get_irradiance`. (:pull:`2740`) +* Add parameters ``g0`` and ``g1`` to allow segmented ground surfaces in + :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` and + :py:func:`~pvlib.bifacial.utils.vf_row_ground_2d_integ`. (:pull:`2740`) +* Accelerate :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` by one or + two orders of magnitude. This also makes :py:mod:`pvlib.bifacial.infinite_sheds` faster. + (:pull:`2740`) Documentation From 15563564dd0d0b7715a70dec60ce31a4e180a806 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 1 Jun 2026 12:05:00 -0400 Subject: [PATCH 55/78] update tests for #1867 --- tests/bifacial/test_utils.py | 53 +++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/tests/bifacial/test_utils.py b/tests/bifacial/test_utils.py index c0a6830167..7b0a7a5607 100644 --- a/tests/bifacial/test_utils.py +++ b/tests/bifacial/test_utils.py @@ -13,6 +13,19 @@ @pytest.fixture def test_system_fixed_tilt(): + """ + row -1 row 0 row 1 row 2 +- - - - + - - - - + - - - - + | - | - | - | + | - | - | - | + | | | | + | | | | + | | | | +----------------------------x-----------x-----------x-------------------------- + 0 1 2 + """ syst = {'height': 1.0, 'pitch': 2., 'surface_tilt': 30., @@ -20,26 +33,34 @@ def test_system_fixed_tilt(): 'axis_azimuth': None, 'rotation': -30.} syst['gcr'] = 1.0 / syst['pitch'] + # view factors from 3 points on the ground between rows to the sky pts = np.linspace(0, 1, num=3) sqr3 = np.sqrt(3) / 4 - # c_i,j = cos(angle from point i to edge of row j), j=0 is row = -1 - # c_i,j = cos(angle from point i to edge of row j), j=0 is row = -1 - c00 = (-2 - sqr3) / np.sqrt(1.25**2 + (2 + sqr3)**2) # right edge row -1 - c01 = -sqr3 / np.sqrt(1.25**2 + sqr3**2) # right edge row 0 - c02 = sqr3 / np.sqrt(0.75**2 + sqr3**2) # left edge of row 0 - c03 = (2 - sqr3) / np.sqrt(1.25**2 + (2 - sqr3)**2) # right edge of row 1 + + # c_ij = cos(angle from point i to a row edge, from left to right) + c00 = (-2 - sqr3) / np.sqrt(1.25**2 + (2 + sqr3)**2) # left edge row -1 + c01 = -sqr3 / np.sqrt(1.25**2 + sqr3**2) # left edge row 0 + c02 = sqr3 / np.sqrt(0.75**2 + sqr3**2) # right edge of row 0 + c03 = (2 - sqr3) / np.sqrt(1.25**2 + (2 - sqr3)**2) # left edge of row 1 + # cannot see sky between rows 1 and 2 vf_0 = 0.5 * (c03 - c02 + c01 - c00) # vf at point 0 - c10 = (-3 - sqr3) / np.sqrt(1.25**2 + (3 + sqr3)**2) # right edge row -1 - c11 = (-1 - sqr3) / np.sqrt(1.25**2 + (1 + sqr3)**2) # right edge row 0 - c12 = (-1 + sqr3) / np.sqrt(0.75**2 + (-1 + sqr3)**2) # left edge row 0 - c13 = (1 - sqr3) / np.sqrt(1.25**2 + (1 - sqr3)**2) # right edge row - vf_1 = 0.5 * (c13 - c12 + c11 - c10) # vf at point 1 - c20 = -(4 + sqr3) / np.sqrt(1.25**2 + (4 + sqr3)**2) # right edge row -1 - c21 = (-2 + sqr3) / np.sqrt(0.75**2 + (-2 + sqr3)**2) # left edge row 0 - c22 = (-2 - sqr3) / np.sqrt(1.25**2 + (2 + sqr3)**2) # right edge row 0 - c23 = (0 - sqr3) / np.sqrt(1.25**2 + (0 - sqr3)**2) # right edge row 1 - vf_2 = 0.5 * (c23 - c22 + c21 - c20) # vf at point 1 + + c10 = (-3 - sqr3) / np.sqrt(1.25**2 + (3 + sqr3)**2) # left edge row -1 + c11 = (-1 - sqr3) / np.sqrt(1.25**2 + (1 + sqr3)**2) # left edge row 0 + c12 = (-1 + sqr3) / np.sqrt(0.75**2 + (-1 + sqr3)**2) # right edge row 0 + c13 = (1 - sqr3) / np.sqrt(1.25**2 + (1 - sqr3)**2) # left edge row 1 + c14 = (1 + sqr3) / np.sqrt(0.75**2 + (1 + sqr3)**2) # right edge row 1 + c15 = (3 - sqr3) / np.sqrt(1.25**2 + (3 - sqr3)**2) # left edge row 2 + vf_1 = 0.5 * (c15 - c14 + c13 - c12 + c11 - c10) # vf at point 1 + + c20 = -(4 + sqr3) / np.sqrt(1.25**2 + (4 + sqr3)**2) # left edge row -1 + c21 = (-2 + sqr3) / np.sqrt(0.75**2 + (-2 + sqr3)**2) # right edge row 0 + c22 = (-2 - sqr3) / np.sqrt(1.25**2 + (2 + sqr3)**2) # left edge row 0 + c23 = (0 - sqr3) / np.sqrt(1.25**2 + (0 - sqr3)**2) # left edge row 1 + c24 = (0 + sqr3) / np.sqrt(0.75**2 + (0 + sqr3)**2) # right edge row 1 + c25 = (2 - sqr3) / np.sqrt(1.25**2 + (2 - sqr3)**2) # left edge row 2 + vf_2 = 0.5 * (c25 - c24 + c23 - c22 + c21 - c20) # vf at point 1 vfs_ground_sky = np.array([[vf_0], [vf_1], [vf_2]]) return syst, pts, vfs_ground_sky From ffb221d8e835b52977053722b2de68804920f47f Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 17 Jun 2026 11:29:00 -0400 Subject: [PATCH 56/78] Apply suggestions from code review Co-authored-by: Cliff Hansen --- pvlib/bifacial/ants2d.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 8dff4082b4..9216d58e18 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -122,12 +122,12 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, gcr, height, pitch, (n_ground_segments, n_timestamps). [unitless] x0 : numeric, default 0 Position on the row's slant length, as a fraction of the slant length. - ``x0=0`` corresponds to the left side of the row. + ``x0=0`` corresponds to the left edge of the row. ``x0`` should be less than ``x1``. If specified as array, it must have the same length as ``x1``. [unitless] x1 : numeric, default 1 Position on the row's slant length, as a fraction of the slant length. - ``x1=1`` corresponds to the right side of the row. + ``x1=1`` corresponds to the right edge of the row. ``x1`` should be greater than ``x0``. If specified as array, it must have the same length as ``x0``. [unitless] g0 : numeric From d47bc7093244609aa28389f4f65b56e15a85220e Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 17 Jun 2026 11:30:11 -0400 Subject: [PATCH 57/78] clarifications from review by @cwhanse --- pvlib/bifacial/ants2d.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 8dff4082b4..5714b7d734 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -13,9 +13,14 @@ def _shaded_fraction(tracker_rotation, phi, gcr, x0=0, x1=1): """ - Calculate fraction (from the bottom) of row slant height that is shaded + Calculate fraction of row slant height interal (x0, x1) that is shaded from direct irradiance by the row in front toward the sun. + The array is assumed to be installed on horizontal terrain, with + ``tracker_rotation`` and ``phi`` having their zero points facing + vertically. Inputs ``tracker_rotation``, ``phi``, and ``gcr`` must be + adjusted to account for terrain slope, if necessary. + Parameters ---------- tracker_rotation : numeric From 7062a29fc7f7e0d27183a4ab8fbe1bfb0fd2cc7c Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 17 Jun 2026 11:44:32 -0400 Subject: [PATCH 58/78] Update pvlib/bifacial/ants2d.py Co-authored-by: Cliff Hansen --- pvlib/bifacial/ants2d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 9216d58e18..c3a2635c07 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -368,7 +368,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, Tilt of the axis of rotation with respect to horizontal. [degree] cross_axis_slope : numeric, default 0 The angle, relative to horizontal, of the line formed by the - intersection between the slope containing the tracker axes and a plane + intersection between the plane containing the tracker axes and a plane perpendicular to the tracker axes. The cross-axis slope should be specified using a right-handed convention. For example, trackers with axis azimuth of 180 degrees (heading south) will have a negative From 944c51327e3183280fc187e669887e344e6a5572 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 17 Jun 2026 11:47:28 -0400 Subject: [PATCH 59/78] transfer whatsnew entries to 0.15.3 --- docs/sphinx/source/whatsnew/v0.15.2.rst | 20 -------------------- docs/sphinx/source/whatsnew/v0.15.3.rst | 20 ++++++++++++++++++++ 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.15.2.rst b/docs/sphinx/source/whatsnew/v0.15.2.rst index b087ba8a94..0f8f975f49 100644 --- a/docs/sphinx/source/whatsnew/v0.15.2.rst +++ b/docs/sphinx/source/whatsnew/v0.15.2.rst @@ -7,15 +7,6 @@ v0.15.2 (June 16, 2026) Deprecations ~~~~~~~~~~~~ -* Parameters ``npoints`` and ``vectorize`` in the following functions - are now unnecessary and are deprecated (:pull:`2740`): - - - :py:func:`pvlib.bifacial.infinite_sheds.get_irradiance` - - :py:func:`pvlib.bifacial.infinite_sheds.get_irradiance_poa` - - :py:func:`pvlib.bifacial.utils.vf_ground_sky_2d_integ` - -* Parameter ``surface_tilt`` is deprecated and renamed to ``tracker_rotation`` - in :py:func:`pvlib.bifacial.utils.vf_ground_sky_2d_integ` (:pull:`2740`). * Deprecate :py:attr:`pvlib.location.Location.pytz`. Use :py:attr:`~pvlib.location.Location.tz` instead. (:issue:`2343`, :pull:`2757`) * Rename :py:func:`!pvlib.irradiance.ghi_from_poa_driesse_2023` to @@ -26,9 +17,6 @@ Deprecations Bug fixes ~~~~~~~~~ -* Fix asymmetry in how ``max_rows`` is interpreted in - :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d`. The difference - should be negligible for typical simulations. (:issue:`1867`, :pull:`2740`) * Corrects a bug in :py:func:`pvlib.temperature.fuentes`. If inputs were data type integer, users can expect modeled cell temperature values to increase slightly. @@ -60,14 +48,6 @@ Enhancements * Added mapping of the parameter ``"albedo"`` in :py:func:`~pvlib.iotools.get_nasa_power` when ``map_variables=True`` (:pull:`2753`) -* Implement the ANTS-2D bifacial irradiance model in - :py:func:`pvlib.bifacial.ants2d.get_irradiance`. (:pull:`2740`) -* Add parameters ``g0`` and ``g1`` to allow segmented ground surfaces in - :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` and - :py:func:`~pvlib.bifacial.utils.vf_row_ground_2d_integ`. (:pull:`2740`) -* Accelerate :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` by one or - two orders of magnitude. This also makes :py:mod:`pvlib.bifacial.infinite_sheds` faster. - (:pull:`2740`) * Added mapping of the parameter ``"albedo"`` in :py:func:`~pvlib.iotools.get_nasa_power` when ``map_variables=True`` (:pull:`2753`) diff --git a/docs/sphinx/source/whatsnew/v0.15.3.rst b/docs/sphinx/source/whatsnew/v0.15.3.rst index 87ded069ee..c53b82ada1 100644 --- a/docs/sphinx/source/whatsnew/v0.15.3.rst +++ b/docs/sphinx/source/whatsnew/v0.15.3.rst @@ -10,10 +10,30 @@ Breaking Changes Deprecations ~~~~~~~~~~~~ +* Parameters ``npoints`` and ``vectorize`` in the following functions + are now unnecessary and are deprecated (:pull:`2740`): + + - :py:func:`pvlib.bifacial.infinite_sheds.get_irradiance` + - :py:func:`pvlib.bifacial.infinite_sheds.get_irradiance_poa` + - :py:func:`pvlib.bifacial.utils.vf_ground_sky_2d_integ` + +* Parameter ``surface_tilt`` is deprecated and renamed to ``tracker_rotation`` + in :py:func:`pvlib.bifacial.utils.vf_ground_sky_2d_integ` (:pull:`2740`). Bug fixes ~~~~~~~~~ +* Fix asymmetry in how ``max_rows`` is interpreted in + :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d`. The difference + should be negligible for typical simulations. (:issue:`1867`, :pull:`2740`) +* Implement the ANTS-2D bifacial irradiance model in + :py:func:`pvlib.bifacial.ants2d.get_irradiance`. (:pull:`2740`) +* Add parameters ``g0`` and ``g1`` to allow segmented ground surfaces in + :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` and + :py:func:`~pvlib.bifacial.utils.vf_row_ground_2d_integ`. (:pull:`2740`) +* Accelerate :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` by one or + two orders of magnitude. This also makes :py:mod:`pvlib.bifacial.infinite_sheds` faster. + (:pull:`2740`) Enhancements From f8575f96cda4ac252a22cfbfc55e22ee19ee0314 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 17 Jun 2026 11:48:24 -0400 Subject: [PATCH 60/78] fix bad merge --- docs/sphinx/source/whatsnew/v0.15.2.rst | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.15.2.rst b/docs/sphinx/source/whatsnew/v0.15.2.rst index 0f8f975f49..97383a5723 100644 --- a/docs/sphinx/source/whatsnew/v0.15.2.rst +++ b/docs/sphinx/source/whatsnew/v0.15.2.rst @@ -17,11 +17,6 @@ Deprecations Bug fixes ~~~~~~~~~ -* Corrects a bug in :py:func:`pvlib.temperature.fuentes`. If inputs were - 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` * Fix :py:func:`pvlib.temperature.fuentes` truncating the output if `poa_global` input was data type integer. Users can expect modeled cell temperature values to increase slightly. (:issue:`2608`, :pull:`2745`) @@ -39,18 +34,11 @@ Bug fixes parameter now has no effect and will be removed in the future. (:pull:`2781`) - Enhancements ~~~~~~~~~~~~ * Add the ``front_side_fraction`` parameter to :py:func:`pvlib.snow.loss_townsend` to support Townsend snow-loss workflows for bifacial systems. (:issue:`2755`, :pull:`2756`) -* Added mapping of the parameter ``"albedo"`` in - :py:func:`~pvlib.iotools.get_nasa_power` when ``map_variables=True`` - (:pull:`2753`) -* Added mapping of the parameter ``"albedo"`` in - :py:func:`~pvlib.iotools.get_nasa_power` when ``map_variables=True`` - (:pull:`2753`) * Add the following parameters to :py:func:`~pvlib.iotools.get_nasa_power` when ``map_variables=True``: ``temp_dew``, ``precipitable_water``, ``relative_humidity``, ``ghi_extra``, ``dhi_clear``, ``longwave_down``, From ba382ab9d0f1df48bb3f7dc6c826e1268d0868c5 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 17 Jun 2026 13:37:54 -0400 Subject: [PATCH 61/78] compute airmass, dni_extra as needed --- pvlib/bifacial/ants2d.py | 18 +++++++++++++++--- tests/bifacial/test_ants2d.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index a7a7d80bb2..0e318f352e 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -5,8 +5,11 @@ import numpy as np import pandas as pd from pvlib.tools import cosd, sind, tand, acosd +from pvlib.atmosphere import get_relative_airmass from pvlib.bifacial import utils -from pvlib.irradiance import aoi_projection, haydavies, perez, perez_driesse +from pvlib.irradiance import ( + aoi_projection, haydavies, perez, perez_driesse, get_extra_radiation +) from pvlib.shading import projected_solar_zenith_angle, shaded_fraction1d from pvlib.tracking import calc_surface_orientation @@ -352,10 +355,11 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, dni_extra : numeric, optional Extraterrestrial direct normal irradiance. Required when ``model='haydavies'``, ``model='perez'``, or ``model='perez_driese'``. - [Wm⁻²] + Computed from the datetime index of the inputs if not provided. [Wm⁻²] airmass : numeric, optional Relative airmass. Required when ``model='perez'`` or - ``model='perez_driese'``. [unitless] + ``model='perez_driese'``. Computed from ``solar_zenith`` if needed. + [unitless] row_segments : int or list of pairs, default 1 If ``row_segments`` is an int, it defines the number of equal-length segments the row width is divided into. Otherwise, it must be a list @@ -466,6 +470,14 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, # preparation steps + if airmass is None and model in ['perez', 'perez_driesse']: + airmass = get_relative_airmass(solar_zenith) + + if (dni_extra is None and + model in ['perez', 'perez_driesse', 'haydavies'] and + pd_index is not None and isinstance(pd_index, pd.DatetimeIndex)): + dni_extra = get_extra_radiation(pd_index) + dni, dhi = _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, solar_azimuth, dni_extra, airmass) diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index aaab72ca3a..3c60711941 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -206,6 +206,22 @@ def ants_params(): return inputs +@pytest.mark.parametrize('model', ['haydavies', 'perez', 'perez_driesse']) +def test_get_irradiance_airmass_dniextra(model, ants_params): + # verify that airmass, dni_extra are computed internally if not provided + ants_params.pop('dni_extra') + ants_params.pop('airmass') + # no error: + _ = ants2d.get_irradiance(**ants_params, model=model) + + # yes error (can't compute dni_extra without a datetime index): + for k, v in ants_params.items(): + if isinstance(v, pd.Series): + ants_params[k].index = [1, 2] + with pytest.raises(ValueError, match='Must supply dni_extra'): + _ = ants2d.get_irradiance(**ants_params, model=model) + + def test_get_irradiance_return_type(ants_params): # verify pandas in -> pandas out, and shapes of numpy outputs out = ants2d.get_irradiance(**ants_params, row_segments=1) From fdefe63e15a4f5af4676f850fe1165a7d1379709 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 17 Jun 2026 13:38:06 -0400 Subject: [PATCH 62/78] clarifying comments --- pvlib/bifacial/ants2d.py | 5 +++++ pvlib/bifacial/utils.py | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 0e318f352e..0abd5c37e5 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -219,6 +219,8 @@ def _ants2d_singleside(tracker_rotation, cos_aoi, phi, gcr, height, pitch, def _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, solar_azimuth, dni_extra, airmass): + # Section II.C.2 + if model in ['haydavies', 'perez', 'perez_driesse']: # determine circumsolar irradiance, add it to DNI @@ -268,6 +270,9 @@ def _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, solar_azimuth, def _apply_ground_slope(height, pitch, gcr, tracker_rotation, ghi, dni, dhi, solar_zenith, solar_azimuth, axis_tilt, axis_azimuth, cross_axis_slope): + # Section II.C.3, equations 14-19 + # transform all inputs to the rotated ANTS-2D frame, where the terrain + # is horizontal slope_azimuth = axis_azimuth + np.degrees( np.arctan2(sind(cross_axis_slope), cosd(cross_axis_slope) * sind(axis_tilt)) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 7cd753adf9..7255e314fd 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -589,8 +589,6 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, if np.isscalar(surface_tilt): squeeze.append(2) - # dimensions: k/max_rows, ground segment, row segment, time - # cheat a little to prevent numerical issues with surface_tilt==180, -180 surface_tilt = np.where(surface_tilt == 180, 179.9999, surface_tilt) surface_tilt = np.where(surface_tilt == -180, -179.9999, surface_tilt) @@ -598,6 +596,7 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, surface_tilt = \ np.atleast_1d(surface_tilt)[np.newaxis, np.newaxis, np.newaxis, :] + # dimensions: k/max_rows, ground segment, row segment, time x0 = np.atleast_1d(x0)[np.newaxis, np.newaxis, :, np.newaxis] x1 = np.atleast_1d(x1)[np.newaxis, np.newaxis, :, np.newaxis] g0 = np.atleast_1d(g0)[np.newaxis, :, np.newaxis, np.newaxis] From 2a7f57eb5cd156bb51c71c69874fbd236a7d4bdb Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 17 Jun 2026 13:39:54 -0400 Subject: [PATCH 63/78] lint --- pvlib/bifacial/ants2d.py | 4 ++-- tests/bifacial/test_utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 0abd5c37e5..67ee64d0ba 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -479,8 +479,8 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, airmass = get_relative_airmass(solar_zenith) if (dni_extra is None and - model in ['perez', 'perez_driesse', 'haydavies'] and - pd_index is not None and isinstance(pd_index, pd.DatetimeIndex)): + model in ['perez', 'perez_driesse', 'haydavies'] and + pd_index is not None and isinstance(pd_index, pd.DatetimeIndex)): dni_extra = get_extra_radiation(pd_index) dni, dhi = _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, diff --git a/tests/bifacial/test_utils.py b/tests/bifacial/test_utils.py index 7b0a7a5607..c6f55c2a00 100644 --- a/tests/bifacial/test_utils.py +++ b/tests/bifacial/test_utils.py @@ -37,7 +37,7 @@ def test_system_fixed_tilt(): # view factors from 3 points on the ground between rows to the sky pts = np.linspace(0, 1, num=3) sqr3 = np.sqrt(3) / 4 - + # c_ij = cos(angle from point i to a row edge, from left to right) c00 = (-2 - sqr3) / np.sqrt(1.25**2 + (2 + sqr3)**2) # left edge row -1 c01 = -sqr3 / np.sqrt(1.25**2 + sqr3**2) # left edge row 0 From 02bc88cbf219e1f606cb87886dbcfd5a460cf652 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 17 Jun 2026 13:42:03 -0400 Subject: [PATCH 64/78] apply suggested edit --- pvlib/bifacial/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 7255e314fd..3c4c9249e7 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -521,8 +521,8 @@ def vf_row_ground_2d(surface_tilt, gcr, x): def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, x0=0, x1=1, max_rows=20, g0=0, g1=1): r''' - Calculate the average view factor to the ground from a segment of the row - surface between x0 and x1. + Calculate the view factor to the ground segment (g0, g1) from the row + surface segment (x0, x1). Assumes a PV system of infinitely long rows with uniform pitch on horizontal ground. The view to the ground is restricted by the row's From f7c9830d41411c1c0276d75145b84459f2755a88 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 17 Jun 2026 13:43:39 -0400 Subject: [PATCH 65/78] Apply suggestions from code review Co-authored-by: Cliff Hansen --- pvlib/bifacial/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 3c4c9249e7..171f2c18bb 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -553,12 +553,12 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, max_rows : int, default 20 Maximum number of rows to consider in front and behind the current row. g0 : numeric, default 0 - Position on the ground surface, as a fraction of the row-to-row - spacing. ``g0=0`` corresponds to ground underneath the middle of the + Position on the ground surface, as a fraction of the spacing between + adjacent rows. ``g0=0`` corresponds to ground underneath the middle of the left row. ``g0`` should be less than ``g1``. [unitless] g1 : numeric, default 1 - Position on the ground surface, as a fraction of the row-to-row - spacing. ``g1=0`` corresponds to ground underneath the middle of the + Position on the ground surface, as a fraction of the spacing between + adjacent rows. ``g1=0`` corresponds to ground underneath the middle of the right row. ``g1`` should be greater than ``g0``. [unitless] Returns From 55056de41c26409feb14f2da26cf916372ae5243 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 17 Jun 2026 13:59:02 -0400 Subject: [PATCH 66/78] lint --- pvlib/bifacial/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 171f2c18bb..1dea2812c9 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -554,12 +554,12 @@ def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, Maximum number of rows to consider in front and behind the current row. g0 : numeric, default 0 Position on the ground surface, as a fraction of the spacing between - adjacent rows. ``g0=0`` corresponds to ground underneath the middle of the - left row. ``g0`` should be less than ``g1``. [unitless] + adjacent rows. ``g0=0`` corresponds to ground underneath the middle + of the left row. ``g0`` should be less than ``g1``. [unitless] g1 : numeric, default 1 Position on the ground surface, as a fraction of the spacing between - adjacent rows. ``g1=0`` corresponds to ground underneath the middle of the - right row. ``g1`` should be greater than ``g0``. [unitless] + adjacent rows. ``g1=0`` corresponds to ground underneath the middle + of the right row. ``g1`` should be greater than ``g0``. [unitless] Returns ------- From 80052b8940bdec32c75571eefdfd362a01c528aa Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 18 Jun 2026 14:22:35 -0400 Subject: [PATCH 67/78] address comment nit --- tests/bifacial/test_ants2d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 3c60711941..62f68624df 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -26,7 +26,7 @@ def test__shaded_fraction(): assert 0 == ants2d._shaded_fraction(10, 20, 0.5) assert 0 == ants2d._shaded_fraction(-10, -20, 0.5) - # sun behind the modules (AOI > 90) + # front surface facing away from sun (AOI > 90) # (debatable whether this should be zero or one) assert 0 == ants2d._shaded_fraction(45, -50, 0.5) assert 0 == ants2d._shaded_fraction(-45, 50, 0.5) From d456715a22188552d8b143288cf79226c76c364f Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Sun, 2 Aug 2026 19:19:57 +0200 Subject: [PATCH 68/78] Apply suggestions from code review Co-authored-by: Will Hobbs <45701090+williamhobbs@users.noreply.github.com> --- pvlib/bifacial/ants2d.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 67ee64d0ba..20c88d5cfe 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -221,7 +221,7 @@ def _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, solar_azimuth, # Section II.C.2 - if model in ['haydavies', 'perez', 'perez_driesse']: + if model in ['haydavies', 'perez', 'perez-driesse']: # determine circumsolar irradiance, add it to DNI if model == 'haydavies': @@ -230,7 +230,7 @@ def _apply_sky_diffuse_model(dni, dhi, model, solar_zenith, solar_azimuth, diffuse_model_func = haydavies extra_kwargs = {} - elif model in ['perez', 'perez_driesse']: + elif model in ['perez', 'perez-driesse']: # note: horizon brightening is ignored if dni_extra is None or airmass is None: raise ValueError( @@ -356,14 +356,14 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, (``n_ground_segments``, ``len(tracker_rotation)``). [unitless] model : str, default 'perez' Irradiance model - can be one of 'isotropic', 'haydavies', 'perez', - or 'perez_driesse'. + or 'perez-driesse'. dni_extra : numeric, optional Extraterrestrial direct normal irradiance. Required when - ``model='haydavies'``, ``model='perez'``, or ``model='perez_driese'``. + ``model='haydavies'``, ``model='perez'``, or ``model='perez-driesse'``. Computed from the datetime index of the inputs if not provided. [Wm⁻²] airmass : numeric, optional Relative airmass. Required when ``model='perez'`` or - ``model='perez_driese'``. Computed from ``solar_zenith`` if needed. + ``model='perez-driesse'``. Computed from ``solar_zenith`` if needed. [unitless] row_segments : int or list of pairs, default 1 If ``row_segments`` is an int, it defines the number of equal-length @@ -447,7 +447,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, Notes ----- - - ``model='perez_driesse'`` was not evaluated in [1]_ and is included + - ``model='perez-driesse'`` was not evaluated in [1]_ and is included in this implementation as an extension. References @@ -475,11 +475,11 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, # preparation steps - if airmass is None and model in ['perez', 'perez_driesse']: + if airmass is None and model in ['perez', 'perez-driesse']: airmass = get_relative_airmass(solar_zenith) if (dni_extra is None and - model in ['perez', 'perez_driesse', 'haydavies'] and + model in ['perez', 'perez-driesse', 'haydavies'] and pd_index is not None and isinstance(pd_index, pd.DatetimeIndex)): dni_extra = get_extra_radiation(pd_index) From 17d3a70500b263d2569f39021480e218f711ef4f Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Sun, 2 Aug 2026 19:32:08 +0200 Subject: [PATCH 69/78] update tests per review --- tests/bifacial/test_ants2d.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/bifacial/test_ants2d.py b/tests/bifacial/test_ants2d.py index 62f68624df..44ae3517cd 100644 --- a/tests/bifacial/test_ants2d.py +++ b/tests/bifacial/test_ants2d.py @@ -47,7 +47,7 @@ def test__shaded_fraction_x0x1(): assert_allclose(fs, np.array([[0.5, 0.0], [0.0, 0.5]])) -@pytest.mark.parametrize('model', ['perez', 'haydavies', 'perez_driesse']) +@pytest.mark.parametrize('model', ['perez', 'haydavies', 'perez-driesse']) def test__apply_sky_diffuse_model(model): inputs = {'dni': 900, 'dhi': 150, 'solar_zenith': 41, 'solar_azimuth': 67, 'dni_extra': 1360, 'airmass': 1.324} @@ -57,7 +57,7 @@ def test__apply_sky_diffuse_model(model): kwargs = inputs.copy() kwargs.pop('dni') kwargs.pop('dhi') - if model not in ['perez', 'perez_driesse']: + if model not in ['perez', 'perez-driesse']: kwargs.pop('airmass') kwargs['surface_tilt'] = 20 kwargs['surface_azimuth'] = 180 @@ -66,7 +66,7 @@ def test__apply_sky_diffuse_model(model): model='isotropic', **kwargs) func = {'perez': pvlib.irradiance.perez, 'haydavies': pvlib.irradiance.haydavies, - 'perez_driesse': pvlib.irradiance.perez_driesse}[model] + 'perez-driesse': pvlib.irradiance.perez_driesse}[model] diffuse = func(dni=inputs['dni'], dhi=inputs['dhi'], **kwargs, return_components=True) aoi_proj = pvlib.irradiance.aoi_projection(kwargs['surface_tilt'], @@ -92,7 +92,7 @@ def test__apply_sky_diffuse_model_errors(): ants2d._apply_sky_diffuse_model(0, 0, 'perez', None, None, None, None) with pytest.raises(ValueError, match='Must supply dni_extra and airmass'): - ants2d._apply_sky_diffuse_model(0, 0, 'perez_driesse', None, + ants2d._apply_sky_diffuse_model(0, 0, 'perez-driesse', None, None, None, None) with pytest.raises(ValueError, match='Invalid model: not_a_model'): ants2d._apply_sky_diffuse_model(0, 0, 'not_a_model', None, @@ -206,7 +206,7 @@ def ants_params(): return inputs -@pytest.mark.parametrize('model', ['haydavies', 'perez', 'perez_driesse']) +@pytest.mark.parametrize('model', ['haydavies', 'perez', 'perez-driesse']) def test_get_irradiance_airmass_dniextra(model, ants_params): # verify that airmass, dni_extra are computed internally if not provided ants_params.pop('dni_extra') @@ -520,7 +520,7 @@ def test_get_irradiance_nonuniform_albedo_limit(): 'poa_back_sky_diffuse': 5.643870374194696, 'poa_back_ground_diffuse': 10.026664442570222, 'shaded_fraction_back': 0.035915234551784025}), - ('perez_driesse', { + ('perez-driesse', { 'poa_front': 1064.6127452995743, 'poa_front_direct': 954.4678403911828, 'poa_front_diffuse': 110.14490490839144, From e915209d4761c5fb912b3ccca8769936c1f50ede Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Sun, 2 Aug 2026 19:44:06 +0200 Subject: [PATCH 70/78] add notes on fixed-tilt --- pvlib/bifacial/ants2d.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 20c88d5cfe..d9fb718376 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -329,9 +329,17 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, with ``axis_tilt=0`` and ``axis_azimuth=180``, ``tracker_theta > 0`` results in ``surface_azimuth`` to the West while ``tracker_theta < 0`` results in ``surface_azimuth`` to the East. [degree] + + For fixed-tilt systems, pretend the array is a tracker and input + the tilt as a scalar. For example, a 20 degree south-facing array + would have ``tracker_rotation=20`` and ``axis_azimuth=90``. axis_azimuth : numeric Axis azimuth angle in degrees. - North = 0°; East = 90°; South = 180°; West = 270° + North = 0°; East = 90°; South = 180°; West = 270°. + For fixed-tilt systems, add or subtract 90 degrees from the panel + azimuth so that ``tracker_rotation`` is still a right-handed rotation. + For south-facing arrays (azimuth=180°), set ``axis_azimuth=90``. + For north-facing arrays (azimuth=0°), set ``axis_azimuth=270``. solar_zenith : numeric Refraction-corrected solar zenith angle. [degree] solar_azimuth : numeric @@ -359,7 +367,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, or 'perez-driesse'. dni_extra : numeric, optional Extraterrestrial direct normal irradiance. Required when - ``model='haydavies'``, ``model='perez'``, or ``model='perez-driesse'``. + ``model='haydavies'``, ``model='perez'``, or ``model='perez-driesse'``. Computed from the datetime index of the inputs if not provided. [Wm⁻²] airmass : numeric, optional Relative airmass. Required when ``model='perez'`` or From 65fb24d41224e1cc21ec645de32e8ed97e9b0419 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Tue, 4 Aug 2026 16:29:08 +0200 Subject: [PATCH 71/78] fix bad whatsnew auto-merge --- docs/sphinx/source/whatsnew/v0.15.3.rst | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.15.3.rst b/docs/sphinx/source/whatsnew/v0.15.3.rst index 837382c9e1..98d237add4 100644 --- a/docs/sphinx/source/whatsnew/v0.15.3.rst +++ b/docs/sphinx/source/whatsnew/v0.15.3.rst @@ -19,6 +19,7 @@ Deprecations * Parameter ``surface_tilt`` is deprecated and renamed to ``tracker_rotation`` in :py:func:`pvlib.bifacial.utils.vf_ground_sky_2d_integ` (:pull:`2740`). +* Deprecate non-polynomial interpolation options in :py:func:`pvlib.iam.interp` (:issue:`2394`, :pull:`2741`) Bug fixes @@ -26,18 +27,6 @@ Bug fixes * Fix asymmetry in how ``max_rows`` is interpreted in :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d`. The difference should be negligible for typical simulations. (:issue:`1867`, :pull:`2740`) -* Implement the ANTS-2D bifacial irradiance model in - :py:func:`pvlib.bifacial.ants2d.get_irradiance`. (:pull:`2740`) -* Add parameters ``g0`` and ``g1`` to allow segmented ground surfaces in - :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` and - :py:func:`~pvlib.bifacial.utils.vf_row_ground_2d_integ`. (:pull:`2740`) -* Accelerate :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` by one or - two orders of magnitude. This also makes :py:mod:`pvlib.bifacial.infinite_sheds` faster. - (:pull:`2740`) -* Deprecate non-polynomial interpolation options in :py:func:`pvlib.iam.interp` (:issue:`2394`, :pull:`2741`) - -Bug fixes -~~~~~~~~~ * :py:func:`~pvlib.solarposition.sun_rise_set_transit_spa` now returns sunrise, sunset, and transit for the *local* calendar day of each input timestamp, restoring the behavior from before v0.11.1. Evening-local @@ -56,6 +45,14 @@ Enhancements :py:func:`~pvlib.iotools.get_nsrdb_psm4_polar_tmy`. (:issue:`2639`, :pull:`2807`) * Ensure all timezones are available in all OSs. (:issue:`2795`, :pull:`2809`) +* Implement the ANTS-2D bifacial irradiance model in + :py:func:`pvlib.bifacial.ants2d.get_irradiance`. (:pull:`2740`) +* Add parameters ``g0`` and ``g1`` to allow segmented ground surfaces in + :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` and + :py:func:`~pvlib.bifacial.utils.vf_row_ground_2d_integ`. (:pull:`2740`) +* Accelerate :py:func:`~pvlib.bifacial.utils.vf_ground_sky_2d_integ` by one or + two orders of magnitude. This also makes :py:mod:`pvlib.bifacial.infinite_sheds` faster. + (:pull:`2740`) Documentation From 0f0fe5f3a397f2cbd1e59ac24b7911652029267f Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 5 Aug 2026 09:54:09 +0200 Subject: [PATCH 72/78] tweaks to user's guide page --- .../source/user_guide/modeling_topics/bifacial.rst | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/sphinx/source/user_guide/modeling_topics/bifacial.rst b/docs/sphinx/source/user_guide/modeling_topics/bifacial.rst index 40b92c24c5..cdc0fd091a 100644 --- a/docs/sphinx/source/user_guide/modeling_topics/bifacial.rst +++ b/docs/sphinx/source/user_guide/modeling_topics/bifacial.rst @@ -12,7 +12,7 @@ the primary source of light. The primary challenge in modeling a PV system with bifacial modules is estimating the irradiance on the front and back surfaces. -pvlib-python provides two groups of functions for estimating front and back +pvlib-python provides three groups of functions for estimating front and back irradiance: 1. a wrapper for convenient use of the pvfactors model: @@ -22,6 +22,7 @@ irradiance: :py:func:`~pvlib.bifacial.infinite_sheds.get_irradiance` :py:func:`~pvlib.bifacial.infinite_sheds.get_irradiance_poa` +3. the ANTS-2D bifacial model: :py:func:`~pvlib.bifacial.ants2d.get_irradiance` pvfactors --------- @@ -50,7 +51,7 @@ after installation, Python code still accesses it as "pvfactors" Infinite Sheds -------------- -The "infinite sheds" model [1] is a 2-dimensional model of irradiance on the +The "infinite sheds" model [1]_ is a 2-dimensional model of irradiance on the front and rear surfaces of a PV array. The model assumes that the array comprises parallel, equally spaced rows (sheds) and calculates irradiance in the middle of a shed which is far from the front and back rows of the array. @@ -101,7 +102,7 @@ View factors from the ground to the sky are calculated at points spaced along a one-dimensional axis on the ground, with the origin under the center of a row and the positive direction toward the right. The positive direction is considered to be towards the "front" of the array. Array height differs in this -code from the description in [1], where array height is described at the row's +code from the description in [1]_, where array height is described at the row's lower edge. If ``model='isotropic'`` (the default), ``dhi`` is assumed to be isotropically @@ -111,7 +112,7 @@ the input ``dhi`` is decomposed into circumsolar and isotropic components using :py:func:`~pvlib.irradiance.haydavies`, with the circumsolar component treated as additional ``dni`` for transposition and shading purposes. -This model is influenced by the 2D model published by Marion, *et al.* in [2]. +This model is influenced by the 2D model published by Marion, *et al.* in [2]_. ANTS-2D @@ -126,6 +127,7 @@ relative to models of comparable capability like pvfactors. Finally, it can compute and return irradiance components at the ground level, which may be useful for agriPV modeling. +For details, see :py:func:`~pvlib.bifacial.ants2d.get_irradiance` and Ref. [3]_. References ---------- @@ -137,4 +139,7 @@ References Riley, D., Stein, J. and Hansen, C. "A Practical Irradiance Model for Bifacial PV Modules".2017 IEEE 44th Photovoltaic Specialists Conference (PVSC), 2017, pp. 1537-1543. doi: 10.1109/PVSC.2017.8366263 +.. [3] K. S. Anderson, A. R. Jensen, and C. W. Hansen, "A Bifacial View + Factor Model Considering Terrain Slope and Nonuniform Albedo," + IEEE JPV, 2026. :doi:`10.1109/JPHOTOV.2026.3677506` From e10394a1be770a388dacc2c6e398ba9634cbc80a Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 5 Aug 2026 11:50:56 +0200 Subject: [PATCH 73/78] changes from review --- pvlib/bifacial/infinite_sheds.py | 8 ++++---- pvlib/bifacial/utils.py | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pvlib/bifacial/infinite_sheds.py b/pvlib/bifacial/infinite_sheds.py index ccfdc42e03..48f455f38d 100644 --- a/pvlib/bifacial/infinite_sheds.py +++ b/pvlib/bifacial/infinite_sheds.py @@ -240,7 +240,7 @@ def get_irradiance_poa(surface_tilt, surface_azimuth, solar_zenith, Number of discretization points for calculating integrated view factors. - .. deprecated:: 0.15.2 + .. deprecated:: 0.16.0 Integrated view factors are now calculated analytically, so this parameter now has no effect and will be removed in the future. @@ -249,7 +249,7 @@ def get_irradiance_poa(surface_tilt, surface_azimuth, solar_zenith, If True, vectorize the view factor calculation across ``surface_tilt``. This increases speed with the cost of increased memory usage. - .. deprecated:: 0.15.2 + .. deprecated:: 0.16.0 Integrated view factors are now calculated more efficiently, so there is no need to disable vectorization to save memory. This parameter now has no effect and will be removed in the @@ -471,7 +471,7 @@ def get_irradiance(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, Number of discretization points for calculating integrated view factors. - .. deprecated:: 0.15.2 + .. deprecated:: 0.16.0 This parameter has no effect; integrated view factors are now calculated exactly instead of with discretized approximations. @@ -481,7 +481,7 @@ def get_irradiance(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, If True, vectorize the view factor calculation across ``surface_tilt``. This increases speed with the cost of increased memory usage. - .. deprecated:: 0.15.2 + .. deprecated:: 0.16.0 This parameter has no effect; calculations are now vectorized with no memory usage penality. diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 1dea2812c9..ffcad6b690 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -238,7 +238,7 @@ def vf_ground_sky_2d(rotation, gcr, x, pitch, height, max_rows=10): return vf -@renamed_kwarg_warning("0.15.2", "surface_tilt", "tracker_rotation") +@renamed_kwarg_warning("0.16.0", "surface_tilt", "tracker_rotation") def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, max_rows=10, npoints=None, vectorize=None, g0=0, g1=1): """ @@ -262,7 +262,7 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, max_rows=10, Maximum number of rows to consider in front and behind the current row. npoints : int, optional - .. deprecated:: 0.15.2 + .. deprecated:: 0.16.0 This parameter has no effect; integrated view factors are now calculated exactly instead of with discretized approximations. @@ -270,7 +270,7 @@ def vf_ground_sky_2d_integ(tracker_rotation, gcr, height, pitch, max_rows=10, vectorize : bool, optional - .. deprecated:: 0.15.2 + .. deprecated:: 0.16.0 This parameter has no effect; calculations are now vectorized with no memory usage penalty. @@ -521,8 +521,8 @@ def vf_row_ground_2d(surface_tilt, gcr, x): def vf_row_ground_2d_integ(surface_tilt, gcr, height=None, pitch=None, x0=0, x1=1, max_rows=20, g0=0, g1=1): r''' - Calculate the view factor to the ground segment (g0, g1) from the row - surface segment (x0, x1). + Calculate the view factor to the ground segment (``g0``, ``g1``) from + the row surface segment (``x0``, ``x1``). Assumes a PV system of infinitely long rows with uniform pitch on horizontal ground. The view to the ground is restricted by the row's From ea37d3daad31bb8d79ecf59dbed7c3e1c451ff76 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 5 Aug 2026 11:53:29 +0200 Subject: [PATCH 74/78] Update pvlib/bifacial/utils.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> --- pvlib/bifacial/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 1dea2812c9..265d941f5f 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -73,11 +73,11 @@ def _unshaded_ground_fraction(tracker_rotation, phi, gcr, height=None, g0 : numeric, default 0 Left position on the ground surface, as a fraction of the row-to-row spacing. ``g0=0`` corresponds to ground underneath the center of the - left row. ``g0`` should be less than ``g1``. [unitless] + left tracker post. ``g0`` should be less than ``g1``. [unitless] g1 : numeric, default 1 Right position on the ground surface, as a fraction of the row-to-row spacing. ``g1=1`` corresponds to ground underneath the center of the - right row. ``g1`` should be greater than ``g0``. [unitless] + right tracker post. ``g1`` should be greater than ``g0``. [unitless] Returns ------- From a397bd777a8e3d283fbd758dd10d75d7b083f50d Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 5 Aug 2026 11:53:39 +0200 Subject: [PATCH 75/78] Update pvlib/bifacial/utils.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> --- pvlib/bifacial/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py index 265d941f5f..c5d566ede6 100644 --- a/pvlib/bifacial/utils.py +++ b/pvlib/bifacial/utils.py @@ -442,8 +442,8 @@ def vf_row_sky_2d_integ(surface_tilt, gcr, x0=0, x1=1): Ratio of the row slant length to the row spacing (pitch). [unitless] x0 : numeric, default 0 Position on the row's slant length, as a fraction of the slant length. - ``x0=0`` corresponds to the bottom of the row. ``x0`` should be less - than ``x1``. [unitless] + ``x0=0`` corresponds to the lower edge of the row. ``x0`` should + be less than ``x1``. [unitless] x1 : numeric, default 1 Position on the row's slant length, as a fraction of the slant length. ``x1`` should be greater than ``x0``. [unitless] From 6e39273afab445d09ad1ecf8d77fbfde4332d501 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 5 Aug 2026 11:53:46 +0200 Subject: [PATCH 76/78] Update pvlib/bifacial/ants2d.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> --- pvlib/bifacial/ants2d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index d9fb718376..601dd0b712 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -305,7 +305,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, """ Get front and rear irradiance using the ANTS-2D bifacial irradiance model. - The ANTS-2D model [1] assumes the PV system comprises parallel, + The ANTS-2D model [1]_ assumes the PV system comprises parallel, evenly spaced rows on flat or uniformly sloped ground. Rows can be on fixed racking or single axis trackers. The model calculates irradiance at a location far from the ends of any rows, in effect, assuming that the From 6ee438bf1e52cf80a91a156e485fb6f7213ab97b Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 5 Aug 2026 11:53:54 +0200 Subject: [PATCH 77/78] Update pvlib/bifacial/ants2d.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> --- pvlib/bifacial/ants2d.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 601dd0b712..1b1eb67e5e 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -442,10 +442,11 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, rows. [unitless] ground_irradiance : dict or DataFrame + Only returned when ``return_ground_components=True``. ``ground_irradiance`` is a DataFrame when inputs are Series and ``n_ground_segments=1``, a dict of scalars when inputs are scalars and ``n_ground_segments=1``, and a dict of ``np.ndarray`` - otherwise. Only returned when ``return_ground_components=True``. + otherwise. The following quantities are included: - ``ground_direct``: direct irradiance incident on the ground surface. From d05d9617d6e8bb12f1b4324242857265389913ad Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 5 Aug 2026 12:02:25 +0200 Subject: [PATCH 78/78] lint --- pvlib/bifacial/ants2d.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pvlib/bifacial/ants2d.py b/pvlib/bifacial/ants2d.py index 1b1eb67e5e..1aac0651d3 100644 --- a/pvlib/bifacial/ants2d.py +++ b/pvlib/bifacial/ants2d.py @@ -320,6 +320,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, - shading of module surfaces by nearby rows. - nonuniform ground albedo. - sloped ground surface. + - cell-level irradiance nonuniformity. Parameters ---------- @@ -446,7 +447,7 @@ def get_irradiance(tracker_rotation, axis_azimuth, solar_zenith, solar_azimuth, ``ground_irradiance`` is a DataFrame when inputs are Series and ``n_ground_segments=1``, a dict of scalars when inputs are scalars and ``n_ground_segments=1``, and a dict of ``np.ndarray`` - otherwise. + otherwise. The following quantities are included: - ``ground_direct``: direct irradiance incident on the ground surface.