Reason for the 2-degree solar altitude cutoff in SolarCal shortwave MRT?

Hi all, and hi Chris,

I am an architect building a browser-based environmental analysis tool. While validating my
own solar position code I ended up measuring a few public implementations against JPL
Horizons, and one measurement in ladybug_comfort raised a question I cannot answer from the
outside.

Where. In ladybug-comfort 0.19.5, ladybug_comfort/solarcal.py carries if alt >= 2:
at four places: line 106 in outdoor_sky_heat_exch, line 188 in indoor_sky_heat_exch, line
261 in shortwave_from_horiz_solar, and line 331 in shortwave_from_horiz_components. Below
that line the direct, diffuse and ground-reflected terms are set to zero together.

What I measured. I installed the package and drove outdoor_sky_heat_exch on both sides
of that line, taking direct and diffuse from ASHRAE’s revised clear-sky model at the same
altitude, so the physics either side is identical and the only variable is which side of the
threshold the sun is on. shortwave_from_horiz_solar returns the same numbers;
shortwave_from_horiz_components runs about 6 percent lower.

altitude span      step in MRT     (DNI 129.2, DHI 22.4)
0.99 - 1.01 deg    0.000 degC
1.49 - 1.51 deg    0.000 degC
1.99 - 2.01 deg    6.63 degC   <-- and below 2.00 the delta is exactly 0.000000
2.49 - 2.51 deg    0.001 degC
2.99 - 3.01 deg    0.001 degC

The step scales with sky clarity, 6.78 degC in a very clear sky down to 0.47 in a very hazy
one, which is the signature of removing the diffuse component rather than the beam.

What I am not saying. This is not a bug report and I am not writing it up as a defect. I
found the same mechanism in my own code first, a 1-degree cutoff on the direct beam, and
closed it. Two unrelated codebases, the same shape, which is why I think it is a property of
the layer between a solar angle and a comfort index rather than a property of either project.
A literature search found thresholds of exactly this kind in print with stated numerical
reasons, so placing one is normal practice.

My question. The function’s own docstring gives its altitude range as [0-90], and ASHRAE
55’s addendum tabulates the projected-area factor from 0 as well. Tracing the code history I
find 0, then 0.1 with a comment about an overflow, then 2 – and I could not find a recorded
reason for that last step.

Is there one? A numerical reason, a physical one, or a practical one from the field would all
answer it.

A guess, offered so it can be shot down. While looking for the reason I found something in
get_projection_factor that would explain a guard, though not obviously a guard at 2. The
table is indexed int(math.ceil(altitude) - 1), and in Python a negative index is valid
rather than an error, so except IndexError never fires:

altitude   index   returns          which is actually
   0.0      -1     0.060            the value for ~90 deg
  -1.0      -2     0.061            the value for ~89 deg
  -5.0      -6     0.068            the value for ~85 deg
 -30.0     -31     0.128            the value for ~60 deg
 -89.0     -90     0.220            the value for ~1 deg
 -90.0     -91     raises           (correct behaviour)

So at exactly zero the sun on the horizon is given the projection factor of the sun overhead,
0.060 where 0.220 is expected, and below the horizon the table is read in mirror. The four
if alt >= 2: guards keep the shipped paths well clear of all of it. Given that the history
runs 0, then 0.1 with an overflow comment, then 2, I wondered whether someone met this and
pushed the guard up rather than down. If that is what happened, the reason exists and simply
was not written down, which would be the best possible answer to my question.

I have not raised this as a separate issue because I do not know whether it is one: the
function is public and its docstring says [0-90], but every caller inside the package is
guarded, so it may be entirely deliberate.

I ask because I would like to write the reason down rather than its absence. “No recorded
reason” is a statement about what I could find in the record, not a claim that no reason
exists, and I have been careful to write it that way. But a gap in my search and an absence
of judgement look identical from outside.

Why here rather than by message. I sent Chris a related measurement privately a while
back, about the sunpath default year, and said then that no reply was needed. This one I am
asking in the open for two reasons: the answer belongs in the record where the next person
looking at that line will find it, and someone other than Chris may simply know.

Happy to share the script that produced the table above, or the four code excerpts, if either
would be useful; I have kept them out of the post because this is a question and not a bug
report. And equally happy to be told I drove the function 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 at 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 thread 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 without the reason in it.

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

Burak

Hi.

I don’t have first hand knowledge of this specific implementation, but at low degrees the cosine of an angle rises sharply. Thus at small angles things start to happen quickly.

It is therefore common in radiation studies/code to set min angle for direct beams, and assume all radiation is negligible or diffuse.

Regards,

LittleBuddha

Thank you, that is useful, and it names the assumption I was testing.

The convention you describe is the one I would expect: below some altitude, treat the direct beam as gone and what remains as diffuse or negligible. What ladybug_comfort 0.19.5 does at alt >= 2 is stronger than that. All three shortwave components are removed together, direct, diffuse and ground-reflected, at solarcal.py lines 106, 188, 261 and 331.

The measurement was of exactly the “negligible” half of that assumption. Holding a clear sky fixed and moving only the solar altitude across the boundary, mean radiant temperature steps by 6.63 degC between 1.99 and 2.01 degrees, while the same function varies by 0.001 degC across every other pair I sampled between 0 and 3 degrees. So at that boundary the removed radiation is not a small correction; it is most of what remains.

On the cosine point you are right, and I think it works against the cutoff rather than for it. The derivative of cos(zenith) is sin(zenith), which is at its maximum exactly at the horizon. A threshold placed where the quantity changes fastest is the placement that produces the largest discontinuity.

None of this makes the behaviour wrong. The function’s own docstring declares alt: [0-90], so what I am trying to establish is narrower: whether the 2-degree form was a deliberate choice with a recorded reason, or the residue of a guard that started at 0, became 0.1 to catch an overflow, and then became 2. I could not find the reason in the code history, which is a weaker statement than saying there is none.

Burak

The note went up on 31 August as planned, so here is the link, as promised above:

It carries the threshold measurement in the form it has in this thread, the projection-factor indexing at and below zero, and the measurement data alongside, so the tables can be re-run rather than taken on trust. Five of the six mechanisms it reports are in my own code. The framing has not changed since the first post: it is not a bug report, and it does not rank one project against another.

The question in the title is still open, and an answer is worth as much to me now as it was a week ago. Zenodo versions rather than replaces, so if the reason for the 2-degree form turns up, I would rather publish a version that records it than leave the note standing with its absence.

Burak