conveyal/modeify

View on GitHub
client/components/conveyal/otp-profile-score/master/index.js

Summary

Maintainability
C
1 day
Test Coverage
var clone
try {
  clone = require('clone')
} catch (e) {
  clone = require('component-clone')
}

var CO2_PER_GALLON = 8.887 // Kilograms of CO2 burned per gallon of gasoline
var CYCLING_MET = 8 // Find MET scores here: http://appliedresearch.cancer.gov/atus-met/met.php
var METERS_TO_MILES = 0.000621371
var SECONDS_TO_HOURS = 1 / 60 / 60
var WALKING_MET = 3.8

var CO2_PER_TRANSIT_TRIP = 239000000 / 200000000 // CO2 per passenger trip. Kilograms of CO2 / Rides. http://www.wmata.com/Images/Mrel/MF_Uploads/sustainability-web-2014-04-22.pdf

var DEFAULT_TIME_FACTORS = {
  calories: -0.01,
  carParking: 5,
  co2: 0.5,
  cost: 5,
  transfer: 5
}

var DEFAULT_RATES = {
  bikeSpeed: 4.1, // in m/s
  carParkingCost: 10,
  co2PerTransitTrip: CO2_PER_TRANSIT_TRIP,
  mileageRate: 0.56, // IRS reimbursement rate per mile http://www.irs.gov/2014-Standard-Mileage-Rates-for-Business,-Medical-and-Moving-Announced
  mpg: 21.4,
  walkSpeed: 1.4, // in m/s
  weight: 75 // in kilograms
}

module.exports = ProfileScore

/**
 * Process & score an OTP Profile response. Tally statistics, score options.
 *
 * @param {Object} opts Options object.
 * @param {Object=} opts.factors Factors to override the defaults.
 * @param {Object=} opts.rates Rates to to override the defaults.
 * @example
 * var ProfileScore = require('otp-profile-score')
 * var scorer = new ProfileScore({ factors: {}, rates: {} })
 */

function ProfileScore (opts) {
  opts = opts || {}

  this.factors = merge(DEFAULT_TIME_FACTORS, opts.factors || {})
  this.rates = merge(DEFAULT_RATES, opts.rates || {})
}

/**
 * Process an individual option, only uses the first access and egress modes given.
 *
 * @param {Object} option Option returned from OTP's profiler.
 * @returns {Object} processedOption Annotated and scored.
 * @example
 * getProfileFromOTP(query, function(err, profile) {
 *   var scoredOption = scorer.processOption(profile[0])
 * })
 */

ProfileScore.prototype.processOption = function (o) {
  // Tally the data
  o = this.tally(o)

  // Score the option
  o.score = this.score(o)

  return o
}

/**
 * Process an array of options that were generated by [OpenTripPlanner](http://www.opentripplanner.org)'s Profiler.
 *
 * @param {Array} options
 * @returns {Array} processedOptions Options that are split up by access mode and annotated with a score.
 * @example
 * getProfileFromOTP(query, function(err, profile) {
 *   var allScoredResults = scorer.processOptions(profile)
 * })
 */

ProfileScore.prototype.processOptions = function (options) {
  var processed = []
  var self = this

  options.forEach(function (o) {
    if (o.access) {
      o.access.forEach(function (a, accessIndex) {
        if (o.egress && o.egress.length > 0) {
          o.egress.forEach(function (e, egressIndex) {
            var opt = clone(o)
            opt.access = [clone(a)]
            opt.egress = [clone(e)]
            processed.push(self.processOption(opt))
          })
        } else {
          var opt = clone(o)
          opt.access = [clone(a)]
          processed.push(self.processOption(opt))
        }
      })
    }
  })

  processed.sort(function (a, b) {
    return a.score - b.score
  })

  return processed
}

/**
 * Get the weighted score of an option based on the factors, weights, and tallied totals for that option.
 *
 * @param {Object} option
 * @returns {Number} score
 */

ProfileScore.prototype.score = function (o) {
  var factors = this.factors
  var score = o.time / 60
  var totalCalories = 0

  o.modes.forEach(function (mode) {
    switch (mode) {
      case 'car':
      case 'car_park':
        // Add time for parking
        score += applyFactor(1, factors.carParking)

        // Add time for CO2 emissions
        score += applyFactor(o.emissions, factors.co2)
        break
      case 'bicycle':
      case 'bicycle_rent':
        // Add time for locking your bike
        score += applyFactor(1, factors.transfer)
        totalCalories += o.bikeCalories
        break
      case 'walk':
        totalCalories += o.walkCalories
        break
    }
  })

  // Add time for each transfer
  score += applyFactor(o.transfers, this.factors.transfer)

  // Add time for each dollar spent
  score += applyFactor(o.cost, this.factors.cost)

  // Add/subtract time for calories
  score += applyFactor(totalCalories, this.factors.calories)

  return score
}

/**
 * Tally values. Add up total calories, cost, transfers, distances, and times.
 *
 * @param {Object} option
 * @returns {Object} talliedOption
 */

ProfileScore.prototype.tally = function (o) {
  // Defaults
  o.bikeCalories = 0
  o.calories = 0
  o.cost = 0
  o.emissions = 0
  o.modes = []
  o.time = 0
  o.timeInTransit = 0
  o.transfers = 0
  o.transitCost = 0
  o.walkCalories = 0

  // Bike/Drive/Walk distances
  o.bikeDistance = 0
  o.driveDistance = 0
  o.walkDistance = 0

  // Tally access
  if (o.access && o.access.length > 0) {
    var access = o.access[0]
    var accessMode = access.mode.toLowerCase()

    addStreetEdges(o, accessMode, access.streetEdges)

    o.time += access.time
  }

  // Tally egress
  if (o.egress && o.egress.length > 0) {
    var egress = o.egress[0]
    var egressMode = egress.mode.toLowerCase()

    addStreetEdges(o, egressMode, egress.streetEdges)

    o.time += egress.time
  }

  // Tally transit
  if (o.transit && o.transit.length > 0) {
    o.transfers = o.transit.length - 1
    o.transitCost = 0
    o.trips = Infinity

    var self = this
    o.transit.forEach(function (segment) {
      o.modes.push(segment.mode.toLowerCase())

      var trips = segment.segmentPatterns ? segment.segmentPatterns[0].nTrips : 0
      if (trips < o.trips) o.trips = trips

      // Total & add the time in transit
      var timeInTransit = (segment.waitStats.avg + segment.rideStats.avg)
      o.timeInTransit += timeInTransit

      // Add walk time, wait time, & ride time
      o.time += segment.walkTime + timeInTransit

      // Increment the total walk distance
      o.walkDistance += segment.walkDistance

      // Add CO2 per transit leg
      o.emissions += self.rates.co2PerTransitTrip
    })

    if (o.fares) {
      o.fares.forEach(function (fare) {
        if (fare && fare.peak) o.transitCost += fare.peak
      })
    }

    o.cost += o.transitCost
  }

  // Set the walking calories burned
  if (o.modes.indexOf('walk') !== -1) {
    o.walkCalories = caloriesBurned(WALKING_MET, this.rates.weight, (o.walkDistance / this.rates.walkSpeed) *
      SECONDS_TO_HOURS)
  }

  // Set the biking calories burned
  if (o.modes.indexOf('bicycle') !== -1 || o.modes.indexOf('bicycle_rent') !== -1) {
    o.bikeCalories = caloriesBurned(CYCLING_MET, this.rates.weight, (o.bikeDistance / this.rates.bikeSpeed) *
      SECONDS_TO_HOURS)
  }

  // Set the parking costs
  if (o.modes.indexOf('car') !== -1 || o.modes.indexOf('car_park') !== -1) {
    o.carCost = this.rates.mileageRate * (o.driveDistance * METERS_TO_MILES) + this.rates.carParkingCost
    o.cost += o.carCost
    o.emissions = o.driveDistance / this.rates.mpg * CO2_PER_GALLON
  }

  // unique modes only
  o.modes = o.modes.reduce(function (modes, mode) {
    return modes.indexOf(mode) === -1 ? modes.concat(mode) : modes
  }, [])

  // Total calories
  o.calories = o.bikeCalories + o.walkCalories

  return o
}

function addStreetEdges (o, mode, streetEdges) {
  if (!streetEdges) return
  o.modes.push(mode)

  switch (mode) {
    case 'car':
    case 'car_park':
      o.driveDistance += streetEdgeDistanceForMode(streetEdges, 'car')
      break
    case 'bicycle':
      o.bikeDistance += streetEdgeDistanceForMode(streetEdges, 'bicycle')
      break
    case 'bicycle_rent':
      o.modes.push('walk')
      o.bikeDistance += streetEdgeDistanceForMode(streetEdges, 'bicycle')
      o.walkDistance += streetEdgeDistanceForMode(streetEdges, 'walk')
      break
    case 'walk':
      o.walkDistance += streetEdgeDistanceForMode(streetEdges, 'walk')
      break
  }
}

function streetEdgeDistanceForMode (streetEdges, mode) {
  var currentMode = 'walk'
  return streetEdges.reduce(function (distance, step) {
    if (step.mode) {
      currentMode = step.mode.toLowerCase()
    }
    if (currentMode === mode) {
      distance += step.distance
    }
    return distance
  }, 0)
}

function caloriesBurned (met, kg, hours) {
  return met * kg * hours
}

function applyFactor (v, f) {
  if (typeof f === 'function') {
    return f(v)
  } else {
    return f * v
  }
}

function merge (a, b) {
  for (var k in b) a[k] = b[k]
  return a
}