Classwork 3

Measure Rooftop Suitability, Not Adoption

Guided R analysis of NREL ZIP-level rooftop-suitability estimates, weighted county summaries, data provenance, and an optional shapefile map.
Published

September 9, 2026

Important

Public-data version. This classwork uses only the public NREL rooftop-PV technical-potential dataset. It does not use or distribute Google Solar API data.

🎯 Research studio goal

This classwork applies Lecture 3’s distinction between technical potential, private economic potential, and observed adoption.

Your final artifact is:

  • one data-provenance record;
  • one audit of invalid values;
  • one building-weighted New York county summary;
  • one figure and one defensible descriptive sentence; and
  • one research design that combines potential with a separate adoption outcome.

Work with a partner. One person drives the code while the other predicts the row grain and denominator. Switch roles before Part 4.

By the end, you should be able to:

  1. download and load an .xlsx workbook reproducibly;
  2. identify a worksheet’s observational unit and preserve geographic identifiers;
  3. detect sentinel values before calculating a rate;
  4. distinguish an unweighted ZIP mean from a building-weighted estimate;
  5. explain why rooftop suitability is not solar adoption; and
  6. load an ESRI shapefile with sf for an optional spatial extension.

📥 Data, meaning, and provenance

We use Rooftop Photovoltaic Technical Potential in the United States, a public dataset prepared by Pieter Gagnon, Robert Margolis, and Caleb Phillips. The files accompany NREL’s 2016 national technical-potential assessment.

The required workbook is about 5.4 MB. Its Small_Building_Suitability sheet contains one estimate for each ZIP area in the contiguous United States and Washington, DC. A small building has a footprint below 5,000 square feet. Where lidar was unavailable, NREL used a statistical model to estimate the number and share of small buildings with at least some roof area meeting its suitability screens for shade, tilt, orientation, and contiguous area. The values underlie a 2016 assessment using mostly 2006–2014 inputs; they are a dated benchmark, not a 2026 building inventory.

Warning

These are historical, model-based estimates of a physical opportunity set. They do not show whether a building has solar, whether installation is profitable, whether the owner can obtain credit, or whether the estimate describes current roof conditions.

We will use these variables:

Variable Meaning Analysis role
state_zip State-prefixed ZIP key Unique row key in this sheet
zip ZIP identifier Geographic label; preserve five digits
fipsstco County FIPS code County grouping and future joins
county, state County name and postal abbreviation Geographic labels
locale NCES locale category Context
nbld Modeled number of small buildings in the ZIP Weight and denominator
pct_suitab Estimated fraction of small buildings with some suitable roof area Technical-suitability measure

Download choices

Use the workbook for Parts 1–6. The shapefile is only for the optional map.

File Size Use
Download Rooftop_PV_Technical_Potential.xlsx 5.4 MB Required tabular analysis
Download small_suitability.zip 174.3 MB compressed Optional ZIP-area map

If you download in a browser, create data/nrel-rooftop-pv inside your RStudio project and move the downloaded file there. Do not open and re-save the workbook before importing it.

Keep this download directory out of Git. Add the line data/nrel-rooftop-pv/ to your project’s .gitignore, especially before downloading the much larger shapefile. Your script and provenance record—not another public copy of the source files—make the analysis reproducible.

Whether you used the browser or want R to download the workbook, run this complete block once from the root of your project. It defines the paths used later and downloads only when the file is absent. Check getwd() first; it should print that project directory.

getwd()

data_dir <- file.path("data", "nrel-rooftop-pv")
dir.create(data_dir, recursive = TRUE, showWarnings = FALSE)

workbook_url <- paste0(
  "https://data.nlr.gov/system/files/121/",
  "Rooftop_PV_Technical_Potential.xlsx"
)

xlsx_file <- file.path(
  data_dir,
  "Rooftop_PV_Technical_Potential.xlsx"
)

if (!file.exists(xlsx_file)) {
  download.file(
    workbook_url,
    destfile = xlsx_file,
    mode = "wb",
    method = "libcurl"
  )
}

file.exists(xlsx_file)
file.size(xlsx_file)

mode = "wb" protects a binary file from corruption, especially on Windows. A size a little above 5.6 million bytes confirms that you did not save an error page instead of the workbook.

Note

The data are free to use under the NREL data notice, not an unspecified public-domain license. Keep the source URL, dataset DOI, worksheet name, download date, and cleaning rules with your project. Before sharing a copy or a derivative file, read the notice and preserve its required notice and credit.

Install and load packages

Run the installation line once in the Console if a package is missing. Do not place install.packages() in code that renders every time.

# Run once in the Console if needed:
# install.packages(c("tidyverse", "readxl"))

library(tidyverse)
library(readxl)

📖 Part 1 · Load the Excel worksheet

An Excel workbook can hold several tables. List the sheet names before choosing one.

excel_sheets(xlsx_file)

Expect Description and Disclaimer, Rooftop_PV_with_Lidar_Coverage, and Small_Building_Suitability. Read the description sheet, but analyze the third sheet: it covers all contiguous-US ZIP areas through lidar or modeled estimates. The lidar-only sheet reports buildings only inside each ZIP’s lidar-covered area and therefore under-reports incompletely covered ZIPs. Also remember that .xlsx is not a CSV; use read_excel() and name the intended sheet.

Read the statewide small-building sheet. Convert numeric geographic codes to padded character identifiers; they are labels, not quantities.

suitability_raw <- read_excel(
  xlsx_file,
  sheet = "Small_Building_Suitability"
) |>
  mutate(
    fipsstco = str_pad(as.character(fipsstco), width = 5, pad = "0"),
    state_fips = str_pad(as.character(state_fips), width = 2, pad = "0"),
    zip = str_pad(as.character(zip), width = 5, pad = "0")
  )

glimpse(suitability_raw)

Record what you used. Keep this table with your output.

provenance <- tibble(
  dataset = "Rooftop Photovoltaic Technical Potential in the United States",
  doi = "10.7799/1575064",
  source_url = workbook_url,
  worksheet = "Small_Building_Suitability",
  downloaded_on = Sys.Date(),
  workbook_bytes = file.size(xlsx_file),
  cleaning_rule = "Treat -999 as missing; retain nonnegative nbld and pct_suitab in [0, 1]"
)

provenance

Answer before summarizing:

One row represents one __________________ area’s modeled small-building rooftop suitability.

Why would storing zip or fipsstco as a quantity create trouble? Name one identifier that can begin with zero.

1B. Audit the national sheet

Complete the one-row audit.

suitability_raw |>
  summarise(
    rows = ___,
    states_and_dc = n_distinct(___),
    zip_keys = n_distinct(___),
    counties = n_distinct(___),
    minimum_building_count = min(___),
    minimum_suitability = min(___)
  )
Note

Checkpoint: expect 31,904 rows, 49 state/DC labels, 31,904 unique state_zip keys, and 3,089 county FIPS codes. A minimum of -999 is a warning, not a plausible building count or percentage.

🧪 Part 2 · Detect sentinels before filtering

Create the uncleaned New York subset, then distinguish sentinel codes from a legitimate modeled count of zero.

ny_raw <- suitability_raw |>
  filter(___ == ___)

ny_audit <- ny_raw |>
  summarise(
    rows = n(),
    zip_keys = n_distinct(___),
    counties = n_distinct(___),
    negative_building_counts = sum(___ < 0),
    zero_building_counts = sum(___ == 0),
    invalid_suitability = sum(!between(___, 0, 1)),
    rows_with_a_sentinel = sum(
      ___ < 0 | !between(___, 0, 1)
    )
  )

ny_audit
Note

Checkpoint: the uncleaned New York data contain 1,711 ZIP rows and all 62 counties. There are 23 negative building counts, 20 zero-building rows, and 10 invalid suitability values. Because seven rows contain both kinds of -999 sentinel, 26 distinct rows contain a sentinel.

Inspect, but do not silently overwrite, the affected observations.

ny_raw |>
  filter(___ < 0 | !between(___, 0, 1)) |>
  select(state_zip, county, nbld, pct_suitab) |>
  arrange(nbld, pct_suitab)

Discuss:

  1. Why is -999 a missing-value code rather than a very negative measurement?
  2. Why does a ZIP with zero modeled small buildings receive zero weight even if the row is retained?
  3. Why should an analyst report the number of exclusions?

🧹 Part 3 · Define the analysis sample

First convert NREL’s -999 codes to R’s explicit missing value, NA. Keep nonmissing New York rows with a nonnegative modeled building count and a suitability fraction in its logical range. Create the estimated number of suitable small buildings; a fractional result is possible because this is a model-based expectation, not a count from a field survey.

ny_suitability <- ny_raw |>
  mutate(
    nbld = na_if(nbld, -999),
    pct_suitab = na_if(pct_suitab, -999)
  ) |>
  filter(
    !is.na(___),
    !is.na(___),
    ___ >= 0,
    between(___, 0, 1)
  ) |>
  mutate(
    estimated_suitable_buildings = ___ * ___
  )

ny_suitability |>
  summarise(
    zip_rows = n(),
    counties = n_distinct(fipsstco),
    modeled_small_buildings = sum(nbld),
    estimated_suitable_buildings = sum(estimated_suitable_buildings)
  )
Note

Checkpoint: the analysis sample has 1,685 ZIP rows across 62 counties, 3,771,939 modeled small buildings, and about 2,991,904 estimated suitable small buildings. The 20 retained zero-building rows add zero to both weighted totals.

Complete this sentence:

A row remains in the analysis when ________________________________________________.

⚖️ Part 4 · Compare two estimands

A simple mean gives every ZIP row equal influence. A building-weighted estimate gives a ZIP with more modeled small buildings more influence.

state_summary <- ny_suitability |>
  summarise(
    zip_rows = n(),
    modeled_small_buildings = sum(___),
    unweighted_zip_mean = mean(___),
    building_weighted_suitability =
      sum(___) / sum(___)
  )

state_summary
Note

Checkpoint: the unweighted ZIP mean is about 79.60%; the building-weighted estimate is about 79.32%.

Fill in the comparison:

Estimate Unit receiving equal weight Interpretation
unweighted_zip_mean __________________ Average of ZIP-level suitability fractions
building_weighted_suitability __________________ Estimated share of modeled small buildings with some suitable roof area

Which estimate better answers, “Among the modeled small buildings in these valid New York rows, what share have at least some suitable roof area?” Why?

🏘️ Part 5 · Change the grain to county

Aggregate the numerator and denominator separately, then divide. Keep both means so their different weighting remains visible.

county_suitability <- ny_suitability |>
  group_by(___, ___) |>
  summarise(
    zip_rows = n(),
    modeled_small_buildings = sum(___),
    estimated_suitable_buildings = sum(___),
    unweighted_zip_mean = mean(___),
    building_weighted_suitability =
      ___ / ___,
    .groups = "drop"
  ) |>
  mutate(county = str_to_title(county))

county_suitability

Before plotting, define the new row grain:

One row in county_suitability represents ________________________________________.

Create a distribution of county estimates. The vertical line marks the statewide building-weighted estimate.

statewide_rate <- state_summary |>
  pull(building_weighted_suitability)

ggplot(
  county_suitability,
  aes(x = building_weighted_suitability)
) +
  geom_histogram(
    binwidth = 0.01,
    boundary = 0,
    color = "white",
    fill = "#2e7d5b"
  ) +
  geom_vline(
    xintercept = statewide_rate,
    color = "#c65d21",
    linewidth = 1
  ) +
  scale_x_continuous(labels = scales::label_percent(accuracy = 1)) +
  labs(
    title = "Modeled small-building rooftop suitability varies across New York counties",
    subtitle = "County estimates weight ZIP rows by NREL's modeled small-building count",
    x = "Estimated share of small buildings with some suitable roof area",
    y = "Counties",
    caption = "Source: Gagnon, Margolis, and Phillips (2019), NREL technical-potential dataset"
  ) +
  theme_minimal(base_size = 13)

Repair this claim:

Counties to the right of the statewide line have higher solar-adoption rates.

Use this structure:

In NREL’s model-based data, __________________ County has an estimated small-building rooftop-suitability share of ________. This describes ____________________, not ____________________, because ____________________.

Warning

Do not turn a narrow modeled difference into a league table. The underlying report gives ZIP-model root-mean-square error of about 4.2 percentage points and relies on lidar coverage plus statistical extrapolation. Use county comparisons as broad technical-potential context, not precise current rankings.

🧠 Part 6 · Turn potential into a research design

Technical suitability can define part of the opportunity set, but it cannot reveal the cause of nonadoption. Connect each concept to evidence.

Concept Possible measure Available in today’s sheet?
Physical rooftop opportunity Building-weighted pct_suitab Yes
Observed solar deployment NYSERDA interconnected projects or capacity No
Exposure denominator Buildings, households, or electricity customers Not necessarily
Private return Installed cost, electricity price, incentives, expected generation No
Financing constraint Credit, income, tenure, or loan access No
Market access Installer presence, permitting, or interconnection conditions No

Choose one mechanism from environmental economics:

  • upfront cost and private energy savings;
  • credit constraints;
  • landlord–tenant or other split incentives;
  • information, uncertainty, or perceived risk;
  • installer and program access; or
  • peer effects.

Write a proposed county-level study:

I would relate [observed adoption outcome] to [NREL suitability measure] and [mechanism measure] for [population and years]. I would join sources using [geographic key]. The comparison would be descriptive/causal because [reason]. One important alternative explanation is [confounder].

Then answer:

  1. Why is county FIPS safer than an informal county-name join?
  2. What year or vintage problem arises when a historical NREL estimate is joined to newer adoption records?
  3. What extra denominator would turn a project count into a more interpretable exposure or adoption rate?
  4. What research design—not just another variable—would be needed for a causal policy claim?

🗺️ Optional extension · Download and load the shapefile

The national archive is 174.3 MB compressed and roughly 270 MB after extraction. It contains 31,904 multipart ZIP-area polygons and may take time to download and map. Complete this section only if you have sufficient storage and sf is installed.

Note

An ESRI shapefile is a set of companion files. Keep the .shp, .dbf, .shx, and .prj files together in the same directory. The .shp file alone is incomplete.

Run the installation line once in the Console if needed.

# Run once in the Console if needed:
# install.packages("sf")

library(sf)

Download and unzip the archive. The stable catalog URL redirects to a temporary storage URL, so save the stable URL shown here in your script.

shape_url <- paste0(
  "https://data.nlr.gov/system/files/121/",
  "small_suitability.zip"
)

shape_zip <- file.path(data_dir, "small_suitability.zip")
shape_file <- file.path(
  data_dir,
  "small_suitability",
  "opv_national_small_suitability.shp"
)

if (!file.exists(shape_zip)) {
  options(timeout = 600)
  download.file(
    shape_url,
    destfile = shape_zip,
    mode = "wb",
    method = "libcurl"
  )
}

if (!file.exists(shape_file)) {
  unzip(shape_zip, exdir = data_dir)
}

file.exists(shape_file)
list.files(file.path(data_dir, "small_suitability"))

Load only valid New York features through the spatial-data driver. This avoids bringing all national polygon geometry into R first.

ny_suitability_sf <- st_read(
  shape_file,
  query = paste(
    'SELECT * FROM "opv_national_small_suitability"',
    "WHERE state = 'NY' AND nbld >= 0",
    "AND pct_suitab >= 0 AND pct_suitab <= 1"
  ),
  quiet = TRUE
)

nrow(ny_suitability_sf)
st_crs(ny_suitability_sf)
Note

Checkpoint: expect 1,685 New York polygons. The source coordinates are WGS 84 longitude/latitude; some software may display the coordinate-system definition without printing the numeric EPSG code.

Transform to a projected continental-US coordinate system and make a ZIP-area map.

ny_suitability_sf <- ny_suitability_sf |>
  st_transform(5070)

ggplot(ny_suitability_sf) +
  geom_sf(aes(fill = pct_suitab), color = NA) +
  scale_fill_viridis_c(
    labels = scales::label_percent(accuracy = 1),
    name = "Suitable\nsmall buildings"
  ) +
  coord_sf(datum = NA) +
  labs(
    title = "Modeled rooftop suitability of small buildings",
    subtitle = "New York ZIP areas with valid NREL estimates",
    caption = "Source: Gagnon, Margolis, and Phillips (2019); DOE/NREL/Alliance"
  ) +
  theme_void(base_size = 13) +
  theme(legend.position = "right")

The polygons visualize the same ZIP-level estimates as the workbook. They do not add building-level observations or turn suitability into adoption.

🛡️ Peer audit

Exchange your audit, county summary, figure, and claim with another pair.

Revise one line of code, one label, or one phrase after the audit. Record what changed and why.

✅ Exit ticket

Submit or save:

  1. your provenance record and ny_audit;
  2. state_summary and county_suitability;
  3. the county-distribution figure;
  4. the repaired one-sentence claim; and
  5. the proposed study from Part 6.

Complete both sentences:

NREL suitability is useful for solar-adoption research because ____________________.

It cannot by itself explain nonadoption because ____________________.

Sources and required credit

Credit: U.S. Department of Energy / National Renewable Energy Laboratory / Alliance for Sustainable Energy, LLC. NREL provides the data as model-based technical-potential estimates; the classwork’s calculations and interpretations are the course author’s responsibility.

Back to top