Repository: pvlib/pvlib-python

Issue:
ZeroDivisionError when gcr is zero
**Describe the bug**

Though maybe not intuitive, setting ground coverage ratio to zero is useful when a plant consists of a single shed, e.g. calculating the irradiance on the backside of the panels. However, e.g., `bifacial.infinite_sheds.get_irradiance_poa` fails with `ZeroDivisionError` whenever `gcr=0`.

**To Reproduce**

```python
from pvlib.bifacial.infinite_sheds import get_irradiance_poa

get_irradiance_poa(surface_tilt=160, surface_azimuth=180, solar_zenith=20, solar_azimuth=180, gcr=0, height=1, pitch=1000, ghi=200, dhi=200, dni=0, albedo=0.2)
```
returns:
```
Traceback (most recent call last):
  File "C:\Python\Python310\lib\site-packages\IPython\core\interactiveshell.py", line 3398, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-7-0cb583b2b311>", line 3, in <cell line: 3>
    get_irradiance_poa(surface_tilt=160, surface_azimuth=180, solar_zenith=20, solar_azimuth=180, gcr=0, height=1, pitch=1, ghi=200, dhi=200, dni=0, albedo=0.2)
  File "C:\Python\Python310\lib\site-packages\pvlib\bifacial\infinite_sheds.py", line 522, in get_irradiance_poa
    vf_shade_sky, vf_noshade_sky = _vf_row_sky_integ(
  File "C:\Python\Python310\lib\site-packages\pvlib\bifacial\infinite_sheds.py", line 145, in _vf_row_sky_integ
    psi_t_shaded = masking_angle(surface_tilt, gcr, x)
  File "C:\Python\Python310\lib\site-packages\pvlib\shading.py", line 56, in masking_angle
    denominator = 1/gcr - (1 - slant_height) * cosd(surface_tilt)
ZeroDivisionError: division by zero
```

**Expected behavior**

One can easily solve this `ZeroDivisionError` by multiplying both numerator and denominator with `gcr` inside `shading.masking_angle` and the same inside `bifacial.infinite_sheds._ground_angle`.

**Versions:**
 - ``pvlib.__version__``: '0.9.3'
 - ``pandas.__version__``: '1.4.4'
 - python: '3.10.4'



Discussion:
@kdebrab thanks for investigating this case, which wasn't consider in the implementation. @pvlib/pvlib-core I think we should fix this. 

For consistency we should also extend `shading.masking_angle_passias` for the limiting case of `gcr=0`. That may be more complicated and could be done in a second PR.

I think `infinite_sheds.get_irradiance_poa(...)` converges to `irradiance.get_total_irradiance(..., model='isotropic')` as gcr approaches zero, so that's an option for modeling this situation in the meantime.  
Probably close enough to be useful, but in theory the rear irradiance would be slightly less than GHI * 1/2 (1 - cos(tilt)), due to the row's shadow.
With a more realistic model (e.g. pvfactors) that considers shadows individually that's true, but `infinite_sheds` models ground-reflected irradiance using integrated averages which dilute the effect of the row's own shadow to nothing as gcr approaches zero.  By decreasing `gcr` and increasing `npoints` you can get `infinite_sheds` as close to `get_total_irradiance` as you like:

![image](https://user-images.githubusercontent.com/57452607/198049857-0c844116-3bc0-48dd-b889-8b658f39b4a0.png)

<details>
  <summary>Source</summary>

```python
import pvlib
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

times = pd.date_range('2019-06-01', '2019-06-02', freq='T', tz='Etc/GMT+5')
location = pvlib.location.Location(40, -80)
sp = location.get_solarposition(times)
cs = location.get_clearsky(times, solar_position=sp)

kwargs = dict(
    surface_tilt=20, surface_azimuth=180,
    solar_zenith=sp.apparent_zenith, solar_azimuth=sp.azimuth,
    ghi=cs.ghi, dhi=cs.dhi, dni=cs.dni, albedo=0.2
)

limit = pvlib.irradiance.get_total_irradiance(**kwargs, model='isotropic')['poa_global']

all_stats = []

for gcr in [0.3, 0.1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6, 1e-7]:
    stats = dict(gcr=gcr)
    for npoints in [100, 1000, 10000]:
        out = pvlib.bifacial.infinite_sheds.get_irradiance_poa(gcr=gcr, height=1, pitch=1/gcr, npoints=npoints, **kwargs)
        stats[f'npoints={npoints}'] = np.max(np.abs(out['poa_global'] - limit))

    all_stats.append(stats)

pd.DataFrame(all_stats).set_index('gcr').plot(logx=True, logy=True)
plt.ylabel('Max Absolute Difference [W/m2]')

```
</details>
@kanderso-nrel Thanks for pointing this out!

I think for a `surface_tilt` equal to 20° (front side of the panel) this result would be fine, as there is no shadow on the front surface from the own row. However, changing `surface_tilt` to 160° (back side of the panel) yields basically the same figure and that is indeed not ideal (and unexpected for me).

If I understand well, this is due to the calculation of the ground-reflected irradiance, where the infinite_sheds method uses (1) the **average** 'view factor from the ground to the sky' of the ground between the rows, and (2) the **average** 'fraction of ground between rows that is unshaded', both of which approach 1 as the pitch approaches infinity.

I think an improved method would not take the average, but some kind of weighted average, considering that the reflection from the ground right in front of the surface is more important than the reflection from the ground further away from the surface. In that case, I would assume that the effect of the row's own shadow would no longer dilute when gcr approaches zero.
> However, changing surface_tilt to 160° (back side of the panel) yields basically the same figure and that is indeed not ideal (and unexpected for me).

Oops, I think I had originally started with rear-side, switched to front-side just to check it as well, and forgot to switch back to rear before posting here.  Good catch.

FYI pvfactors (`pvlib.bifacial.pvfactors.pvfactors_timeseries`) can model irradiance for single rows (`n_pvrows=1, index_observed_pvrow=0`) and does not suffer from the averaging issues you mention. 

Patch:
diff --git a/pvlib/bifacial/infinite_sheds.py b/pvlib/bifacial/infinite_sheds.py
--- a/pvlib/bifacial/infinite_sheds.py
+++ b/pvlib/bifacial/infinite_sheds.py
@@ -216,8 +216,8 @@ def _ground_angle(x, surface_tilt, gcr):
     #  :         \  v      *-.\
     #  :          \<-----P---->\
 
-    x1 = x * sind(surface_tilt)
-    x2 = (x * cosd(surface_tilt) + 1 / gcr)
+    x1 = gcr * x * sind(surface_tilt)
+    x2 = gcr * x * cosd(surface_tilt) + 1
     psi = np.arctan2(x1, x2)  # do this first because it handles 0 / 0
     return np.rad2deg(psi)
 
diff --git a/pvlib/shading.py b/pvlib/shading.py
--- a/pvlib/shading.py
+++ b/pvlib/shading.py
@@ -52,8 +52,8 @@ def masking_angle(surface_tilt, gcr, slant_height):
     # The original equation (8 in [1]) requires pitch and collector width,
     # but it's easy to non-dimensionalize it to make it a function of GCR
     # by factoring out B from the argument to arctan.
-    numerator = (1 - slant_height) * sind(surface_tilt)
-    denominator = 1/gcr - (1 - slant_height) * cosd(surface_tilt)
+    numerator = gcr * (1 - slant_height) * sind(surface_tilt)
+    denominator = 1 - gcr * (1 - slant_height) * cosd(surface_tilt)
     phi = np.arctan(numerator / denominator)
     return np.degrees(phi)
 


Test patch:
diff --git a/pvlib/tests/bifacial/test_infinite_sheds.py b/pvlib/tests/bifacial/test_infinite_sheds.py
--- a/pvlib/tests/bifacial/test_infinite_sheds.py
+++ b/pvlib/tests/bifacial/test_infinite_sheds.py
@@ -106,6 +106,14 @@ def test__ground_angle(test_system):
     assert np.allclose(angles, expected_angles)
 
 
+def test__ground_angle_zero_gcr():
+    surface_tilt = 30.0
+    x = np.array([0.0, 0.5, 1.0])
+    angles = infinite_sheds._ground_angle(x, surface_tilt, 0)
+    expected_angles = np.array([0, 0, 0])
+    assert np.allclose(angles, expected_angles)
+
+
 def test__vf_row_ground(test_system):
     ts, _, _ = test_system
     x = np.array([0., 0.5, 1.0])
diff --git a/pvlib/tests/test_shading.py b/pvlib/tests/test_shading.py
--- a/pvlib/tests/test_shading.py
+++ b/pvlib/tests/test_shading.py
@@ -45,6 +45,13 @@ def test_masking_angle_scalar(surface_tilt, masking_angle):
         assert np.isclose(masking_angle_actual, angle)
 
 
+def test_masking_angle_zero_gcr(surface_tilt):
+    # scalar inputs and outputs, including zero
+    for tilt in surface_tilt:
+        masking_angle_actual = shading.masking_angle(tilt, 0, 0.25)
+        assert np.isclose(masking_angle_actual, 0)
+
+
 def test_masking_angle_passias_series(surface_tilt, average_masking_angle):
     # pandas series inputs and outputs
     masking_angle_actual = shading.masking_angle_passias(surface_tilt, 0.5)