From af26a6611dfa0cfb2c6fbc5b6d84b2c3f1cd9925 Mon Sep 17 00:00:00 2001 From: Emil Visbech Sindberg Thomsen Date: Tue, 17 Mar 2026 16:12:44 +0100 Subject: [PATCH 1/7] Create shadowband.py --- src/solarpy/instrument/shadowband.py | 127 +++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/solarpy/instrument/shadowband.py diff --git a/src/solarpy/instrument/shadowband.py b/src/solarpy/instrument/shadowband.py new file mode 100644 index 0000000..4d1eff4 --- /dev/null +++ b/src/solarpy/instrument/shadowband.py @@ -0,0 +1,127 @@ +""" +shadowband_correction.py +------------------------ +Shadowband diffuse-irradiance correction factor for the Kipp & Zonen CM 121 +shadow-ring pyranometer accessory. + +Reference +--------- +Kipp & Zonen *CM 121 B/C Shadow Ring Instruction Manual*, Appendix §6.1. +https://www.kippzonen.com/Download/46/CM121-B-C-Shadow-Ring-Manual +""" + +import numpy as np +import pvlib as pv +import pandas as pd + + +def shadowband_correction_factor( + input_date, + latitude: float, + V: float = 0.185, +): + """Calculate the daily shadowband correction factor for a Kipp & Zonen CM 121. + + A shadow ring blocks a strip of the sky dome to prevent direct beam + radiation from reaching the pyranometer, so that only diffuse radiation + is measured. Because the ring also blocks some of the diffuse sky, a + correction factor C > 1 must be applied to recover the true diffuse + irradiance:: + + G_d_true = C * G_d_measured + + The factor depends on the ring geometry (angular half-width *V*), the + site latitude, and the solar declination on the measurement day. It is + derived analytically by integrating the fraction of the isotropic sky + hemisphere obscured by the ring over the daily arc of the sun. + + Parameters + ---------- + input_date : scalar or array-like + Date(s) for which to compute the factor. Accepts anything that + ``pandas.to_datetime`` understands: a ``pd.Timestamp``, a + ``datetime.date`` / ``datetime.datetime``, an ISO-8601 string, a + list/array of any of the above, or a ``pd.DatetimeIndex``. + Sub-daily time information is ignored; only the calendar date matters. + latitude : float + Observer latitude in decimal degrees. Positive = North, negative = South. + Valid range: −90 to +90. + V : float, optional + Angular width of the shadow band in radians. This is arc subtended by + the ring as seen from the sensor. Default is 0.185 rad (~10.6°), + which matches the standard Kipp & Zonen CM 121 ring geometry. + + Returns + ------- + float or array-like + Dimensionless correction factor C ≥ 1. + + Notes + ----- + The correction factor is computed as: + + .. math:: + + S = \\frac{2V \\cos\\delta}{\\pi} + \\bigl(U_0 \\sin\\phi\\sin\\delta + + \\sin U_0 \\cos\\phi\\cos\\delta\\bigr) + + C = \\frac{1}{1 - S} + + where + + * :math:`\\delta` – solar declination (Spencer 1971 approximation) + * :math:`\\phi` – site latitude + * :math:`U_0` – sunrise/sunset hour angle, + :math:`\\arccos(-\\tan\\phi\\,\\tan\\delta)`, clamped to [−1, 1] to + handle polar day/night conditions + """ + # ------------------------------------------------------------------ + # 1. Normalise input to pandas datetime so scalar and array paths + # share a single code route below. + # ------------------------------------------------------------------ + dates = pd.to_datetime(input_date) + + # ------------------------------------------------------------------ + # 2. Solar declination δ for each day of year. + # Spencer (1971) approximation, returned in radians by pvlib. + # ------------------------------------------------------------------ + day_of_year = dates.dayofyear + delta = pv.solarposition.declination_spencer71(day_of_year) # radians + + # ------------------------------------------------------------------ + # 3. Site latitude φ in radians. + # ------------------------------------------------------------------ + phi = np.deg2rad(latitude) + + # ------------------------------------------------------------------ + # 4. Sunrise/sunset hour angle U₀. + # cos U₀ = −tan φ · tan δ. + # Clamped to [−1, 1] so arccos is valid at the poles (midnight sun + # / polar night), where the tangent product can exceed ±1. + # ------------------------------------------------------------------ + cos_U0 = np.clip(-np.tan(phi) * np.tan(delta), -1.0, 1.0) + U0 = np.arccos(cos_U0) + + # ------------------------------------------------------------------ + # 5. Fraction S of the isotropic sky dome obscured by the ring. + # Derived by integrating the ring's shadow across the daily solar arc + # (Kipp & Zonen manual, Appendix §6.1). + # ------------------------------------------------------------------ + S = (2 * V * np.cos(delta) / np.pi) * ( + U0 * np.sin(phi) * np.sin(delta) + + np.sin(U0) * np.cos(phi) * np.cos(delta) + ) + + # ------------------------------------------------------------------ + # 6. Correction factor C = 1 / (1 − S). + # S is always < 1 for physically reasonable inputs, so C > 1. + # ------------------------------------------------------------------ + C = 1.0 / (1.0 - S) + + # ------------------------------------------------------------------ + # 7. Return a plain float for scalar input, Series for array input. + # ------------------------------------------------------------------ + if np.isscalar(input_date) or isinstance(input_date, pd.Timestamp): + return float(np.asarray(C)) + return pd.Series(np.asarray(C), index=dates) From c9f860cc51b13b697ac5cd716a41d02dbadc142f Mon Sep 17 00:00:00 2001 From: Emil Visbech Sindberg Thomsen Date: Tue, 17 Mar 2026 16:33:43 +0100 Subject: [PATCH 2/7] Add function to init and docs --- docs/source/documentation.rst | 1 + pyproject.toml | 2 +- src/solarpy/__init__.py | 1 + src/solarpy/instrument/__init__.py | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 src/solarpy/instrument/__init__.py diff --git a/docs/source/documentation.rst b/docs/source/documentation.rst index a0f97e7..1f56034 100644 --- a/docs/source/documentation.rst +++ b/docs/source/documentation.rst @@ -10,3 +10,4 @@ Code documentation plotting.two_part_colormap plotting.plot_google_maps plotting.plot_intraday_heatmap + instrument.shadowband_correction_factor diff --git a/pyproject.toml b/pyproject.toml index 745f2e1..15f3d87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dynamic = ["version"] [project.optional-dependencies] test = ["pytest>=7", "pytest-cov", "packaging"] doc = [ - "sphinx==9.1.0", + "sphinx==9.0.4", "myst-nb==1.3.0", "sphinx-book-theme==1.1.4", "pvlib==0.14.0", diff --git a/src/solarpy/__init__.py b/src/solarpy/__init__.py index 6a44cbd..c3e5ab9 100644 --- a/src/solarpy/__init__.py +++ b/src/solarpy/__init__.py @@ -10,4 +10,5 @@ plotting, quality, example, + instrument, ) diff --git a/src/solarpy/instrument/__init__.py b/src/solarpy/instrument/__init__.py new file mode 100644 index 0000000..5adce80 --- /dev/null +++ b/src/solarpy/instrument/__init__.py @@ -0,0 +1 @@ +from solarpy.instrument.shadowband import shadowband_correction_factor # noqa: F401 From fa53793dce01491605322838dc369b06bb569ae1 Mon Sep 17 00:00:00 2001 From: Emil Visbech Sindberg Thomsen Date: Tue, 17 Mar 2026 16:55:56 +0100 Subject: [PATCH 3/7] Add tests --- src/solarpy/instrument/shadowband.py | 4 ++-- tests/test_instrument.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 tests/test_instrument.py diff --git a/src/solarpy/instrument/shadowband.py b/src/solarpy/instrument/shadowband.py index 4d1eff4..cbe28b2 100644 --- a/src/solarpy/instrument/shadowband.py +++ b/src/solarpy/instrument/shadowband.py @@ -123,5 +123,5 @@ def shadowband_correction_factor( # 7. Return a plain float for scalar input, Series for array input. # ------------------------------------------------------------------ if np.isscalar(input_date) or isinstance(input_date, pd.Timestamp): - return float(np.asarray(C)) - return pd.Series(np.asarray(C), index=dates) + return float(C) + return pd.Series(C, index=dates) diff --git a/tests/test_instrument.py b/tests/test_instrument.py new file mode 100644 index 0000000..3f52e58 --- /dev/null +++ b/tests/test_instrument.py @@ -0,0 +1,21 @@ +import solarpy +import pandas as pd + + +def test_shadowband_correction(): + # Norhtern hemisphere, winter + assert solarpy.instrument.shadowband_correction_factor("2026-03-17", latitude=55.29007) == 1.0668908588370676 + # Norhtern hemisphere, winter - pd.Timestamp + assert solarpy.instrument.shadowband_correction_factor(pd.Timestamp("2026-03-17"), latitude=55.29007) == 1.0668908588370676 + # Southern hemisphere + + # Norhtern hemisphere, summer + + +def test_shadowband_correction_series(): + index = pd.to_datetime(["2026-03-17"]) + pd.testing.assert_series_equal( + solarpy.instrument.shadowband_correction_factor(index, latitude=55.29007), + pd.Series(1.0668908588370676, index=index)) + + \ No newline at end of file From f70f3483af4a52c5fadaa477de5aabb3858e8115 Mon Sep 17 00:00:00 2001 From: Emil Visbech Sindberg Thomsen Date: Tue, 17 Mar 2026 16:56:34 +0100 Subject: [PATCH 4/7] Update test_instrument.py --- tests/test_instrument.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_instrument.py b/tests/test_instrument.py index 3f52e58..d6d62cd 100644 --- a/tests/test_instrument.py +++ b/tests/test_instrument.py @@ -17,5 +17,3 @@ def test_shadowband_correction_series(): pd.testing.assert_series_equal( solarpy.instrument.shadowband_correction_factor(index, latitude=55.29007), pd.Series(1.0668908588370676, index=index)) - - \ No newline at end of file From d6d43958d174e688e7f433c0c9d3d7c81a73aefe Mon Sep 17 00:00:00 2001 From: Emil Visbech Sindberg Thomsen Date: Thu, 19 Mar 2026 15:47:52 +0100 Subject: [PATCH 5/7] Wrote tests for shadowband correction factor --- src/solarpy/instrument/shadowband.py | 3 +- tests/test_instrument.py | 41 +++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/solarpy/instrument/shadowband.py b/src/solarpy/instrument/shadowband.py index cbe28b2..5eb2b38 100644 --- a/src/solarpy/instrument/shadowband.py +++ b/src/solarpy/instrument/shadowband.py @@ -13,6 +13,7 @@ import numpy as np import pvlib as pv import pandas as pd +import datetime def shadowband_correction_factor( @@ -122,6 +123,6 @@ def shadowband_correction_factor( # ------------------------------------------------------------------ # 7. Return a plain float for scalar input, Series for array input. # ------------------------------------------------------------------ - if np.isscalar(input_date) or isinstance(input_date, pd.Timestamp): + if np.isscalar(input_date) or isinstance(input_date, (pd.Timestamp, datetime.date, datetime.datetime)): return float(C) return pd.Series(C, index=dates) diff --git a/tests/test_instrument.py b/tests/test_instrument.py index d6d62cd..08d88e5 100644 --- a/tests/test_instrument.py +++ b/tests/test_instrument.py @@ -1,19 +1,52 @@ import solarpy import pandas as pd +import datetime + +import pytest def test_shadowband_correction(): - # Norhtern hemisphere, winter + # Northern hemisphere, spring (near-zero declination) assert solarpy.instrument.shadowband_correction_factor("2026-03-17", latitude=55.29007) == 1.0668908588370676 - # Norhtern hemisphere, winter - pd.Timestamp + # Northern hemisphere, spring - pd.Timestamp assert solarpy.instrument.shadowband_correction_factor(pd.Timestamp("2026-03-17"), latitude=55.29007) == 1.0668908588370676 - # Southern hemisphere + # datetime.date scalar + assert solarpy.instrument.shadowband_correction_factor(datetime.date(2026, 3, 17), latitude=55.29007) == pytest.approx(1.0668908588370676) + # datetime.datetime scalar + assert solarpy.instrument.shadowband_correction_factor(datetime.datetime(2026, 3, 17, 14, 30), latitude=55.29007) == pytest.approx(1.0668908588370676) - # Norhtern hemisphere, summer + # Northern hemisphere, summer + assert solarpy.instrument.shadowband_correction_factor("2026-06-21", latitude=55.29007) == pytest.approx(1.1408347415382885) + # Northern hemisphere, autumn + assert solarpy.instrument.shadowband_correction_factor("2026-10-21", latitude=55.29007) == pytest.approx(1.0418060380205798) + # Northern hemisphere, winter + assert solarpy.instrument.shadowband_correction_factor("2026-12-21", latitude=55.29007) == pytest.approx(1.0126112472695903) + # Southern hemisphere + assert solarpy.instrument.shadowband_correction_factor("2026-10-21", latitude=-33.87) == pytest.approx(1.1282175055807726) + def test_shadowband_correction_series(): + # Single value series index = pd.to_datetime(["2026-03-17"]) pd.testing.assert_series_equal( solarpy.instrument.shadowband_correction_factor(index, latitude=55.29007), pd.Series(1.0668908588370676, index=index)) + + # Multiple value series + index = pd.DatetimeIndex(["2026-03-17", "2026-10-21"]) + pd.testing.assert_series_equal( + solarpy.instrument.shadowband_correction_factor(index, latitude=55.29007), + pd.Series([1.0668908588370676, 1.0418060380205798], index=index)) + + +def test_shadowband_correction_always_above_one(): + # Test if shadowband correction factor C > 1 + dates = pd.DatetimeIndex(["2026-03-20", "2026-06-21", "2026-09-22", "2026-12-21"]) + result = solarpy.instrument.shadowband_correction_factor(dates, latitude=55.29007) + assert (result > 1.0).all() + + +def test_shadowband_correction_v_zero(): + # With no ring there is nothing to correct for + assert solarpy.instrument.shadowband_correction_factor("2026-06-21", latitude=45.0, V=0.0) == pytest.approx(1.0) From e8d5298aa4e01d9f6d22b41530dbef933303b465 Mon Sep 17 00:00:00 2001 From: Emil Visbech Sindberg Thomsen Date: Thu, 19 Mar 2026 16:18:34 +0100 Subject: [PATCH 6/7] Finished test of shadowband correction factor (test_instrument.py) --- tests/test_instrument.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/test_instrument.py b/tests/test_instrument.py index 08d88e5..5edc2ee 100644 --- a/tests/test_instrument.py +++ b/tests/test_instrument.py @@ -1,7 +1,6 @@ import solarpy import pandas as pd import datetime - import pytest @@ -10,6 +9,8 @@ def test_shadowband_correction(): assert solarpy.instrument.shadowband_correction_factor("2026-03-17", latitude=55.29007) == 1.0668908588370676 # Northern hemisphere, spring - pd.Timestamp assert solarpy.instrument.shadowband_correction_factor(pd.Timestamp("2026-03-17"), latitude=55.29007) == 1.0668908588370676 + # pd.Timestamp with time component - sub-daily info must be ignored + assert solarpy.instrument.shadowband_correction_factor(pd.Timestamp("2026-03-17 14:30:01"), latitude=55.29007) == pytest.approx(1.0668908588370676) # datetime.date scalar assert solarpy.instrument.shadowband_correction_factor(datetime.date(2026, 3, 17), latitude=55.29007) == pytest.approx(1.0668908588370676) # datetime.datetime scalar @@ -25,7 +26,6 @@ def test_shadowband_correction(): # Southern hemisphere assert solarpy.instrument.shadowband_correction_factor("2026-10-21", latitude=-33.87) == pytest.approx(1.1282175055807726) - def test_shadowband_correction_series(): # Single value series index = pd.to_datetime(["2026-03-17"]) @@ -38,8 +38,7 @@ def test_shadowband_correction_series(): pd.testing.assert_series_equal( solarpy.instrument.shadowband_correction_factor(index, latitude=55.29007), pd.Series([1.0668908588370676, 1.0418060380205798], index=index)) - - + def test_shadowband_correction_always_above_one(): # Test if shadowband correction factor C > 1 dates = pd.DatetimeIndex(["2026-03-20", "2026-06-21", "2026-09-22", "2026-12-21"]) @@ -50,3 +49,11 @@ def test_shadowband_correction_always_above_one(): def test_shadowband_correction_v_zero(): # With no ring there is nothing to correct for assert solarpy.instrument.shadowband_correction_factor("2026-06-21", latitude=45.0, V=0.0) == pytest.approx(1.0) + +def test_shadowband_correction_polar(): + # Test that the clip works and it follows the table provided by Kipp & Zonen + # At the pole in winter the sun never rises — ring blocks nothing, C = 1 + assert solarpy.instrument.shadowband_correction_factor("2026-01-01", latitude=90.0) == pytest.approx(1.0) + # At the pole in summer the sun skims the horizon — C rises above 1 + assert solarpy.instrument.shadowband_correction_factor("2026-06-21", latitude=90.0) > 1.0 + \ No newline at end of file From 4f4ff5fea727640ae9d303eb66d5235444b225f3 Mon Sep 17 00:00:00 2001 From: Emil Visbech Sindberg Thomsen Date: Tue, 24 Mar 2026 11:24:02 +0100 Subject: [PATCH 7/7] Finalized tests of shadowband corrrection factor --- tests/test_instrument.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_instrument.py b/tests/test_instrument.py index 5edc2ee..7574e57 100644 --- a/tests/test_instrument.py +++ b/tests/test_instrument.py @@ -56,4 +56,3 @@ def test_shadowband_correction_polar(): assert solarpy.instrument.shadowband_correction_factor("2026-01-01", latitude=90.0) == pytest.approx(1.0) # At the pole in summer the sun skims the horizon — C rises above 1 assert solarpy.instrument.shadowband_correction_factor("2026-06-21", latitude=90.0) > 1.0 - \ No newline at end of file