Shadows point the wrong way or have the wrong length

On this page

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})")
elevation 60.09°, azimuth 159.11°
10 m building: shadow 5.8 m, offset (-2.1, +5.4)

At 13:00 local time on the summer solstice in The Hague the sun is 60° up at azimuth 159°, south-south-east — solar noon is not until about 13:40 — so a 10 m building casts 5.8 m and dy must be positive: the shadow goes north, slightly west. If your dy is negative, the sign is wrong.

Four shadow errors and the symptom that identifies each. four causes, four signaturescausesignatureoffset sign wrongnoon shadows point south in thenorthazimuth convention differseverything rotated 90° or 180°naive timestamp read as UTCright at noon, wrong at the endsdegrees passed as radianslengths wrong unpredictably,sometimes negativeOne test case at a known time distinguishes all four spatialworkflow.io
Each cause has a distinct signature; one test case identifies it.

Away from the sun, azimuth conventions, local time, radians

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 12:00 local clock time in The Hague: 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.

Azimuth measured clockwise from north with the shadow pointing away from the sun. the conventionN 0°E 90°S 180°0 = N, 90 = E, 180 = Ssun at 180°shadow northshadow offset is +yoffset = (−L·sin(az), −L·cos(az)); the minus signs are the whole fix spatialworkflow.io
Sun at azimuth 180° means shadow towards 0°, which is +y in a north-up projected CRS.

One test that finds all four errors

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))
          case  elev   azim  length_m    dx    dy points
   summer noon 60.09 159.11       5.8  -2.1   5.4      N
  equinox noon 38.22 183.17      12.7   0.7  12.7      N
   winter noon 14.41 184.50      38.9   3.1  38.8      N
summer morning 11.89  66.87      47.5 -43.7 -18.7      W
summer evening 25.07 276.58      21.4  21.2  -2.4      E

Expected in the northern hemisphere: midday shadows point N, morning shadows point W, evening shadows point E — and in June the 07:00 shadow is 47.5 m against 5.8 m at 13:00. 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 5 < L < 7, f"expected about 6 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")


test_northern_noon_shadow_points_north()
test_winter_shadows_are_longer()
print("both tests pass")
both tests pass

Two tests in a CI run are cheaper than rediscovering the sign convention next year.

Why the sign error persists

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⁡1/\tan, a half-degree difference halves or doubles the shadow — which is why, in the shadow analysis guide, the winter-morning case at 0.86° elevation gives a 1,679 m shadow from the tallest building in the tile.

The expected shadow direction at five times, as a self-test. the self-test, 52°Ncaseexpected directionexpected length, 10 msummer noonnorth≈ 7 mequinox noonnorth≈ 13 mwinter noonnorth≈ 40 msummer morningwestlongsummer eveningeastlonganything else is one of the four causes aboveFive assertions, two of which only a timezone bug can fail spatialworkflow.io
The morning and evening rows are the ones a midday-only test misses.

Southern hemisphere, tropics and solar noon

  • 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.