library(catchmentACS)
library(dplyr)
library(sf) # needed to subset the bundled sf objects with [
library(tidyr)The last three of the five functions that cacs_run()
calls are run here one at a time, for three example sites. The result is
then checked against a single call to cacs_run() and turned
into tables for a report.
The tables answer questions of the kind asked about Pre-K sites. What share of the people within a short drive of each site are below the poverty level, for example, and at which site is that share highest?
cacs_run() needs a point for each site, with an
identifier in a column site_id; ?cacs_run
describes the two forms it accepts. The package’s dataset
cacs_alabama_sites is a table of this kind. Its ten points
were made from public coordinates of the centers of ten Alabama cities,
each moved by a small random amount, less than 1 km. They are not the
locations of Pre-K programs or of any other facility:
cacs_alabama_sites |>
sf::st_drop_geometry() |>
select(site_id, site_name, county_fips, region_label)#> site_id site_name county_fips region_label
#> 1 AL_SITE_01 Sample Site 1 01073 Birmingham Metro
#> 2 AL_SITE_02 Sample Site 2 01097 South Coast
#> 3 AL_SITE_03 Sample Site 3 01089 North
#> 4 AL_SITE_04 Sample Site 4 01101 Central
#> 5 AL_SITE_05 Sample Site 5 01125 West Central
#> 6 AL_SITE_06 Sample Site 6 01081 East Central
#> 7 AL_SITE_07 Sample Site 7 01103 North Central
#> 8 AL_SITE_08 Sample Site 8 01077 Northwest
#> 9 AL_SITE_09 Sample Site 9 01069 Southeast
#> 10 AL_SITE_10 Sample Site 10 01055 Northeast
cacs_run() uses site_id and the points and
ignores the other columns. The points are in longitude and latitude on
WGS 84 (EPSG:4326), the coordinate reference system that
cacs_run() requires for sites given as an sf object:
#> [1] 4326
# X is the longitude and Y the latitude of each point.
data.frame(
site_id = cacs_alabama_sites$site_id,
sf::st_coordinates(cacs_alabama_sites)
)#> site_id X Y
#> 1 AL_SITE_01 -86.80902 33.52203
#> 2 AL_SITE_02 -88.03820 30.68891
#> 3 AL_SITE_03 -86.58800 34.73295
#> 4 AL_SITE_04 -86.29990 32.36788
#> 5 AL_SITE_05 -87.56317 33.20985
#> 6 AL_SITE_06 -85.48650 32.61439
#> 7 AL_SITE_07 -86.98385 34.60084
#> 8 AL_SITE_08 -87.67252 34.79879
#> 9 AL_SITE_09 -85.38495 31.22738
#> 10 AL_SITE_10 -85.99976 34.02062
Building drive-time areas around these points needs a routing
service, and downloading American Community Survey (ACS) estimates for
census tracts needs a Census API key. The calculations in this article
therefore use three other files installed with the package.
legacy_2025_sites.rds holds 20 made-up sites,
AL_SITE_01 to AL_SITE_20.
legacy_2025_isochrones.rds holds their drive-time areas for
5, 10, and 15 minutes, drawn as circles 5, 10, and 15 km in radius
instead of by a routing service. sample_alabama_subset.rds
holds made-up ACS estimates for 911 squares of about 9 km² that play the
part of census tracts, each with a margin of error (MOE), the half-width
of its 90 percent confidence interval. The Census Bureau publishes the
margins of error of ACS estimates at this level (U.S. Census Bureau 2020, chap. 7). The squares
lie apart from each other, so a circle takes in only a few of them.
The site file uses the site_id values of
cacs_alabama_sites for other points:
AL_SITE_07 and AL_SITE_08 below are far from
the points of the same names in cacs_alabama_sites (see
?cacs_alabama_sites). The article uses three of the 20
sites, which keeps the tables short. AL_SITE_07,
AL_SITE_08, and AL_SITE_11 lie in Alabama, and
none of the squares in their drive-time areas has a missing estimate, so
every row of the results below has a value.
The package also installs legacy_2025_golden_output.rds,
which holds some of the variables and rates of a cacs_run()
result for the 20 example sites; one of the package’s tests compares it
with a new run. Its name refers to an analysis from 2025, but it was
computed from the same made-up data as this article and says nothing
about real places. Half of its rows are placeholders with
NA values for population weighting, which is not
implemented.
cacs_run() calls five functions in turn:
cacs_acs_prefetch(), cacs_isochrone(),
cacs_intersect_weight(), cacs_propagate_moe(),
and cacs_derive_rates(). The first two get the ACS
estimates from the Census Bureau and the drive-time areas from a routing
service, and the other three compute from those two results.
The first call needs a Census API key, and both need an internet connection:
# Needs a Census API key and an internet connection.
acs <- cacs_acs_prefetch(state = "AL", year = 2023)# Needs the osrm package and an internet connection; the public OSRM server
# needs no key.
iso <- cacs_isochrone(
sites = cacs_alabama_sites,
drive_times = c(5, 10, 15),
provider = "osrm",
osrm_mode = "demo"
)cacs_acs_prefetch() returns an sf table with one row for
each tract and variable and the columns GEOID,
NAME, variable, estimate,
moe, and geometry.
cacs_isochrone() returns an sf table with one row for each
site and drive time. The two example files have the same columns, so the
other three steps run on them as they would on downloaded data:
# The three example files described above
sites <- readRDS(system.file(
"extdata", "legacy_2025_sites.rds", package = "catchmentACS"
))
acs <- readRDS(system.file(
"extdata", "sample_alabama_subset.rds", package = "catchmentACS"
))
iso <- readRDS(system.file(
"extdata", "legacy_2025_isochrones.rds", package = "catchmentACS"
))
# Keep the three sites and the drive times of 5, 10, and 15 minutes.
analysis_sites <- sites |>
filter(site_id %in% site_ids)
iso <- iso |>
filter(site_id %in% site_ids, drive_time_min %in% c(5L, 10L, 15L))
list(
acs_dim = dim(acs),
acs_crs = sf::st_crs(acs)$epsg,
acs_variables = length(unique(acs$variable)),
acs_tracts = length(unique(acs$GEOID)),
iso_dim = dim(iso),
iso_crs = sf::st_crs(iso)$epsg
)#> $acs_dim
#> [1] 12754 6
#>
#> $acs_crs
#> [1] 4269
#>
#> $acs_variables
#> [1] 14
#>
#> $acs_tracts
#> [1] 911
#>
#> $iso_dim
#> [1] 9 16
#>
#> $iso_crs
#> [1] 4326
The ACS data hold 14 variables for 911 squares in NAD83 (EPSG:4269),
and the drive-time areas are in WGS 84 (EPSG:4326): the two coordinate
reference systems that cacs_intersect_weight()
requires.
cacs_intersect_weight() finds the tracts, here the
squares, that overlap each drive-time area and combines their
estimates:
weighted <- cacs_intersect_weight(
iso_sf = iso,
acs_sf = acs,
weight_method = "area",
verbose = FALSE
)
dim(weighted)#> [1] 126 23
The result has a row for each site, drive time, and ACS variable.
These are the rows for the 10-minute area of
AL_SITE_11:
area_11 <- weighted |>
filter(site_id == "AL_SITE_11", drive_time_min == 10) |>
select(variable, estimate, weight_basis, weight_sum, n_tracts)
area_11#> # A tibble: 14 × 5
#> variable estimate weight_basis weight_sum n_tracts
#> <chr> <dbl> <chr> <dbl> <int>
#> 1 B01003_001 5156. coverage 1.11 3
#> 2 B11001_001 1786. coverage 1.11 3
#> 3 B17001_001 3637. coverage 1.11 3
#> 4 B17001_002 579. coverage 1.11 3
#> 5 B19013_001 54801. area_mean 1.11 3
#> 6 B19056_001 1693. coverage 1.11 3
#> 7 B19056_002 143. coverage 1.11 3
#> 8 B19301_001 36264. area_mean 1.11 3
#> 9 B22003_001 1731. coverage 1.11 3
#> 10 B22003_002 403. coverage 1.11 3
#> 11 B23025_001 2515. coverage 1.11 3
#> 12 B23025_002 1338. coverage 1.11 3
#> 13 B23025_003 2188. coverage 1.11 3
#> 14 B23025_005 122. coverage 1.11 3
A count, such as the total population (B01003_001), is
the sum of the tract estimates, each multiplied by the tract’s coverage
weight: the share of the tract’s area that lies inside the drive-time
area (weight_basis = "coverage"). This assumes that
whatever a variable counts is spread evenly over each tract. Median
household income (B19013_001) and per capita income
(B19301_001) are instead averages of the tract values,
weighted by each tract’s share of the overlapping area
("area_mean"). These weights ignore how many people live in
each tract, so the average can be far from the median or per-person
value of the drive-time area when the tracts differ in population
density. The help page of cacs_intersect_weight() defines
both weights, and
vignette("theory-spatial-aggregation", package = "catchmentACS")
explains their assumptions.
n_tracts counts the tracts in the area, and
weight_sum adds up their coverage weights. The two are
equal only when every tract lies wholly inside the area. Here
n_tracts is 3 and weight_sum is 1.11: the
square around the site lies inside the 10-minute area, and two
neighboring squares lie mostly outside it.
cacs_intersect_weight() has already combined the margins
of error of the tracts. cacs_propagate_moe() computes them
again at the confidence level given by its argument level,
0.9 by default, and records the formula used for each row in
moe_formula_effective:
with_moe <- cacs_propagate_moe(weighted, verbose = FALSE)
with_moe |>
select(site_id, drive_time_min, variable, estimate, moe,
moe_formula_effective) |>
arrange(site_id, drive_time_min, variable) |>
head(8)#> # A tibble: 8 × 6
#> site_id drive_time_min variable estimate moe moe_formula_effective
#> <chr> <int> <chr> <dbl> <dbl> <chr>
#> 1 AL_SITE_07 5 B01003_001 4543. 612. weighted_sum
#> 2 AL_SITE_07 5 B11001_001 1506. 309. weighted_sum
#> 3 AL_SITE_07 5 B17001_001 1867. 110. weighted_sum
#> 4 AL_SITE_07 5 B17001_002 1154. 181. weighted_sum
#> 5 AL_SITE_07 5 B19013_001 68369 16626 weighted_mean
#> 6 AL_SITE_07 5 B19056_001 998. 76.0 weighted_sum
#> 7 AL_SITE_07 5 B19056_002 167. 35.0 weighted_sum
#> 8 AL_SITE_07 5 B19301_001 16355 2422 weighted_mean
At the default level, the estimates and margins of error are those of
step 3. weighted_sum, used for counts, is the formula for
the margin of error of a sum, applied to the tract margins of error
multiplied by the coverage weights. weighted_mean, used for
medians and per-person values, is the same formula with the area shares.
Both treat the tract estimates as independent and the weights as fixed.
vignette("theory-moe-propagation", package = "catchmentACS")
gives the formulas and discusses these assumptions.
cacs_derive_rates() adds five rows for each site and
drive time, one for each rate in cacs_acs_default_rates,
after the rows of the ACS variables. Each rate divides one weighted
count by another, and its margin of error is computed from those of the
two counts:
#> [1] 171 27
The rate rows are those whose estimand_family, the kind
of quantity in the row, is "derived_rate":
final |>
filter(estimand_family == "derived_rate") |>
select(site_id, drive_time_min, variable, estimate, moe,
moe_formula_effective) |>
arrange(site_id, drive_time_min, variable) |>
head(10)#> # A tibble: 10 × 6
#> site_id drive_time_min variable estimate moe moe_formula_effective
#> <chr> <int> <chr> <dbl> <dbl> <chr>
#> 1 AL_SITE_07 5 labor_force… 0.851 0.115 general_ratio_conser…
#> 2 AL_SITE_07 5 poverty_rate 0.618 0.104 general_ratio_conser…
#> 3 AL_SITE_07 5 snap_rate 0.0399 0.00553 general_ratio_conser…
#> 4 AL_SITE_07 5 ssi_rate 0.167 0.0373 general_ratio_conser…
#> 5 AL_SITE_07 5 unemp_rate 0.0620 0.0151 general_ratio_conser…
#> 6 AL_SITE_07 10 labor_force… 0.846 0.112 general_ratio_conser…
#> 7 AL_SITE_07 10 poverty_rate 0.602 0.0984 general_ratio_conser…
#> 8 AL_SITE_07 10 snap_rate 0.0426 0.00557 general_ratio_conser…
#> 9 AL_SITE_07 10 ssi_rate 0.165 0.0360 general_ratio_conser…
#> 10 AL_SITE_07 10 unemp_rate 0.0630 0.0149 general_ratio_conser…
By default, the margins of error of all five rates come from the
ratio formula, recorded as general_ratio_conservative. The
Census Bureau’s handbook gives this formula for a ratio whose numerator
is not part of its denominator (U.S. Census
Bureau 2020, chap. 8). The numerator of each rate is part of its
denominator, so each rate is a proportion, for which the handbook gives
the proportion formula; that formula never gives a wider margin of error
than the ratio formula. The argument formula_dispatch of
cacs_derive_rates() and cacs_run() chooses
between the two: with "auto" or
"proportion_subset", poverty_rate and
labor_force_participation use the proportion formula, and
snap_rate, ssi_rate, and
unemp_rate keep the ratio formula. A row for which the
value under the square root of the proportion formula is negative gets
the ratio formula instead, with moe_fallback = TRUE.
vignette("theory-derived-rates", package = "catchmentACS")
describes the five rates, gives both formulas, and explains why
unemp_rate keeps the ratio formula.
cacs_run() runs the same five functions. The call below
supplies the drive-time areas through
precomputed_isochrones and the ACS data through
acs, so cacs_run() skips the download and the
routing and runs the last three functions on the example data:
result <- cacs_run(
sites = analysis_sites,
state = "AL",
year = 2023,
drive_times = c(5, 10, 15),
variables = unname(cacs_acs_default_vars),
provider = "osrm",
precomputed_isochrones = iso,
acs = acs,
weight_method = "area",
output = "long",
verbose = FALSE
)
class(result)#> [1] "cacs_run_result" "tbl_df" "tbl" "data.frame"
#> [1] 171 27
The estimates and margins of error are the same as those computed step by step above:
manual <- final |>
arrange(site_id, drive_time_min, variable)
oneshot <- tibble::as_tibble(result) |>
arrange(site_id, drive_time_min, variable)
cols <- c("site_id", "drive_time_min", "variable", "estimate", "moe")
all.equal(as.data.frame(manual)[cols], as.data.frame(oneshot)[cols])#> [1] TRUE
The rest of the article works from result.
The tables below are made with dplyr and tidyr from
result.
tibble::as_tibble() turns result into a
plain tibble, which keeps its rows in their order. The tables use seven
of its columns:
result_tbl <- tibble::as_tibble(result)
report_cols <- c(
"site_id", "drive_time_min", "variable",
"estimate", "moe", "n_tracts", "failure_origin"
)
result_tbl |>
select(all_of(report_cols)) |>
head(12)#> # A tibble: 12 × 7
#> site_id drive_time_min variable estimate moe n_tracts failure_origin
#> <chr> <int> <chr> <dbl> <dbl> <int> <chr>
#> 1 AL_SITE_07 5 B01003_001 4543. 6.12e2 1 none
#> 2 AL_SITE_07 5 B11001_001 1506. 3.09e2 1 none
#> 3 AL_SITE_07 5 B17001_001 1867. 1.10e2 1 none
#> 4 AL_SITE_07 5 B17001_002 1154. 1.81e2 1 none
#> 5 AL_SITE_07 5 B19013_001 68369 1.66e4 1 none
#> 6 AL_SITE_07 5 B19056_001 998. 7.60e1 1 none
#> 7 AL_SITE_07 5 B19056_002 167. 3.50e1 1 none
#> 8 AL_SITE_07 5 B19301_001 16355 2.42e3 1 none
#> 9 AL_SITE_07 5 B22003_001 1704. 1.58e2 1 none
#> 10 AL_SITE_07 5 B22003_002 68.0 7.00e0 1 none
#> 11 AL_SITE_07 5 B23025_001 2020. 1.14e2 1 none
#> 12 AL_SITE_07 5 B23025_002 1720. 2.12e2 1 none
In this result failure_origin, which records the step at
which a row failed, is "none" on every row;
?cacs_run lists its other values and explains how missing
tract estimates show up in the result.
tidyr::pivot_wider() turns the rate rows into a table
with one row for each site and drive time and one column for each
rate:
rate_matrix <- result_tbl |>
filter(variable %in% names(cacs_acs_default_rates)) |>
select(site_id, drive_time_min, variable, estimate) |>
pivot_wider(names_from = variable, values_from = estimate) |>
arrange(site_id, drive_time_min)
print(rate_matrix, width = Inf)#> # A tibble: 9 × 7
#> site_id drive_time_min poverty_rate snap_rate ssi_rate unemp_rate
#> <chr> <int> <dbl> <dbl> <dbl> <dbl>
#> 1 AL_SITE_07 5 0.618 0.0399 0.167 0.0620
#> 2 AL_SITE_07 10 0.602 0.0426 0.165 0.0630
#> 3 AL_SITE_07 15 0.360 0.111 0.124 0.0922
#> 4 AL_SITE_08 5 0.457 0.334 0.0626 0.0331
#> 5 AL_SITE_08 10 0.428 0.315 0.0636 0.0378
#> 6 AL_SITE_08 15 0.182 0.182 0.0772 0.117
#> 7 AL_SITE_11 5 0.161 0.244 0.0882 0.0499
#> 8 AL_SITE_11 10 0.159 0.233 0.0846 0.0559
#> 9 AL_SITE_11 15 0.149 0.155 0.0649 0.0984
#> labor_force_participation
#> <dbl>
#> 1 0.851
#> 2 0.846
#> 3 0.726
#> 4 0.614
#> 5 0.614
#> 6 0.614
#> 7 0.527
#> 8 0.532
#> 9 0.561
summary(result) holds the same rates in its element
rates_per_site. Its element rates_per_site_moe
writes each rate with its margin of error, rounded to three
decimals:
#> # A tibble: 9 × 7
#> site_id drive_time_min poverty_rate snap_rate ssi_rate
#> <chr> <int> <chr> <chr> <chr>
#> 1 AL_SITE_07 5 0.618 ± 0.104 0.040 ± 0.006 0.167 ± 0.037
#> 2 AL_SITE_07 10 0.602 ± 0.098 0.043 ± 0.006 0.165 ± 0.036
#> 3 AL_SITE_07 15 0.360 ± 0.047 0.111 ± 0.012 0.124 ± 0.017
#> 4 AL_SITE_08 5 0.457 ± 0.142 0.334 ± 0.077 0.063 ± 0.017
#> 5 AL_SITE_08 10 0.428 ± 0.125 0.315 ± 0.069 0.064 ± 0.016
#> 6 AL_SITE_08 15 0.182 ± 0.030 0.182 ± 0.026 0.077 ± 0.011
#> 7 AL_SITE_11 5 0.161 ± 0.045 0.244 ± 0.048 0.088 ± 0.021
#> 8 AL_SITE_11 10 0.159 ± 0.040 0.233 ± 0.044 0.085 ± 0.018
#> 9 AL_SITE_11 15 0.149 ± 0.020 0.155 ± 0.021 0.065 ± 0.010
#> unemp_rate labor_force_participation
#> <chr> <chr>
#> 1 0.062 ± 0.015 0.851 ± 0.115
#> 2 0.063 ± 0.015 0.846 ± 0.112
#> 3 0.092 ± 0.012 0.726 ± 0.096
#> 4 0.033 ± 0.005 0.614 ± 0.054
#> 5 0.038 ± 0.006 0.614 ± 0.051
#> 6 0.117 ± 0.012 0.614 ± 0.068
#> 7 0.050 ± 0.012 0.527 ± 0.090
#> 8 0.056 ± 0.012 0.532 ± 0.082
#> 9 0.098 ± 0.015 0.561 ± 0.076
A rate that could not be computed for a site and drive time is
NA in these tables.
The code below orders the three sites by the poverty rate of their
15-minute areas, highest first. This rate is the share of the people for
whom poverty status is determined who are below the poverty level. The
filter on failure_origin leaves out a site whose poverty
rate is NA because routing failed or because a count or
margin of error that the rate needs is missing:
ranked_15 <- result_tbl |>
filter(
drive_time_min == 15,
variable == "poverty_rate",
failure_origin == "none"
) |>
arrange(desc(estimate)) |>
transmute(
rank = row_number(),
site_id,
poverty_rate = estimate,
moe_90 = moe
)
ranked_15#> # A tibble: 3 × 4
#> rank site_id poverty_rate moe_90
#> <int> <chr> <dbl> <dbl>
#> 1 1 AL_SITE_07 0.360 0.0472
#> 2 2 AL_SITE_08 0.182 0.0296
#> 3 3 AL_SITE_11 0.149 0.0201
Two estimates and differ at the 90 percent level when , where the standard error of each estimate is its margin of error at the 90 percent level divided by 1.645. This is the Census Bureau’s test for comparing two estimates (U.S. Census Bureau 2020, chap. 7). The code below applies it to each site and the next one in the ranking:
# Each site against the next one in the ranking, at the 90 percent level
ranking_test <- ranked_15 |>
mutate(
se = moe_90 / 1.645,
next_site = lead(site_id),
difference = poverty_rate - lead(poverty_rate),
threshold = 1.645 * sqrt(se^2 + lead(se)^2),
differ = difference > threshold
) |>
filter(!is.na(next_site)) |>
select(site_id, next_site, difference, threshold, differ)
ranking_test#> # A tibble: 2 × 5
#> site_id next_site difference threshold differ
#> <chr> <chr> <dbl> <dbl> <lgl>
#> 1 AL_SITE_07 AL_SITE_08 0.178 0.0557 TRUE
#> 2 AL_SITE_08 AL_SITE_11 0.0328 0.0358 FALSE
The poverty rate of AL_SITE_07 exceeds that of
AL_SITE_08 by 17.8 percentage points, more than the
threshold of 5.6 points. The difference between AL_SITE_08
and AL_SITE_11, 3.3 points, is below its threshold of 3.6
points, so the test does not show that their poverty rates differ.
The margins of error in this test come from the ratio formula, which
cacs_run() uses for all five rates by default. With
formula_dispatch = "auto", poverty_rate uses
the proportion formula where it can, and that formula never gives a
wider margin of error. With the default margins of error, the test
therefore finds no more differences than with those from
"auto", and it can find fewer. The code below computes the
rates again with "auto" and repeats the test:
# suppressWarnings() hides a warning that counts the rows for which the ratio
# formula replaced the proportion formula.
rates_auto <- suppressWarnings(
cacs_derive_rates(with_moe, formula_dispatch = "auto", verbose = FALSE)
)
ranking_auto <- rates_auto |>
filter(
drive_time_min == 15,
variable == "poverty_rate",
failure_origin == "none"
) |>
arrange(desc(estimate)) |>
mutate(
se = moe / 1.645,
next_site = lead(site_id),
difference = estimate - lead(estimate),
threshold = 1.645 * sqrt(se^2 + lead(se)^2),
differ = difference > threshold
) |>
select(site_id, moe, moe_fallback, next_site, difference, threshold, differ)
ranking_auto#> # A tibble: 3 × 7
#> site_id moe moe_fallback next_site difference threshold differ
#> <chr> <dbl> <lgl> <chr> <dbl> <dbl> <lgl>
#> 1 AL_SITE_07 0.0472 TRUE AL_SITE_08 0.178 0.0483 TRUE
#> 2 AL_SITE_08 0.0102 FALSE AL_SITE_11 0.0328 0.0225 TRUE
#> 3 AL_SITE_11 0.0201 TRUE <NA> NA NA NA
With "auto", the margin of error of the 15-minute
poverty rate of AL_SITE_08 is 0.0102 instead of 0.0296. For
AL_SITE_07 and AL_SITE_11, the value under the
square root of the proportion formula is negative, so their margins of
error still come from the ratio formula
(moe_fallback = TRUE). The difference between
AL_SITE_08 and AL_SITE_11, 3.3 points, is now
above its threshold of 2.3 points. Whether the test shows that these two
poverty rates differ therefore depends on the formula for their margins
of error.
The test treats the two estimates as independent, which is reasonable
when the two areas share no tract, as here, where the sites are far
apart. Areas that overlap share tracts, and so do the areas of one site
for different drive times; the Limitations section of
vignette("methodology", package = "catchmentACS") explains
what this means for the test. With many sites, the test is run on many
pairs, so some of the differences it shows may be due to chance.
The five rates of AL_SITE_08 for its three drive-time
areas, with their margins of error and 90 percent confidence
intervals:
focal_site <- "AL_SITE_08"
result_tbl |>
filter(
site_id == focal_site,
variable %in% names(cacs_acs_default_rates)
) |>
arrange(variable, drive_time_min) |>
transmute(
variable,
drive_time_min,
estimate,
moe_90 = moe,
ci_low = estimate - moe,
ci_high = estimate + moe
)#> # A tibble: 15 × 6
#> variable drive_time_min estimate moe_90 ci_low ci_high
#> <chr> <int> <dbl> <dbl> <dbl> <dbl>
#> 1 labor_force_participation 5 0.614 0.0536 0.560 0.667
#> 2 labor_force_participation 10 0.614 0.0514 0.562 0.665
#> 3 labor_force_participation 15 0.614 0.0682 0.546 0.682
#> 4 poverty_rate 5 0.457 0.142 0.315 0.600
#> 5 poverty_rate 10 0.428 0.125 0.302 0.553
#> 6 poverty_rate 15 0.182 0.0296 0.153 0.212
#> 7 snap_rate 5 0.334 0.0769 0.257 0.411
#> 8 snap_rate 10 0.315 0.0685 0.247 0.384
#> 9 snap_rate 15 0.182 0.0263 0.156 0.208
#> 10 ssi_rate 5 0.0626 0.0166 0.0460 0.0792
#> 11 ssi_rate 10 0.0636 0.0161 0.0475 0.0796
#> 12 ssi_rate 15 0.0772 0.0113 0.0659 0.0885
#> 13 unemp_rate 5 0.0331 0.00525 0.0279 0.0384
#> 14 unemp_rate 10 0.0378 0.00563 0.0322 0.0434
#> 15 unemp_rate 15 0.117 0.0115 0.105 0.128
ci_low and ci_high are the ends of the 90
percent confidence interval, the estimate minus and plus its margin of
error. Because each of the three areas lies inside the next larger one,
the test above does not apply to the differences between them as it
stands.
The labor force participation rate of AL_SITE_08 happens
to be almost the same at all three drive times. The made-up values of
each variable were drawn separately, so in one square of the 15-minute
area the labor force (B23025_002) exceeds the population 16
years and over (B23025_001), of which it is part.
The last table keeps, for each rate, the estimate and its margin of
error as proportions and as percentages. Its columns
failure_origin, moe_formula_effective, and
moe_fallback record whether and how the rate and its margin
of error were computed:
export_tbl <- result_tbl |>
filter(variable %in% names(cacs_acs_default_rates)) |>
transmute(
site_id,
drive_time_min,
variable,
estimate,
moe_90 = moe,
estimate_pct = 100 * estimate,
moe_90_pct = 100 * moe,
failure_origin,
moe_formula_effective,
moe_fallback
) |>
arrange(site_id, drive_time_min, variable)
head(export_tbl, 15)#> # A tibble: 15 × 10
#> site_id drive_time_min variable estimate moe_90 estimate_pct moe_90_pct
#> <chr> <int> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 AL_SITE_07 5 labor_for… 0.851 0.115 85.1 11.5
#> 2 AL_SITE_07 5 poverty_r… 0.618 0.104 61.8 10.4
#> 3 AL_SITE_07 5 snap_rate 0.0399 0.00553 3.99 0.553
#> 4 AL_SITE_07 5 ssi_rate 0.167 0.0373 16.7 3.73
#> 5 AL_SITE_07 5 unemp_rate 0.0620 0.0151 6.20 1.51
#> 6 AL_SITE_07 10 labor_for… 0.846 0.112 84.6 11.2
#> 7 AL_SITE_07 10 poverty_r… 0.602 0.0984 60.2 9.84
#> 8 AL_SITE_07 10 snap_rate 0.0426 0.00557 4.26 0.557
#> 9 AL_SITE_07 10 ssi_rate 0.165 0.0360 16.5 3.60
#> 10 AL_SITE_07 10 unemp_rate 0.0630 0.0149 6.30 1.49
#> 11 AL_SITE_07 15 labor_for… 0.726 0.0958 72.6 9.58
#> 12 AL_SITE_07 15 poverty_r… 0.360 0.0472 36.0 4.72
#> 13 AL_SITE_07 15 snap_rate 0.111 0.0120 11.1 1.20
#> 14 AL_SITE_07 15 ssi_rate 0.124 0.0169 12.4 1.69
#> 15 AL_SITE_07 15 unemp_rate 0.0922 0.0118 9.22 1.18
#> # ℹ 3 more variables: failure_origin <chr>, moe_formula_effective <chr>,
#> # moe_fallback <lgl>
write.csv(export_tbl, "rates.csv", row.names = FALSE)
saves it as a CSV file.
The map below needs the leaflet package. It shows, on a web base map,
AL_SITE_08, its 15-minute area, and the squares of the
example data that overlap the area, shaded by their poverty rate:
# The site and its 15-minute area (both in EPSG:4326)
focal_pt <- analysis_sites[analysis_sites$site_id == focal_site, ]
area_15 <- iso[iso$site_id == focal_site & iso$drive_time_min == 15, ]
# The poverty rate of each tract, from its ACS counts, for the tracts that
# overlap the 15-minute area.
pov <- acs |>
sf::st_drop_geometry() |>
filter(variable %in% c("B17001_001", "B17001_002")) |>
select(GEOID, variable, estimate) |>
tidyr::pivot_wider(
id_cols = GEOID, names_from = variable, values_from = estimate
) |>
transmute(GEOID, poverty_rate = B17001_002 / B17001_001)
tract_geom <- acs |>
filter(variable == "B17001_001") |>
select(GEOID) |>
left_join(pov, by = "GEOID") |>
sf::st_transform(4326)
squares <- tract_geom[
sf::st_intersects(tract_geom, area_15, sparse = FALSE)[, 1],
]
pal <- leaflet::colorNumeric("YlOrRd", domain = squares$poverty_rate)
leaflet::leaflet(squares) |>
leaflet::addProviderTiles("OpenStreetMap") |>
leaflet::addPolygons(
fillColor = ~pal(poverty_rate), fillOpacity = 0.6,
weight = 0.5, color = "#666666",
label = ~sprintf("Tract %s: %.1f%%", GEOID, 100 * poverty_rate)
) |>
leaflet::addPolygons(
data = area_15, fill = FALSE,
weight = 2, opacity = 0.8, color = "#c05621"
) |>
leaflet::addCircleMarkers(
data = focal_pt, radius = 5, color = "#2b6cb0",
fillOpacity = 0.9, stroke = FALSE, popup = ~site_id
) |>
leaflet::addLegend(
pal = pal, values = ~poverty_rate,
title = "Tract poverty rate",
labFormat = leaflet::labelFormat(transform = function(x) 100 * x, suffix = "%")
)The blue dot is the site, and the orange outline is its 15-minute
area, a circle with a 15 km radius in the example data. The three shaded
squares are the tracts from which the estimates for this area come; the
rest of the circle holds no square and adds nothing to them.
vignette("visual-walkthrough", package = "catchmentACS")
maps each step for one site in Birmingham, with a drive-time area built
by OSRM and ACS estimates for real tracts.
With your own sites in sites and without
precomputed_isochrones and acs, the call to
cacs_run() above builds the drive-time areas with the
routing service named in provider and downloads the ACS
data. This needs the osrm package, a Census API key, and an internet
connection; the routing services, the API keys, and the cache that keeps
downloaded data and drive-time areas for later calls (by default, until
the R session ends) are the subject of
vignette("providers", package = "catchmentACS"). The tables
in this article then come out the same way, with the estimates of real
tracts in place of the made-up ones.