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