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.

Triage of four shadow errors โ€” sign, convention, timezone and units โ€” with the symptom and fix for each.
Each cause has a distinct signature; one test case identifies it.

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.

Scene showing azimuth measured clockwise from north with the sun at 180 degrees and the shadow pointing north.
Sun at azimuth 180ยฐ means shadow towards 0ยฐ, which is +y in a north-up projected CRS.

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.

Table of expected shadow directions and lengths at summer, equinox and winter noon plus summer morning and evening, as a self-test.
The morning and evening rows are the ones a midday-only test misses.

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.
  • azimuth and solar_azimuth differ between libraries. Check the documentation, not the name.
  • DST transitions duplicate or skip an hour. tz_localize needs 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.

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.