Could Brian O’Neill lose District 10?

Brian O’Neill has held the City Council seat for the Northeast’s District 10 since 1980. He’s the lone Republican in a Council District seat, in addition to the two Republicans who hold the reserved At Large seats.

After running unopposed in 2015, O’Neill is being challenged this November by Democratic candidate Judy Moore.

Could he lose?

In the the surprising Primary in West Philly’s District 3, the cohorts that would decide the race were clear. In District 10, it’s harder to say. Is O’Neill a beloved incumbent whose ability to cross party lines will ensure his victory? Or have all elections become nationalized, dooming even a city-level official in Philadelphia’s most Republican (but still Democratic) neighborhoods?

District 10

District 10 covers the farthest sections of the Northeast, stretching down to Fox Chase and Rhawnhurt.

View code
library(tidyverse)
library(sf)
library(ggmap)
source("../../admin_scripts/util.R")

sf_council <- st_read("../../data/gis/city_council/Council_Districts_2016.shp")sf_divs <- st_read("../../data/gis/2019/Political_Divisions.shp")df <- readRDS("../../data/processed_data/df_all_2019_09_05.Rds")
df <- df %>%
  group_by(year, election, office, district, candidate, party, warddiv19) %>%
  summarise(votes=sum(votes)) %>%
  ungroup() %>%
  mutate(party = ifelse(party == "DEMOCRAT", "DEMOCRATIC", party))

bb <- st_bbox(sf_council %>% filter(DISTRICT == "10"))

expand_bb <- function(x, factor){
  mean_x <- mean(x)
  return(mean_x + factor * (x - mean_x))
}

bb[c(1,3)] <- expand_bb(bb[c(1,3)], 1.2)
bb[c(2,4)] <- expand_bb(bb[c(2,4)], 1.2)

names(bb) <- c("left", "bottom", "right", "top")
basemap <- get_map(location=bb, maptype="toner-lite")


get_labpt_df <- function(sf){
  df <- st_centroid(sf) %>%
    mutate(
      x=sapply(geometry, "[", 1),
      y=sapply(geometry, "[", 2)
    ) %>%
    as.data.frame() %>%
    select(-geometry)
  return(df)
}

phila_whole <- st_union(sf_council)

ggplot(sf_council) +
  geom_sf(data=phila_whole, fill="white", color=NA) +
  geom_sf(
    fill=strong_green, color = "white", size = 1, alpha=0.5
  ) +
  geom_sf(
    data=sf_council %>% filter(DISTRICT == "10"),
    fill=strong_green, color = "white", size = 1
  ) +
  geom_text(
    data = get_labpt_df(sf_council),
    aes(x=x,y=y,label=DISTRICT)
  ) +
  ggtitle("Philadelphia Council Districts") +
  theme_map_sixtysix()

View code
district_map <- ggmap(
  basemap, 
  extent="device",
  base_layer = ggplot(sf_council),
  maprange = FALSE
) +
  theme_map_sixtysix()

sf_divs$council_district <- sf_divs %>%
  st_centroid() %>%
  st_covered_by(sf_council) %>%
  (function(x){sf_council$DISTRICT[unlist(x)]})

district_map +
  geom_sf(
    aes(alpha = (DISTRICT == "10")),
    fill="black",
    color = "grey50",
    size=2
  ) +
  scale_alpha_manual(values = c(`TRUE` = 0.2, `FALSE` = 0), guide = FALSE) +
  ggtitle(sprintf("Council District %s", 10))

The district is consistently Philadelphia’s most Republican. It voted the most strongly for Trump (though Clinton still won overall), and is home to PA State Legislative District 170, represented by Republican Martina White.

(You can find all of these election maps, in interactive form, at the Ward Portal!)

View code
races <- tribble(
  ~election, ~office, ~district, ~short_office, 
  "general", "PRESIDENT OF THE UNITED STATES", NA, "President",
  "general", "GOVERNOR", NA, "Governor",
  "general", "REPRESENTATIVE IN CONGRESS", "2", "US Congress District 2",
  "general", "MAYOR", NA, "Mayor"
  # "2018", "general", "SENATOR IN THE GENERAL ASSEMBLY-2ND DISTRICT", "PA Senate"
)

candidate_votes <- df %>% 
  inner_join(races) %>%
  group_by(warddiv19, short_office, year, election) %>%
  mutate(
    total_votes = sum(votes),
    pvote = votes / sum(votes)
  ) %>% 
  group_by()

candidates_overall <- candidate_votes %>%
  group_by(short_office, year, election, candidate, party) %>%
  summarise(votes = sum(votes)) %>%
  group_by(short_office, year, election) %>%
  mutate(
    pvote = votes/sum(votes),
    use = pvote > 0.05
  ) %>%
  filter(use)

candidate_votes <- candidate_votes %>% 
  inner_join(candidates_overall %>% select(year, election, short_office, candidate))

div_winners <- candidate_votes %>%
  group_by(year, election, short_office, warddiv19) %>%
  summarise(
    winner = candidate[which.max(votes)],
    winner_party = party[which.max(votes)],
    winner_pvote = pvote[which.max(votes)],
    twoparty_pvote = pvote[which.max(votes)] / 
      sum(pvote[party %in% c("DEMOCRATIC", "REPUBLICAN")], na.rm=TRUE),
    total_votes = total_votes[1]
  )
  
sf_divs <- sf_divs %>% rename(warddiv19 = DIVISION_N)

sf_divs$in_bbox <- sapply(
  sf_divs$geometry,
  function(g) {
    coords <- as.matrix(g)
    any(
      coords[,1] > bb["left"] &
      coords[,1] < bb["right"] &
      coords[,2] > bb["bottom"] &
      coords[,2] < bb["top"] 
    )
  }
)

ggraces <- sf_divs %>% 
      filter(in_bbox) %>%
      left_join(div_winners) %>% 
      mutate(
        winner_party = format_name(substr(winner_party,1,3)),
        race=sprintf("%s (%s)", short_office, year)
      ) %>%
      mutate(race=factor(race, levels=unique(race[order(year)])))

district_map + 
  geom_sf(data=phila_whole, fill="white", color=NA) +
  geom_sf(
    data=ggraces %>% filter(year %in% c(2015, 2016, 2018)),
    aes(fill = winner_party, alpha=100 * 2 * (twoparty_pvote - 0.5)),
    color=NA
  ) +
  geom_sf(
    data=sf_council %>% filter(DISTRICT == "10"),
    fill=NA,
    color="white",
    size=1
  )+
  facet_wrap(~race) +
  scale_fill_manual(NULL, values=c("Dem"=strong_blue, "Rep"=strong_red)) +
  theme_map_sixtysix() %+replace% 
  theme(legend.position = "bottom", legend.direction = "horizontal") +
  scale_alpha_continuous("Win Margin (%)") +
  guides(alpha = guide_legend(override.aes = list(fill = strong_blue)))+
  ggtitle("District 10's recent elections")

View code
sth_re <- "REPRESENTATIVE IN THE GENERAL ASSEMBLY"
sth_candidates <- df %>% 
  filter(
    year == "2018", election=="general", 
    office == sth_re
  ) %>%
  mutate(
    party=format_name(substr(party,1,3))
  ) %>%
  group_by(warddiv19, district, year, election) %>%
  mutate(
    total_votes = sum(votes),
    pvote = votes / sum(votes)
  ) %>% 
  group_by()

sth_div_winners <- sth_candidates %>%
  filter(party %in% c("Dem", "Rep")) %>%
  group_by(year, election, district, warddiv19) %>%
  summarise(
    winner = candidate[which.max(votes)],
    winner_party = party[which.max(votes)],
    winner_pvote = pvote[which.max(votes)],
    twoparty_pvote = pvote[which.max(votes)] / sum(pvote)
  )

sf_sth <- st_read("../../data/gis/state_house/PaHouse2017_01.shp")sth_labpts <- sf_divs %>%
  filter(in_bbox) %>%
  left_join(sth_div_winners) %>%
  group_by(district) %>% 
  dplyr::do(data.frame(geometry = st_union(.$geometry))) %>%
  ungroup() %>%
  mutate(geometry = st_sfc(geometry)) %>%
  st_as_sf() %>%
  get_labpt_df()

sth_labpts[sth_labpts$district=="170", c('x','y')] <- c(-75.02, 40.111)
sth_labpts[sth_labpts$district=="172", c('x','y')] <- c(-75.07, 40.079)
sth_labpts[sth_labpts$district=="173", c('x','y')] <- c(-75.002, 40.096)

district_map + 
  geom_sf(data=phila_whole, fill="white", color=NA) +
  geom_sf(
    data=sf_divs %>% 
      filter(in_bbox) %>%
      left_join(sth_div_winners),
    aes(fill = winner_party, alpha=100 * 2 * (twoparty_pvote - 0.5)),
    color=NA
  ) +
  geom_sf(
    data=sf_sth, fill=NA, color="black"
  ) +
  geom_sf(
    data=sf_council %>% filter(DISTRICT == "10"),
    fill=NA,
    color=rgb(1,1,1,0.8),
    size=2
  )+
  geom_text(
    data=sth_labpts,
    aes(x=x,y=y, label=district)
  )+
  scale_fill_manual(NULL, values=c("Dem"=strong_blue, "Rep"=strong_red)) +
  theme_map_sixtysix() %+replace% 
  theme(legend.position = "bottom", legend.direction = "horizontal") +
  scale_alpha_continuous("Win Margin (%)") +
  guides(alpha = guide_legend(override.aes = list(fill = strong_blue)))+
  ggtitle("District 10's 2018 PA House elections")

Many of the State House races were won by unopposed Democrats, with the two exceptions being Martina White’s 170, and the nub of Montgomery County’s 152. This makes it hard to project these races onto the District 10 race: how would those 100% districts have voted if there were a contest? There’s a big difference between if they would have gone 70% Democratic or 90%.

Recent races have varied in how divisive and how nationalized they were. In 2015, Kenney won broadly. The correlation between his performance and Clinton’s performance one year later was surprisingly weak. He won the district by 34 points, versus Clinton’s 5 point win, and won Divisions that Clinton lost handily.

View code
d10_scatter <- div_winners %>% 
  inner_join(sf_divs %>% filter(council_district == "10")) %>%
  unite("office", short_office, year) %>%
  mutate(
    pdem_twoparty = ifelse(winner_party == "DEMOCRATIC", twoparty_pvote, 1-twoparty_pvote),
    win_gap = 2 * 100 * (pdem_twoparty-0.5)
  ) %>%
  select(office, warddiv19, win_gap, total_votes) %>%
  gather("key", "value", win_gap, total_votes) %>%
  unite(key, office, key) %>%
  spread(key, value) 

gap_labeller <- function(x){
  sprintf(
    "%s%s%%%s",
    ifelse(x==0, "", "+"),
    abs(x),
    ifelse(x>0, " D", ifelse(x<0, " R", ""))
  )
}

plot_scatter <- function(col, office, title){
  col <- enquo(col)
  ggplot(
    d10_scatter,
    aes(x=President_2016_win_gap, y=!!col)
  ) +
    geom_abline(slope=1, intercept=0, linetype="dashed") +
    geom_hline(yintercept=0, color="black") +
    geom_vline(xintercept=0, color="black") +
    geom_point(aes(size = President_2016_total_votes), alpha = 0.3, pch = 16) +
    coord_fixed() +
    theme_sixtysix() %+replace%
    theme(legend.position = "right", legend.direction="vertical") +
    scale_size_area("Votes in 2016") +
    scale_x_continuous(
      "Win Gap for President (2016)", 
      labels=gap_labeller  
    )+
    scale_y_continuous(
      sprintf("Win Gap for %s", office), 
      labels=gap_labeller  
    ) +
    ggtitle(title, "Division-level results") 
}

plot_scatter(Mayor_2015_win_gap, "Mayor (2015)", "Kenney was popular even in Trump's Divisions")

In comparison, Brendan Boyle’s 24-point win was much more correlated with Clinton. I interpret this as the race being nationalized, with party affiliation becoming salient. The main question for November is if O’Neill is a Kenney-esque candidate, who transcends party and Northeasters will continue to support, or if all elections are nationalized in a post-2016 world.

View code
plot_scatter(`US Congress District 2_2018_win_gap`, "US Congress District 2 (2018)", "Boyle won a nationalized race")

The challenge for Moore is that O’Neill consistenty runs ahead of every other Republican in the district.

Since 2003, there have been 21 uncontested District Council races. Only one of those was a Republican: O’Neill himself in 2015. So there’s not any history of uncontested Republicans to gain insights from.

View code
council <- df %>%
  filter(election == "general") %>%
  filter(office == "DISTRICT COUNCIL") 

council_totals <- council %>% 
  filter(candidate != "Write In") %>%
  filter(party %in% c("DEMOCRATIC", "REPUBLICAN")) %>%
  group_by(year, candidate, party, district) %>% 
  summarise(votes=sum(votes)) %>%
  mutate(is_competitive = votes > 100) %>%
  group_by(year, district) %>%
  summarise(
    n_contested = sum(is_competitive),
    winner = candidate[which.max(votes)],
    winner_pct = votes[which.max(votes)] / sum(votes),
    winner_party = party[which.max(votes)]
  )

## council_totals %>% filter(winner_pct == 1)

oneill_df <- council_totals %>%
  filter(district == 10) %>%
  mutate(office = "Council", short_office="Council District 10") %>%
  rename(prep = winner_pct) %>%
  select(year, prep, short_office) %>%
  bind_rows(
    df %>% 
      filter(
        office %in% c("MAYOR", "PRESIDENT OF THE UNITED STATES", "GOVERNOR"),
        election == "general",
        party %in% c("DEMOCRATIC", "REPUBLICAN")
      ) %>%
      mutate(short_office = format_name(office)) %>%
      inner_join(sf_divs %>% as.data.frame() %>% filter(council_district == 10) %>% select(warddiv19)) %>%
      group_by(year, party, short_office) %>%
      summarise(votes = sum(votes)) %>%
      group_by(year, short_office) %>%
      summarise(
        prep = votes[party == "REPUBLICAN"] / sum(votes)
      )
  )

ggplot(
  oneill_df ,
  aes(x=asnum(year), y=2*100*(prep-0.5), color=short_office)
) +
  geom_hline(yintercept = 00, color="grey20") +
  geom_point(
    size=3
  ) +
  geom_line(
    aes(group=short_office),
    size=1
  ) +
  theme_sixtysix() +
  ylab("Percent for Republican") +
  geom_text(
    data=data.frame(
      year=2015.2, prep=c(0.30, 0.95, 0.55, 0.4), label=c("Mayor", "O'Neill", "President", "Governor"), 
      short_office=c("Mayor", "Council District 10", "President of the United States", "Governor")
    ),
    hjust=0, fontface="bold",
    aes(label=label)
  ) +
  scale_color_manual(
    guide=FALSE,
    values=c(
      "Mayor"=light_blue, 
      "Council District 10"=light_red, 
      "President of the United States"=light_purple, 
      "Governor"=light_green
    )
  )+
  xlab(NULL) +
  scale_x_continuous(breaks = seq(2003, 2015, 4)) +
  scale_y_continuous(labels=function(x) gap_labeller(-x)) +
  ggtitle("O'Neill runs far ahead of other Republicans")

O’Neill ran 17 points ahead of the Republican candidate for Mayor in 2007 and 2011 (meaning a gap of 34 points). Then in 2015 he was unupposed. In thatDemocrats have won every single Presidential, Gubernatorial, and Mayoral race in the district since, with the only exception being voting for Katz over Street in 2003.

Comparative turnout in Municipal Elections

If the race becomes close, relative turnout of Republican and Democratic divisions might be decisive. The Republican divisions typically turn out more strongly in non-Presidential elections. Using turnout in 2016 as the baseline (my preferred method), very-Republican divisions had nearly 1.5 the relative turnout of the very-Democratic ones in 2015.

View code
turnout <- df %>% filter(
  election=="general",
  year %in% 2015:2018,
  office %in% c("MAYOR", "GOVERNOR", "PRESIDENT OF THE UNITED STATES", "DISTRICT ATTORNEY")
) %>%
  group_by(year, warddiv19) %>%
  summarise(turnout=sum(votes))

turnout <- sf_divs %>% 
  as.data.frame() %>%
  select(warddiv19, Shape__Are) %>%
  left_join(turnout) %>%
  group_by(year) %>%
  mutate(
    turnout_per_area = turnout / Shape__Are,
    turnout_per_area_std = (
      turnout_per_area - mean(turnout_per_area)
    ) / sd(turnout_per_area)
  )

turnout_wide <- turnout %>% 
  select(-turnout_per_area_std) %>%
  gather("key", "value", turnout, turnout_per_area) %>%
  unite("key", key, year) %>%
  spread(key, value) %>%
  inner_join(sf_divs %>% as.data.frame() %>% filter(council_district == "10") %>% select(warddiv19))

turnout_scatter <- function(year, title){
  turnout_col <- sym(paste0("turnout_", year))
  ggplot(
    turnout_wide %>% left_join(d10_scatter),
    aes(
      x=President_2016_win_gap,
      y=100*!!turnout_col / turnout_2016
    )
  ) + 
    geom_point(
      aes(size=President_2016_total_votes),
      alpha = 0.5, pch=16
    ) +
    theme_sixtysix() +
    geom_smooth(
      aes(weight=President_2016_total_votes),
      color = "black", method=lm
    ) +
    scale_y_continuous(
      sprintf("Votes in %s / Votes in 2016 (%%)", year),
      breaks=seq(0,100,10)
    )+
    scale_x_continuous(
      "Win Gap for President (2016)",
      labels=gap_labeller
    ) +
    scale_size_area("Votes in 2016") +
    expand_limits(y=0) +
    ggtitle(title)
}

turnout_scatter(2015, "Republican Divisions voted disproportionately\n in 2015")

That changed in 2018. The gap was still about ten percentage points, but at a much higher overall level. Republicans’ proportional advantage was significantly weakened.

View code
turnout_scatter(2018, "Republican Divisions had less turnout advantage\n in 2018")

How many votes is the turnout difference between 2015 and 2018 worth? Using the division results from 2016, but turnout from 2015 and 2018, the difference between 2015 and 2018 would have been a gap of 1.2 for the Democrat. Not enough to change the results in all but the closest of races.

View code
turnout_wide %>% 
  left_join(d10_scatter) %>%
  summarise(
    gap_2015=weighted.mean(President_2016_win_gap, w=turnout_2015),
    gap_2016=weighted.mean(President_2016_win_gap, w=turnout_2016),
    gap_2018=weighted.mean(President_2016_win_gap, w=turnout_2018)
  )
## # A tibble: 1 x 3
##   gap_2015 gap_2016 gap_2018
##      <dbl>    <dbl>    <dbl>
## 1     3.44     5.35     4.60

Moore needs to hope that even City Council has become nationalized.

Really the only way Moore can win is if the divisions that regularly vote for Democrats, the divisions that carried Kenney and Clinton and Boyle to victory, turn against O’Neill. This is increasingly happening across the country, but O’Neill has a big cushion.

Maybe, just maybe, Boyle’s decisive 2018 win was a sign of a changing district. The only way to know for sure would be polling. Without that, we’ll see in November.

The neighborhoods (or wards) that decide District 7

Could Maria Lose?

Maria Quiñones-Sánchez is facing a significant challenge for the second election in a row. The three term councilmember was challenged by Manny Morales four years ago, and eked out a 53.5% – 46.5% win, a margin of only 868 votes. She’s being challenged again this year by state Rep and ward leader Angel Cruz. What should we expect?

The 7th district is decidedly different from Jannie Blackwell’s 3rd or Kenyatta Johnson’s 2nd. Those heavily-segregated districts had clear racial coalitions that swung differently from year to year.

North Philly’s 7th is segregated, but more homogenously so. It’s Philadelphia’s most Hispanic district, with a predominantly Black section of Frankford in the Northeast and the beginnings of White Kensington gentrification expanding up in the South. In the manner of Philadelphia’s Hispanic neighborhoods, it has very low turnout.

But the vote this May won’t split along racial lines. Instead, what matters is the Wards.

Four years ago, three of the district’s ward leaders coordinated to support Quiñones-Sánchez’s challenger, with heavy involvement from Johnny Doc’s Local 98, including more than $100,000 in contributions and over 1,000 “voter assist requests” for help in the voting booth.

The leaders are coordinating a challenge again, with a candidate Cruz who has apparently less baggage than Morales.

(I can’t quite do justice to the story of this race. Check out the links above and Billy Penn’s summary. )

The challenge seems stronger this year, with eight of the twelve ward leaders in the district supporting Cruz in the party endorsement. But Quiñones-Sánchez held it off last year. What should we expect?

District 7’s voting blocks

The 7th Council district covers North Philly, with 6th and 9th Streets serving as the Western border.

View code
library(tidyverse)
library(rgdal)
library(rgeos)
library(sp)
library(ggmap)

sp_council <- readOGR("../../../data/gis/city_council/Council_Districts_2016.shp", verbose = FALSE)
sp_council <- spChFIDs(sp_council, as.character(sp_council$DISTRICT))

sp_divs <- readOGR("../../../data/gis/2016/2016_Ward_Divisions.shp", verbose = FALSE)
sp_divs <- spChFIDs(sp_divs, as.character(sp_divs$WARD_DIVSN))
sp_divs <- spTransform(sp_divs, CRS(proj4string(sp_council)))

load("../../../data/processed_data/df_major_2017_12_01.Rda")

ggcouncil <- fortify(sp_council) %>% mutate(council_district = id)
ggdivs <- fortify(sp_divs) %>% mutate(WARD_DIVSN = id)
View code
## Need to add District result election from 2015
raw_d2 <-  read.csv("../../../data/raw_election_data/2015_primary.csv") 
raw_d2 <- raw_d2 %>% 
  filter(OFFICE == "DISTRICT COUNCIL-7TH DISTRICT-DEM") %>%
  mutate(
    WARD = sprintf("%02d", asnum(WARD)),
    DIV = sprintf("%02d", asnum(DIVISION))
  )

load('../../../data/gis_crosswalks/div_crosswalk_2013_to_2016.Rda')
crosswalk_to_16 <- crosswalk_to_16 %>% group_by() %>%
  mutate(
    WARD = sprintf("%02s", as.character(WARD)),
    DIV = sprintf("%02s", as.character(DIV))
  )

d2 <- raw_d2 %>% 
  left_join(crosswalk_to_16) %>%
  group_by(WARD16, DIV16, OFFICE, CANDIDATE) %>%
  summarise(VOTES = sum(VOTES * weight_to_16)) %>%
  mutate(PARTY="DEMOCRATIC", year="2015", election="primary")
df_major <- bind_rows(df_major, d2)
View code
races <- tribble(
  ~year, ~OFFICE, ~office_name,
  "2015", "MAYOR", "Mayor",
  "2015", "DISTRICT COUNCIL-7TH DISTRICT-DEM", "Council 7th District",
  "2016", "PRESIDENT OF THE UNITED STATES", "President",
  "2017", "DISTRICT ATTORNEY", "District Attorney"
) %>% mutate(election_name = paste(year, office_name))

candidate_votes <- df_major %>% 
  filter(election == "primary" & PARTY == "DEMOCRATIC") %>%
  inner_join(races %>% select(year, OFFICE)) %>%
  mutate(WARD_DIVSN = paste0(WARD16, DIV16)) %>%
  group_by(WARD_DIVSN, OFFICE, year, election) %>%
  mutate(
    total_votes = sum(VOTES),
    pvote = VOTES / sum(VOTES)
  ) %>% 
  group_by()
  
turnout_df <- candidate_votes %>%
  filter(!grepl("COUNCIL", OFFICE)) %>% 
  group_by(WARD_DIVSN, OFFICE, year, election) %>%
  summarise(total_votes = sum(VOTES)) %>%
  left_join(
    sp_divs@data %>% select(WARD_DIVSN, AREA_SFT)
  )

turnout_df$AREA_SFT <- asnum(turnout_df$AREA_SFT)
View code
get_labpt_df <- function(sp){
  mat <- sapply(sp@polygons, slot, "labpt")
  df <- data.frame(x = mat[1,], y=mat[2,])
  return(
    cbind(sp@data, df)
  )
}

ggplot(ggcouncil, aes(x=long, y=lat)) +
  geom_polygon(
    aes(group=group),
    fill = strong_green, color = "white", size = 1
  ) +
  geom_text(
    data = get_labpt_df(sp_council),
    aes(x=x,y=y,label=DISTRICT)
  ) +
  theme_map_sixtysix() +
  coord_map() +
  ggtitle("Council Districts")

plot of chunk council_map

View code
DISTRICT <- "7"
sp_district <- sp_council[row.names(sp_council) == DISTRICT,]

bbox <- sp_district@bbox
## expand the bbox 20%for mapping
bbox <- rowMeans(bbox) + 1.2 * sweep(bbox, 1, rowMeans(bbox))

if(file.exists("map_cache.Rda")){
  load("map_cache.Rda")
} else {
    basemap <- get_map(bbox, maptype="toner-lite", filename="map_cache.png")
    save(basemap, file="map_cache.Rda")
}

district_map <- ggmap(
  basemap, 
  extent="normal", 
  base_layer=ggplot(ggcouncil, aes(x=long, y=lat, group=group)),
  maprange = FALSE
) 
## without basemap:
# district_map <- ggplot(ggcouncil, aes(x=long, y=lat, group=group))

district_map <- district_map +
  theme_map_sixtysix() +
  coord_map(xlim=bbox[1,], ylim=bbox[2,])


sp_divs$council_district <- over(
  gCentroid(sp_divs, byid = TRUE), 
  sp_council
)$DISTRICT

polygon_in_bbox <- function(p) {
  coords <- p@Polygons[[1]]@coords
  any(
    coords[,1] > bbox[1,1] &
      coords[,1] < bbox[1,2] &
      coords[,2] > bbox[2,1] &
      coords[,2] < bbox[2,2] 
  )
}

sp_divs$in_bbox <- sapply(
  sp_divs@polygons,
  polygon_in_bbox
)

ggdivs <- ggdivs %>% 
  left_join(
    sp_divs@data %>% select(WARD_DIVSN, in_bbox)
  )

district_map +
  geom_polygon(
    aes(alpha = (id == DISTRICT)),
    fill="black",
    color = "grey50",
    size=2
  ) +
  scale_alpha_manual(values = c(`TRUE` = 0.2, `FALSE` = 0), guide = FALSE) +
  ggtitle(sprintf("Council District %s", DISTRICT))

plot of chunk district_map The district has the lowest turnout in the city. Philadelphia’s Hispanic neighborhoods have very low turnout, and this district is the most Hispanic. Curiously, not only does the neighborhood have low turnout in Presidential elections, but it then has disproportionately lower turnout in municipal elections even given that: even residents who vote for President are less likely to vote in other years.

View code
# hist(turnout_df$total_votes / turnout_df$AREA_SFT, breaks = 1000)

turnout_df <- turnout_df %>%
  left_join(races)

district_map +
  geom_polygon(
    data = ggdivs %>%
      filter(in_bbox) %>%
      left_join(turnout_df, by =c("id" = "WARD_DIVSN")),
    aes(fill = pmin(total_votes / AREA_SFT, 0.0005) * 5280^2)
  ) +
  scale_fill_viridis_c(
    "Votes per mile", 
    labels=scales::comma, 
    guide=guide_colorbar(label.theme=element_text(angle=90, size = 10), label.hjust=1)
  ) +
  geom_polygon(
    fill=NA,
    color = "white",
    size=1
  ) +
  facet_wrap(~ election_name) +
  expand_limits(fill=0) +
  ggtitle(
    "Votes per mile in the Democratic Primary", 
    sprintf("Council District %s", DISTRICT)
  ) +
  theme(legend.position = "bottom", legend.direction = "horizontal")

plot of chunk turnout_map The district as a whole cast 25,000 votes in the 2016 Presidential primary, 12,000 last time Quninones-Sanchez ran, and only 6,000 in the the 2017 District Attorney race. They did not see the Krasner surge.

Demographically, the district is Philadelphia’s most heavily hispanic, though with Frankford in its Northeast being predominantly Black:

View code
bg_17_acs <- read.csv("../../../data/census/acs_2013_2017_phila_bg_race_income.csv")
bg_17_acs <- bg_17_acs %>% 
  mutate(Geo_FIPS = as.character(Geo_FIPS)) %>%
  select(Geo_FIPS, pop, pop_nh_white, pop_nh_black, pop_nh_asian, pop_hisp, pop_median_income_2017)

library(tigris)
options(tigris_use_cache = TRUE)
bg_shp <- block_groups(42, 101, year = 2015)
bg_shp <- spChFIDs(bg_shp, as.character(bg_shp$GEOID))
bg_shp <- spTransform(bg_shp, CRS(proj4string(sp_divs)))

bg_shp$in_bbox <- sapply(
  bg_shp@polygons,
  polygon_in_bbox
)

gg_bgs <- fortify(bg_shp)
gg_bgs <- gg_bgs %>%
  left_join(bg_shp@data[,c("GEOID", "ALAND", "in_bbox")], by = c("id" = "GEOID")) %>%
  left_join(bg_17_acs, by = c("id" = "Geo_FIPS"))

district_map +
  geom_polygon(
    data = gg_bgs %>%
      filter(in_bbox) %>%
      gather("key", "race_pop",pop_nh_white, pop_nh_black, pop_nh_asian, pop_hisp) %>%
      mutate(
        pct_pop = 100 * race_pop / pop,
        race = c(
          pop_hisp = "Hispanic", pop_nh_white="NH White", pop_nh_black="Black", pop_nh_asian="Asian"
        )[key]
      ),
    aes(fill = pct_pop)
  ) + 
  geom_polygon(
    fill=NA,
    color = "white",
    size=1
  ) +
  facet_wrap(~race) +
  scale_fill_viridis_c("Percent of\n Population") +
  theme(legend.position = "right") +
  ggtitle(sprintf("Race and Ethnicity of District %s", DISTRICT))

plot of chunk census

The district is less obviously politically split than other parts of the city. When it does split, it often does so for Latino candidates. Below are the results from the last race for the 7th and three other recent, compelling Democratic Primary races: 2015 Mayor, 2016 President, and 2017 District Attorney. The maps below show the vote for the top two candidates in District 2 (except for City Council in 2015, where I use Helen Gym and Isaiah Thomas, who were 4th and 5th in the district, and 5th and 6th citywide.)

View code
candidate_votes <- candidate_votes %>%
  left_join(sp_divs@data %>% select(WARD_DIVSN, council_district))

## Choose the top two candidates in district 3
## Except for city council, where we choose Gym and Thomas
# candidate_votes %>%
#   group_by(OFFICE, year, CANDIDATE) %>%
#   summarise(
#     city_votes = sum(VOTES),
#     district_votes = sum(VOTES * (council_district == DISTRICT))
#   ) %>%
#   arrange(desc(city_votes)) %>%
#   filter(OFFICE == "MAYOR")

candidates_to_compare <- tribble(
  ~year, ~OFFICE, ~CANDIDATE, ~candidate_name, ~row,
  "2015", "DISTRICT COUNCIL-7TH DISTRICT-DEM", "MANNY MORALES", "Manny Morales", 1,
  "2015", "DISTRICT COUNCIL-7TH DISTRICT-DEM", "MARIA QUINONES SANCHEZ", "Maria Quiñones-Sánchez", 2,
  "2015", "MAYOR", "JIM KENNEY", "Jim Kenney",  2,
  "2015", "MAYOR", "NELSON DIAZ", "Nelson Diaz", 1,
  "2016", "PRESIDENT OF THE UNITED STATES", "BERNIE SANDERS", "Bernie Sanders", 2,
  "2016", "PRESIDENT OF THE UNITED STATES", "HILLARY CLINTON", "Hillary Clinton", 1,
  "2017", "DISTRICT ATTORNEY", "LAWRENCE S KRASNER", "Larry Krasner", 2,
  "2017", "DISTRICT ATTORNEY", "RICH NEGRIN","Rich Negrin", 1
)

candidate_votes <- candidate_votes %>%
  left_join(races) %>%
  left_join(candidates_to_compare)

vote_adjustment <- function(pct_vote, office){
  ifelse(office == "COUNCIL AT LARGE", pct_vote * 4, pct_vote)
}

district_map +
  geom_polygon(
    data = ggdivs %>%
      filter(in_bbox) %>%
      left_join(
        candidate_votes %>% filter(!is.na(row))
      ),
    aes(fill = 100 * vote_adjustment(pvote, OFFICE))
  ) +
  scale_fill_viridis_c("Percent of Vote") +
  theme(
    legend.position =  "bottom",
    legend.direction = "horizontal",
    legend.justification = "center"
  ) +
  geom_polygon(
    fill=NA,
    color = "white",
    size=1
  ) +
  geom_label(
    data=candidates_to_compare %>% left_join(races),
    aes(label = candidate_name),
    group=NA,
    hjust=0, vjust=1,
    x=bbox[1,1],
    y=bbox[2,2]
  ) +
  facet_grid(row ~ election_name) +
  theme(strip.text.y = element_blank()) +
  ggtitle(
    sprintf("Candidate performance in District %s", DISTRICT) 
    # "Percent of vote (times 4 for Council, times 1 for other offices)"
  )

plot of chunk proportion The district split for Mayor and for District Attorney along racial lines: the densest latino neighborhoods voted for Diaz and Negrin, while the rest of the district voted for the citywide winners Kenney and Krasner. However, the cleavage was geographically different for Quiñones-Sánchez v Morales, presumably because both candidates were latinx. That’s true this time around, too.

Just because there wasn’t a racial split doesn’t mean the district voted uniformly. There indeed were stark variations in how Quiñones-Sánchez did, but the reason isn’t obviously clear. She did well in the Northwest of the district, especially West of 6th St, and in the Northeast of the district except for South of the Boolevard and East of Oxford.

What gives? Those are all Ward boundaries.

View code
wards <- readOGR("../../../data/gis/2016","2016_Wards", verbose=FALSE) %>%
  spTransform(CRS(proj4string(sp_divs)))

ggwards <- fortify(wards)

wards_centers <- sapply(wards@polygons, slot, "labpt") %>% t
wards_centers <- as.data.frame(wards_centers)
names(wards_centers) <- c("x", "y")

wards@data <- cbind(wards@data, wards_centers)


district_map + 
  geom_polygon(data = ggwards, fill = NA, color = strong_red, size= 2) +
  geom_polygon(
    data = ggcouncil %>% filter(council_district == 7), 
    fill=NA, 
    color = "black", 
    size = 2
  ) +
  geom_text(
    data = wards@data,
    aes(x=x, y=y, label=WARD),
    group = NA,
    color = strong_red,
    fontface="bold"
  ) +
  ggtitle("The wards of District 7")

plot of chunk wards

Quiñones-Sánchez’s performance is a potent example of the power of Ward endorsements: she performed very differently in different wards, in some cases on literally the other side of the street. That thin sliver in the Northwest of the District where she did exceptionally well was the 43rd Ward. She received large percentages in the 23rd and 54th in the Northwest, too, but that region where she did poorly, South of the Boolevard and East of Oxford, exactly lines up with the boundaries to the 62nd.

So the question for the 2019 race becomes how the ward endorsements will shake out and how powerful each ward is, both in terms of ability to swing the vote and typicaly turnout. Below are measures of their strength. I’ve also pulled in the Ward leaders’ endorsements from the DCC vote.

View code
## Get vote-weighted populations
div_centroids <- gCentroid(sp_divs[sp_divs$council_district == DISTRICT,], byid=TRUE)
div_centroids$WARD_DIVSN <- attr(div_centroids@coords, "dimnames")[[1]]
div_centroids$bg_GEOID <- over(div_centroids, bg_shp)$GEOID
div_centroids@data <- left_join(div_centroids@data, bg_17_acs, by = c("bg_GEOID" = "Geo_FIPS"))

district_7_results <- df_major %>%
  filter(
    year == 2015 & grepl("7TH DISTRICT", OFFICE) & CANDIDATE != "Write In"
  ) %>%
  mutate(WARD_DIVSN = paste0(WARD16, DIV16)) %>%
  select(WARD_DIVSN, CANDIDATE, VOTES) %>%
  spread(CANDIDATE, VOTES) %>%
  mutate(
    total_votes = (`MARIA QUINONES SANCHEZ` + `MANNY MORALES`),
    p_quinones_sanchez = `MARIA QUINONES SANCHEZ` / total_votes
  )

div_centroids@data <- left_join(div_centroids@data, district_7_results)

ward_pops <- div_centroids@data %>%
  mutate(ward = substr(WARD_DIVSN, 1, 2)) %>%
  group_by(ward) %>%
  summarise(
    p_quinones_sanchez = 100 * weighted.mean(p_quinones_sanchez, w = total_votes),
    pct_nh_white = 100 * weighted.mean(pop_nh_white / pop, w = total_votes),
    pct_nh_black = 100 * weighted.mean(pop_nh_black / pop, w = total_votes),
    pct_nh_asian = 100 * weighted.mean(pop_nh_asian / pop, w = total_votes),
    pct_hisp = 100 * weighted.mean(pop_hisp / pop, w = total_votes),
    council_votes = sum(total_votes)
  )

ward_results <- df_major %>%
  mutate(WARD_DIVSN = paste0(WARD16, DIV16)) %>%
  inner_join(
    tribble(
      ~year, ~election, ~PARTY, ~OFFICE,
      "2016", "primary", "DEMOCRATIC", "PRESIDENT OF THE UNITED STATES",
      "2017", "primary", "DEMOCRATIC", "DISTRICT ATTORNEY"
    )
  ) %>%
  inner_join(div_centroids@data) %>%
  group_by(year, WARD16) %>%
  summarise(total_votes = sum(VOTES)) %>%
  group_by() %>%
  group_by(year) %>%
  mutate(pct_of_year = 100 * total_votes / sum(total_votes)) %>%
  gather("key", "value", total_votes, pct_of_year) %>%
  unite("key", key, year) %>%
  spread(key, value)
    
ward_pops <- ward_pops %>% 
  left_join(
    ward_results %>% rename(ward = WARD16)
  ) %>%
  group_by() %>%
  mutate(pct_of_year_2015 = 100 * council_votes / sum(council_votes))

ward_leaders <- tribble(
  ~ward, ~leader, ~endorsement,
  "07", "Angel Cruz", "Cruz",
  "18", "Theresa Alicea", "Cruz",
  "19", "Carlos Matos","Cruz",
  "23", "Timothy Savage","Quiñones-Sánchez",
  "25", "Thomas Johnson","Cruz",
  "31", "Margaret Rzepski","Cruz",
  "33", "Donna Aument","Cruz",
  "42", "Sharon Vaughn","Quiñones-Sánchez",
  "43", "Emilio Vazquez","Quiñones-Sánchez",
  "49", "Shirley Gregory","Cruz",
  "54", "Alan Butkovitz","Quiñones-Sánchez",
  "62", "Margaret Tartaglione","Cruz"
)

ward_pops <- ward_pops %>% left_join(ward_leaders)

knitr::kable(
  ward_pops %>%
    select(ward, council_votes, pct_of_year_2015, p_quinones_sanchez, leader, endorsement, pct_hisp, pct_nh_white, pct_nh_black, total_votes_2016, pct_of_year_2016, total_votes_2017, pct_of_year_2017) %>%
    arrange(desc(council_votes)),
    digits=0, 
    format.args=list(big.mark=','),
    col.names=c("Ward", "Votes for the 7th in 2015 Primary", "Pct of District", "% for Quiñones-Sánchez",  "Leader", "Endorsement", "% Hispanic", "% White", "% Black", "Votes in 2016 Primary", "Pct of District", "Votes in 2017 Primary", "Pct of District")
  )
Ward Votes for the 7th in 2015 Primary Pct of District % for Quiñones-Sánchez Leader Endorsement % Hispanic % White % Black Votes in 2016 Primary Pct of District Votes in 2017 Primary Pct of District
23 2,108 17 71 Timothy Savage Quiñones-Sánchez 28 19 46 4,021 16 1,284 22
62 1,774 14 33 Margaret Tartaglione Cruz 31 22 41 3,546 14 913 16
19 1,683 14 37 Carlos Matos Cruz 77 6 15 2,817 11 589 10
07 1,655 14 49 Angel Cruz Cruz 80 8 11 3,340 13 549 9
33 1,455 12 57 Donna Aument Cruz 63 10 18 3,337 13 566 10
42 1,177 10 63 Sharon Vaughn Quiñones-Sánchez 64 8 16 2,581 10 543 9
43 1,124 9 65 Emilio Vazquez Quiñones-Sánchez 66 3 29 2,259 9 445 8
18 611 5 51 Theresa Alicea Cruz 45 29 16 1,340 5 527 9
54 311 3 66 Alan Butkovitz Quiñones-Sánchez 17 18 49 906 4 196 3
25 133 1 59 Thomas Johnson Cruz 47 33 15 350 1 77 1
49 117 1 72 Shirley Gregory Cruz 28 0 72 192 1 44 1
31 98 1 63 Margaret Rzepski Cruz 23 57 8 265 1 129 2

The ward with the most votes for the 7th in 2015 also voted hard for Quiñones-Sánchez: the 23rd. Her 71% dominance of the 2,108 votes cast gave her an 886 vote edge, more than what she won the entire District by. That ward, which includes the district’s predominantly Black neighborhoods, turns out stronger than the rest. It represented 17% of the district’s votes in 2015, and then a whopping 22% of the votes in low-turnout 2017.

The next three most dominant wards were 62, 19, and 7 and those went for Morales with 63, 67, and 51% of the vote. (Notice that 62 is actually split by Council Districts; the number above is only for District 7). Those are the three wards led by State Senator Margaret Tartaglione, Carlos Matos, and Angel Cruz himself, the ward leaders that organized the challenge. Quiñones-Sánchez won all of the other Wards, but those three were enough to make the race close.

We can simplify the table above by combining all of the wards whose leaders supported Quiñones-Sánchez and those whose leaders supported Cruz.

View code
get_line <- function(x_total_votes, y_total_votes){
  ## solve p_x t_x+ p_y t_y > 50
  tot <- x_total_votes + y_total_votes
  tx <- x_total_votes / tot
  ty <- y_total_votes / tot

  slope <- -tx / ty
  intercept <- 50 / ty  # use 50 since proportions are x100
  c(intercept, slope)
}

endorsement_summary <- ward_pops %>%
  group_by(endorsement) %>%
  summarise(
    p_quinones_sanchez = weighted.mean(p_quinones_sanchez, w = council_votes),
    council_votes = sum(council_votes),
    total_votes_2016 = sum(total_votes_2016),
    total_votes_2017 = sum(total_votes_2017)
  )

candidate_results = with(
  endorsement_summary,
  tribble(
    ~candidate, 
    ~p_in_mqs_wards,
    ~p_in_challenger_wards, 
    ~total_votes_in_mqs_wards, 
    ~total_votes_in_challenger_wards,
    "Quiñones-Sánchez", 
    p_quinones_sanchez[endorsement == "Quiñones-Sánchez"],
    p_quinones_sanchez[endorsement == "Cruz"],
    council_votes[endorsement == "Quiñones-Sánchez"],
    council_votes[endorsement == "Cruz"],
    "Morales", 
    100 - p_quinones_sanchez[endorsement == "Quiñones-Sánchez"],
    100 - p_quinones_sanchez[endorsement == "Cruz"],
    council_votes[endorsement == "Quiñones-Sánchez"],
    council_votes[endorsement == "Cruz"]
  )
) %>%
  mutate(
    votes_in_mqs_wards = p_in_mqs_wards * total_votes_in_mqs_wards / 100,
    votes_in_challenger_wards = p_in_challenger_wards * total_votes_in_challenger_wards / 100
  )

knitr::kable(
  candidate_results %>% select(
    candidate,
    p_in_mqs_wards,
    votes_in_mqs_wards,
    p_in_challenger_wards,
    votes_in_challenger_wards
  ),
  digits=0, 
  format.args=list(big.mark=','),
  col.names=c("Candidate", "Percent in MQS-Endorsed Wards", "Votes in MQS-Endorsed Wards", "Percent in Cruz-Endorsed Wards",  "Votes in Cruz-Endorsed Wards")
)
Candidate Percent in MQS-Endorsed Wards Votes in MQS-Endorsed Wards Percent in Cruz-Endorsed Wards Votes in Cruz-Endorsed Wards
Quiñones-Sánchez 67 3,174 45 3,383
Morales 33 1,546 55 4,143

The four wards that backed Quiñones-Sánchez constituted only 39% of the votes in 2015, but she won them 2:1. The other eight wards combined for 61% of the vote, but Morales only won them 5:4. Quiñones-Sánchez’s wards represented more of the votes in 2017, boding well for this year, but it’s also likely that Cruz will do better in his wards than Morales did.

View code
line_2017 <- with(
  endorsement_summary,
  get_line(
    total_votes_2017[endorsement == "Quiñones-Sánchez"],
    total_votes_2017[endorsement == "Cruz"]
  )
)

line_2015 <- with(
  endorsement_summary,
  get_line(
    council_votes[endorsement == "Quiñones-Sánchez"],
    council_votes[endorsement == "Cruz"]
  )
)

library(ggrepel)
ggplot(
  candidate_results,
  aes(
    x=p_in_mqs_wards,
    y=p_in_challenger_wards
  )
) +
  geom_point() +
  geom_text_repel(aes(label=candidate)) +
  geom_abline(
    intercept = c(line_2015[1], line_2017[1]),
    slope = c(line_2015[2], line_2017[2]),
    linetype="dashed"
  ) +
  coord_fixed() +
  scale_x_continuous(
    "Percent in wards that endorsed Quiñones-Sánchez",
    breaks = seq(0,100,10)
  ) +
  scale_y_continuous(
    "Percent in wards that endorsed Cruz",
    breaks = seq(0, 100, 10)
  ) +
  annotate(
    geom="text",
    label=paste(c(2015, 2017), "turnout"),
    x=c(10, 8),
    y=c(
      line_2015[1] + 10 * line_2015[2],
      line_2017[1] + 8 * line_2017[2]
    ),
    hjust=0,
    vjust=-0.2,
    angle = atan(c(line_2015[2], line_2017[2])) / pi * 180,
    color="grey40"
  )+
  annotate(
    geom="text",
    x = 80,
    y=75,
    label="Candidate wins",
    fontface="bold",
    color = strong_green
  ) +
  geom_hline(yintercept = 50, color="grey50") +
  geom_vline(xintercept = 50, color="grey50")+
  expand_limits(x=100, y=30)+
  theme_sixtysix() +
  ggtitle(
    "The strength of District 7's wards in 2015",
    "Candidates to the top-right of dashed lines win."
  )

plot of chunk plot

Looking to May

So what are we to make of this race? Keep your eyes on the mobilization behind the Ward endorsements, especially the top turnout wards. Cruz will presumably do even better in his own ward than Morales did, so Quiñones-Sánchez’s success will likely hinge on continued high turnout in the 23rd and trying to consolidate the votes of the rest.

She managed to do that just well enough four years ago to eke out a 9 point victory. This year she faces a candidate without homophobic Facebook posts who also happens to be a Ward leader. If the results in every district were the same as in 2015 except Angel Cruz managed to win 77% of the vote in his own ward, the race would be an exact tie.

It’ll be close.