AI Solar Panel

Shading Is Not Binary: Modelling Partial Shade Hour by Hour

Your installer’s survey said “a bit of shading in the morning from the neighbour’s chimney.” That sentence is doing an enormous amount of work. It might mean you lose 40 kWh a year. It might mean you lose 900 kWh a year and your south-east string spends every winter morning dragging the whole array down to nothing. Those two outcomes differ by about £250 a year at current export-and-offset rates, which over a 20-year system life is the difference between a good investment and a mediocre one.

The fix is not a better installer. It’s a horizon profile: a list of obstruction heights measured in degrees, indexed by compass bearing, that you build once for your specific roof and then reuse forever. Combine it with the sun’s position at every hour of the year and you get an hourly shading factor. Not “some shading” but 0.00 at 08:00 on 12 February, 0.62 at 09:00, 1.00 by 10:15.

This post builds that thing. It’s the missing input to everything in Forecasting What Your Roof Will Actually Generate, because a yield model with no horizon is just a brochure number wearing a spreadsheet.

What a horizon profile actually is

Stand at the middle of your roof. (Or, more realistically, at the point on the ground directly below it, then mentally add your roof height.) Face due south. Look at the skyline. Everything below that skyline is blocked sun.

A horizon profile records the elevation angle of that skyline at each azimuth. Azimuth is compass bearing, with 180° = due south in the northern hemisphere. Elevation is how high above flat the obstruction rises, in degrees. A flat, open field horizon is 0° everywhere. A four-storey block 20 metres to your south-east might be 25°.

Here’s a real one, from a terraced house in Bristol with a hipped roof to the east and a mature lime tree to the south-west:

azimuth_deg,horizon_elev_deg,source
 90, 18.0, neighbouring roof ridge (east)
105, 17.5, same ridge
120, 14.0, ridge falls away
135,  6.5, gap over rear gardens
150,  4.0, distant rooftops
165,  3.5, distant rooftops
180,  3.5, open
195,  4.0, open
210, 11.0, lime tree canopy edge
225, 21.0, lime tree, densest part
240, 19.0, lime tree
255,  8.0, tree ends, chimney stack begins
270, 12.0, own chimney stack
285,  2.0, open to west

Fifteen numbers. That’s the whole thing. Everything downstream is arithmetic.

Measuring it without buying a SunEye

The professional tool is a Solmetric SunEye 210 or a Solar Pathfinder, and they cost £1,500 and £250 respectively. You do not need either.

Option 1: a phone app with an AR horizon trace. On iOS, Sun Seeker (£10.99) and Sun Surveyor (£9.99) both overlay the sun’s path for any date onto the live camera view, and Sun Surveyor will export a horizon profile as CSV. Stand at the roof position (a first-floor window that opens outward is often good enough), sweep the phone through the arc, and trace. On Android, Sun Surveyor is the same app; PV Sun Position is a free alternative with cruder export.

Option 2: a protractor and a compass app, twenty minutes. Genuinely fine. Use a phone clinometer (the iPhone Measure app has one built in under Level, or use Clinometer on Android). Take a bearing with the compass, sight along the phone edge at the top of the obstruction, read the angle. Do this every 15° of azimuth from 75° through to 285°. Write it in a notebook. That’s your CSV.

Option 3: derive it from geometry. If the obstruction is a building you can measure on a map, you don’t need to sight at all:

elevation = atan( (obstruction_height - observer_height) / horizontal_distance )

Neighbour’s ridge is 8.5 m above ground. Your panels sit at 6.2 m. Horizontal distance from a Google Maps measure tool: 11.0 m.

atan((8.5 - 6.2) / 11.0) = atan(0.209) = 11.8°

Note how sensitive this is. Get the distance wrong by 2 m and you’re at 14.3° or 10.0°, which shifts your morning shade-clear time by roughly 20 minutes in winter. Measure twice.

Option 4: let the data do it. PVGIS (the European Commission’s tool at re.jrc.ec.europa.eu/pvg_tools/en/) has a “calculated horizon” option that derives a horizon profile from SRTM terrain data. This is excellent for hills and valleys and completely useless for buildings and trees, because 30-metre terrain tiles don’t know your neighbour has a conservatory. Use it as your far-field baseline, then overlay your own near-field measurements on top by taking the maximum of the two at each azimuth.

Turning a horizon into hourly shading factors

Now the sun. You need solar azimuth and elevation for every hour of the year at your latitude and longitude. Three ways to get it, all free:

pvlib in Python is the serious option:

import pandas as pd, pvlib

loc = pvlib.location.Location(51.4545, -2.5879, tz='Europe/London', altitude=60)
times = pd.date_range('2025-01-01', '2025-12-31 23:00', freq='1h', tz='Europe/London')
sp = loc.get_solarposition(times)
# sp['azimuth'], sp['apparent_elevation']

If you’d rather not write Python, the NOAA Solar Calculator spreadsheet (gml.noaa.gov/grad/solcalc/calcdetails.html) is an .xls with the full SPA algorithm in cell formulas. Drop in your lat/long, drag down 8,760 rows, and you have the same numbers. Or use the SUNPOS add-in approach with LibreOffice if Excel’s array handling annoys you.

Now the join. For each hour, look up the horizon elevation at the sun’s azimuth (interpolating between your 15° samples) and compare:

import numpy as np

hz_az   = np.array([90,105,120,135,150,165,180,195,210,225,240,255,270,285])
hz_elev = np.array([18,17.5,14,6.5,4,3.5,3.5,4,11,21,19,8,12,2])

def horizon_at(az):
    return np.interp(az, hz_az, hz_elev, left=25.0, right=10.0)

sp['hz'] = horizon_at(sp['azimuth'].values)
sp['beam_visible'] = (sp['apparent_elevation'] > sp['hz']).astype(float)

That gives you a binary beam mask, which is the wrong answer, and the reason for this post’s title. Two corrections turn it into something real.

Correction one: diffuse light still arrives

On a UK overcast December day, 90%+ of the irradiance reaching your panels is diffuse. The sun being behind a chimney is irrelevant if there’s no direct beam anyway. So split your irradiance into beam and diffuse (pvlib’s erbs or disc models will do this from GHI), apply the shading mask only to the beam component, and apply a separate, much gentler sky-view-factor reduction to the diffuse.

Sky view factor from a horizon profile, approximated:

# fraction of hemisphere still visible, weighted by cos(elev) for isotropic sky
svf = np.mean(np.cos(np.radians(hz_elev))**2)

For the Bristol profile above that comes out around 0.93. So diffuse irradiance gets multiplied by 0.93 all year, every hour, including the ones where beam shading is zero. Most amateur models miss this entirely and then wonder why their summer numbers look fine but their winter numbers are 15% optimistic.

Correction two: the penumbra is wide

The sun is half a degree across, and a tree is not an opaque disc. A branch structure at 20 m distance produces a soft edge that takes 10 to 25 minutes to pass, not an instant. Modelling that transition as a step function gives you a sawtooth output curve that no real inverter has ever produced.

Replace the hard comparison with a ramp:

delta = sp['apparent_elevation'] - sp['hz']
# 3° transition band for vegetation, 1° for hard edges like chimneys
band = 3.0
sp['beam_factor'] = np.clip(delta / band + 0.5, 0, 1)

For vegetation you can go further and add a transmittance floor. A leafless winter lime passes perhaps 45% of beam irradiance; in full July leaf, maybe 8%. So rather than 0.00 behind the tree, use a seasonally varying minimum:

leaf_on = sp.index.month.isin([5,6,7,8,9,10])
tree_az = (sp['azimuth'] > 200) & (sp['azimuth'] < 250)
floor = np.where(leaf_on, 0.08, 0.45)
sp.loc[tree_az, 'beam_factor'] = np.maximum(
    sp.loc[tree_az, 'beam_factor'], floor[tree_az])

A worked afternoon

Take 12 February, Bristol, the lime tree to the south-west. Solar position at hourly steps:

TimeSun azSun elevHorizonDeltaBeam factor
13:00190°21.4°3.7°+17.71.00
14:00204°19.1°8.4°+10.71.00
15:00217°15.2°16.2°−1.00.45 (leafless floor)
16:00229°10.0°20.4°−10.40.45
17:00241°4.1°18.7°−14.60.45

Compare the same hours on 12 July:

TimeSun azSun elevHorizonDeltaBeam factor
15:00231°44.6°20.1°+24.51.00
17:00259°27.3°9.6°+17.71.00
19:00283°10.5°2.6°+7.91.00

The tree is a winter problem and a non-problem in summer, because the July sun rides right over it. This is exactly the kind of thing an installer’s “a bit of shading” flattens into mush, and exactly the kind of thing that changes whether a west-facing extension roof is worth panelling.

From shading factor to kWh

Multiply through. Beam POA irradiance × beam_factor, plus diffuse POA × svf, times array area times module efficiency times system losses. Annual sum, compared against the same model with the horizon flattened to zero:

Unshaded model:        4,180 kWh/yr
With horizon applied:  3,724 kWh/yr
Loss:                    456 kWh  (10.9%)

Breakdown:
  East ridge (morning, Oct-Feb)   -187 kWh
  Lime tree (afternoon, Nov-Mar)  -161 kWh
  Own chimney (narrow, year-round) -34 kWh
  Diffuse SVF reduction (all yr)   -74 kWh

That breakdown is the actionable part. The lime tree costs £45 a year at a 28p blended value. Crown-lifting it costs £280 once. That’s a six-year payback on a tree surgeon, which is a conversation you can now have with actual numbers, and with your neighbour if it’s their tree.

The string-level multiplier nobody mentions

Everything above assumes shading reduces output proportionally. With a single MPPT string, it often doesn’t. One heavily shaded module in a series string can pull the whole string’s operating point down, and bypass diodes only help in thirds. A 15% geometric shading loss can become a 30% electrical loss.

Model this by grouping your modules into strings, computing a shading factor per module (each module gets its own horizon if the obstruction is close), then applying:

string_output ≈ min(module_factors) for the shaded third,
                full output for unshaded thirds

It’s crude. It’s also much closer to reality than the naive average, and it’s the calculation that tells you whether £480 of Tigo TS4-A-O optimisers on four modules pays for itself. Run both scenarios. In the Bristol case, optimisers recovered 118 kWh/yr, about £33, so the answer was no at 14 years. On a house with a heavier morning obstruction the same maths came out at five years.

Checking your model against reality

Pull a clear-sky day from your inverter’s own data. SolarEdge, GivEnergy and Fox ESS all export per-inverter 5-minute CSV; Solis needs SolisCloud’s API or a Modbus dongle. Pick a cloudless morning in the shoulder season, plot measured AC power against your modelled beam factor, and look at when the ramp starts.

If your model says shade clears at 09:20 and the inverter shows the knee at 09:05, your horizon at that azimuth is about 1.5° too high. Adjust and re-run. Two or three iterations against real clear-sky days will get you within a few percent, which is better than any survey you’ll be handed.

The measurement takes an afternoon. The model takes an evening. After that you own a permanent, reusable description of your own sky, and every quote you receive from that day onward can be checked against it rather than believed.