Skip to content

delta_t=67.0 default: putting a number on "sufficient for most simulations" #2849

Description

@burakgurtunca

Hi all,

This is not a bug report, and the mechanism I would otherwise be asking for already exists in
the library. It is a question about a default and about one sentence of documentation.

Where. In pvlib 0.15.2, delta_t=67.0 is the shipped default at four public entry
points: spa_c (solarposition.py:129), spa_python (:282), sun_rise_set_transit_spa
(:394) and nrel_earthsun_distance (:965). Passing delta_t=None routes to
spa.calculate_deltat instead, so pvlib already carries its own delta-T model and already
offers the alternative. The docstring says:

For most simulations the default delta_t is sufficient.

I could not find a number attached to "most", so I measured one.

What I measured. One variable, nothing else: same instant, same site, same function,
called twice, delta_t=67.0 against delta_t=None, and the two directions compared as a
true angular separation rather than as a difference in zenith alone. The yardstick is
pvlib's own domain rather than mine: the +-0.0003 degrees that Reda and Andreas state for the
SPA. Site 39.93 N, 32.86 E, 15 June, 12:00 UTC.

year pvlib's own calculate_deltat error in the pinned 67.0 angular cost x SPA's stated 0.0003 deg
1900 -2.1 s +69.1 s 7.635e-04 deg 2.55
1950 29.3 s +37.7 s 4.174e-04 deg 1.39
2000 64.0 s +3.0 s 3.313e-05 deg 0.11
2026 75.4 s -8.4 s 9.244e-05 deg 0.31
2050 93.9 s -26.9 s 2.979e-04 deg 0.99
2075 146.9 s -79.9 s 8.835e-04 deg 2.95
2100 203.8 s -136.8 s 1.513e-03 deg 5.04

Read first in the direction that favours the default, because that is the honest direction
for pvlib's actual domain.
Scanned year by year rather than interpolated, the pinned
constant stays inside the algorithm's own stated uncertainty for 81 consecutive years, 1970
through 2050
, and 2050 itself lands at 0.99, which is as close to the line as a boundary can
sit. For PV performance work on TMY data the docstring is simply right. Nobody models an array
in 1900.

Where it stops being right is the whole of what I am reporting. At the two ends of that
table the library disagrees with itself: measured against pvlib's own calculate_deltat, the
shipped default is 69.1 seconds wrong at 1900 and 136.8 seconds wrong at 2100, worth 2.6 and
5.0 times the uncertainty the algorithm advertises. Neither raises anything: the warning
inside calculate_deltat fires only outside -1999 to 3000, and it is not on the default path
at all.

And 2100 is a prediction, which cuts both ways. Published extrapolations of delta-T for
2100 differ by more than 100 seconds, so that last row inherits an uncertainty its own
denominator does not show. I used pvlib's own calculate_deltat deliberately for exactly
that reason, so the comparison stays internal: whatever the true value turns out to be, the
library's shipped default and the library's own model disagree by 136.8 seconds at that
epoch. The 2050 row needs no such caveat, and it is the one I would build a docstring
sentence on.

What I am asking for. Three options in increasing order of disruption, and I hold no
stake in which, or in none:

  1. A clause in the docstring giving "most" a boundary, e.g. that the default sits inside the
    SPA's stated uncertainty for roughly 1970 to 2050 and reaches about five times it by 2100.
  2. A warning when the requested epoch is far enough from the constant's own era that the
    pinned value exceeds the algorithm's uncertainty.
  3. delta_t=None as the default, so the library uses its own model unless told otherwise.

Option 1 costs a sentence and closes the gap I actually found, which is that a documented
claim had no number behind it. Options 2 and 3 change behaviour and are yours to weigh, not
mine.

Disclosure, because it should change how this is read. I found this constant in my own
code first, not in pvlib. I build a browser-based environmental analysis tool, I had pinned
the same 67.0 for the same reason anyone does, and I came looking at pvlib only after
measuring what it had cost me. So this is not an outside audit. It is one person who made the
same choice reporting what the choice turned out to be worth, and the fact that the same
constant is pvlib's default is the part I find most interesting rather than the part I find
damning: it means 67.0 is a convention, and conventions are exactly the things nobody prices.

Minimal reproduction:

import numpy as np, pandas as pd
from pvlib import solarposition

def angsep(z1, a1, z2, a2):
    z1, a1, z2, a2 = map(np.radians, (z1, a1, z2, a2))
    c = np.cos(z1)*np.cos(z2) + np.sin(z1)*np.sin(z2)*np.cos(a1 - a2)
    return np.degrees(np.arccos(np.clip(c, -1.0, 1.0)))

for year in (1900, 2000, 2050, 2100):
    t = pd.DatetimeIndex([f"{year}-06-15 12:00:00"], tz="UTC")
    a = solarposition.spa_python(t, 39.93, 32.86, delta_t=67.0)
    b = solarposition.spa_python(t, 39.93, 32.86, delta_t=None)
    d = angsep(a["zenith"].iloc[0], a["azimuth"].iloc[0],
               b["zenith"].iloc[0], b["azimuth"].iloc[0])
    print(year, f"{d:.4e} deg", f"= {d/0.0003:.2f} x SPA stated uncertainty")

which on pvlib 0.15.2 prints:

1900 7.6353e-04 deg = 2.55 x SPA stated uncertainty
2000 3.3133e-05 deg = 0.11 x SPA stated uncertainty
2050 2.9788e-04 deg = 0.99 x SPA stated uncertainty
2100 1.5130e-03 deg = 5.04 x SPA stated uncertainty

Happy to be told I drove the functions in a way you would not recommend. During this work I
found four errors in my own measuring apparatus, three of which looked like real findings
first, so I am not assuming this one is different.

Method note: the measurements, the code and this text were produced by me working with Claude
Opus 5. The technical note this comes from carries the same statement. I am publishing it on
31 August 2026 and will add the link to this issue then. An answer after that date is as useful
to me as one before it: the note goes to a repository that versions rather than replaces, and I
would rather revise it than have it stand as it is.

Thanks for pvlib, and for the amount of it that is readable.

Burak Gurtunca

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions