Skip to content

Commit

Permalink
Add option for holiday inclusion/exclusion (#84)
Browse files Browse the repository at this point in the history
* add holidays API endpoint

* create placeholder HolidayOptions class

* send names too

* fetch holidays once from data store

* create three-way holiday logic + form

* pass options through to query and results

* use holiday dates in hoursPossible counts

* field has been added

* note change in readme
  • Loading branch information
Nate-Wessel authored Oct 16, 2023
1 parent 48d1cf9 commit d8f9a50
Show file tree
Hide file tree
Showing 7 changed files with 156 additions and 20 deletions.
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ When you [visit the app](https://trans-bdit.intra.prod-toronto.ca/traveltime-req
* a time range, given in hours of the day, 00 - 23
* a date range (note that the end of the date range is exclusive)
* a day of week selection
* _coming soon_! a selection of whether or not to include statutory holidays
* a selection of whether or not to include statutory holidays

The app will combine these factors together to request travel times for all possible combinations. If one of each type of factor is selected, only a single travel time will be estimated with the given parameters.

Expand All @@ -28,7 +28,7 @@ The app can return results in either CSV or JSON format. The fields in either ca
* time range
* date range
* days of week
* holiday inclusion (will be added shortly)
* holiday inclusion

The other fields may require some explanation:

Expand All @@ -39,7 +39,6 @@ The other fields may require some explanation:
| `hoursInRange` | The total number of hours that are theoretically within the scope of this request. This does not imply that data is/was available at all times. It's possible to construct requests with zero hours in range such as e.g `2023-01-01` to `2023-01-02`, Mondays only (There's only one Sunday in that range). Impossible combinations are included in the output for clarity and completeness but are not actually executed against the API and should return an error. |



## Methodology

Data for travel time estimation through the app are sourced from [HERE](https://github.com/CityofToronto/bdit_data-sources/tree/master/here)'s [traffic API](https://developer.here.com/documentation/traffic-api/api-reference.html) and are available back to about 2012. HERE collects data from motor vehicles that report their speed and position to HERE, most likely as a by-poduct of the driver making use of an in-car navigation system connected to the Internet.
Expand Down
22 changes: 19 additions & 3 deletions backend/app/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,7 @@ def aggregate_travel_times(start_node, end_node, start_time, end_time, start_dat
# test URL /date-bounds
@app.route('/date-bounds', methods=['GET'])
def get_date_bounds():
"""
Get the earliest date and latest data in the travel database.
"""
"Get the earliest date and latest data in the travel database."
connection = getConnection()
with connection:
with connection.cursor() as cursor:
Expand All @@ -327,3 +325,21 @@ def get_date_bounds():
"start_time": min_date.strftime('%Y-%m-%d'),
"end_time": max_date.strftime('%Y-%m-%d')
}

# test URL /holidays
@app.route('/holidays', methods=['GET'])
def get_holidays():
"Return dates of all known holidays in ascending order"
connection = getConnection()
with connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT
dt::text,
holiday
FROM ref.holiday
ORDER BY dt;
""")
dates = [ {'date': dt, 'name': nm} for (dt, nm) in cursor.fetchall()]
connection.close()
return dates
53 changes: 53 additions & 0 deletions frontend/src/Sidebar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export default function SidebarContent(){
<DateRangesContainer/>
<div className='big-math-symbol'>&#xd7;</div>
<DaysContainer/>
<div className='big-math-symbol'>&#xd7;</div>
<HolidaysContainer/>
<div className='big-math-symbol'>=</div>
<Results/>
</div>
Expand Down Expand Up @@ -132,6 +134,57 @@ function DaysContainer(){
)
}

function HolidaysContainer(){
const { logActivity, data } = useContext(DataContext)
let options = data.holidayOptions
let included, excluded
if(options.length == 1){
included = options[0].holidaysIncluded
excluded = ! included
}else{
included = true
excluded = true
}
function handleChange(option){
if(option=='no'){
data.excludeHolidays()
}else if(option=='yeah'){
data.includeHolidays()
}else{
data.includeAndExcludeHolidays()
}
logActivity(`include holidays? ${option}`)
}
return (
<FactorContainer>
<div><b>Include holidays?</b></div>
<label>
<input type="radio" value="yes"
checked={included && !excluded}
onChange={()=>handleChange('yeah')}
/>
Yes
</label>
<br/>
<label>
<input type="radio" value="no"
checked={excluded && !included}
onChange={()=>handleChange('no')}
/>
No
</label>
<br/>
<label>
<input type="radio" value="yeahNo"
checked={included&&excluded}
onChange={()=>handleChange('yeah no')}
/>
Yes & No (do it both ways)
</label>
</FactorContainer>
)
}

function Welcome(){
return ( <>
<h2>Toronto Historic Travel Times</h2>
Expand Down
23 changes: 18 additions & 5 deletions frontend/src/dateRange.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,27 +48,40 @@ export class DateRange extends Factor {
get endDateFormatted(){
return DateRange.dateFormatted(this.#endDate)
}
daysInRange(daysOptions){ // number of days covered by this dateRange
// TODO this will need to be revisited with the holiday options enabled
// number of days covered by this dateRange, considering DoW and holidays
daysInRange(daysOptions,holidayOptions){
// TODO: this logic is pretty convoluted - clean it up!!
if( ! (this.isComplete && daysOptions.isComplete) ){
return undefined
}
let holidayDates = new Set(holidayOptions.holidays.map(h=>h.date))
// iterate each day in the range
let d = new Date(this.#startDate.valueOf())
let dayCount = 0
const holidaysExcluded = ! holidayOptions.holidaysIncluded
while(d < this.#endDate){
let dow = d.getUTCDay()
let dow = d.getUTCDay()
let isodow = dow == 0 ? 7 : dow
if( daysOptions.hasDay(isodow) ){
dayCount ++
// if holidays are NOT included, check the date isn't a holiday
if( ! ( holidaysExcluded && holidayDates.has(formatISODate(d)) ) ){
dayCount ++
}
}
// incrememnt, modified in-place
// incrememnt one day, modified in-place
d.setUTCDate(d.getUTCDate() + 1)
}
return dayCount
}
}

function formatISODate(dt){ // this is waaay too complicated... alas
let year = dt.getUTCFullYear() // should be good
let month = 1 + dt.getUTCMonth() // 0 - 11 -> 1 - 12
let day = dt.getUTCDate() // 1 - 31
return `${year}-${('0'+month).slice(-2)}-${('0'+day).slice(-2)}`
}

function DateRangeElement({dateRange}){
const [ startInput, setStartInput ] = useState(dateRange.startDateFormatted)
const [ endInput, setEndInput ] = useState(dateRange.endDateFormatted)
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/holidayOption.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Factor } from './factor.js'

export class HolidayOption extends Factor {
#includeHolidays
#dataContext
constructor(dataContext,includeHolidays){
super(dataContext)
// store this here too to actually access the holiday data
this.#dataContext = dataContext
// selection for whether to include holidays
this.#includeHolidays = includeHolidays
}
get holidaysIncluded(){ return this.#includeHolidays }
get holidays(){
return this.#dataContext.holidays
}
}
38 changes: 35 additions & 3 deletions frontend/src/spatialData.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,33 @@ import { Corridor } from './corridor.js'
import { TimeRange } from './timeRange.js'
import { DateRange } from './dateRange.js'
import { Days } from './days.js'
import { HolidayOption } from './holidayOption.js'
import { TravelTimeQuery } from './travelTimeQuery.js'
import { domain } from './domain.js'

// instantiated once, this is the data store for all spatial and temporal data
export class SpatialData {
#factors = []
#queries = new Map() // store/cache for travelTimeQueries, letting them remember their results if any
#knownHolidays = []
constructor(){
this.#factors.push(new Days(this))
this.#factors.push(new HolidayOption(this,true))
fetch(`${domain}/holidays`)
.then( response => response.json() )
.then( holidayList => this.#knownHolidays = holidayList )
}
get corridors(){ return this.#factors.filter( f => f instanceof Corridor ) }
get timeRanges(){ return this.#factors.filter( f => f instanceof TimeRange ) }
get dateRanges(){ return this.#factors.filter( f => f instanceof DateRange ) }
get days(){ return this.#factors.filter( f => f instanceof Days ) }
get holidayOptions(){
return this.#factors.filter( f => f instanceof HolidayOption )
}
get activeCorridor(){
return this.corridors.find( cor => cor.isActive )
}
get holidays(){ return this.#knownHolidays }
createCorridor(){
let corridor = new Corridor(this)
this.#factors.push(corridor)
Expand Down Expand Up @@ -52,16 +63,37 @@ export class SpatialData {
if(f != factor) f.deactivate()
} )
}
includeHolidays(){
this.holidayOptions.forEach(f => this.dropFactor(f))
this.#factors.push(new HolidayOption(this,true))
}
excludeHolidays(){
this.holidayOptions.forEach(f => this.dropFactor(f))
this.#factors.push(new HolidayOption(this,false))
}
includeAndExcludeHolidays(){
this.holidayOptions.forEach(f => this.dropFactor(f))
this.#factors.push(new HolidayOption(this,true))
this.#factors.push(new HolidayOption(this,false))
}
get travelTimeQueries(){
// is the crossproduct of all complete/valid factors
const crossProduct = []
this.corridors.filter(c=>c.isComplete).forEach( corridor => {
this.timeRanges.filter(tr=>tr.isComplete).forEach( timeRange => {
this.dateRanges.filter(dr=>dr.isComplete).forEach( dateRange => {
this.days.filter(d=>d.isComplete).forEach( days => {
crossProduct.push(
new TravelTimeQuery({corridor,timeRange,dateRange,days})
)
this.holidayOptions.forEach( holidayOption => {
crossProduct.push(
new TravelTimeQuery({
corridor,
timeRange,
dateRange,
days,
holidayOption
})
)
} )
} )
} )
} )
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/travelTimeQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ export class TravelTimeQuery {
#timeRange
#dateRange
#days
#holidayOption
#travelTime
constructor({corridor,timeRange,dateRange,days}){
constructor({corridor,timeRange,dateRange,days,holidayOption}){
this.#corridor = corridor
this.#timeRange = timeRange
this.#dateRange = dateRange
this.#days = days
this.#holidayOption = holidayOption
}
get URI(){
let path = `${domain}/aggregate-travel-times`
Expand All @@ -20,16 +22,17 @@ export class TravelTimeQuery {
path += `/${this.#timeRange.startHour}/${this.#timeRange.endHour}`
// start and end dates
path += `/${this.#dateRange.startDateFormatted}/${this.#dateRange.endDateFormatted}`
// options not yet supported: holidays and days of week
path += `/true/${this.#days.apiString}`
// holiday inclusion
path += `/${this.#holidayOption.holidaysIncluded}`
// days of week
path += `/${this.#days.apiString}`
return path
}
get corridor(){ return this.#corridor }
get timeRange(){ return this.#timeRange }
get dateRange(){ return this.#dateRange }
get days(){ return this.#days }
async fetchData(){
console.log(this.hoursInRange)
if( this.hoursInRange < 1 ){
return this.#travelTime = -999
}
Expand All @@ -43,7 +46,9 @@ export class TravelTimeQuery {
return Boolean(this.#travelTime)
}
get hoursInRange(){ // number of hours covered by query options
return this.timeRange.hoursInRange * this.dateRange.daysInRange(this.days)
let hoursPerDay = this.timeRange.hoursInRange
let numDays = this.dateRange.daysInRange(this.days,this.#holidayOption)
return hoursPerDay * numDays
}
resultsRecord(type='json'){
const record = {
Expand All @@ -52,6 +57,7 @@ export class TravelTimeQuery {
timeRange: this.timeRange.name,
dateRange: this.dateRange.name,
daysOfWeek: this.days.name,
holidaysIncluded: this.#holidayOption.holidaysIncluded,
hoursInRange: this.hoursInRange,
mean_travel_time_minutes: this.#travelTime
}
Expand All @@ -71,6 +77,6 @@ export class TravelTimeQuery {
return 'invalid type requested'
}
static csvHeader(){
return 'URI,corridor,timeRange,dateRange,daysOfWeek,hoursPossible,mean_travel_time_minutes'
return 'URI,corridor,timeRange,dateRange,daysOfWeek,holidaysIncluded,hoursPossible,mean_travel_time_minutes'
}
}

0 comments on commit d8f9a50

Please sign in to comment.