Shadows point the wrong way or have the wrong length
Problem statement
The shadow map is finished and the shadows fall to the south at midday in the northern hemisphere, or they are ten times too long, or they rotate through the day in the wrong direction. All three look like plausible output โ a shadow is a grey polygon, and nothing about a grey polygon says which way it should point.
There are four causes, and each has a signature you can read off a single test case: the azimuth sign, the azimuth convention, a naive timestamp, and degrees where the code expects radians. This guide diagnoses each from one building at one known time.
Quick answer
Test against a case you can verify by hand โ midday, northern hemisphere, shadow to the north:
import numpy as np, pandas as pd, pvlib
lat, lon = 52.104, 4.2726
ts = pd.DatetimeIndex(["2026-06-21 13:00"]).tz_localize("Europe/Amsterdam")
sp = pvlib.solarposition.get_solarposition(ts, lat, lon).iloc[0]
print(f"elevation {sp.apparent_elevation:.2f}ยฐ, azimuth {sp.azimuth:.2f}ยฐ")
L = 10 / np.tan(np.radians(sp.apparent_elevation))
dx = -L * np.sin(np.radians(sp.azimuth))
dy = -L * np.cos(np.radians(sp.azimuth))
print(f"10 m building: shadow {L:.1f} m, offset ({dx:+.1f}, {dy:+.1f})")
At 13:00 local time on the summer solstice in The Hague the sun is nearly due south and 55ยฐ up, so a 10 m building casts about 7 m and dy must be positive โ the shadow goes north. If your dy is negative, the sign is wrong.
Step-by-step solution
1. Shadows point away from the sun
With azimuth measured clockwise from north, the sun's direction is (sin(az), cos(az)) and the shadow's is the negative of that. The offset is (-Lยทsin(az), -Lยทcos(az)). Using + puts every shadow on the sunny side.
Symptom: shadows to the south at midday in the northern hemisphere, and the pattern is mirrored through the day.
2. Check the azimuth convention of your source
pvlib and most solar libraries use degrees clockwise from north, so 180ยฐ is south. Some astronomy conventions measure from south, and some graphics code measures counter-clockwise from east. A 180ยฐ convention error looks identical to a sign error; a 90ยฐ one makes shadows perpendicular to where they should be.
Symptom: shadows consistently rotated by 90ยฐ or 180ยฐ.
3. Localise the timestamp
A naive timestamp is treated as UTC. In western Europe in summer that is two hours early, which moves the sun about 30ยฐ and rotates every shadow by the same amount. The error is largest at the ends of the day and vanishes near solar noon, so a midday test will not catch it.
ts = pd.DatetimeIndex(["2026-06-21 13:00"]).tz_localize("Europe/Amsterdam") # not naive
Symptom: shadows correct at noon and increasingly wrong towards morning and evening.
4. Check degrees against radians
np.tan and np.sin take radians. Passing degrees wraps the argument around ฯ, so an elevation of 55 becomes 91.3ยฐ and a 10 m building casts โ0.22 m instead of 7.0 m; an elevation of 14 becomes 82.1ยฐ and gives 1.38 m instead of 40.11 m.
Symptom: lengths wrong by a factor that changes unpredictably with time of day, sometimes negative.
5. Check the elevation is apparent and positive
Below the horizon there is no shadow, and tan of a negative angle gives a negative length that translates the footprint towards the sun. Skip any timestep with elevation at or below zero.
Symptom: enormous shadows pointing the wrong way at dawn and dusk.
6. Check the CRS is projected
Offsets are in metres. In a geographic CRS the same code adds metres to degrees, which moves the footprint entirely off the planet.
Symptom: shadows thousands of kilometres long, or nothing renders at all.
7. Verify the length against a hand calculation
L = h / tan(elevation). At The Hague's solar noon: 55.06ยฐ in June gives 7.0 m for a 10 m building, 37.19ยฐ in March gives 13.2 m, and 13.99ยฐ in December gives 40.1 m. If your numbers do not match those ratios, the elevation is wrong before the geometry is.
Code examples
Example 1 โ the single test that finds all four
import numpy as np, pandas as pd, pvlib
def shadow_selftest(lat=52.104, lon=4.2726, tz="Europe/Amsterdam", height=10.0):
cases = [("2026-06-21 13:00", "summer noon"),
("2026-03-21 13:00", "equinox noon"),
("2026-12-21 13:00", "winter noon"),
("2026-06-21 07:00", "summer morning"),
("2026-06-21 19:00", "summer evening")]
rows = []
for stamp, label in cases:
ts = pd.DatetimeIndex([stamp]).tz_localize(tz)
sp = pvlib.solarposition.get_solarposition(ts, lat, lon).iloc[0]
if sp.apparent_elevation <= 0:
rows.append({"case": label, "note": "sun below horizon"})
continue
L = height / np.tan(np.radians(sp.apparent_elevation))
dx = -L * np.sin(np.radians(sp.azimuth))
dy = -L * np.cos(np.radians(sp.azimuth))
rows.append({"case": label, "elev": round(sp.apparent_elevation, 2),
"azim": round(sp.azimuth, 2), "length_m": round(L, 1),
"dx": round(dx, 1), "dy": round(dy, 1),
"points": "N" if dy > abs(dx) else "E" if dx > abs(dy)
else "W" if -dx > abs(dy) else "S"})
return pd.DataFrame(rows)
print(shadow_selftest().to_string(index=False))
Expected in the northern hemisphere: noon shadows point N, morning shadows point W, evening shadows point E. Anything else is one of the four causes.
Example 2 โ lengths you can check against
import numpy as np, pandas as pd, pvlib
for month, day in [(6, 21), (3, 21), (12, 21)]:
ts = pd.DatetimeIndex([f"2026-{month:02d}-{day:02d} 12:00"]).tz_localize("Europe/Amsterdam")
sp = pvlib.solarposition.get_solarposition(ts, 52.104, 4.2726).iloc[0]
print(f"{month:02d}-{day:02d} noon: elevation {sp.apparent_elevation:5.2f}ยฐ, "
f"10 m building casts {10 / np.tan(np.radians(sp.apparent_elevation)):5.1f} m")
06-21 noon: elevation 55.06ยฐ, 10 m building casts 7.0 m
03-21 noon: elevation 37.19ยฐ, 10 m building casts 13.2 m
12-21 noon: elevation 13.99ยฐ, 10 m building casts 40.1 m
A factor of 5.7 between midsummer and midwinter at this latitude. If your winter shadows are not several times your summer ones, the date is not reaching the solar position calculation.
Example 3 โ assert the conventions in a test
import numpy as np, pandas as pd, pvlib, pytest
def test_northern_noon_shadow_points_north():
ts = pd.DatetimeIndex(["2026-06-21 13:00"]).tz_localize("Europe/Amsterdam")
sp = pvlib.solarposition.get_solarposition(ts, 52.104, 4.2726).iloc[0]
assert 150 < sp.azimuth < 210, "sun should be near due south at solar noon"
L = 10 / np.tan(np.radians(sp.apparent_elevation))
dy = -L * np.cos(np.radians(sp.azimuth))
assert dy > 0, "northern-hemisphere noon shadows point north"
assert 6 < L < 8, f"expected about 7 m, got {L:.1f}"
def test_winter_shadows_are_longer():
def length(stamp):
ts = pd.DatetimeIndex([stamp]).tz_localize("Europe/Amsterdam")
sp = pvlib.solarposition.get_solarposition(ts, 52.104, 4.2726).iloc[0]
return 10 / np.tan(np.radians(sp.apparent_elevation))
assert length("2026-12-21 12:00") > 4 * length("2026-06-21 13:00")
Two tests in a CI run are cheaper than rediscovering the sign convention next year.
Explanation
Why the sign error is so persistent
A map of shadows pointing the wrong way is perfectly legible. Buildings have shadows, the shadows are the right length, they are all consistent with each other, and the map has the polish of a finished product. Only somebody who checks the direction against the time of day notices, and the check takes a deliberate moment because the intuition โ "shadows go with the sun" โ is the wrong way round.
Why a timezone error hides at noon
The sun's azimuth changes by roughly 15ยฐ per hour, but near solar noon it changes slowly in elevation while sweeping fastest in azimuth. A two-hour offset at 13:00 moves the azimuth by about 30ยฐ, which on a short summer shadow is a small displacement; the same offset at 07:00 moves a long shadow a long way. Testing only at midday systematically misses the problem.
Why degrees-for-radians is intermittent
tan in radians is periodic with period ฯ โ 3.14, so a degree value is wrapped to an effectively random angle. An elevation of 55 becomes 91.3ยฐ, giving โ0.22 m for a 10 m building; 37 becomes 139.9ยฐ, giving โ11.89 m; 14 becomes 82.1ยฐ, giving 1.38 m; 10 becomes 33.0ยฐ, giving 15.42 m. Sometimes negative, sometimes short, never a constant factor โ which is why it is harder to spot than a simple scaling bug.
Why apparent elevation matters most at the ends of the day
Refraction raises the apparent sun by about 34 arcminutes at the horizon and by a few arcminutes at moderate elevations. High in the sky the difference in shadow length is negligible. Near the horizon, where length goes as 1/tan, a half-degree difference halves or doubles the shadow โ which is why the reference winter-morning case at 0.86ยฐ elevation gives a 1,679 m shadow from a 25 m building.
Edge cases or notes
- Southern hemisphere is mirrored. Noon shadows point south.
- Between the tropics the sun crosses overhead and noon shadows change direction seasonally.
- Solar noon is not 12:00. Longitude within the zone and the equation of time shift it.
azimuthandsolar_azimuthdiffer between libraries. Check the documentation, not the name.- DST transitions duplicate or skip an hour.
tz_localizeneeds a policy. - Negative elevation means no shadow. Skip, do not compute.
- Check the CRS is projected. Metres in, metres out.
- Write the conventions in a comment. You will read it in a year.
Internal links
- How to compute building shadows for a given time in Python โ the full calculation
- How to estimate rooftop solar potential in Python โ the same sun positions
- How to create a hillshade in Python โ azimuth conventions in a raster context
- A hillshade looks flat or inverted โ the raster version of this bug
- Spatial data has timezone errors in the time column โ the naive timestamp problem generally
- How to extrude building footprints into 3D in Python โ the geometry the shadow comes from
- How to choose a projected CRS for an area โ working in metres
- How to test a GIS pipeline with pytest โ where the assertions belong
FAQ
Why do my shadows point towards the sun?
The offset sign is wrong. With azimuth clockwise from north, the shadow offset is (-Lยทsin(az), -Lยทcos(az)).
Which way should a midday shadow point?
North in the northern hemisphere, south in the southern. Between the tropics it depends on the season.
Why are my shadows right at noon and wrong in the morning?
A naive timestamp being read as UTC. The offset rotates the sun by about 15ยฐ per hour, which matters most when shadows are long.
Why are my shadow lengths inconsistent?
Degrees passed where radians are expected. Because tan is periodic, the error changes unpredictably with elevation rather than scaling.
How long should a shadow be?
height / tan(elevation). At The Hague's solar noon a 10 m building casts 7.0 m in June, 13.2 m in March and 40.1 m in December.
Should I use true or apparent elevation?
Apparent. Refraction lifts the sun by about half a degree near the horizon, which halves or doubles a long shadow.