diff --git a/docs/sphinx/source/reference/bifacial.rst b/docs/sphinx/source/reference/bifacial.rst index f19195429f..2f70d0d883 100644 --- a/docs/sphinx/source/reference/bifacial.rst +++ b/docs/sphinx/source/reference/bifacial.rst @@ -3,9 +3,11 @@ Bifacial ======== -Methods for calculating back surface irradiance +Functions for calculating front and back surface irradiance .. autosummary:: :toctree: generated/ - bifacial.pvfactors_timeseries + bifacial.pvfactors.pvfactors_timeseries + bifacial.infinite_sheds.get_irradiance + bifacial.infinite_sheds.get_irradiance_poa diff --git a/docs/sphinx/source/user_guide/bifacial.rst b/docs/sphinx/source/user_guide/bifacial.rst new file mode 100644 index 0000000000..ec18d04a5a --- /dev/null +++ b/docs/sphinx/source/user_guide/bifacial.rst @@ -0,0 +1,105 @@ +.. _bifacial: + +Bifacial modeling +================= + +This section reviews the bifacial modeling capabilities of +pvlib-python. + +A bifacial module accepts light on both surfaces. Bifacial modules usually have +a front and back surface, with the back surface intended to face away from +the primary source of light. The primary challenge in modeling a PV system +with bifacial modules is estimating the irradiance on the front and back +surfaces. + +pvlib-python provides two groups of functions for estimating front and back +irradiance: + +1. a wrapper for convenient use of the pvfactors package: +:py:func:`~pvlib.bifacial.pvfactors.pvfactors_timeseries` + +2. the infinite sheds bifacial model: +:py:func:`~pvlib.bifacial.infinite_sheds.get_irradiance` +:py:func:`~pvlib.bifacial.infinite_sheds.get_irradiance_poa` + + +pvfactors +========= + +The `pvfactors `_ package calculates +incident irradiance on the front and back surfaces of an array. pvfactors uses +a 2D geometry which assumes that the array is made up of long, regular rows. +Irradiance is calculated in the middle of a row; end-of-row effects are not +included. pvfactors can model arrays in fixed racking or on single-axis +trackers. + + +Infinite Sheds +============== + +The "infinite sheds" model [1] is a 2-dimensional model of irradiance on the +front and rear surfaces of a PV array. The model assumes that the array +comprises parallel, equally spaced rows (sheds) and calculates irradiance in +the middle of a shed which is far from the front and back rows of the array. +Sheds are assumed to be long enough that end-of-row effects can be +neglected. Rows can be at fixed tilt or on single-axis trackers. The ground +is assumed to be horizontal and level, and the array is mounted at a fixed +height above the ground. + +The infinite sheds model accounts for the following effects: + + - limited view from the row surfaces to the sky due to blocking of the + sky by nearby rows; + - reduction of irradiance reaching the ground due to shadows cast by + rows and due to blocking of the sky by nearby rows. + +The model operates in the following steps: + +1. Find the fraction of unshaded ground between rows, ``f_gnd_beam`` where + both direct and diffuse irradiance is received. The model assumes that + there is no direct irradiance in the shaded fraction ``1 - f_gnd_beam``. +2. Calculate the view factor, ``fz_sky``, from the ground to the sky accounting + for the parts of the sky that are blocked from view by the array's rows. + The view factor is multiplied by the sky diffuse irradiance to calculate + the diffuse irradiance reaching the ground. Sky diffuse irradiance is thus + assumed to be isotropic. +3. Calculate the view factor from the row surface to the ground which + determines the fraction of ground-reflected irradiance that reaches the row + surface. +4. Find the fraction of the row surface that is shaded from direct irradiance. + Only sky and ground-reflected irradiance reach the the shaded fraction of + the row surface. +5. For the front and rear surfaces, apply the incidence angle modifier to + the direct irradiance and sum the diffuse sky, diffuse ground, and direct + irradiance to compute the plane-of-array (POA) irradiance on each surface. +6. Apply the bifaciality factor, shade factor and transmission factor to + the rear surface POA irradiance and add the result to the front surface + POA irradiance to calculate the total POA irradiance on the row. + +Array geometry is defined by the following: + + - ground coverage ratio (GCR), ``gcr``, the ratio of row slant height to + the spacing between rows (pitch). + - height of row center above ground, ``height``. + - tilt of the row from horizontal, ``surface_tilt``. + - azimuth of the row's normal vector, ``surface_azimuth``. +View factors from the ground to the sky are calculated at points spaced along +a one-dimensional axis on the ground, with the origin under the center of a +row and the positive direction toward the right. The positive direction is +considered to be towards the "front" of the array. Array height differs in this +code from the description in [1], where array height is described at the row's +lower edge. + +This model is influenced by the 2D model published by Marion, *et al.* in [2]. + +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] Marion. B., MacAlpine, S., Deline, C., Asgharzadeh, A., Toor, F., + Riley, D., Stein, J. and Hansen, C. "A Practical Irradiance Model for + Bifacial PV Modules".2017 IEEE 44th Photovoltaic Specialists Conference + (PVSC), 2017, pp. 1537-1543. doi: 10.1109/PVSC.2017.8366263 + diff --git a/docs/sphinx/source/user_guide/index.rst b/docs/sphinx/source/user_guide/index.rst index 1ee6059acb..3a0c204ffa 100644 --- a/docs/sphinx/source/user_guide/index.rst +++ b/docs/sphinx/source/user_guide/index.rst @@ -13,6 +13,7 @@ User Guide modelchain timetimezones clearsky + bifacial forecasts comparison_pvlib_matlab variables_style_rules diff --git a/docs/sphinx/source/whatsnew/v0.9.1.rst b/docs/sphinx/source/whatsnew/v0.9.1.rst index fe731bbd66..55cac900b2 100644 --- a/docs/sphinx/source/whatsnew/v0.9.1.rst +++ b/docs/sphinx/source/whatsnew/v0.9.1.rst @@ -8,6 +8,9 @@ Breaking changes Deprecations ~~~~~~~~~~~~ +* Moved :py:func:`pvlib.bifacial.pvfactors_timeseries` to + :py:func:`pvlib.bifacial.pvfactors.pvfactors_timeseries`. + :py:module:`pvlib.bifacial` is now a sub-package. (:pull:`717`) * :py:func:`pvlib.modelchain.basic_chain` is deprecated. See :py:meth:`pvlib.modelchain.ModelChain.with_pvwatts` and :py:meth:`pvlib.modelchain.ModelChain.with_sapm` for alternative simplified @@ -16,6 +19,8 @@ Deprecations Enhancements ~~~~~~~~~~~~ +* Added `pvlib.bifacial.infinite_sheds`, containing a model for irradiance + on front and back surfaces of bifacial arrays. (:pull:`717`) * Added ``map_variables`` option to :func:`~pvlib.iotools.read_crn` (:pull:`1368`) * Added :py:func:`pvlib.temperature.prilliman` for modeling cell temperature at short time steps (:issue:`1081`, :pull:`1391`) diff --git a/pvlib/bifacial/__init__.py b/pvlib/bifacial/__init__.py new file mode 100644 index 0000000000..0a6b98d4f5 --- /dev/null +++ b/pvlib/bifacial/__init__.py @@ -0,0 +1,13 @@ +""" +The ``bifacial`` module contains functions to model irradiance for bifacial +modules. + +""" +from pvlib._deprecation import deprecated +from pvlib.bifacial import pvfactors, infinite_sheds, utils # noqa: F401 + +pvfactors_timeseries = deprecated( + since='0.9.1', + name='pvlib.bifacial.pvfactors_timeseries', + alternative='pvlib.bifacial.pvfactors.pvfactors_timeseries' +)(pvfactors.pvfactors_timeseries) diff --git a/pvlib/bifacial/infinite_sheds.py b/pvlib/bifacial/infinite_sheds.py new file mode 100644 index 0000000000..3dd37e3b05 --- /dev/null +++ b/pvlib/bifacial/infinite_sheds.py @@ -0,0 +1,767 @@ +r""" +Functions for the infinite sheds bifacial irradiance model. +""" + +import numpy as np +import pandas as pd +from pvlib.tools import cosd, sind, tand +from pvlib.bifacial import utils +from pvlib.shading import masking_angle +from pvlib.irradiance import beam_component, aoi + + +def _vf_ground_sky_integ(surface_tilt, surface_azimuth, gcr, height, + pitch, max_rows=10, npoints=100): + """ + Integrated and per-point view factors from the ground to the sky at points + between interior rows of the array. + + Parameters + ---------- + surface_tilt : numeric + Surface tilt angle in degrees from horizontal, e.g., surface facing up + = 0, surface facing horizon = 90. [degree] + surface_azimuth : numeric + Surface azimuth angles in decimal degrees east of north + (e.g. North = 0, South = 180, East = 90, West = 270). + ``surface_azimuth`` must be >=0 and <=360. + gcr : float + Ratio of row slant length to row spacing (pitch). [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``. + 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. + + Returns + ------- + fgnd_sky : float + Integration of view factor over the length between adjacent, interior + rows. [unitless] + fz : ndarray + Fraction of distance from the previous row to the next row. [unitless] + fz_sky : ndarray + View factors at discrete points between adjacent, interior rows. + [unitless] + + """ + # TODO: vectorize over surface_tilt + # Abuse utils._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) + fz_sky = np.zeros((len(rotation), npoints)) + for k, r in enumerate(rotation): + vf, _ = utils._vf_ground_sky_2d(z, r, gcr, pitch, height, max_rows) + fz_sky[k, :] = vf + # calculate the integrated view factor for all of the ground between rows + return np.trapz(fz_sky, z, axis=1) + + +def _poa_ground_shadows(poa_ground, f_gnd_beam, df, vf_gnd_sky): + """ + Reduce ground-reflected irradiance to the tilted plane (poa_ground) to + account for shadows on the ground. + + Parameters + ---------- + poa_ground : numeric + Ground reflected irradiance on the tilted surface, assuming full GHI + illumination on all of the ground. [W/m^2] + f_gnd_beam : numeric + Fraction of the distance between rows that is illuminated (unshaded). + [unitless] + df : numeric + Diffuse fraction, the ratio of DHI to GHI. [unitless] + vf_gnd_sky : numeric + View factor from the ground to the sky, integrated along the distance + between rows. [unitless] + + Returns + ------- + poa_gnd_sky : numeric + Adjusted ground-reflected irradiance accounting for shadows on the + ground. [W/m^2] + + """ + return poa_ground * (f_gnd_beam*(1 - df) + df*vf_gnd_sky) + + +def _vf_row_sky_integ(f_x, surface_tilt, gcr, npoints=100): + """ + Integrated view factors from the shaded and unshaded parts of + the row slant height to the sky. + + Parameters + ---------- + f_x : numeric + Fraction of row slant height from the bottom that is shaded. [unitless] + surface_tilt : numeric + Surface tilt angle in degrees from horizontal, e.g., surface facing up + = 0, surface facing horizon = 90. [degree] + gcr : float + Ratio of row slant length to row spacing (pitch). [unitless] + npoints : int, default 100 + Number of points for integration. [unitless] + + Returns + ------- + vf_shade_sky_integ : numeric + Integrated view factor from the shaded part of the row to the sky. + [unitless] + vf_noshade_sky_integ : numeric + Integrated view factor from the unshaded part of the row to the sky. + [unitless] + + Notes + ----- + The view factor to the sky at a point x along the row slant height is + given by + + .. math :: + \\large{f_{sky} = \frac{1}{2} \\left(\\cos\\left(\\psi_t\\right) + + \\cos \\left(\\beta\\right) \\right) + + where :math:`\\psi_t` is the angle from horizontal of the line from point + x to the top of the facing row, and :math:`\\beta` is the surface tilt. + + View factors are integrated separately over shaded and unshaded portions + of the row slant height. + + """ + # handle Series inputs + surface_tilt = np.array(surface_tilt) + cst = cosd(surface_tilt) + # shaded portion + x = np.linspace(0, f_x, num=npoints) + psi_t_shaded = masking_angle(surface_tilt, gcr, x) + y = 0.5 * (cosd(psi_t_shaded) + cst) + # integrate view factors from each point in the discretization. This is an + # improvement over the algorithm described in [2] + vf_shade_sky_integ = np.trapz(y, x, axis=0) + # unshaded portion + x = np.linspace(f_x, 1., num=npoints) + psi_t_unshaded = masking_angle(surface_tilt, gcr, x) + y = 0.5 * (cosd(psi_t_unshaded) + cst) + vf_noshade_sky_integ = np.trapz(y, x, axis=0) + return vf_shade_sky_integ, vf_noshade_sky_integ + + +def _poa_sky_diffuse_pv(f_x, dhi, vf_shade_sky_integ, vf_noshade_sky_integ): + """ + Sky diffuse POA from integrated view factors combined for both shaded and + unshaded parts of the surface. + + Parameters + ---------- + f_x : numeric + Fraction of row slant height from the bottom that is shaded. [unitless] + dhi : numeric + Diffuse horizontal irradiance (DHI). [W/m^2] + vf_shade_sky_integ : numeric + Integrated view factor from the shaded part of the row to the sky. + [unitless] + vf_noshade_sky_integ : numeric + Integrated view factor from the unshaded part of the row to the sky. + [unitless] + + Returns + ------- + poa_sky_diffuse_pv : numeric + Total sky diffuse irradiance incident on the PV surface. [W/m^2] + """ + return dhi * (f_x * vf_shade_sky_integ + (1 - f_x) * vf_noshade_sky_integ) + + +def _ground_angle(x, surface_tilt, gcr): + """ + Angle from horizontal of the line from a point x on the row slant length + to the bottom of the facing row. + + The angles are clockwise from horizontal, rather than the usual + counterclockwise direction. + + Parameters + ---------- + x : numeric + fraction of row slant length from bottom, ``x = 0`` is at the row + bottom, ``x = 1`` is at the top of the row. + surface_tilt : numeric + Surface tilt angle in degrees from horizontal, e.g., surface facing up + = 0, surface facing horizon = 90. [degree] + gcr : float + ground coverage ratio, ratio of row slant length to row spacing. + [unitless] + + Returns + ------- + psi : numeric + Angle [degree]. + """ + # : \\ \ + # : \\ \ + # : \\ \ + # : \\ \ facing row + # : \\.___________\ + # : \ ^*-. psi \ + # : \ x *-. \ + # : \ v *-.\ + # : \<-----P---->\ + + x1 = x * sind(surface_tilt) + x2 = (x * cosd(surface_tilt) + 1 / gcr) + psi = np.arctan2(x1, x2) # do this first because it handles 0 / 0 + return np.rad2deg(psi) + + +def _vf_row_ground(x, surface_tilt, gcr): + """ + View factor from a point x on the row to the ground. + + Parameters + ---------- + x : numeric + Fraction of row slant height from the bottom. [unitless] + surface_tilt : numeric + Surface tilt angle in degrees from horizontal, e.g., surface facing up + = 0, surface facing horizon = 90. [degree] + gcr : float + Ground coverage ratio, ratio of row slant length to row spacing. + [unitless] + + Returns + ------- + vf : numeric + View factor from the point at x to the ground. [unitless] + + """ + cst = cosd(surface_tilt) + # angle from horizontal at the point x on the row slant height to the + # bottom of the facing row + psi_t_shaded = _ground_angle(x, surface_tilt, gcr) + # view factor from the point on the row to the ground + return 0.5 * (cosd(psi_t_shaded) - cst) + + +def _vf_row_ground_integ(f_x, surface_tilt, gcr, npoints=100): + """ + View factors to the ground from shaded and unshaded parts of a row. + + Parameters + ---------- + f_x : numeric + Fraction of row slant height from the bottom that is shaded. [unitless] + surface_tilt : numeric + Surface tilt angle in degrees from horizontal, e.g., surface facing up + = 0, surface facing horizon = 90. [degree] + gcr : float + Ground coverage ratio, ratio of row slant length to row spacing. + [unitless] + npoints : int, default 100 + Number of points for integration. [unitless] + + Returns + ------- + vf_shade_ground_integ : numeric + View factor from the shaded portion of the row to the ground. + [unitless] + vf_noshade_ground_integ : numeric + View factor from the unshaded portion of the row to the ground. + [unitless] + + Notes + ----- + The view factor to the ground at a point x along the row slant height is + given by + + .. math :: + \\large{f_{gr} = \frac{1}{2} \\left(\\cos\\left(\\psi_t\\right) - + \\cos \\left(\\beta\\right) \\right) + + where :math:`\\psi_t` is the angle from horizontal of the line from point + x to the bottom of the facing row, and :math:`\\beta` is the surface tilt. + + Each view factor is integrated over the relevant portion of the row + slant height. + """ + # handle Series inputs + surface_tilt = np.array(surface_tilt) + # shaded portion of row slant height + x = np.linspace(0, f_x, num=npoints) + # view factor from the point on the row to the ground + y = _vf_row_ground(x, surface_tilt, gcr) + # integrate view factors along the shaded portion of the row slant height. + # This is an improvement over the algorithm described in [2] + vf_shade_ground_integ = np.trapz(y, x, axis=0) + + # unshaded portion of row slant height + x = np.linspace(f_x, 1., num=npoints) + # view factor from the point on the row to the ground + y = _vf_row_ground(x, surface_tilt, gcr) + # integrate view factors along the unshaded portion. + # This is an improvement over the algorithm described in [2] + vf_noshade_ground_integ = np.trapz(y, x, axis=0) + + return vf_shade_ground_integ, vf_noshade_ground_integ + + +def _poa_ground_pv(f_x, poa_ground, f_gnd_pv_shade, f_gnd_pv_noshade): + """ + Reduce ground-reflected irradiance to account for limited view of the + ground from the row surface. + + Parameters + ---------- + f_x : numeric + Fraction of row slant height from the bottom that is shaded. [unitless] + poa_ground : numeric + Ground-reflected irradiance that would reach the row surface if the + full ground was visible. poa_gnd_sky accounts for limited view of the + sky from the ground. [W/m^2] + f_gnd_pv_shade : numeric + fraction of ground visible from shaded part of PV surface. [unitless] + f_gnd_pv_noshade : numeric + fraction of ground visible from unshaded part of PV surface. [unitless] + + Returns + ------- + numeric + Ground diffuse irradiance on the row plane. [W/m^2] + """ + return poa_ground * (f_x * f_gnd_pv_shade + (1 - f_x) * f_gnd_pv_noshade) + + +def _shaded_fraction(solar_zenith, solar_azimuth, surface_tilt, + surface_azimuth, gcr): + """ + 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 + ---------- + 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] + + 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 + """ + tan_phi = utils._solar_projection_tangent( + solar_zenith, solar_azimuth, surface_azimuth) + # length of shadow behind a row as a fraction of pitch + x = gcr * (sind(surface_tilt) * tan_phi + cosd(surface_tilt)) + f_x = 1 - 1. / x + # set f_x to be 1 when sun is behind the array + ao = aoi(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth) + f_x = np.where(ao < 90, f_x, 1.) + # when x < 1, the shadow is not long enough to fall on the row surface + f_x = np.where(x > 1., f_x, 0.) + return f_x + + +def get_irradiance_poa(surface_tilt, surface_azimuth, solar_zenith, + solar_azimuth, gcr, height, pitch, ghi, dhi, dni, + albedo, iam=1.0, npoints=100): + 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 + ---------- + 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] + + 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 + Number of points used to discretize distance along the ground. + + Returns + ------- + output : dict or DataFrame + Output is a DataFrame when input ghi is a Series. 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] + + 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 + """ + # Calculate some geometric quantities + # rows to consider in front and behind current row + # ensures that view factors to the sky are computed to within 5 degrees + # of the horizon + 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) + # 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 = _vf_ground_sky_integ( + surface_tilt, surface_azimuth, gcr, height, pitch, max_rows, npoints) + # fraction of row slant height that is shaded from direct irradiance + f_x = _shaded_fraction(solar_zenith, solar_azimuth, surface_tilt, + surface_azimuth, gcr) + + # Integrated view factors to the sky from the shaded and unshaded parts of + # the row slant height + # Differs from [1] Eq. 15 and Eq. 16. Here, we integrate over each + # interval (shaded or unshaded) rather than averaging values at each + # interval's end points. + vf_shade_sky, vf_noshade_sky = _vf_row_sky_integ( + f_x, surface_tilt, gcr, npoints) + + # view factors from the ground to shaded and unshaded portions of the row + # slant height + # Differs from [1] Eq. 17 and Eq. 18. Here, we integrate over each + # interval (shaded or unshaded) rather than averaging values at each + # interval's end points. + f_gnd_pv_shade, f_gnd_pv_noshade = _vf_row_ground_integ( + f_x, surface_tilt, gcr, npoints) + + # Total sky diffuse received by both shaded and unshaded portions + poa_sky_pv = _poa_sky_diffuse_pv( + f_x, dhi, vf_shade_sky, vf_noshade_sky) + + # irradiance reflected from the ground before accounting for shadows + # and restricted views + # this is a deviation from [1], because the row to ground view factor + # is accounted for in a different manner + ground_diffuse = ghi * albedo + + # diffuse fraction + diffuse_fraction = np.clip(dhi / ghi, 0., 1.) + # make diffuse fraction 0 when ghi is small + diffuse_fraction = np.where(ghi < 0.0001, 0., diffuse_fraction) + + # Reduce ground-reflected irradiance because other rows in the array + # block irradiance from reaching the ground. + # [2], Eq. 9 + ground_diffuse = _poa_ground_shadows( + ground_diffuse, f_gnd_beam, diffuse_fraction, vf_gnd_sky) + + # Ground-reflected irradiance on the row surface accounting for + # the view to the ground. This deviates from [1], Eq. 10, 11 and + # subsequent. Here, the row to ground view factor is computed. In [1], + # the usual ground-reflected irradiance includes the single row to ground + # view factor (1 - cos(tilt))/2, and Eq. 10, 11 and later multiply + # this quantity by a ratio of view factors. + poa_gnd_pv = _poa_ground_pv( + f_x, ground_diffuse, f_gnd_pv_shade, f_gnd_pv_noshade) + + # add sky and ground-reflected irradiance on the row by irradiance + # component + poa_diffuse = poa_gnd_pv + poa_sky_pv + # beam on plane, make an array for consistency with poa_diffuse + poa_beam = np.atleast_1d(beam_component( + surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, dni)) + poa_direct = poa_beam * (1 - f_x) * iam # direct only on the unshaded part + poa_global = poa_direct + poa_diffuse + + output = { + 'poa_global': poa_global, 'poa_direct': poa_direct, + 'poa_diffuse': poa_diffuse, 'poa_ground_diffuse': poa_gnd_pv, + 'poa_sky_diffuse': poa_sky_pv} + if isinstance(poa_global, pd.Series): + output = pd.DataFrame(output) + return output + + +def get_irradiance(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, + gcr, height, pitch, ghi, dhi, dni, + albedo, iam_front=1.0, iam_back=1.0, + bifaciality=0.8, shade_factor=-0.02, + transmission_factor=0, npoints=100): + """ + 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 + ---------- + 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] + + 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 + Number of points used to discretize distance along the ground. + + 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] + - ``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] + + 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 + """ + # backside is rotated and flipped relative to front + backside_tilt, backside_sysaz = _backside(surface_tilt, surface_azimuth) + # front side POA irradiance + irrad_front = get_irradiance_poa( + surface_tilt=surface_tilt, surface_azimuth=surface_azimuth, + solar_zenith=solar_zenith, solar_azimuth=solar_azimuth, + gcr=gcr, height=height, pitch=pitch, ghi=ghi, dhi=dhi, dni=dni, + albedo=albedo, iam=iam_front, npoints=npoints) + # back side POA irradiance + irrad_back = get_irradiance_poa( + surface_tilt=backside_tilt, surface_azimuth=backside_sysaz, + solar_zenith=solar_zenith, solar_azimuth=solar_azimuth, + gcr=gcr, height=height, pitch=pitch, ghi=ghi, dhi=dhi, dni=dni, + albedo=albedo, iam=iam_back, npoints=npoints) + + 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', + } + colmap_back = { + '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', + } + + if isinstance(ghi, pd.Series): + irrad_front = irrad_front.rename(columns=colmap_front) + irrad_back = irrad_back.rename(columns=colmap_back) + output = pd.concat([irrad_front, irrad_back], axis=1) + else: + for old_key, new_key in colmap_front.items(): + irrad_front[new_key] = irrad_front.pop(old_key) + for old_key, new_key in colmap_back.items(): + irrad_back[new_key] = irrad_back.pop(old_key) + irrad_front.update(irrad_back) + output = irrad_front + + effects = (1 + shade_factor) * (1 + transmission_factor) + output['poa_global'] = output['poa_front'] + \ + output['poa_back'] * bifaciality * effects + return output + + +def _backside(tilt, surface_azimuth): + backside_tilt = 180. - tilt + backside_sysaz = (180. + surface_azimuth) % 360. + return backside_tilt, backside_sysaz diff --git a/pvlib/bifacial.py b/pvlib/bifacial/pvfactors.py similarity index 97% rename from pvlib/bifacial.py rename to pvlib/bifacial/pvfactors.py index c7247e2f27..3c70b779fb 100644 --- a/pvlib/bifacial.py +++ b/pvlib/bifacial/pvfactors.py @@ -1,6 +1,6 @@ """ -The ``bifacial`` module contains functions for modeling back surface -plane-of-array irradiance under various conditions. +The ``bifacial.pvfactors`` module contains functions for modeling back surface +plane-of-array irradiance using the pvfactors package. """ import pandas as pd diff --git a/pvlib/bifacial/utils.py b/pvlib/bifacial/utils.py new file mode 100644 index 0000000000..bac8923536 --- /dev/null +++ b/pvlib/bifacial/utils.py @@ -0,0 +1,149 @@ +""" +The bifacial.utils module contains functions that support bifacial irradiance +modeling. +""" +import numpy as np +from pvlib.tools import sind, cosd, tand + + +def _solar_projection_tangent(solar_zenith, solar_azimuth, surface_azimuth): + """ + Tangent of the angle between the zenith vector and the sun vector + projected to the plane defined by the zenith vector and surface_azimuth. + + .. math:: + \\tan \\phi = \\cos\\left(\\text{solar azimuth}-\\text{system azimuth} + \\right)\\tan\\left(\\text{solar zenith}\\right) + + Parameters + ---------- + solar_zenith : numeric + Solar zenith angle. [degree]. + solar_azimuth : numeric + Solar azimuth. [degree]. + surface_azimuth : numeric + Azimuth of the module surface, i.e., North=0, East=90, South=180, + West=270. [degree] + + Returns + ------- + tan_phi : numeric + Tangent of the angle between vertical and the projection of the + sun direction onto the YZ plane. + """ + rotation = solar_azimuth - surface_azimuth + tan_phi = cosd(rotation) * tand(solar_zenith) + return tan_phi + + +def _unshaded_ground_fraction(surface_tilt, surface_azimuth, solar_zenith, + solar_azimuth, gcr, max_zenith=87): + 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 + ---------- + 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]. + gcr : float + Ground coverage ratio, which is the ratio of row slant length to row + spacing (pitch). [unitless] + max_zenith : numeric, default 87 + Maximum zenith angle. For solar_zenith > max_zenith, unshaded ground + fraction is set to 0. [degree] + + Returns + ------- + f_gnd_beam : numeric + Fraction of distance betwen rows (pitch) with direct irradiance + (unshaded). [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. + """ + 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)) + np.where(solar_zenith > max_zenith, 0., f_gnd_beam) # [1], Eq. 4 + return f_gnd_beam # 1 - min(1, abs()) < 1 always + + +def _vf_ground_sky_2d(x, rotation, gcr, pitch, height, max_rows=10): + r""" + Calculate the fraction of the sky dome visible from point x on the ground. + + The view factor accounts for the obstruction of the sky by array rows that + are assumed to be infinitely long. View factors are thus calculated in + a 2D geometry. The ground is assumed to be flat and level. + + Parameters + ---------- + x : numeric + 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] + rotation : float + Rotation angle of the row's right edge relative to row center. + [degree] + gcr : float + Ratio of the row slant length to the row spacing (pitch). [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``. + max_rows : int, default 10 + Maximum number of rows to consider on either side of the current + row. [unitless] + + Returns + ------- + vf : numeric + Fraction of sky dome visible from each point on the ground. [unitless] + wedge_angles : array + Angles defining each wedge of sky that is blocked by a row. Shape is + (2, len(x), 2*max_rows+1). ``wedge_angles[0,:,:]`` is the + starting angle of each wedge, ``wedge_angles[1,:,:]`` is the end angle. + [degree] + """ + x = np.atleast_1d(x) # handle float + all_k = np.arange(-max_rows, max_rows + 1) + width = gcr * pitch / 2. + # angles from x to right edge of each row + a1 = height + width * sind(rotation) + b1 = (all_k - x[:, np.newaxis]) * pitch + width * cosd(rotation) + phi_1 = np.degrees(np.arctan2(a1, b1)) + # angles from x to left edge of each row + a2 = height - width * sind(rotation) + b2 = (all_k - x[:, np.newaxis]) * pitch - width * cosd(rotation) + phi_2 = np.degrees(np.arctan2(a2, b2)) + phi = np.stack([phi_1, phi_2]) + swap = phi[0, :, :] > phi[1, :, :] + # swap where phi_1 > phi_2 so that phi_1[0,:,:] is the lesser angle + phi = np.where(swap, phi[::-1], phi) + # right edge of next row - left edge of previous row + wedge_vfs = 0.5 * (cosd(phi[1, :, 1:]) - cosd(phi[0, :, :-1])) + vf = np.sum(np.where(wedge_vfs > 0, wedge_vfs, 0.), axis=1) + return vf, phi diff --git a/pvlib/tests/bifacial/__init__.py b/pvlib/tests/bifacial/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pvlib/tests/bifacial/test_infinite_sheds.py b/pvlib/tests/bifacial/test_infinite_sheds.py new file mode 100644 index 0000000000..05d1bc58db --- /dev/null +++ b/pvlib/tests/bifacial/test_infinite_sheds.py @@ -0,0 +1,347 @@ +""" +test infinite sheds +""" + +import numpy as np +import pandas as pd +from pvlib.bifacial import infinite_sheds +from pvlib.tools import cosd +from ..conftest import assert_series_equal + +import pytest + + +@pytest.fixture +def test_system(): + syst = {'height': 1.0, + 'pitch': 2., + 'surface_tilt': 30., + 'surface_azimuth': 180., + 'rotation': -30.} # rotation of right edge relative to horizontal + syst['gcr'] = 1.0 / syst['pitch'] + pts = np.linspace(0, 1, num=3) + sqr3 = np.sqrt(3) / 4 + # c_i,j = cos(angle from point i to edge of row j), j=0 is row = -1 + # c_i,j = cos(angle from point i to edge of row j), j=0 is row = -1 + c00 = (-2 - sqr3) / np.sqrt(1.25**2 + (2 + sqr3)**2) # right edge row -1 + c01 = -sqr3 / np.sqrt(1.25**2 + sqr3**2) # right edge row 0 + c02 = sqr3 / np.sqrt(0.75**2 + sqr3**2) # left edge of row 0 + c03 = (2 - sqr3) / np.sqrt(1.25**2 + (2 - sqr3)**2) # right edge of row 1 + vf_0 = 0.5 * (c03 - c02 + c01 - c00) # vf at point 0 + c10 = (-3 - sqr3) / np.sqrt(1.25**2 + (3 + sqr3)**2) # right edge row -1 + c11 = (-1 - sqr3) / np.sqrt(1.25**2 + (1 + sqr3)**2) # right edge row 0 + c12 = (-1 + sqr3) / np.sqrt(0.75**2 + (-1 + sqr3)**2) # left edge row 0 + c13 = (1 - sqr3) / np.sqrt(1.25**2 + (1 - sqr3)**2) # right edge row + vf_1 = 0.5 * (c13 - c12 + c11 - c10) # vf at point 1 + c20 = -(4 + sqr3) / np.sqrt(1.25**2 + (4 + sqr3)**2) # right edge row -1 + c21 = (-2 + sqr3) / np.sqrt(0.75**2 + (-2 + sqr3)**2) # left edge row 0 + c22 = (-2 - sqr3) / np.sqrt(1.25**2 + (2 + sqr3)**2) # right edge row 0 + c23 = (0 - sqr3) / np.sqrt(1.25**2 + (0 - sqr3)**2) # right edge row 1 + vf_2 = 0.5 * (c23 - c22 + c21 - c20) # vf at point 1 + vfs_ground_sky = np.array([vf_0, vf_1, vf_2]) + return syst, pts, vfs_ground_sky + + +def test__vf_ground_sky_integ(test_system): + ts, pts, vfs_gnd_sky = test_system + # 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 = infinite_sheds._vf_ground_sky_integ( + ts['rotation'], ts['surface_azimuth'], + ts['gcr'], ts['height'], ts['pitch'], + max_rows=1, npoints=3) + expected_vf_integ = np.trapz(vfs_gnd_sky, pts) + assert np.isclose(vf_integ, expected_vf_integ, rtol=0.1) + + +def test__vf_row_sky_integ(test_system): + ts, _, _ = test_system + gcr = ts['gcr'] + surface_tilt = ts['surface_tilt'] + f_x = np.array([0., 0.5, 1.]) + shaded = [] + noshade = [] + for x in f_x: + s, ns = infinite_sheds._vf_row_sky_integ( + x, surface_tilt, gcr, npoints=100) + shaded.append(s) + noshade.append(ns) + + def analytic(gcr, surface_tilt, x): + c = cosd(surface_tilt) + a = 1. / gcr + dx = np.sqrt(a**2 - 2 * a * c * x + x**2) + return - a * (c**2 - 1) * np.arctanh((x - a * c) / dx) - c * dx + + expected_shade = 0.5 * (f_x * cosd(surface_tilt) + - analytic(gcr, surface_tilt, 1 - f_x) + + analytic(gcr, surface_tilt, 1.)) + expected_noshade = 0.5 * ((1 - f_x) * cosd(surface_tilt) + + analytic(gcr, surface_tilt, 1. - f_x) + - analytic(gcr, surface_tilt, 0.)) + shaded = np.array(shaded) + noshade = np.array(noshade) + assert np.allclose(shaded, expected_shade) + assert np.allclose(noshade, expected_noshade) + + +def test__poa_sky_diffuse_pv(): + dhi = np.array([np.nan, 0.0, 500.]) + f_x = np.array([0.2, 0.2, 0.5]) + vf_shade_sky_integ = np.array([1.0, 0.5, 0.2]) + vf_noshade_sky_integ = np.array([0.0, 0.5, 0.8]) + poa = infinite_sheds._poa_sky_diffuse_pv( + f_x, dhi, vf_shade_sky_integ, vf_noshade_sky_integ) + expected_poa = np.array([np.nan, 0.0, 500 * (0.5 * 0.2 + 0.5 * 0.8)]) + assert np.allclose(poa, expected_poa, equal_nan=True) + + +def test__ground_angle(test_system): + ts, _, _ = test_system + x = np.array([0., 0.5, 1.0]) + angles = infinite_sheds._ground_angle( + x, ts['surface_tilt'], ts['gcr']) + expected_angles = np.array([0., 5.866738789543952, 9.896090638982903]) + assert np.allclose(angles, expected_angles) + + +def test__vf_row_ground(test_system): + ts, _, _ = test_system + x = np.array([0., 0.5, 1.0]) + sqr3 = np.sqrt(3) + vfs = infinite_sheds._vf_row_ground( + x, ts['surface_tilt'], ts['gcr']) + expected_vfs = np.array([ + 0.5 * (1. - sqr3 / 2), + 0.5 * ((4 + sqr3 / 2) / np.sqrt(17 + 4 * sqr3) - sqr3 / 2), + 0.5 * ((4 + sqr3) / np.sqrt(20 + 8 * sqr3) - sqr3 / 2)]) + assert np.allclose(vfs, expected_vfs) + + +def test__vf_row_ground_integ(test_system): + ts, _, _ = test_system + gcr = ts['gcr'] + surface_tilt = ts['surface_tilt'] + f_x = np.array([0., 0.5, 1.0]) + shaded, noshade = infinite_sheds._vf_row_ground_integ( + f_x, surface_tilt, gcr) + + def analytic(x, surface_tilt, gcr): + c = cosd(surface_tilt) + a = 1. / gcr + dx = np.sqrt(a**2 + 2 * a * c * x + x**2) + return c * dx - a * (c**2 - 1) * np.arctanh((a * c + x) / dx) + + expected_shade = 0.5 * (analytic(f_x, surface_tilt, gcr) + - analytic(0., surface_tilt, gcr) + - f_x * cosd(surface_tilt)) + expected_noshade = 0.5 * (analytic(1., surface_tilt, gcr) + - analytic(f_x, surface_tilt, gcr) + - (1. - f_x) * cosd(surface_tilt)) + assert np.allclose(shaded, expected_shade) + assert np.allclose(noshade, expected_noshade) + + +def test__poa_ground_shadows(): + poa_ground, f_gnd_beam, df, vf_gnd_sky = (300., 0.5, 0.5, 0.2) + result = infinite_sheds._poa_ground_shadows( + poa_ground, f_gnd_beam, df, vf_gnd_sky) + expected = 300. * (0.5 * 0.5 + 0.5 * 0.2) + assert np.isclose(result, expected) + # vector inputs + poa_ground = np.array([300., 300.]) + f_gnd_beam = np.array([0.5, 0.5]) + df = np.array([0.5, 0.]) + vf_gnd_sky = np.array([0.2, 0.2]) + result = infinite_sheds._poa_ground_shadows( + poa_ground, f_gnd_beam, df, vf_gnd_sky) + expected_vec = np.array([expected, 300. * 0.5]) + assert np.allclose(result, expected_vec) + + +def test__shaded_fraction_floats(): + result = infinite_sheds._shaded_fraction( + solar_zenith=60., solar_azimuth=180., surface_tilt=60., + surface_azimuth=180., gcr=1.0) + assert np.isclose(result, 0.5) + + +def test__shaded_fraction_array(): + solar_zenith = np.array([0., 60., 90., 60.]) + solar_azimuth = np.array([180., 180., 180., 180.]) + surface_azimuth = np.array([180., 180., 180., 210.]) + surface_tilt = np.array([30., 60., 0., 30.]) + gcr = 1.0 + result = infinite_sheds._shaded_fraction( + solar_zenith, solar_azimuth, surface_tilt, surface_azimuth, gcr) + x = 0.75 + np.sqrt(3) / 2 + expected = np.array([0.0, 0.5, 0., (x - 1) / x]) + assert np.allclose(result, expected) + + +def test_get_irradiance_poa(): + # singleton inputs + solar_zenith = 0. + solar_azimuth = 180. + surface_tilt = 0. + surface_azimuth = 180. + gcr = 0.5 + height = 1. + pitch = 1 + ghi = 1000 + dhi = 300 + dni = 700 + 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) + expected_diffuse = np.array([300.]) + expected_direct = np.array([700.]) + expected_global = expected_diffuse + expected_direct + assert np.isclose(res['poa_global'], expected_global) + assert np.isclose(res['poa_diffuse'], expected_diffuse) + assert np.isclose(res['poa_direct'], expected_direct) + # vector inputs + surface_tilt = np.array([0., 0., 0., 0.]) + height = 1. + surface_azimuth = np.array([180., 180., 180., 180.]) + gcr = 0.5 + pitch = 1 + solar_zenith = np.array([0., 45., 45., 90.]) + solar_azimuth = np.array([180., 180., 135., 180.]) + expected_diffuse = np.array([300., 300., 300., 300.]) + expected_direct = np.array( + [700., 350. * np.sqrt(2), 350. * np.sqrt(2), 0.]) + expected_global = expected_diffuse + expected_direct + 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) + # series inputs + surface_tilt = pd.Series(surface_tilt) + surface_azimuth = pd.Series(data=surface_azimuth, index=surface_tilt.index) + solar_zenith = pd.Series(solar_zenith, index=surface_tilt.index) + solar_azimuth = pd.Series(data=solar_azimuth, index=surface_tilt.index) + expected_diffuse = pd.Series( + data=expected_diffuse, index=surface_tilt.index) + expected_direct = pd.Series( + data=expected_direct, index=surface_tilt.index) + expected_global = expected_diffuse + expected_direct + expected_global.name = 'poa_global' # 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) + assert isinstance(res, pd.DataFrame) + assert_series_equal(res['poa_global'], expected_global) + assert all(k in res.columns for k in [ + 'poa_global', 'poa_diffuse', 'poa_direct', 'poa_ground_diffuse', + 'poa_sky_diffuse']) + + +def test__backside_tilt(): + tilt = np.array([0., 30., 30., 180.]) + system_azimuth = np.array([180., 150., 270., 0.]) + back_tilt, back_az = infinite_sheds._backside(tilt, system_azimuth) + assert np.allclose(back_tilt, np.array([180., 150., 150., 0.])) + assert np.allclose(back_az, np.array([0., 330., 90., 180.])) + + +def test_get_irradiance(): + # singleton inputs + solar_zenith = 0. + solar_azimuth = 180. + surface_tilt = 0. + surface_azimuth = 180. + gcr = 0.5 + height = 1. + pitch = 1. + ghi = 1000. + dhi = 300. + dni = 700. + albedo = 0. + 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) + expected_front_diffuse = np.array([300.]) + expected_front_direct = np.array([700.]) + expected_front_global = expected_front_diffuse + expected_front_direct + assert np.isclose(result['poa_front'], expected_front_global) + assert np.isclose(result['poa_front_diffuse'], expected_front_diffuse) + assert np.isclose(result['poa_front_direct'], expected_front_direct) + assert np.isclose(result['poa_global'], result['poa_front']) + # series inputs + ghi = pd.Series([1000., 500., 500., np.nan]) + dhi = pd.Series([300., 500., 500., 500.], index=ghi.index) + 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) + 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) + assert isinstance(result, pd.DataFrame) + expected_poa_global = pd.Series( + [1000., 500., result_front['poa_global'][2] * (1 + 0.8 * 0.98), + np.nan], index=ghi.index, name='poa_global') + assert_series_equal(result['poa_global'], expected_poa_global) + + +def test_get_irradiance_limiting_gcr(): + # test confirms that irradiance on widely spaced rows is approximately + # the same as for a single row array + solar_zenith = 0. + solar_azimuth = 180. + surface_tilt = 90. + surface_azimuth = 180. + gcr = 0.00001 + height = 1. + pitch = 100. + ghi = 1000. + dhi = 300. + dni = 700. + albedo = 1. + 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) + expected_ground_diffuse = np.array([500.]) + expected_sky_diffuse = np.array([150.]) + expected_direct = np.array([0.]) + expected_diffuse = expected_ground_diffuse + expected_sky_diffuse + expected_poa = expected_diffuse + expected_direct + assert np.isclose(result['poa_front'], expected_poa, rtol=0.01) + assert np.isclose(result['poa_front_diffuse'], expected_diffuse, rtol=0.01) + assert np.isclose(result['poa_front_direct'], expected_direct) + assert np.isclose(result['poa_front_sky_diffuse'], expected_sky_diffuse, + rtol=0.01) + assert np.isclose(result['poa_front_ground_diffuse'], + expected_ground_diffuse, rtol=0.01) + assert np.isclose(result['poa_front'], result['poa_back']) + assert np.isclose(result['poa_front_diffuse'], result['poa_back_diffuse']) + assert np.isclose(result['poa_front_direct'], result['poa_back_direct']) + assert np.isclose(result['poa_front_sky_diffuse'], + result['poa_back_sky_diffuse']) + assert np.isclose(result['poa_front_ground_diffuse'], + result['poa_back_ground_diffuse']) diff --git a/pvlib/tests/test_bifacial.py b/pvlib/tests/bifacial/test_pvfactors.py similarity index 96% rename from pvlib/tests/test_bifacial.py rename to pvlib/tests/bifacial/test_pvfactors.py index 92207f905f..a199999364 100644 --- a/pvlib/tests/test_bifacial.py +++ b/pvlib/tests/bifacial/test_pvfactors.py @@ -1,7 +1,7 @@ import pandas as pd from datetime import datetime -from pvlib.bifacial import pvfactors_timeseries -from .conftest import requires_pvfactors, assert_series_equal +from pvlib.bifacial.pvfactors import pvfactors_timeseries +from ..conftest import requires_pvfactors, assert_series_equal import pytest diff --git a/pvlib/tests/bifacial/test_utils.py b/pvlib/tests/bifacial/test_utils.py new file mode 100644 index 0000000000..388a222394 --- /dev/null +++ b/pvlib/tests/bifacial/test_utils.py @@ -0,0 +1,88 @@ +""" +test bifical.utils +""" +import numpy as np +import pytest +from pvlib.bifacial import utils + + +@pytest.fixture +def test_system_fixed_tilt(): + syst = {'height': 1.0, + 'pitch': 2., + 'surface_tilt': 30., + 'surface_azimuth': 180., + 'axis_azimuth': None, + 'rotation': -30.} + syst['gcr'] = 1.0 / syst['pitch'] + # view factors from 3 points on the ground between rows to the sky + pts = np.linspace(0, 1, num=3) + sqr3 = np.sqrt(3) / 4 + # c_i,j = cos(angle from point i to edge of row j), j=0 is row = -1 + # c_i,j = cos(angle from point i to edge of row j), j=0 is row = -1 + c00 = (-2 - sqr3) / np.sqrt(1.25**2 + (2 + sqr3)**2) # right edge row -1 + c01 = -sqr3 / np.sqrt(1.25**2 + sqr3**2) # right edge row 0 + c02 = sqr3 / np.sqrt(0.75**2 + sqr3**2) # left edge of row 0 + c03 = (2 - sqr3) / np.sqrt(1.25**2 + (2 - sqr3)**2) # right edge of row 1 + vf_0 = 0.5 * (c03 - c02 + c01 - c00) # vf at point 0 + c10 = (-3 - sqr3) / np.sqrt(1.25**2 + (3 + sqr3)**2) # right edge row -1 + c11 = (-1 - sqr3) / np.sqrt(1.25**2 + (1 + sqr3)**2) # right edge row 0 + c12 = (-1 + sqr3) / np.sqrt(0.75**2 + (-1 + sqr3)**2) # left edge row 0 + c13 = (1 - sqr3) / np.sqrt(1.25**2 + (1 - sqr3)**2) # right edge row + vf_1 = 0.5 * (c13 - c12 + c11 - c10) # vf at point 1 + c20 = -(4 + sqr3) / np.sqrt(1.25**2 + (4 + sqr3)**2) # right edge row -1 + c21 = (-2 + sqr3) / np.sqrt(0.75**2 + (-2 + sqr3)**2) # left edge row 0 + c22 = (-2 - sqr3) / np.sqrt(1.25**2 + (2 + sqr3)**2) # right edge row 0 + c23 = (0 - sqr3) / np.sqrt(1.25**2 + (0 - sqr3)**2) # right edge row 1 + vf_2 = 0.5 * (c23 - c22 + c21 - c20) # vf at point 1 + vfs_ground_sky = np.array([vf_0, vf_1, vf_2]) + return syst, pts, vfs_ground_sky + + +def test__solar_projection_tangent(): + tan_phi_f = utils._solar_projection_tangent( + 30, 150, 180) + tan_phi_b = utils._solar_projection_tangent( + 30, 150, 0) + assert np.allclose(tan_phi_f, 0.5) + assert np.allclose(tan_phi_b, -0.5) + assert np.allclose(tan_phi_f, -tan_phi_b) + + +@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)), + ]) +def test__unshaded_ground_fraction( + surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, gcr, + expected): + # frontside, same for both sides + f_sky_beam_f = utils._unshaded_ground_fraction( + surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, 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) + assert np.allclose(f_sky_beam_b, expected) + + +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(pts, ts['rotation'], ts['gcr'], + ts['pitch'], ts['height'], max_rows=1) + assert np.allclose(vfs, vfs_gnd_sky, rtol=0.1) # middle point vf is off + # test with singleton x + vf, _ = utils._vf_ground_sky_2d(pts[0], ts['rotation'], ts['gcr'], + ts['pitch'], ts['height'], max_rows=1) + assert np.isclose(vf, vfs_gnd_sky[0])