The rooftop classifier (roofclf)¶
What it is, in one paragraph¶
roofclf is a per-building classifier that answers a single question: does this roof
carry solar PV? It takes one building footprint, reads the Sentinel-2 pixels that fall
inside it, and returns a probability. It never draws a panel outline, never says how many
modules there are, and never looks at anything but that footprint and its own
neighbourhood. It is a regularised logistic regression, about 17 numbers wide, trained on
roughly a hundred thousand hand-checked buildings. It is deliberately the smallest model
in this project.
Why the question changes at 400 m2¶
At Sentinel-2's 10 m ground sample distance, a 100 m2 array is roughly one
mixed pixel. There is no outline to find, and the segmentation model is not even asked to
look: chips.MIN_PV_AREA burns everything below 400 m2 into the training mask
as ignore, so the model receives no gradient there.
That is not a small blind spot. Germany's MaStR register is legally complete, and measured against it 65.5% of German rooftop capacity sits in units below 72 kWp, which is what 400 m2 of module comes to. In Pakistan the whole sub-500 m2 class contributes about 8 MWp to the segmentation-based national estimate, while one exhaustively mapped square kilometre of residential Lahore holds 3.3 times more sub-100 m2 PV area than the model finds in the entire country.
The recall correction cannot repair this, because 1 / recall x ~0 is still about zero. A
class that is never detected is not recoverable by reweighting the class that is. It needs
a different estimator, and asking "does this building carry PV" is a far easier question
at one mixed pixel than "where are its panel edges": it only needs the footprint's
spectral signature to differ from a PV-free roof of the same kind. The
pixel-grid figure on the detection page shows the
geometry of that handover directly: at 100 m2 no pixel is even half PV, so
shape is gone and colour is all that is left.
The flow, end to end¶
The six stages below follow the chart from top to bottom.
1. Where the labels come from, and why they have to be quadrats¶
Ordinary OpenStreetMap is incomplete at small sizes, so a building with no mapped PV is not a negative: it may simply be unmapped. A classifier trained on that learns to predict mapping effort rather than solar panels.
The fix is the calibration quadrat: a small area, typically 1 to 7 km2, that a mapper has swept exhaustively and declared Rule-1 complete, meaning every visible panel inside the boundary is mapped. Inside such a box, and only inside it, a roof with no mapped PV is a genuine negative. That is exactly the supervision missing in the failure regime, which is why the quadrats are spent on training rather than only on after-the-fact correction.
Thirty-one quadrats now carry Rule-1, with 16,469 mapped installations between them; 30 feed the current fit (Kalat Rural is registered and Rule-1 but held out -- see Calibration quadrats).
Rule-1 is relative to the mapping imagery, not to the model's imagery
Mapping happens against JOSM's background layers (Esri, Bing, Maxar), whose capture
date is generally older than the Sentinel-2 composite the model reads. A panel built
between the two dates is present in the model's input and absent from the labels. So
measured precision is a lower bound, base_rate is a lower bound, and
rate_ratio is an upper bound. Recall over mapped installations is unaffected, since
it only ever divides by labels that exist. Closing this properly needs contemporaneous
high-resolution imagery; scripts/fraction_stale_label_audit.py is the interim way to
bound the size of the gap.
2. One row per building¶
roofclf.building_table turns each quadrat into a table, one row per VIDA building whose
representative point falls inside the boundary.
The label. A building is a positive (has_pv = 1) when mapped PV polygons cover at
least 5% of its footprint (MIN_PV_OVERLAP_FRAC). The threshold exists so an array that
merely clips a neighbour's roof edge does not label that neighbour. The true intersected
area is kept as pv_area_true_m2, and it is that column, not the building's size, that
later measures how much of a flagged roof the panels actually cover.
The features. Sixteen numbers, all read off the same dry-season composite the segmentation model uses. No new imagery is fetched at any point.
| Block | Columns | What it carries |
|---|---|---|
| Size | log_roof_area |
the adoption propensity that comes with a bigger roof |
| Reflectance | 10 band means over the footprint | the roof's spectral signature, averaged over whatever real pixels it has |
| Derived | ndvi, ndbi, brightness, swir_vis_ratio, blue_red_ratio |
vegetation and built-up contrast, and the dark, comparatively flat visible response with a SWIR drop that characterises a module |
Until 2026-10-30 the size block also held bf_confidence, VIDA's footprint confidence. It
is empty on every Microsoft-sourced footprint, which is all of Yemen and France, and the
empty value was filled with 0, ten Pakistani standard deviations below Pakistan's mean:
about 1.5 times too high a level for every foreign roof scored by Pakistan's model.
Without it Pakistan's median fold AUC is 0.8680 against 0.8682. Pakistan's published
figures still come from the 17-feature model until the page is rebuilt
(experiments register).
Roughly half of Pakistani VIDA footprints are smaller than one pixel and rasterise to no
pixel at all. Those fall back to the pixel under their representative point, the same
convention density.py uses. Without that fallback the entire small-building population,
which is the population this module exists for, would drop out of the table.
Two feature blocks are computed and left switched off by default, because the ablation did not support them: footprint shape (compactness, rectangularity, aspect ratio) and the segmentation and fraction rasters as inputs. See the ablation below.
What a PV roof looks like in the spectral domain¶
The features above are numbers, and it is worth seeing what they actually measure. Take every sub-400 m2 building in the Rule-1 quadrats, split by whether it carries mapped PV, resample the PV-free class to the same roof-size mix so nothing below is a size effect in disguise, and plot the two median spectra:
Two honest readings of that figure, and both matter:
- There is a real, physically sensible signal. A roof carrying panels reads darker in
every band, because glass over silicon absorbs where a concrete or metal roof scatters.
The dip is deepest in the red and near-infrared (6 to 8% from B04 through B08,
which is the light a module exists to absorb) and shallowest in blue and in the first
SWIR band (glass reflects blue slightly better, and silicon stops absorbing beyond its
band gap, so B11 recovers to a 3% gap). How roofclf sees panels
walks through that physics with a cross-section and the same comparison on roofs at
least 60% covered.
That shape, "dark, slightly blue, with a relative SWIR excess", is what the derived
features (
blue_red_ratio,ndbi,brightness) each capture one slice of, and it is the same physics SPPI hard-codes as a formula. - The signal is statistical, not diagnostic. The interquartile ribbons overlap almost completely: plenty of PV-free roofs are darker than the median PV roof. No band, and no threshold on any band, sorts individual buildings cleanly. That is why this instrument is a calibrated probability that gets summed into an adoption rate, never a per-roof verdict, and why everything downstream (the deployment threshold, the coverage ratio, the domain restriction) exists.
The same point, measured rather than eyeballed: each spectral cue on its own separates the two populations only weakly, and the working instruments are combinations.
Read the levels, not the exact values: this chart pools out-of-fold scores across all
quadrats and restricts to size-matched sub-400 m2 buildings, which is the
hardest cut and makes both combinations look closer to each other than the
per-fold skill numbers below, which are the
ones to quote. What the chart establishes is the ordering: a vegetation index knows
almost nothing here, each single physical cue is worth a few points over chance, and
stacking ten bands plus the derived ratios is what turns a tint into an instrument.
Source data: results/detection_spectral_signatures.csv and
results/detection_feature_auc.csv, written by
scripts/detection_domain_examples.py from the quadrat building table.
The parcel label (--parcel-label, 2026-08-16)¶
The roof term above is the geometric intersection of mapped PV with the footprint, so an
array two metres off the wall contributes exactly zero to pv_area_true_m2, and therefore
zero to the coverage ratio and zero to the area recall, on a building this classifier
usually flags anyway. --parcel-label closes that by adding the mapped PV that sits off
every footprint but within 20 m of one, attributed wholly to its single nearest building.
Three rules stop it double-counting. Each installation has the footprints it intersects subtracted before the remainder is measured, so the roof and yard terms partition one polygon. The remainder goes to one building, not to every building in range. And anything at or above 400 m2 is skipped entirely, because above that floor ground-mount belongs to segmentation and the atlas already counts it there. The 20 m ring also sits inside the 30 m radius the OSM and candidate dedup masks already use, so nothing it picks up can survive a dedup that a nearby mapped feature should have removed.
Two different things arrive in that term, and the table keeps them apart. Measured across the 27 quadrats:
| Term | Area | Share of parcel PV |
|---|---|---|
pv_area_roof_m2 |
1,763,024 m2 | 92.3% |
pv_area_yard_ground_m2 (OSM placement=ground) |
29,764 m2 | 1.6% |
pv_area_yard_overhang_m2 (rooftop PV past its VIDA outline) |
117,003 m2 | 6.1% |
Only the middle row is the small ground-mounted PV the widening was built for. Most of what
it adds is mapped rooftop PV whose polygon extends past an imagery-derived VIDA footprint,
which is a real undercount in the roof-only label rather than ground-mount: VIDA outlines
are routinely undersized or a metre or two off. Including it is self-consistent, because an
undersized footprint shrinks the calibration denominator and the national flagged roof area
by the same bias, so the ratio still transfers. But it means the resulting capacity figure
is not "rooftop plus ground-mount" in the proportion the headline suggests, and both
capacity summaries carry a parcel_label_composition block reporting the split for exactly
that reason.
What the label costs and what it moves, both measured on the same 27 quadrats:
| roof-only | parcel | |
|---|---|---|
| median fold AUC | 0.8786 | 0.8735 |
| within-size AUC | 0.8342 | 0.8312 |
| deployment threshold (precision 0.5) | 0.2511, recall 0.634 | 0.2535, recall 0.633 |
rate_ratio-trusted quadrats |
16 | 17 |
| sub-400 m2 pooled coverage ratio | 0.1935 | 0.2096 |
| sub-400 m2 pooled area recall | 0.8179 | 0.8041 |
| >= 400 m2 pooled coverage ratio | 0.2313 | 0.2303 |
The small AUC cost is expected rather than a regression: the parcel label is a harder target, adding 817 positives whose evidence lies partly outside the footprint the features describe. The capacity effect runs the other way, and almost entirely through the sub-400 m2 half: a coverage ratio 8.3% higher divided by an area recall 1.7% lower.
Scored nationally (4,470 cells, 75.7M buildings) and run through the full capacity chain, it moves every roofclf-derived component:
| Component | roof-only | parcel |
|---|---|---|
| sub-400 m2 central | 7,890.2 MWp | 9,201.7 MWp |
| sub-400 m2 AND-gate (the internal floor's small-PV half) | 2,179.7 MWp | 2,647.0 MWp |
| >= 400 m2 roofclf rooftop replacement | 7,189.4 MWp | 7,405.0 MWp |
| Best estimate | 18,218.4 MWp | 19,745.9 MWp |
| Best estimate 90% CI | 14,346 to 21,768 | 16,051 to 23,520 |
About two thirds of the sub-400 m2 move is the larger flagged population (1.24M to 1.36M buildings in-domain, since the widened label has 5% more positives and the precision-targeted threshold recalibrates to them) and one third the higher coverage ratio.
Unlike the 2026-08-15 recall correction, this one moves the internal floor too, from 5,389.5 to 5,856.8 MWp. That is intended and does not weaken the floor's meaning. The recall correction was kept out of the AND-gate tiers because it extrapolates to installations neither detector saw, which a floor may not do. The parcel label does the opposite: it prices the buildings both detectors already agree on more completely, using PV that is mapped and measured on those same parcels.
Published 2026-08-17
These are the published figures. The parcel label passed its
random-cell manual validation (20 cells, seed 20260817,
tiles in results/pakistan_roofclf_parcel_validation/), reviewed by the repository owner,
and the calibration and national scoring were promoted to the canonical paths
(data/roofclf/, data/roofclf_national_with_sppi/<aoi>/). The roof-only versions they
replaced are kept alongside as *_PRE_20260817_parcel_label, so every pre-widening figure
remains reproducible.
The yard feature block does not ship. yard_features computes the same statistics over
the yard that the roof block computes over the footprint, partitioned by a distance-transform
Voronoi so no pixel is credited to two buildings, plus SPPI, the strongest single spectral
discriminator for a yard array. Against the parcel label itself it still loses to the
roof-only feature set (median fold AUC 0.8712 against 0.8734, and 0.7538 for the yard block
alone against a 0.7382 size-only baseline). The reason is in the table above: 80% of what
the parcel label adds is rooftop PV that roof features already see, and the ground-mounted
term is 1.6% of quadrat PV area, too small to move a 118,755-row fit. It stays available
behind --yard-features for re-measurement once cropland quadrats exist.
3. The model, and how skill is measured¶
A regularised logistic regression, fitted with scipy's L-BFGS on standardised features, with an unpenalised intercept. Gradient boosting was passed over deliberately. The output is summed into an adoption rate and a capacity, not thresholded and forgotten, so it has to be a calibrated probability; and with a few tens of thousands of rows across two dozen spatial folds, a linear model in good features is the appropriate capacity. Leaving out scikit-learn also keeps this stage runnable in the base environment, with no PyTorch.
Skill is measured leave-one-quadrat-out. Every building's reported score comes from a model that never saw its quadrat. A random split would put two roofs on the same street in train and test and report skill the model does not have.
| Measure | Value | Read it as |
|---|---|---|
| Median fold AUC | 0.868 | ranking skill on a quadrat the model has never seen |
| Median fold AUC within roof-size band | 0.819 | the same, with size removed as a discriminator |
| Segmentation raster, within size band | 0.500 | chance. The 400 m2 floor, measured |
| Fraction head, unconditional | 0.599 | better than segmentation, still well behind |
Why the within-size number is the honest one¶
Adoption genuinely rises with house size: mappers report large houses packed with PV and
small ones much less. A classifier handed footprint area therefore scores well above
chance from that propensity alone, and in the ablation below area_only reaches 0.744
without the imagery contributing anything at all. auc_within_size scores inside roof-area
bands and weights by band size, which removes size as a discriminator entirely. What is
left is the pixels separating a PV roof from a PV-free roof of the same size. Quote
0.834, not 0.879.
What the pixels actually add¶
Leave-one-quadrat-out median AUC per feature block:
| Feature block | AUC | AUC on buildings below 500 m2 |
|---|---|---|
| Size only | 0.737 | 0.721 |
| Reflectance only | 0.840 | 0.840 |
| Size plus reflectance (shipped) | 0.879 | 0.877 |
| Plus footprint shape | 0.878 | 0.876 |
| Plus the segmentation and fraction rasters | 0.878 | 0.870 |
Reflectance alone beats size alone by a wide margin, which is the result that matters: the
model is reading roofs, not guessing from a size prior. The last two rows move the number
by no more than about 0.001, well inside fold noise, so neither block is switched on. Adding the
segmentation raster in particular has no case: it is trained with sub-400 m2
arrays burned as ignore, so its probability there is noise a fit can only chase.
Folds are not one population, and should never be pooled¶
Skill has to be read per quadrat. Industrial estates and dense residential neighbourhoods
are not the same problem, and the folds say so: the best fold reaches 0.98
(Muzaffargarh Rural Wide, 9 mapped installations) and the worst 0.66 (Khairpur Rural, a
rural box with three mapped installations, where AUC is barely defined). Mardan at 0.766
is the weakest fold with a real sample behind it and is excluded by name from the
capacity calibration. Nasirabad Rural, this project's first confirmed-zero-installation
quadrat (see Calibration quadrats), has no AUC at all --
roofclf.auc() returns NaN by design when a fold has no positives to rank against,
rather than raising.
Ranking transfers between places. Absolute rates do not.
rate_ratio, the model's predicted adoption rate divided by the true one, spans
0.28 to 4.41 across the 26 quadrats with a defined true base rate. The predicted
rate is nearly flat (mean about 0.14) while the true base rate spans under 1% to over
25%, so the ratio is close to constant / base_rate by arithmetic. Any published
adoption rate or capacity needs a per-stratum correction first. Everything in stage 5
exists because of this. (Nasirabad Rural's own rate_ratio is nominally in the
millions -- dividing by a true base rate of exactly zero -- which is why it is excluded
from this span rather than reported as if it meant something.)
4. The deployment threshold¶
Wherever a building needs a yes or a no, one operating point is used, and it is chosen for precision, not for balanced sensitivity: a false positive is expensive in a figure no human reviews in a way it is not in the mapping-leads queue.
p_roofclf >= 0.2446 is the smallest threshold holding precision at 0.50 on the pooled
out-of-fold scores, and it catches 66% of PV-carrying buildings there. Both numbers are
still leave-one-quadrat-out measurements: one threshold instead of 30 per-fold ones, but
no building was ever scored by a model that saw its own quadrat.
Since 2026-10-25 the threshold no longer decides what Best estimate counts. It defines the roofclf-AND-SPPI floor, where two detectors agreeing on a building is a yes/no rule by design, and it picks the buildings for mapping leads and for the random-cell validation. The two Best-estimate components count every roof in proportion to its score instead; stage 6, Count every roof by its score, says why.
The threshold moves whenever the quadrat set changes -- it has ranged from about 0.24 to 0.46 across refits as quadrats were added or dropped. Anything downstream that hard-codes it is a bug.
5. Scoring a country¶
earthpv roofclf-score-national applies one pooled fit, on all quadrats together, to
every VIDA building in the AOI, working one 0.1 degree cell at a time, resumable per cell
in the same way density.py is. For Pakistan that is about 75.7 million buildings across
4,463 cells and two to three hours on CPU. Each building gets p_roofclf and, from the
same five bands at no extra read cost, an SPPI
value.
Two bugs were found here that are worth knowing about, because both produced enormous, plausible-looking false-positive populations rather than crashing:
- Composite fill read as near-certain PV. A tile's bounds round-trip through lat/lon and back inflates the requested window past the tile, and the merge fills the excess with zeros. Zero reflectance is darker than any roof, and PV is dark, so an all-fill footprint scored 0.73. That was 2.86 million buildings, 45.6% of every flagged building in the country, in a band along every cell edge. Fill pixels are now excluded from the zonal statistics, and a footprint left with no valid pixel keeps its row but scores NaN, which can never clear a threshold. Full write-up: cell-edge false positives.
- The same building scored twice. Composite tiles overlap, and Pakistan's tile set carries two grid origins describing the same ground, so a building could be claimed by two differently-named cells. Cells now come from a canonical, deduplicated manifest and each reads its own exact 0.1 degree box.
6. From a probability to a capacity number¶
A score is not yet a megawatt. Four steps stand between them, and each one exists because skipping it produced a number that was wrong by a factor rather than by a few percent.
Restrict the domain. roofclf only counts buildings in cells whose building density
falls inside the range spanned by the calibration quadrats themselves, currently 48.5 to
5,258 buildings per km2. That is 2,957 of Pakistan's 4,463 cells, 66.3% of cells
and 94.7% of national buildings. The restriction is the whole answer to the rate_ratio
problem above: rather than correcting a rate the evidence cannot support, the module
refuses to speak where no quadrat resembles the ground. Rescaling the domain figure by
its share of cells or buildings to get a national total is exactly the error this design
prevents, and sub400_capacity's own returned summary says so.
The histogram also shows why widening is hard: the out-of-domain tail is sparser than every quadrat tick, and most cells sit just left of the band's lower edge. One low-density quadrat moves the edge much further than another urban one ever could -- which is what the deliberately rural extensions below were for.
Widening the domain therefore needs new ground truth, not new code, and the constraint is subtle: a quadrat extends the range only if its own average density falls outside the current band. A boundary traced around a village, the natural way to draw one, is dense by construction no matter how empty the surrounding cell is. Two quadrats deliberately drawn to include farmland alongside a settlement took the lower edge from 553 to 141 buildings/km2 and grew the domain from 646 to 1,680 cells; a third, Bahawalnagar Rural (hand-drawn in JOSM, own density 123.5 buildings/km2), pushed it down again to 1,868 cells; a fourth, Nasirabad Rural (this project's first confirmed-zero-installation quadrat, own density 48.5 buildings/km2), pushed it down again to 2,957 cells the same day. Tank Rural, added alongside Nasirabad Rural, measured 55.75 buildings/km2 -- inside the widened range, but not itself the new floor.
Remove what is already counted. A building within 30 m of an existing segmentation candidate, or of a mapped OpenStreetMap installation, is dropped. Anything whose own footprint is at least 400 m2 is dropped from the sub-400 figure too, since that is a matching gap rather than small-PV signal. The OSM half of this was missing until 2026-08-06 and was double-counting about 3% of the capacity.
Convert area honestly. Panels cover part of a roof, not all of it. Multiplying flagged
roof area by precision alone, which was the original approach, corrects for false positives
and silently assumes full coverage. Measured against the quadrats' own mapped
pv_area_true_m2, real coverage was about 0.19 of a flagged footprint for roofclf alone and
0.27 where roofclf and SPPI agree, so the original figures were 2.4 to 2.7 times too high.
The coverage ratio is now fitted per roof-size bin and per building-density band, then
0.18 kWp per m2 of module converts covered area to capacity. Since 2026-10-25 the
ratio is fitted on score-weighted roof area (next subsection), the population it is then
applied to.
The quadrat ground truth shows directly what that ratio is pricing, and why it must be fitted by size rather than pooled:
Two readings. First, the coverage share is not one number: it is U-shaped in roof size (small homes fill half the roof, mid-size buildings barely a quarter, big industrial roofs climb back up), which is exactly why the ratio is fitted per size bin. Note these medians sit above the 0.19 quoted in the previous paragraph because they are measured on buildings that truly carry PV, while the deployed ratio is measured over roofclf-flagged roofs, false flags included. Second, the left panel is the measured case for the ≥ 400 m2 roofclf replacement below: a building above the floor usually carries an array below it (median 127 m2 of PV on a roughly 485 m2 roof), so segmentation can miss a large building's array outright without that being evidence the roof is empty.
Count every roof by its score¶
What changed on 2026-10-25. Until then both Best-estimate components were
classify-and-count: a building with p_roofclf >= 0.2446 counted in full, a building below
it not at all, and the coverage ratio and area recall then scaled flagged roof area to true
PV area. Now every in-domain building counts in proportion to its score. A roof at 0.10
counts as a tenth of a PV roof, a roof at 0.80 as four fifths. Both corrections are refitted
on the same weights: the coverage ratio becomes the score-weighted true PV area over the
score-weighted roof area in each size bin and density band, and the area recall the share of
true PV area that the score-weighted roofs carry. The classifier, its scores, the domain and
every exclusion are unchanged; only the summing changes.
Why. Held out one quadrat at a time, in the chain's own structure (size deciles by two
density bands, the 19 trusted quadrats; scripts/roofclf_soft_capacity_check.py,
results/roofclf_soft_capacity.json), here is the error on each held-out quadrat's true
sub-400 m2 PV area:
| Pricing, same scores | Median error | IQR | Better than thresholded |
|---|---|---|---|
| No classifier (roof area times ratio) | 0.518 | 0.23-1.84 | -- |
| Thresholded, published until 2026-10-25 | 0.386 | 0.16-0.83 | -- |
| Probability-weighted, published | 0.094 | 0.07-0.67 | 20 of 29 quadrats, Wilcoxon p = 0.0035 |
It holds for four other classifier variants (median 0.18-0.24 against 0.28-0.38, better in 18 to 23 of 29) and for the roof-only label. Thresholding beats the no-classifier null in only 16 of 29 quadrats.
The reason is the rate_ratio problem from stage 3, seen from the other side. The score's
level barely moves between places, so a cut chosen for precision 0.5 on pooled scores
admits almost nobody where adoption is rare: below 300 buildings/km2 the mean
score matches the true adoption rate (0.020 against 0.020), yet only 0.2-0.3% of roofs clear
the threshold. Thresholding books 12% of the mapped PV in those quadrats and probability
weighting 92% (truth-weighted). The coverage ratio cannot repair that, because it is
fitted on the handful of flagged roofs in exactly those strata. Summing scores keeps what the
cut throws away, which is also what the model was built for: stage 3 chose a calibrated
logistic regression because its output is summed.
What it changed in Pakistan, through the full chain and the atlas:
| Thresholded (until 2026-10-25) | Probability-weighted (published) | |
|---|---|---|
| Sub-400 m2, roofclf alone | 13,262.6 MWp | 24,206.3 MWp |
| At or above 400 m2, roofclf rooftop replacement | 8,256.7 MWp | 7,503.2 MWp |
| Sub-400 m2, roofclf and SPPI agreeing (floor) | 3,784.3 MWp | 3,784.3 MWp |
| Best estimate | 24,438.3 MWp (90% 20,894-33,666) | 34,588.8 MWp (90% 29,308-41,736) |
| Verified | 6,994.1 MWp | 6,994.1 MWp |
| Best as a share of panels imported from China (57.6 GWp) | 42% | 60% |
Where it lands. 80% of the +10,150 MWp is in cells of 100-1,000 buildings/km2, the least calibrated part of the domain (Best there rises 65% in cells of 100-300 and 45% in cells of 300-1,000); the densest cells, 1,000-5,258, rise 21%, and cells outside the domain not at all. Punjab takes +6.1 GWp (+52%). It is not simply more of everything: below 400 m2 the scores add up to 2.89 million expected PV roofs where the threshold admitted 1.73 million, while at or above 400 m2 they add up to 155,536 where it admitted 238,466, so the larger-roof component falls 9%. Checked against the independent rooftop-solar reference on the capacity map, which this project never trains on, the new geography agrees better: Spearman 0.868 to 0.882 across its 3,303 hexagons, and 0.773 to 0.861 where this project's estimate is non-zero.
The floor stays thresholded. The roofclf-AND-SPPI population is a floor because two
detectors that share no training data both said yes about the same building. Weighting one
of them by its score would let the floor extrapolate, so sub400_capacity refuses
probability weighting for that gate.
What it trusts, and what is not yet measured. The same four reasons held the switch back from 2026-10-22, when it was measured (the capacity chain is a quantification problem), to 2026-10-25, and they remain open risks:
- The gain sits where calibration is thinnest. Four trusted quadrats lie below 1,000 buildings/km2 (Bahawalnagar Rural, Layyah, Lodhran, Multan), and the six quadrats below 300 buildings/km2 hold 3,822 m2 of mapped sub-400 m2 PV between them.
- National sparse cells do not look like sparse quadrats. They flag 1.3-1.4% of roofs against 0.2-0.3%, because a cell's 0.1-degree average density hides villages that are locally dense, while a sparse quadrat really is farmland.
- It takes the score's level at face value. A small, consistent lean in the score on rural roofs adds up over tens of millions of buildings. Thresholding is immune to that and biased low instead; neither failure has been measured nationally. Where it can be measured, the level holds: in the 19 trusted quadrats the out-of-fold scores of sub-400 m2 buildings sum to 12,620 against 14,129 roofs that carry PV (0.89), and at or above 400 m2 to 2,193 against 2,127 (1.03).
- The random-cell validation of the current scoring is unreviewed (roofclf random-cell validation).
Two measurements would settle it, with the rule declared before either is made. Map sparse and mid-density quadrats (imagery date checked before drawing) and score them with the current model, no refit: if thresholding is closer on per-quadrat |log| error in a clear majority, or probability weighting's truth-weighted ratio across them falls outside 0.5-2, the default goes back to thresholding. And review a random, score-stratified sample of a few hundred sub-400 m2 roofs in cells of 100-1,000 buildings/km2 on recent imagery, to test whether the sum of scores there matches the count of PV roofs.
Reproducing the thresholded figures. --weighting threshold on both
ge400-roof-capacity and sub400-capacity restores the old count; rerun on 2026-10-25 it
reproduced every published component, bootstrap interval and atlas total exactly (Best
24,438.3 MWp). The two weightings also differ in output shape. A probability-weighted
component carries one row per cell and size bin (with n_buildings and sum_p), because
pricing all 68 million in-domain buildings one row each would not fit in memory downstream.
Per-cell and per-size-bin sums are exact, and the atlas's size chart counts its rooftop
objects as the sum of scores, the expected number of PV roofs. The parcel label's
ground-mount share is measured on the same score weights.
Where the numbers land¶
| Component | Pricing | Population | MWp |
|---|---|---|---|
| Sub-400 m2, roofclf and SPPI agreeing, in domain | thresholded | Internal floor on Best estimate | 3,784 |
| Sub-400 m2, roofclf alone, in domain | by score | Best estimate | 24,206 |
| At or above 400 m2 rooftop, roofclf, in domain | by score | Best estimate | 7,503 |
| Sub-400 m2, roofclf and SPPI agreeing, outside the domain | thresholded | Not published (dropped 2026-08-15) | 189 |
The published atlas total, which also carries hand-mapped OSM and the segmentation model's
own ground-mount and out-of-domain rooftop estimates, is Best estimate 34,588.8 MWp (90%
range 29,308 to 41,736) -- the first three rows only. It was 24,438.3 MWp (20,894 to
33,666) until 2026-10-25, when the two Best-estimate rows switched from counting the roofs
above the threshold to counting every roof by its score (13,263 and 8,257 before; see
Count every roof by its score), and 24,330.0 until 2026-10-20, when
OSM-mapped misses stopped being counted twice and the two sub-400 m2 rows stopped
dropping roofs next to a segmentation detection Best does not use (13,231 and 3,761 before). The fourth was dropped from the
published atlas on 2026-08-15 (see the last bullet below); its capacity function and CLI
flag remain for anyone who wants that estimate explicitly. (Updated 2026-09-21: roofclf
was refit and rescored nationally with area-weighted zonal means, which weight each 10 m
pixel by the fraction of the footprint covering it instead of requiring the pixel centre to
fall inside -- 72.4% of Pakistani VIDA footprints own no pixel by that test. It is worth
+0.0119 AUC within size band and +2.0 points of recall at fixed precision, and it moved every
row in this table up together: 2,515 to 3,761, 8,923 to 13,231, 6,747 to 8,257, and Best
estimate 18,826.7 to 24,330.0 MWp. The previous revision, 2026-08-20, folded three peri-urban
quadrats -- Attock, Layyah, Lodhran, docs/issues/pakistan-calibration-boxes.md's Box 18 --
into a 30-quadrat refit.)
The two Best-estimate roofclf rows are recall-corrected as of 2026-08-15: the coverage ratio prices the PV the scores account for, and dividing by the measured share of true mapped PV area that the score-weighted roofs carry (pooled over the trusted quadrats 0.517 sub-400 m2 and 0.867 at or above 400 m2, fitted per size bin and density stratum; 0.834 and 0.982 for flagged roofs under the old count) extends that to the PV they miss -- the same correction the segmentation half has always used. The floor row is deliberately left uncorrected, because a floor that extrapolates to installations neither detector saw is not a floor. See the capacity map for the full derivation and the three ways the correction is a lower bound on itself.
Three things about that table are worth stating plainly:
- SPPI is a second opinion, not a feature. Adding SPPI as a roofclf input changes AUC from 0.8736 to 0.8734, which is nothing. Requiring the two to agree raises precision from 0.52 to 0.63 on the same quadrats, at 0.39 recall instead of 0.73. They share no training data, which is why agreement between them is evidence and why it sets the internal floor under the atlas's headline figure.
- At or above 400 m2, roofclf replaces segmentation rather than adding to it, inside the calibrated domain only. Measured on the identical 92 cells at the time of the swap, roofclf's rooftop estimate came to 2.18 times segmentation's. Outside the domain, segmentation's own recall-corrected number stays authoritative, because it is the only evidence-backed figure there.
- The out-of-domain row is a labelled extrapolation, and is no longer published (2026-08-15). All of those cells sit below the calibrated density band, with a median density roughly six times sparser than the least dense quadrat, so a coverage ratio measured on urban quadrats was being applied to rural ground where nothing constrains it, and the JOSM pass meant to test that population could not be done: the reference imagery there is too old to confirm or refute recent installations. It fed Best estimate only, drawn with its own dotted outline; dropping it moved the headline 18,280 to 18,218 MWp (pre-parcel-label figures) and left the floor untouched. Everything the atlas now reports is measured where it is applied.
What roofclf cannot do¶
- Ground-mounted PV below 400 m2 has no instrument at all. roofclf scores a building footprint, and a small free-standing array does not have one. This is a distinct open gap, not a smaller version of the rooftop one.
- Very bright non-PV roofs. A cluster of white-roofed buildings in cell
0061_0012scores 0.98 to 1.00, and SPPI does not catch them either, so the AND-gate does not help. Retraining on the six known examples changed nothing, and oversampling them traded away general skill; mining more of the pattern is the open lead, and mining them through glint works only for roofs at or above 1,000 m2, which is not where this failure lives. - Exact attribution in dense clusters. In the tightest quadrats the median spacing between neighbouring small installations is 15 to 20 m, at or below one Sentinel-2 pixel. A flagged polygon there can sit among several real arrays rather than on the one carrying the panels. That is a sensor-resolution ceiling, not a training defect.
- Speak for the country. Everything above is scoped to the calibrated domain, and the restriction is enforced in code rather than left to the reader.
Running it¶
# 1. Fit on every discovered quadrat, evaluate leave-one-quadrat-out, pick the threshold.
pixi run earthpv roof-classifier --aoi pakistan
# 2. Score every building in the country. Long: two to three hours, CPU only, resumable.
pixi run earthpv roofclf-score-national --aoi pakistan
# 3. Essential, not optional: validate against randomly drawn cells in JOSM.
pixi run roofclf-tiles -- --random-cells 20 --seed <fresh int> --mapcss
# 4. Turn probabilities into capacity, ge400 first (sub400 reads its domain). CPU only;
# priced by score by default, which reads every in-domain building: about 6 minutes
# for ge400 and 40 for sub400 in Pakistan. sub400 peaked near 7 GB but was killed
# under a 12 GB cgroup cap once, so give it 20 GB. --weighting threshold on both
# restores the flagged-roofs-only count published before 2026-10-25.
pixi run earthpv ge400-roof-capacity --aoi pakistan --osm-solar <national OSM solar pull>
pixi run earthpv sub400-capacity --aoi pakistan --osm-solar <national OSM solar pull>
Then rebuild the evidence atlas from the building-level parquets those two steps write:
see Setup New Country steps 13 to 15 for the exact earthpv atlas
invocation and its four --sub400-* / --ge400-roof-cells flags.
Quadrats are discovered from disk, so a new *_calib_*_boundary.geojson with a matching
mapped-solar pull is picked up with no flag. roof-classifier writes data/roofclf/:
model_full.json (the pooled fit every later step reads), buildings.geoparquet (every
labelled building with its out-of-fold probability), folds.csv, ablation.csv and
summary.json. Step 3 is part of the workflow rather than an extra: the quadrats are
curated and industrial-leaning, so scoring well on them is not evidence that the model
works on the un-curated rest of the country.
Read next¶
| Topic | Page |
|---|---|
| Why panels read dark in the near infrared, and the physical limits that follow | How roofclf sees panels |
| The quadrats: how they are drawn, mapped and declared complete | Calibration quadrats, Quadrat protocol |
| The random-cell validation protocol and its log | Roofclf random-cell validation |
| How the sub-400 m2 capacity bracket was arrived at | Capacity density |
| The national deployment write-up and the temporal features that failed | Roofclf national deployment |
| The cell-edge and tile-overlap bugs in full | Roofclf cell-edge false positives |
| SPPI, the zero-training index it is cross-checked against | SPPI spectral index |
| Whether a complete register agrees with any of this | Validation against MaStR |