From d6d6f31e026a0673ed746df0d150b764871861a2 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 29 Sep 2025 14:15:47 -0400 Subject: [PATCH 01/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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)