# Case study: Richmond electorate (2001–2025)

Richmond is a federal electorate in northern New South Wales, covering the Northern Rivers region including Ballina, Lismore, Byron Bay, and Tweed Heads. Historically a Labor–National marginal, it has been contested by the Nationals, Labor, and (more recently) teal/independent candidates.

This vignette walks through a complete single-electorate analysis using readaec.

    library(readaec)
    library(dplyr)
    library(ggplot2)
    library(purrr)

## 1. Available elections

Start by confirming which elections we have data for.

    list_elections()
    #>   year event_id       date               type has_downloads
    #> 1 2001    10822 2001-11-10            general         FALSE
    #> 2 2004    12246 2004-10-09            general         FALSE
    #> 3 2007    13745 2007-11-24            general          TRUE
    #> 4 2010    15508 2010-08-21            general          TRUE
    #> 5 2013    17496 2013-09-07            general          TRUE
    #> 6 2016    20499 2016-07-02 double_dissolution          TRUE
    #> 7 2019    24310 2019-05-18            general          TRUE
    #> 8 2022    27966 2022-05-21            general          TRUE
    #> 9 2025    31496 2025-05-03            general          TRUE

## 2. Two-party preferred trend

Pull the TPP result for Richmond in every election since 2001. The `get_tpp()` function returns one row per division; we filter to Richmond and stack the years.

    # AEC CSV downloads are available from 2007 onwards
    years <- list_elections()$year[list_elections()$has_downloads]

    tpp_richmond <- map_dfr(years, function(yr) {
      get_tpp(yr) |>
        filter(tolower(division) == "richmond") |>
        select(division, division_id, state, alp_pct, lnp_pct, total_votes, year)
    })

    tpp_richmond
    #> # A tibble: 7 × 7
    #>   division division_id state alp_pct lnp_pct total_votes  year
    #>   <chr>          <dbl> <chr>   <dbl>   <dbl>       <dbl> <dbl>
    #> 1 Richmond         145 NSW      58.9    41.1       81486  2007
    #> 2 Richmond         145 NSW      57.0    43.0       80835  2010
    #> 3 Richmond         145 NSW      53.0    47.0       85278  2013
    #> 4 Richmond         145 NSW      54.0    46.0       98398  2016
    #> 5 Richmond         145 NSW      54.1    45.9      100320  2019
    #> 6 Richmond         145 NSW      58.2    41.8       99784  2022
    #> 7 Richmond         145 NSW      60      40        104952  2025

    ggplot(tpp_richmond, aes(x = year)) +
      geom_line(aes(y = alp_pct, colour = "ALP"), linewidth = 1.2) +
      geom_point(aes(y = alp_pct, colour = "ALP"), size = 3) +
      geom_line(aes(y = lnp_pct, colour = "LNP/Nat"), linewidth = 1.2) +
      geom_point(aes(y = lnp_pct, colour = "LNP/Nat"), size = 3) +
      geom_hline(yintercept = 50, linetype = "dashed", colour = "grey60") +
      annotate("text", x = min(years) + 0.3, y = 51,
               label = "50% — majority", size = 3, colour = "grey50", hjust = 0) +
      scale_colour_manual(values = c("ALP" = "#E4281B", "LNP/Nat" = "#1C4F9C")) +
      scale_x_continuous(breaks = years) +
      scale_y_continuous(limits = c(30, 70),
                         labels = function(x) paste0(x, "%")) +
      labs(
        title    = "Richmond (NSW): two-party preferred vote, 2001–2025",
        subtitle = "ALP vs LNP/National Coalition",
        x        = NULL,
        y        = "TPP vote share",
        colour   = NULL,
        caption  = "Source: Australian Electoral Commission via readaec"
      ) +
      theme_minimal(base_size = 13) +
      theme(legend.position = "bottom",
            panel.grid.minor = element_blank())

## 3. Who won each election?

`get_members_elected()` returns the elected member for every division. We filter to Richmond to build a candidate-by-year summary.

    members_richmond <- map_dfr(years, function(yr) {
      tryCatch(
        get_members_elected(yr) |>
          filter(tolower(divisionnm) == "richmond") |>
          select(divisionnm, surname, givennm, partyab, stateab, year),
        error = function(e) NULL
      )
    })

    members_richmond |>
      select(year, given_name = givennm, surname, party = partyab) |>
      arrange(year)
    #> # A tibble: 7 × 4
    #>    year given_name surname party
    #>   <dbl> <chr>      <chr>   <chr>
    #> 1  2007 Justine    ELLIOT  ALP
    #> 2  2010 Justine    ELLIOT  ALP
    #> 3  2013 Justine    ELLIOT  ALP
    #> 4  2016 Justine    ELLIOT  ALP
    #> 5  2019 Justine    ELLIOT  ALP
    #> 6  2022 Justine    ELLIOT  ALP
    #> 7  2025 Justine    ELLIOT  ALP

## 4. First preference vote breakdown

First preferences show how votes were distributed across all candidates before preferences were distributed. This reveals the full competitive landscape beyond just the two-party contest.

    fp_richmond <- map_dfr(years, function(yr) {
      get_fp(yr) |>
        filter(tolower(division) == "richmond") |>
        select(year, division, surname, given_name, party, party_name, total_votes)
    })

    # Total formal votes per year (for calculating shares)
    fp_totals <- fp_richmond |>
      group_by(year) |>
      summarise(total_formal = sum(total_votes, na.rm = TRUE))

    # Major party shares
    fp_major <- fp_richmond |>
      filter(party %in% c("ALP", "NAT", "NP", "LIB", "GRN")) |>
      left_join(fp_totals, by = "year") |>
      mutate(
        pct = round(total_votes / total_formal * 100, 1),
        party_label = case_when(
          party %in% c("NAT", "NP") ~ "Nationals",
          party == "ALP"             ~ "ALP",
          party == "LIB"             ~ "Liberal",
          party == "GRN"             ~ "Greens",
          TRUE                       ~ party
        )
      )

    ggplot(fp_major, aes(x = year, y = pct, colour = party_label)) +
      geom_line(linewidth = 1.1) +
      geom_point(size = 2.5) +
      scale_colour_manual(values = c(
        "ALP"       = "#E4281B",
        "Nationals" = "#006644",
        "Liberal"   = "#1C4F9C",
        "Greens"    = "#10C25B"
      )) +
      scale_x_continuous(breaks = years) +
      scale_y_continuous(labels = function(x) paste0(x, "%")) +
      labs(
        title   = "Richmond (NSW): first preference vote shares",
        x       = NULL,
        y       = "First preference share",
        colour  = NULL,
        caption = "Source: AEC via readaec"
      ) +
      theme_minimal(base_size = 13) +
      theme(legend.position = "bottom",
            panel.grid.minor = element_blank())

## 5. How did Richmond swing relative to NSW?

Compare Richmond’s election-to-election swing against all other NSW divisions to see whether it moved with or against the state trend.

    # Build consecutive election pairs from years with downloads
    election_pairs <- Map(c, head(years, -1), tail(years, -1))

    nsw_swings <- map_dfr(election_pairs, function(pair) {
      get_swing(pair[1], pair[2], state = "NSW") |>
        filter(!redistribution_flag) |>
        mutate(period = paste0(pair[1], "–", pair[2]))
    })

    richmond_swing <- nsw_swings |>
      filter(tolower(division) == "richmond") |>
      select(period, division, alp_swing, year_from, year_to)

    nsw_avg_swing <- nsw_swings |>
      group_by(period, year_from, year_to) |>
      summarise(avg_alp_swing = mean(alp_swing, na.rm = TRUE), .groups = "drop")

    swing_comparison <- richmond_swing |>
      left_join(nsw_avg_swing, by = c("period", "year_from", "year_to")) |>
      mutate(relative_swing = alp_swing - avg_alp_swing)

    swing_comparison |>
      select(period, richmond_swing = alp_swing, nsw_avg = avg_alp_swing,
             relative_swing)
    #> # A tibble: 6 × 4
    #>   period    richmond_swing nsw_avg relative_swing
    #>   <chr>              <dbl>   <dbl>          <dbl>
    #> 1 2007–2010          -1.88   -4.56          2.68
    #> 2 2010–2013          -4.01   -3.27         -0.739
    #> 3 2013–2016           0.98    4.11         -3.13
    #> 4 2016–2019           0.12   -1.31          1.43
    #> 5 2019–2022           4.15    3.14          1.01
    #> 6 2022–2025           1.77    3.97         -2.20

    swing_comparison |>
      select(period, richmond_swing = alp_swing, nsw_avg = avg_alp_swing) |>
      tidyr::pivot_longer(c(richmond_swing, nsw_avg),
                          names_to = "series", values_to = "swing") |>
      mutate(series = if_else(series == "richmond_swing", "Richmond", "NSW average")) |>
      ggplot(aes(x = period, y = swing, fill = series)) +
      geom_col(position = "dodge") +
      geom_hline(yintercept = 0, colour = "grey40") +
      scale_fill_manual(values = c("Richmond" = "#E4281B", "NSW average" = "grey70")) +
      scale_y_continuous(labels = function(x) paste0(ifelse(x > 0, "+", ""), x, "pp")) +
      labs(
        title   = "Richmond ALP swing vs NSW average",
        x       = NULL,
        y       = "ALP swing (percentage points)",
        fill    = NULL,
        caption = "Source: AEC via readaec"
      ) +
      theme_minimal(base_size = 13) +
      theme(legend.position = "bottom",
            axis.text.x = element_text(angle = 30, hjust = 1),
            panel.grid.minor = element_blank())

## 6. Enrolment and turnout over time

Is the electorate growing? Is turnout holding up?

    turnout_richmond <- map_dfr(years, function(yr) {
      get_turnout(yr) |>
        filter(tolower(divisionnm) == "richmond") |>
        select(divisionnm, year, everything())
    })

    turnout_richmond
    #> # A tibble: 7 × 8
    #>   divisionnm  year divisionid stateab enrolment turnout turnoutpercentage
    #>   <chr>      <dbl>      <dbl> <chr>       <dbl>   <dbl>             <dbl>
    #> 1 Richmond    2007        145 NSW         90103   85133              94.5
    #> 2 Richmond    2010        145 NSW         92391   85587              92.6
    #> 3 Richmond    2013        145 NSW         97421   89681              92.1
    #> 4 Richmond    2016        145 NSW        112715  102146              90.6
    #> 5 Richmond    2019        145 NSW        119332  108381              90.8
    #> 6 Richmond    2022        145 NSW        118638  107208              90.4
    #> 7 Richmond    2025        145 NSW        126814  113552              89.5
    #> # ℹ 1 more variable: turnoutswing <dbl>

    enrolment_richmond <- map_dfr(years, function(yr) {
      get_enrolment(yr) |>
        filter(tolower(divisionnm) == "richmond") |>
        select(divisionnm, year, everything())
    })

    enrolment_richmond
    #> # A tibble: 7 × 12
    #>   divisionnm  year divisionid stateab closeofrollsenrolment
    #>   <chr>      <dbl>      <dbl> <chr>                   <dbl>
    #> 1 Richmond    2007        145 NSW                     90018
    #> 2 Richmond    2010        145 NSW                     92384
    #> 3 Richmond    2013        145 NSW                     97338
    #> 4 Richmond    2016        145 NSW                    112820
    #> 5 Richmond    2019        145 NSW                    119446
    #> 6 Richmond    2022        145 NSW                    118652
    #> 7 Richmond    2025        145 NSW                    126908
    #> # ℹ 7 more variables: notebookrolladditions <dbl>, notebookrolldeletions <dbl>,
    #> #   reinstatementspostal <dbl>, reinstatementsprepoll <dbl>,
    #> #   reinstatementsabsent <dbl>, reinstatementsprovisional <dbl>,
    #> #   enrolment <dbl>

## Summary

This case study shows how a few lines of readaec code can reconstruct the full electoral history of any Australian federal division:

Function | What it gives you
--- | ---
`get_tpp(year)` | ALP vs LNP two-party preferred by division
`get_fp(year)` | First preferences by candidate
`get_members_elected(year)` | Who won each seat
`get_swing(from, to)` | Election-to-election TPP change
`get_enrolment(year)` | Enrolled voters by division
`get_turnout(year)` | Turnout metrics by division
`get_fp_by_booth(year, state)` | First preferences at booth level

For spatial analysis, combine `get_fp_by_booth()` with `get_polling_places()` to map results at the polling-place level.