diff --git a/docs/sphinx/source/_images/FSLR_irrad_shade_loss_slope_terrain.png b/docs/sphinx/source/_images/FSLR_irrad_shade_loss_slope_terrain.png
new file mode 100644
index 0000000000..d2f01c2e88
Binary files /dev/null and b/docs/sphinx/source/_images/FSLR_irrad_shade_loss_slope_terrain.png differ
diff --git a/docs/sphinx/source/reference/effects_on_pv_system_output/shading.rst b/docs/sphinx/source/reference/effects_on_pv_system_output/shading.rst
index a68dd94b2a..189f597dce 100644
--- a/docs/sphinx/source/reference/effects_on_pv_system_output/shading.rst
+++ b/docs/sphinx/source/reference/effects_on_pv_system_output/shading.rst
@@ -8,4 +8,7 @@ Shading
 
    shading.masking_angle
    shading.masking_angle_passias
-   shading.sky_diffuse_passias
\ No newline at end of file
+   shading.sky_diffuse_passias
+   shading.tracker_shaded_fraction
+   shading.linear_shade_loss
+   
\ No newline at end of file
diff --git a/docs/sphinx/source/whatsnew/v0.9.6.rst b/docs/sphinx/source/whatsnew/v0.9.6.rst
index 7d1271086f..e09ff4eb1c 100644
--- a/docs/sphinx/source/whatsnew/v0.9.6.rst
+++ b/docs/sphinx/source/whatsnew/v0.9.6.rst
@@ -11,12 +11,16 @@ Deprecations
 
 Enhancements
 ~~~~~~~~~~~~
-
+* added functions `pvlib.shading.tracker_shaded_fraction` and
+  `pvlib.shading.linear_shade_loss` to calculate row-to-row shade and apply
+  linear shade loss for thin film CdTe modules like First Solar.
+  (:issue:`1689`, :issue:`1690`, :pull:`1725`)
 
 Bug fixes
 ~~~~~~~~~
 * `data` can no longer be left unspecified in
-  :py:meth:`pvlib.modelchain.ModelChain.run_model_from_effective_irradiance`. (:issue:`1713`, :pull:`1720`)
+  :py:meth:`pvlib.modelchain.ModelChain.run_model_from_effective_irradiance`.
+  (:issue:`1713`, :pull:`1720`)
 
 Testing
 ~~~~~~~
@@ -41,6 +45,6 @@ Contributors
 * Lakshya Garg (:ghuser:`Lakshyadevelops`)
 * Adam R. Jensen (:ghuser:`adamrjensen`)
 * Siddharth Kaul (:ghuser:`k10blogger`)
+* Mark A. Mikofski (:ghuser:`mikofski`)
 * Kshitiz Gupta (:ghuser:`kshitiz305`)
 * Stefan de Lange (:ghuser:`langestefan`)
-
diff --git a/pvlib/shading.py b/pvlib/shading.py
index 01d725207c..9e511d600e 100644
--- a/pvlib/shading.py
+++ b/pvlib/shading.py
@@ -191,3 +191,117 @@ def sky_diffuse_passias(masking_angle):
        Available at https://www.nrel.gov/docs/fy18osti/67399.pdf
     """
     return 1 - cosd(masking_angle/2)**2
+
+
+def tracker_shaded_fraction(tracker_theta, gcr, projected_solar_zenith,
+                            cross_axis_slope=0):
+    r"""
+    Shaded fraction for trackers with a common angle on an east-west slope.
+
+    Parameters
+    ----------
+    tracker_theta : numeric
+        The tracker rotation angle in degrees from horizontal.
+    gcr : float
+        The ground coverage ratio as a fraction equal to the collector width
+        over the horizontal row-to-row pitch.
+    projected_solar_zenith : numeric
+        Zenith angle in degrees of the solar vector projected into the plane
+        perpendicular to the tracker axes.
+    cross_axis_slope : float, default 0
+        Angle of the plane containing the tracker axes in degrees from
+        horizontal.
+
+    Returns
+    -------
+    shaded_fraction : numeric
+        The fraction of the collector width shaded by an adjacent row. A
+        value of 1 is completely shaded and zero is no shade.
+
+    See also
+    --------
+    pvlib.shading.linear_shade_loss
+
+
+    The shaded fraction is derived using trigonometery and similar triangles
+    from the tracker rotation :math:`\beta`, the ground slope :math:`\theta_g`,
+    the projected solar zenith (psz) :math:`\theta`, the collector width
+    :math:`L`, the row-to-row pitch :math:`P`, and the shadow length :math:`z`
+    as shown in the image below.
+
+    .. image:: /_images/FSLR_irrad_shade_loss_slope_terrain.png
+
+    The ratio of the shadow length to the pitch, :math:`z/P`, is given by the
+    following relation where the ground coverage ratio (GCR) is :math:`L/P`:
+
+    .. math::
+       \frac{z/P}{\sin{\left(\frac{\pi}{2}-\beta+\theta\right)}}
+       = \frac{GCR}{\sin{\left(\frac{\pi}{2}-\theta-\theta_g\right)}}
+
+    Then the shaded fraction :math:`w/L` is derived from :math:`z/P` as
+    follows:
+
+    .. math::
+       \frac{w}{L} = 1 - \frac{P}{z\cos{\theta_g}}
+
+    Finally, shade is zero if :math:`z\cos{\theta_g}/P \le 1`.
+
+    References
+    ----------
+    Mark A. Mikofski, "First Solar Irradiance Shade Losses on Sloped Terrain,"
+    PVPMC, 2023
+    """
+    theta_g_rad = np.radians(cross_axis_slope)
+    # angle opposite shadow cast on the ground, z
+    angle_z = (
+        np.pi / 2 - np.radians(tracker_theta)
+        + np.radians(projected_solar_zenith))
+    # angle opposite the collector width, L
+    angle_gcr = (
+        np.pi / 2 - np.radians(projected_solar_zenith)
+        - theta_g_rad)
+    # ratio of shadow, z, to pitch, P
+    zp = gcr * np.sin(angle_z) / np.sin(angle_gcr)
+    # there's only row-to-row shade loss if the shadow on the ground, z, is
+    # longer than row-to-row pitch projected on the ground, P*cos(theta_g)
+    zp_cos_g = zp*np.cos(theta_g_rad)
+    # shaded fraction (fs)
+    fs = np.where(zp_cos_g <= 1, 0, 1 - 1/zp_cos_g)
+    return fs
+
+
+def linear_shade_loss(shaded_fraction, diffuse_fraction):
+    """
+    Fraction of power lost to linear shade loss applicable to monolithic thin
+    film modules like First Solar CdTe, where the shadow is perpendicular to
+    cell scribe lines.
+
+    Parameters
+    ----------
+    shaded_fraction : numeric
+        The fraction of the collector width shaded by an adjacent row. A
+        value of 1 is completely shaded and zero is no shade.
+    diffuse_fraction : numeric
+        The ratio of diffuse plane of array (poa) irradiance to global poa.
+        A value of 1 is completely diffuse and zero is no diffuse.
+
+    Returns
+    -------
+    linear_shade_loss : numeric
+        The fraction of power lost due to linear shading. A value of 1 is all
+        power lost and zero is no loss.
+
+    See also
+    --------
+    pvlib.shading.tracker_shaded_fraction
+
+    Example
+    -------
+    >>> from pvlib import shading
+    >>> fs = shading.tracker_shaded_fraction(45.0, 0.8, 45.0, 0)
+    >>> loss = shading.linear_shade_loss(fs, 0.2)
+    >>> P_no_shade = 100  # [kWdc]  DC output from modules
+    >>> P_linear_shade = P_no_shade * (1-loss)  # [kWdc] output after loss
+    # 90.71067811865476 [kWdc]
+    """
+    return shaded_fraction * (1 - diffuse_fraction)
diff --git a/pvlib/tests/test_shading.py b/pvlib/tests/test_shading.py
index 0558cb76ad..a2d90b89aa 100644
--- a/pvlib/tests/test_shading.py
+++ b/pvlib/tests/test_shading.py
@@ -76,3 +76,46 @@ def test_sky_diffuse_passias_scalar(average_masking_angle, shading_loss):
     for angle, loss in zip(average_masking_angle, shading_loss):
         actual_loss = shading.sky_diffuse_passias(angle)
         assert np.isclose(loss, actual_loss)
+
+
+@pytest.fixture
+def expected_fs():
+    # trivial case, 80% gcr, no slope, trackers & psz at 45-deg
+    z0 = np.sqrt(2*0.8*0.8)
+    # another trivial case, 60% gcr, no slope, trackers & psz at 60-deg
+    z1 = 2*0.6
+    # 30-deg isosceles, 60% gcr, no slope, 30-deg trackers, psz at 60-deg
+    z2 = 0.6*np.sqrt(3)
+    z = np.array([z0, z1, z2])
+    return 1 - 1/z
+
+
+def test_tracker_shade_fraction(expected_fs):
+    """closes gh1690"""
+    fs = shading.tracker_shaded_fraction(45.0, 0.8, 45.0)
+    assert np.isclose(fs, expected_fs[0])
+    # same trivial case with 40%, shadow is only 0.565-m long < 1-m r2r P
+    zero_fs = shading.tracker_shaded_fraction(45.0, 0.4, 45.0)
+    assert np.isclose(zero_fs, 0)
+    # test vectors
+    tracker_theta = [45.0, 60.0, 30.0]
+    gcr = [0.8, 0.6, 0.6]
+    psz = [45.0, 60.0, 60.0]
+    slope = [0]*3
+    fs_vec = shading.tracker_shaded_fraction(
+        tracker_theta, gcr, psz, slope)
+    assert np.allclose(fs_vec, expected_fs)
+
+
+def test_linear_shade_loss(expected_fs):
+    loss = shading.linear_shade_loss(expected_fs[0], 0.2)
+    assert np.isclose(loss, 0.09289321881345258)
+    # if no diffuse, shade fraction is the loss
+    loss_no_df = shading.linear_shade_loss(expected_fs[0], 0)
+    assert np.isclose(loss_no_df, expected_fs[0])
+    # if all diffuse, no shade loss
+    no_loss = shading.linear_shade_loss(expected_fs[0], 1.0)
+    assert np.isclose(no_loss, 0)
+    vec_loss = shading.linear_shade_loss(expected_fs, 0.2)
+    expected_loss = np.array([0.09289322, 0.13333333, 0.03019964])
+    assert np.allclose(vec_loss, expected_loss)