Clear-Sky Ceilings: Using Theoretical Maxima to Spot Underperformance
Your inverter tells you it made 18.4 kWh yesterday. Good day? Bad day? Unless you have a pyranometer bolted to the roof, you genuinely don’t know, and that’s the problem with almost every solar monitoring setup in the UK. You’re staring at a number with no denominator.
The fix is to build the denominator yourself. For any given day, at your latitude, with your array’s tilt and azimuth, there is a hard physical ceiling on how much energy can land on your panels: the clear-sky maximum. It’s computable from first principles, it needs no sensors, and it doesn’t care whether it was actually cloudy. That last part sounds like a weakness. It’s the whole trick.
Why the ceiling beats the forecast
Most people trying to answer “is my system underperforming?” reach for a forecast API. Solcast, Forecast.Solar, Open-Meteo — they’ll all give you a predicted kWh for tomorrow based on cloud cover models. Useful for battery scheduling. Nearly useless for fault detection, because when your actual output comes in 30% under forecast you have two candidate explanations and no way to separate them: the cloud model was wrong, or your array has a problem.
The clear-sky ceiling has exactly one interpretation when you breach it: something in your model is wrong. And when you sit at it, there is also exactly one interpretation: your array is working correctly, because you cannot exceed physics. Cloud cover only ever pushes you down, never up. So the ceiling gives you a clean, one-sided test.
Here’s the shape of it. Over a rolling window of days, your actual output traces a jagged line under a smooth envelope. The envelope is the ceiling. On genuinely clear days your actuals should kiss that envelope. If they never do, not once in a month of decent weather, you have a fault. That fault might be soiling, a shaded string, a failing optimiser, a clipping inverter, or a DC isolator that’s been quietly doing nothing since March.
Computing the ceiling
You need four things: solar position, an extraterrestrial irradiance value, a clear-sky atmospheric model, and a transposition step to get from horizontal irradiance onto your tilted plane.
In Python, pvlib does all four and is the tool worth learning here. It’s the reference implementation the research community uses, it’s free, and it runs happily in a Jupyter notebook on a laptop.
import pandas as pd
import pvlib
# Bristol-ish. Replace with your own coordinates.
site = pvlib.location.Location(51.454, -2.588, tz='Europe/London', altitude=11)
times = pd.date_range('2026-06-21 04:00', '2026-06-21 22:00',
freq='5min', tz='Europe/London')
cs = site.get_clearsky(times) # Ineichen model, GHI/DNI/DHI in W/m2
solpos = site.get_solarposition(times)
poa = pvlib.irradiance.get_total_irradiance(
surface_tilt=35,
surface_azimuth=180, # due south
solar_zenith=solpos['apparent_zenith'],
solar_azimuth=solpos['azimuth'],
dni=cs['dni'], ghi=cs['ghi'], dhi=cs['dhi'],
)
# W/m2 at 5-min steps -> kWh/m2 for the day
kwh_per_m2 = poa['poa_global'].sum() * (5/60) / 1000
print(round(kwh_per_m2, 2))
On midsummer day at that latitude, a 35° south-facing plane gets roughly 8.3 kWh/m² under a clear sky. Run the same code for 21 December and it drops to about 1.4 kWh/m². That six-to-one swing is why a flat “expected solar output per day uk” figure from a comparison site is worthless for diagnostics: the honest expectation on a December Tuesday and a June Tuesday differ by more than most people’s annual variance.
From irradiance to your kWh ceiling
Irradiance on the plane is not electricity. You need the system model.
Take a fairly typical setup: 12 panels at 440 W, so 5.28 kWp, on a single south-facing roof at 35°, with a 5 kW hybrid inverter. The DC-to-AC chain on a clear summer day loses you: temperature derating (cells run hot, roughly 0.35%/°C above 25°C, and on a still sunny day in June cell temperature hits 50°C easily, so call it −8%), inverter conversion (−3%), DC wiring and mismatch (−2%), and module soiling even when clean (−1%).
Clear-sky POA: 8.30 kWh/m²
Array area factor: 5.28 kWp ÷ 1000 W/m² STC = 5.28 m²-equivalent
Raw DC: 8.30 × 5.28 = 43.8 kWh
Temperature (-8%): = 40.3 kWh
Inverter (-3%): = 39.1 kWh
Wiring/mismatch (-2%): = 38.3 kWh
Soiling (-1%): = 37.9 kWh
Clipping at 5 kW AC: ~1.1 kWh shaved off peak= 36.8 kWh
So your June ceiling is around 36.8 kWh. Note the clipping line: with 5.28 kWp into a 5 kW inverter, midday clear-sky output pins against the limit for about ninety minutes and you lose real energy. That’s not a fault, it’s a design choice, and if your ceiling model doesn’t include it you’ll chase a phantom 3% shortfall every clear summer day.
pvlib’s ModelChain does all of this properly, including the Sandia and PVsyst temperature models and inverter curves, if you’d rather not hand-roll the multipliers:
from pvlib.modelchain import ModelChain
from pvlib.pvsystem import PVSystem, FixedMount
from pvlib.temperature import TEMPERATURE_MODEL_PARAMETERS
system = PVSystem(
mount=FixedMount(surface_tilt=35, surface_azimuth=180),
module_parameters={'pdc0': 5280, 'gamma_pdc': -0.0035},
inverter_parameters={'pdc0': 5200, 'eta_inv_nom': 0.97},
temperature_model_parameters=TEMPERATURE_MODEL_PARAMETERS['sapm']['open_rack_glass_glass'],
)
mc = ModelChain(system, site, aoi_model='physical', spectral_model='no_loss')
mc.run_model(weather) # pass clear-sky + a 20C ambient assumption
The gap signal
Now you have two daily series: ceiling_kwh and actual_kwh. Define the ratio:
clear-sky index, CSI = actual ÷ ceiling
On an overcast January day, CSI might be 0.12. On a bright breezy April day with cumulus, 0.65. On a genuinely clear day, a healthy system lands at 0.92 to 0.98. Anything above 1.02 means your model is wrong (usually tilt, azimuth, or a missing panel in the kWp figure).
The single most useful statistic is the rolling 30-day maximum CSI. Not the mean, the max. The mean is polluted by weather. The max asks: in the last month, did this system ever manage to reach its ceiling? A healthy UK array hits max-CSI above 0.90 in every month from February to October. If your rolling max drops to 0.78 and stays there while neighbouring days look sunny, you have a real, quantified 12% loss.
Date Ceiling Actual CSI 30d max CSI
2026-05-02 31.2 29.6 0.95 0.96
2026-05-09 32.0 11.4 0.36 0.96
2026-05-14 32.6 30.9 0.95 0.96
2026-06-03 35.1 27.4 0.78 0.95
2026-06-11 36.2 28.1 0.78 0.86 <-- ceiling never reached
2026-06-19 36.8 28.7 0.78 0.78 <-- flat, suspiciously exact
That table is a real fingerprint. The 9 May reading at 0.36 is weather, it’s noise, ignore it. The interesting thing is the June run: three clear days, all landing at exactly 0.78. A consistent ratio, not a random one. One string out of a four-string array, or one optimiser dead, or a bird-mess shadow on a bypass-diode boundary. Ratios that are stable across different absolute irradiance levels point at a proportional loss, which means something is out of circuit. A loss that varies with the day points at shading or thermal.
Building it without Python
If your comfort zone is a spreadsheet, you can get 90% of the way there. Pull hourly clear-sky GHI/DNI/DHI from the PVGIS API (the EU Joint Research Centre tool, free, no key required) for a typical meteorological year at your coordinates, or use its seriescalc endpoint. Even simpler: PVGIS’s “Grid-connected PV” tab will output monthly expected kWh for your exact tilt, azimuth and kWp, which you can drop into a sheet as a monthly baseline.
The crude spreadsheet version: take PVGIS monthly output, divide by days in month to get a mean daily figure, then multiply by 1.55 to approximate a clear-sky day for that month (because a UK monthly mean sits well below its clear-day value). It’s rough. It will not catch a 6% degradation. It will absolutely catch a dead string.
For Home Assistant users, the Forecast.Solar integration plus a template sensor works well. Set the damping factors to zero and treat the “estimate” as your ceiling proxy; it’s not a true clear-sky model but it correlates closely enough on the top end. Then push daily CSI into a statistics sensor with max over 30 days. That’s your tripwire, and it lives alongside whatever else you’re already tracking in your monitoring and fault detection setup.
Where the method lies to you
Three honest caveats.
Snow and heavy frost will tank your CSI legitimately for days. Build a rule: ignore days where the Met Office observed minimum was below 1°C and there was recorded precipitation.
Clipping changes seasonally. An array that clips for ninety minutes in June clips for zero minutes in October, so a fixed clipping deduction will make your autumn ceilings too low and your autumn CSI artificially high. Model clipping per-timestep, not per-day, or accept that your summer numbers run a little pessimistic.
Horizon shading is the big one. Trees, a neighbour’s chimney, a hill to the southwest. pvlib will happily model a clear sky that your roof never sees. Spend an afternoon with a compass app measuring horizon elevation every 15° of azimuth, feed it in as a mask, and your ceiling becomes site-specific rather than textbook. Skip it and you’ll have a permanent CSI deficit that looks like a fault and never resolves.
The first week
Compute yesterday’s ceiling. Compare it to what your inverter actually made. Then do the same for the sunniest day you can find in your export history, ideally a cloudless one in April or May, when panels are cool and the sun is high.
That single day tells you more than a year of dashboard-watching. If it lands above 0.92, your array is fine and every future shortfall is weather. If it lands at 0.80, you have found something, and you found it with arithmetic rather than a call-out fee.