E

Increased Name Recognition and Approximating Number of Engagements


Sign Placement

Yard signs are perhaps the most and least important part of campaigns—if you don’t have them people wonder if you’re really running a campaign, but putting up five times more than your opponent has no noticeable affect on your campaign.

Sign waving is similar—it’s great for volunteers and you look like a failure without it, but excess sign waving doesn’t correlate with an additional N percent of the vote.

Given its ability to harm a campaign if done poorly and the large amount of manpower required to place signs or stand on the corners and wave (a campaign needs a good showing), it makes sense to plan ahead to make sure you’ve selected the spots with the highest ROI.


The Data

WSDOT and various counties release annual traffic, called the Annual Average Daily Traffic (AADT). This data allows us to determine which roads receive the largest number of vehicles, and with some other data we can plan the best sign spots.

(For this post I’ll be using Pierce County’s data, found here, handily extracted with Tabula.)

The first thing we need to do is clean up the data.

// +build ignore

package main

import (
	"encoding/csv"
	"io"
	"log"
	"os"
	"strings"
)

func main() {
	file, err := os.Open("tabula-aadt.csv")
	if err != nil {
		log.Fatalln(err)
	}
	defer file.Close()

	r := csv.NewReader(file)
	r.LazyQuotes = true
	r.TrimLeadingSpace = true
	r.FieldsPerRecord = -1

	out, err := os.Create("fixed-data.csv")
	if err != nil {
		log.Fatalln(err)
	}
	defer file.Close()

	w := csv.NewWriter(out)
	w.Comma = '\t'

	header, err := r.Read()
	if err != nil {
		log.Fatalln(err)
	}
	if err := w.Write(header); err != nil {
		log.Fatalln(err)
	}

	rec, err := r.Read()
	if err != nil {
		log.Fatalln(err)
	}
	street := rec[0]

	for {
		rec, err := r.Read()
		if err != nil {
			if err == io.EOF {
				break
			}
			log.Fatalln(err)
		}
		// First record is 'street name', but some fields are missing
		// values (because of how the original PDF was laid out).
		if rec[0] == "" {
			rec[0] = street
		} else {
			street = rec[0]
		}

		for i := range rec {
			rec[i] = strings.Replace(rec[i], `"`, "", -1)
			rec[i] = strings.TrimSpace(rec[i])
		}

		// Some rows lack full fields, so pad them.
		if diff := len(header) - len(rec); diff > 0 {
			rec = append(rec, make([]string, diff)...)
		}

		if err := w.Write(rec); err != nil {
			log.Fatalln(err)
		}
	}
	w.Flush()
}

With the data cleaned up, we need to analyze it to determine which roads best suit our needs.

In order to analyze the data, we need to sort the data. We’ll sort the data by the most recent year—2015—which was extrapolated from 2013 using Growth Factor formula found here.

// +build ignore

package main

import (
	"bytes"
	"encoding/csv"
	"fmt"
	"io"
	"log"
	"math"
	"os"
	"sort"
	"strconv"
	"strings"
)

func main() {
	file, err := os.Open("fixed-data.csv")
	if err != nil {
		log.Fatalln(err)
	}
	defer file.Close()

	r := csv.NewReader(file)
	r.Comma = '\t'

	rows, err := buildRows(r)
	if err != nil {
		log.Fatalln(err)
	}
	sort.Sort(rows)
	fmt.Println(rows)
}

// parseInt parses s into an integer, accounting for quotes and commas.
// It'll panic on invalid inputs.
func parseInt(s string) int {
	if s == "" {
		return 0
	}
	s = strings.Replace(s, `"`, "", -1)
	s = strings.Replace(s, ",", "", -1)
	n, err := strconv.ParseInt(s, 10, 64)
	if err != nil {
		log.Fatalln(err)
	}
	return int(n)
}

func buildRows(r *csv.Reader) (rs rows, err error) {
	// Header.
	_, err = r.Read()
	if err != nil {
		return nil, err
	}
	for {
		rec, err := r.Read()
		if err != nil {
			if err == io.EOF {
				return rs, nil
			}
			return nil, err
		}

		rw := row{
			name:     rec[0],
			location: rec[1],
			aadt09:   parseInt(rec[2]),
			aadt10:   parseInt(rec[3]),
			aadt11:   parseInt(rec[4]),
			aadt12:   parseInt(rec[5]),
			aadt13:   parseInt(rec[6]),
		}

		applyGF := func(n int, old ...int) int {
			for _, v := range old {
				if v != 0 {
					// From http://www.wsdot.wa.gov/mapsdata/travel/pdf/ShortCountFactoringGuide2016.pdf
					return int(math.Ceil(float64(n) * 1.0261))
				}
			}
			return 0 // no data
		}

		rw.aadt14 = applyGF(rw.aadt13, rw.aadt12, rw.aadt11, rw.aadt10, rw.aadt09)
		rw.aadt15 = applyGF(rw.aadt14, rw.aadt13, rw.aadt12, rw.aadt11, rw.aadt10, rw.aadt09)

		rs = append(rs, rw)
	}
	return rs, nil
}

type row struct {
	name     string
	location string

	aadt09 int
	aadt10 int
	aadt11 int
	aadt12 int
	aadt13 int

	// Extrapolated.
	aadt14 int
	aadt15 int
}

type rows []row

func (r rows) Len() int {
	return len(r)
}

func (r rows) Swap(i, j int) {
	r[i], r[j] = r[j], r[i]
}

func (r rows) Less(i, j int) bool {
	return r[i].aadt15 < r[j].aadt15
}

func (r rows) String() string {
	if r == nil {
		return "rows(nil)"
	}
	if len(r) == 0 {
		return "[]"
	}

	var buf bytes.Buffer
	buf.WriteString(r[0].location)
	buf.WriteString(" @ ")
	buf.WriteString(r[0].name)
	buf.WriteString(": ")
	buf.WriteString(strconv.Itoa(r[0].aadt15))

	if len(r) > 0 {
		for _, rw := range r[1:] {
			buf.WriteByte('\n')
			buf.WriteString(rw.location)
			buf.WriteString(" @ ")
			buf.WriteString(rw.name)
			buf.WriteString(": ")
			buf.WriteString(strconv.Itoa(rw.aadt15))
		}
	}
	return buf.String()
}

Once we’ve sorted our locations by their most recent AADT, we need to be able to determine which locations are in our legislative district.

A typical way of finding if a location is inside a plane is through use of a ray cast algorithm, seen here.

(Other methods include the various R{*+}-Trees, etc.)

From the US Census’ website we can find the KML boundary files for all Washington state’s legislative districts.

These KML files contain the coordinates outlining the outer boundaries of each district, and from there we just have to plug each plane into an R-Tree and then iterate through our previously found locations to determine whether it lies inside a specific plane.

First, though, we need to find the coordinates for each location. Here’s a couple sample points we’re going to work with:

Address Coordinates
N/O 112 ST E @ CANYON ROAD E 47.1543674, -122.3572617
176 STREET E @ E/O CANYON RD E 47.0964796, -122.3563723
S/O 160 ST CANYON ROAD E 46.8798403, -117.3639205

Determining whether the three points fall inside the 25th LD’s plane is straightforward.


Moving Forward

It’s easy to find ‘hot’ locations—all you have to do is find the coordinates of the area you want to cover, select N areas with the highest AADT that fall within the area, and go wave or plant signs.

It’s not an Internet or Facebook advertisement, but ~50,000 impressions per day is a decent return for the little amount of work required.

Future tests could fiddle with how the hot locations are sorted. In the previous sorting code, just the extrapolated AADT rates for 2015 are compared—future tests could weight the rates so if A is ‘hotter’ than B, but B’s 2009-2013 rates are ‘hotter’ than A’s, the sorting algorithm could rank B higher than A.

If the data exists, it would be interesting to see what percentage of the traffic is repeat traffic; that is, the same drivers driving the same routes every day.

Typically corners are rotated and reused, but if the repeat ratio is high enough moving more might be required.

Additionally, if WSDOT provides the data it would be useful to note which hours have the highest traffic (e.g., rush hour: 6-8 AM and 4-7 PM) so campaigns can receive the largest ROI.


Demo code for this post can be found here.

All content is CC0 1.0 Universal.

| |

comments powered by Disqus