diff --git a/docs/spec/.pages b/docs/spec/.pages
index ac55641..283e6c4 100644
--- a/docs/spec/.pages
+++ b/docs/spec/.pages
@@ -2,3 +2,4 @@ nav:
- Reference: index.md
- Revision History: revision-history.md
- Examples: examples.md
+ - Roster Examples: examples/rostering.md
diff --git a/docs/spec/examples.md b/docs/spec/examples.md
index 94fe436..35d5d95 100644
--- a/docs/spec/examples.md
+++ b/docs/spec/examples.md
@@ -168,3 +168,7 @@ weekday,10000,20,BLOCK-A,deadhead ,,garage,08:50:00,stop-1,09:00:00
weekday,10000,30,BLOCK-A,run-as-directed,,stop-1,09:00:00,stop-1,12:00:00
weekday,10000,30,BLOCK-A,deadhead ,,stop-1,12:00:00,garage,12:10:00
```
+
+## Roster Assignments
+
+See examples about how to do crew / roster assignments in [Rostering Examples](./rostering.md).
diff --git a/docs/spec/examples/rostering.md b/docs/spec/examples/rostering.md
new file mode 100644
index 0000000..a0a30b9
--- /dev/null
+++ b/docs/spec/examples/rostering.md
@@ -0,0 +1,568 @@
+# Rostering Examples
+
+A series of examples about how to use [roster.txt](/docs/spec.md#rostertxt), [roster_dates.txt](/docs/spec.md#roster_datestxt), [employee_roster.txt](/docs/spec.md#employee_rostertxt), and [employee_run_dates.txt](/docs/spec.md#employee_run_datestxt) to assign employees to roster positions and runs.
+
+## Simplest example: employee_run_dates.txt only
+
+The simplest way to assign employees to runs is to use [`employee_run_dates.txt`](/docs/spec.md#employee_run_datestxt). This will require one row per employee per date.
+
+In this example, `A` and `B` work Saturday Feb 1 and Wednesday Feb 5 through Friday Feb 7. `C` and `D` work Sunday Feb 2 through Tuesday Feb 4.
+
+**[`calendar.txt`](https://gtfs.org/documentation/schedule/reference/#calendartxt)**
+
+```csv
+service_id,monday,tuesday,wednesday,thursday,friday,saturday,start_date,end_date
+weekend,0,0,0,0,0,1,1,20250201,20250207
+weekday,1,1,1,1,1,0,0,20250201,20250207
+```
+
+February 1, 2025 was a Saturday.
+
+**[`run_events.txt`](/docs/spec.md#run_eventstxt)**
+
+For this example, the purpose of this file is just to show which runs exist. Real runs would have more interesting data.
+
+```csv
+service_id,run_id,event_sequence,event_type,start_location,start_time,end_location,end_time
+weekend,101,1,work,station,09:00:00,station,17:00:00
+weekend,102,1,work,station,09:00:00,station,17:00:00
+weekday,103,1,work,station,09:00:00,station,17:00:00
+weekday,104,1,work,station,09:00:00,station,17:00:00
+```
+
+**[`employee_run_dates.txt`](/docs/spec.md#employee_run_datestxt)**
+
+```csv
+date,employee_id,service_id,run_id
+20250201,A,weekend,101
+20250201,B,weekend,102
+20250202,C,weekend,101
+20250202,D,weekend,102
+20250203,C,weekday,103
+20250203,D,weekday,104
+20250204,C,weekday,103
+20250204,D,weekday,104
+20250205,A,weekday,103
+20250205,B,weekday,104
+20250206,A,weekday,103
+20250206,B,weekday,104
+20250207,A,weekday,103
+20250207,B,weekday,104
+```
+
+## Example with Rosters
+
+This example represents the same schedule of runs as above, but it groups multiple days of work into roster positions that employees are assigned to all at once. Roster positions `A` and `B` work Saturdays, Wednesdays, Thursdays, and Fridays. Positions `C` and `D` work Sundays, Mondays, and Tuesdays.
+
+[`calendar.txt`](https://gtfs.org/documentation/schedule/reference/#calendartxt) and [`run_events.txt`](/docs/spec.md#run_eventstxt) are the same as the previous example.
+
+This example does not have any exceptions to the regular schedule, so doesn't need [`roster_dates.txt`](/docs/spec.md#roster_datestxt) or [`employee_run_dates.txt`](/docs/spec.md#employee_run_datestxt).
+
+**[`roster_positions.txt`](/docs/spec.md#roster_positionstxt)**
+
+```csv
+roster_position_id,start_date,end_date,monday_service_id,monday_run_id,tuesday_service_id,tuesday_run_id,wednesday_service_id,wednesday_run_id,thursday_service_id,thursday_run_id,friday_service_id,friday_run_id,saturday_service_id,saturday_run_id,sunday_service_id,sunday_run_id
+wed_to_sat_1,20250201,20250207,,,,,weekday,103,weekday,103,weekday,103,weekend,101,,
+wed_to_sat_2,20250201,20250207,,,,,weekday,104,weekday,104,weekday,104,weekend,102,,
+sun_to_tue_1,20250201,20250207,weekday,103,weekday,103,,,,,,,,,weekend,101
+sun_to_tue_2,20250201,20250207,weekday,104,weekday,104,,,,,,,,,weekend,102
+```
+
+**[`employee_roster.txt`](/docs/spec.md#employee_rostertxt)**
+
+```csv
+roster_position_id,start_date,end_date,employee_id
+wed_to_sat_1,20250201,20250207,A
+wed_to_sat_2,20250201,20250207,B
+sun_to_tue_1,20250201,20250207,C
+sun_to_tue_2,20250201,20250207,D
+```
+
+## Example algorithm for looking up data
+
+Here are example algorithms (written in pseudocode) for a couple typical lookups a consumer might do in the roster files. These examples show how to connect data across all the roster files.
+
+This algorithm will cover edge cases and work for both simple and complex cases. If you know you have simpler data, you may be able to use simpler rules.
+
+### Given a trip ID and date, look up which employees are working on that trip
+
+```ruby
+# Returns a list of employee IDs
+def employees_on_trip(service_date, trip_id):
+ # List of (service_id, run_id). There may be 0 (run_events.txt is incomplete), 1, or many (if multiple people work on that trip).
+ runs_on_trip =
+ SELECT DISTINCT (service_id, run_id) FROM run_events.txt
+ WHERE run_events.trip_id = ${trip_id}
+
+ employees_on_trip = []
+ for each (service_id, run_id) in runs_on_trip:
+ roster_positions_on_run = roster_positions_on_run(service_id, run_id, service_date)
+ employees_on_run = employees_on_run(service_id, run_id, roster_positions_on_run, service_date)
+ employees_on_trip.add(employees_on_run)
+
+ return employees_on_trip
+
+# Returns a list of roster position IDs
+# There could be 0 (if the data is incomplete), 1 (the normal situation), or many (shouldn't happen but isn't prohibited by the spec)
+def roster_positions_on_run(service_id, run_id, service_date):
+ day_of_week = day_of_week_for_date(service_date)
+ result = []
+ # start with roster positions that do this run on a regular week
+ result.add(
+ SELECT roster_position_id FROM roster_positions.txt
+ WHERE start_date <= ${service_date}
+ AND end_date >= ${service_date}
+ AND ${day_of_week}_run_id = ${run_id}
+ AND ("${day_of_week}_service_id" IS NULL OR "${day_of_week}_service_id" = ${service_id})
+ )
+ # remove roster positions with exception_type=2 in roster_dates.txt
+ result.remove(
+ SELECT roster_position_id FROM roster_dates.txt
+ WHERE date = ${service_date}
+ AND exception_type = 2
+ AND roster_position_id = ${roster_position_id}
+ )
+ # add roster positions with exception_type=1 in roster_dates.txt
+ result.remove(
+ SELECT roster_position_id FROM roster_dates.txt
+ WHERE date = ${service_date}
+ AND (exception_type IS NULL OR exception_type = 1)
+ AND roster_position_id = ${roster_position_id}
+ )
+ return result
+
+# Returns a list of employee IDs
+# There could be 0 (if the work is unassigned), 1 (the normal situation), or many (shouldn't happen but isn't prohibited by the spec)
+def employees_on_run(service_id, run_id, roster_positions_on_run, service_date):
+ result = []
+ # start with the employees assigned to the run via the roster
+ result.add(
+ SELECT employee_id FROM employee_roster.txt
+ WHERE employee_roster.roster_position_id in ${roster_positions_on_run}
+ AND employee_roster.start_date <= ${service_date}
+ AND employee_roster.end_date >= ${service_date}
+ )
+ # remove any employees with exception_type=2
+ result.remove(
+ SELECT employee_id FROM employee_run_dates.txt
+ WHERE employee_run_dates.date = ${service_date}
+ AND employee_run_dates.exception_type = 2
+ AND employee_run_dates.employee_id in ${employees_on_run}
+ )
+ # add any employees with exception_type=1 for this run
+ result.add(
+ SELECT employee_id FROM employee_run_dates.txt
+ WHERE date = ${service_date}
+ AND (exception_type IS NULL OR exception_type = 1)
+ AND run_id = ${run_id}
+ AND (service_id IS NULL OR service_id = ${service_id})
+ )
+ return result
+```
+
+### Given an employee ID and date, look up which trips they're working on that day
+
+```ruby
+# Returns a list of trip IDs
+def trips_for_employee(employee_id, service_date):
+ # List of roster postions that the employee is doing on this date
+ # There could be 0 (if the employee doesn't have regular work, such as someone on a spare list)
+ # or 1 (a normal situation),
+ # or many (if an employee is assigned to multiple roster positions, which would be unusual but is allowed by the spec)
+ roster_position_ids =
+ SELECT roster_position_id FROM employee_roster.txt
+ WHERE employee_id = ${employee_id}
+ AND start_date <= ${service_date}
+ AND end_date >= ${service_date}
+
+ # List of (service_id, run_id) pairs, or (NULL, run_id) if the data omits the service id
+ runs = []
+ # first get runs that come from roster position assignments
+ for roster_position_id in roster_position_ids:
+ runs.add(runs_for_roster_position(roster_position_id, service_date))
+ # then remove any runs removed by employee_run_dates.txt
+ runs.remove(
+ SELECT (service_id, run_id) FROM employee_run_dates.txt
+ WHERE date = ${service_date}
+ AND exception_type = 2
+ AND employee_id = ${employee_id}
+ )
+ # then add any runs added by employee_run_dates.txt
+ runs.add(
+ SELECT (service_id, run_id) FROM employee_run_dates.txt
+ WHERE date = ${service_date}
+ AND exception_type = 1
+ AND employee_id = ${employee_id}
+ )
+
+ # Now we know which runs the employee is working on this date. Look up the trips on those runs in run_events.txt
+ trip_ids = []
+ # note service_id might be NULL here
+ for (service_id, run_id) in runs:
+ trip_ids.add(trips_for_run(service_id, run_id, service_date))
+ return trip_ids
+
+# returns List of (service_id, run_id) pairs
+# service_id could be NULL if the data omits it
+# There could be 0 results if the roster position is not working that day
+# 1 result if it is
+# or many if the roster position is working multiple runs, which would not be common but is allowed by the roster_dates.txt spec.
+def runs_for_roster_position(roster_position_id, service_date):
+ day_of_week = day_of_week_for_date(service_date)
+ result = []
+ # start with runs from the roster assignment, at most one result
+ result.add(
+ SELECT ("${day_of_week}_service_id", "${day_of_week}_run_id")
+ FROM roster_positions.txt
+ WHERE roster_position_id = ${roster_position_id}
+ AND start_date <= ${service_date}
+ AND end_date >= ${service_date}
+ )
+ # if the roster position has exception_type=2 in run_dates.txt, then ignore the run from roster_positions.txt
+ if (
+ SELECT (service_id, run_id)
+ FROM roster_dates.txt
+ WHERE roster_position_id = ${roster_position_id}
+ AND date = ${service_date}
+ AND exception_type = 2
+ ):
+ result = []
+ # add any runs with exception_type=1 in run_dates.txt
+ result.add(
+ SELECT (service_id, run_id)
+ FROM roster_dates.txt
+ WHERE roster_position_id = ${roster_position_id}
+ AND date = ${service_date}
+ AND exception_type = 1
+ )
+ return result
+
+# input service_id might be NULL
+# Returns list of trip IDs
+def trips_for_run(service_id, run_id, service_date):
+ # If the roster data omits service_ids, allow matching to a run on any service_id that's active on this date
+ # Look up which services are active from the GTFS calendar (after applying any TODS supplement files), based on the service_date and day_of_week.
+ # In real code you'd probably want to calculate this once for all queries
+ active_services = ...
+
+ if service_id == NULL:
+ SELECT trip_id FROM run_events.txt
+ WHERE run_id = ${run_id}
+ AND service_id in ${active_services}
+ else:
+ SELECT trip_id FROM run_events.txt
+ WHERE run_id = ${run_id}
+ AND service_id = ${service_id}
+```
+
+## Holiday
+
+In this example, the roster includes holidays, which are defined as exceptions in [`roster_dates.txt`](/docs/spec.md#roster_datestxt).
+
+This agency has two workers, who work M-F. There's no service on weekends. On each holiday, the agency runs half the service, and each worker gets one holiday off.
+
+The holidays are built into the roster positions, so there's no need for [`employee_run_dates.txt`](/docs/spec.md#employee_run_datestxt).
+
+**[`calendar.txt`](https://gtfs.org/documentation/schedule/reference/#calendartxt)**
+
+```csv
+service_id,monday,tuesday,wednesday,thursday,friday,saturday,start_date,end_date
+weekday,1,1,1,1,1,0,0,20240701,20240714
+```
+
+July 1, 2024 was a Monday.
+
+**[`calendar_dates.txt`](https://gtfs.org/documentation/schedule/reference/#calendar_datestxt)**
+
+```csv
+service_id,date,exception_type
+weekday,20240702,2
+holiday,20240702,1
+weekday,20240711,2
+holiday,20240711,1
+```
+
+Holidays are Tuesday, July 2, and Thursday, July 11.
+
+**[`run_events.txt`](/docs/spec.md#run_eventstxt)**
+
+For this example, the purpose of this file is just to show which runs exist. Real runs would have more interesting data.
+
+```csv
+service_id,run_id,event_sequence,event_type,start_location,start_time,end_location,end_time
+weekday,101,1,work,station,09:00:00,station,17:00:00
+weekday,102,1,work,station,09:00:00,station,17:00:00
+holiday,999,1,work,station,09:00:00,station,17:00:00
+```
+
+**[`roster_positions.txt`](/docs/spec.md#roster_positionstxt)**
+
+```csv
+roster_position_id,start_date,end_date,monday_service_id,monday_run_id,tuesday_service_id,tuesday_run_id,wednesday_service_id,wednesday_run_id,thursday_service_id,thursday_run_id,friday_service_id,friday_run_id,saturday_service_id,saturday_run_id,sunday_service_id,sunday_run_id
+POSITION-A,20240701,20240714,weekday,101,weekday,101,weekday,101,weekday,101,weekday,101,,,,
+POSITION-B,20240701,20240714,weekday,102,weekday,102,weekday,102,weekday,102,weekday,102,,,,
+```
+
+**[`roster_dates.txt`](/docs/spec.md#roster_datestxt)**
+
+```csv
+roster_position_id,date,exception_type,service_id,run_id
+POSITION-A,20240702,2,,
+POSITION-B,20240702,2,,
+POSITION-A,20240702,1,holiday,999
+POSITION-A,20240711,2,,
+POSITION-B,20240711,2,,
+POSITION-B,20240711,1,holiday,999
+```
+
+**[`employee_roster.txt`](/docs/spec.md#employee_rostertxt)**
+
+```csv
+roster_position_id,start_date,end_date,employee_id
+POSITION-A,20240701,20240714,EMPLOYEE-A
+POSITION-B,20240701,20240714,EMPLOYEE-B
+```
+
+## Vacation (part of the roster position)
+
+In this example, this agency runs service 4 days a week, with two employees who each regularly work two days per week. When each employee goes on vacation, their work is covered by a third employee on a third roster position.
+
+These vacations are built in to the roster.
+
+The third employee has no regular work, so appears in [`roster_dates.txt`](/docs/spec.md#roster_datestxt) but not [`roster_positions.txt`](/docs/spec.md#roster_positionstxt).
+
+**[`calendar.txt`](https://gtfs.org/documentation/schedule/reference/#calendartxt)**
+
+```csv
+service_id,monday,tuesday,wednesday,thursday,friday,saturday,start_date,end_date
+weekday,1,1,0,1,1,0,0,20240701,20240721
+```
+
+July 1, 2024 was a Monday.
+
+**[`run_events.txt`](/docs/spec.md#run_eventstxt)**
+
+In this simple example, there's only one employee working per day, and they only do one run_event per day.
+
+```csv
+service_id,run_id,event_sequence,event_type,start_location,start_time,end_location,end_time
+weekday,100,1,work,station,09:00:00,station,17:00:00
+```
+
+**[`roster_positions.txt`](/docs/spec.md#roster_positionstxt)**
+
+One works Monday and Tuesady, the other works Thursday and Friday.
+
+```csv
+roster_position_id,start_date,end_date,monday_service_id,monday_run_id,tuesday_service_id,tuesday_run_id,wednesday_service_id,wednesday_run_id,thursday_service_id,thursday_run_id,friday_service_id,friday_run_id,saturday_service_id,saturday_run_id,sunday_service_id,sunday_run_id
+POSITION-A,20240701,20240721,weekday,100,weekday,100,,,,,,,,,,
+POSITION-B,20240701,20240721,,,,,,,weekday,100,weekday,100,,,,
+```
+
+**[`roster_dates.txt`](/docs/spec.md#roster_datestxt)**
+
+When each roster position goes on vacation for two days, the third subsitute roster position fills in.
+
+```csv
+roster_position_id,date,exception_type,service_id,run_id
+POSITION-A,20240708,2,,
+POSITION-C,20240708,1,weekday,100
+POSITION-A,20240709,2,,
+POSITION-C,20240709,1,weekday,100
+POSITION-B,20240718,2,,
+POSITION-C,20240718,1,weekday,100
+POSITION-B,20240719,2,,
+POSITION-C,20240719,1,weekday,100
+```
+
+**[`employee_roster.txt`](/docs/spec.md#employee_rostertxt)**
+
+```csv
+roster_position_id,start_date,end_date,employee_id
+POSITION-A,20240701,20240721,EMPLOYEE-A
+POSITION-B,20240701,20240721,EMPLOYEE-B
+POSITION-C,20240701,20240721,EMPLOYEE-C
+```
+
+The vacations are built into the roster positions, so the employees stay assigned to the roster position the whole time. There's no need for [`employee_run_dates.txt`](/docs/spec.md#employee_run_datestxt).
+
+## Vacation (not part of the roster position)
+
+In this case, the same employees work on the same days as in the previous example. But this time the roster position continue their regular assignments on the vacation day, and the employees get their vacations by being unassigned from their roster position in [`employee_run_dates.txt`](/docs/spec.md#employee_run_datestxt).
+
+[`calendar.txt`](https://gtfs.org/documentation/schedule/reference/#calendartxt) and [`run_events.txt`](/docs/spec.md#run_eventstxt) are the same as above.
+
+**[`roster_positions.txt`](/docs/spec.md#roster_positionstxt)**
+
+[`roster_positions.txt`](/docs/spec.md#roster_positionstxt) is the same as in the previous example, because the regular week is the same.
+
+One works Monday and Tuesady, the other works Thursday and Friday.
+
+```csv
+roster_position_id,start_date,end_date,monday_service_id,monday_run_id,tuesday_service_id,tuesday_run_id,wednesday_service_id,wednesday_run_id,thursday_service_id,thursday_run_id,friday_service_id,friday_run_id,saturday_service_id,saturday_run_id,sunday_service_id,sunday_run_id
+POSITION-A,20240701,20240721,weekday,100,weekday,100,,,,,,,,,,
+POSITION-B,20240701,20240721,,,,,,,weekday,100,weekday,100,,,,
+```
+
+In this example, there is no [`roster_dates.txt`](/docs/spec.md#roster_datestxt) file, because the roster positions continue reguarly. There is no 3rd roster position.
+
+**[`employee_roster.txt`](/docs/spec.md#employee_rostertxt)**
+
+```csv
+roster_position_id,start_date,end_date,employee_id
+POSITION-A,20240701,20240721,EMPLOYEE-A
+POSITION-B,20240701,20240721,EMPLOYEE-B
+```
+
+`EMPLOYEE-C` has no roster position that they are regularly assigned to.
+
+**[`employee_run_dates.txt`](/docs/spec.md#employee_run_datestxt)**
+
+```csv
+date,employee_id,exception_type,service_id,run_id
+20240708,EMPLOYEE-A,2,
+20240708,EMPLOYEE-C,1,weekday,100
+20240709,EMPLOYEE-A,2,
+20240709,EMPLOYEE-C,1,weekday,100
+20240718,EMPLOYEE-B,2,
+20240718,EMPLOYEE-C,1,weekday,100
+20240719,EMPLOYEE-B,2,
+20240719,EMPLOYEE-C,1,weekday,100
+```
+
+## Multi-week roster positions
+
+At some agencies (especially in Europe), roster positions repeat every two weeks. In TODS, this can be represented by having the first and second weeks be two different roster positions, and using [`employee_roster.txt`](/docs/spec.md#employee_rostertxt) to assign employees to each roster position each week.
+
+In this example, there's one run per day on weekdays only. Position A works Mondays and Tuesdays. Position B works Thursdays and Fridays. They trade off on Wednesdays, so each position works an average of 2½ days per week.
+
+**[`calendar.txt`](https://gtfs.org/documentation/schedule/reference/#calendartxt)**
+
+```csv
+service_id,monday,tuesday,wednesday,thursday,friday,saturday,start_date,end_date
+weekday,1,1,1,1,1,0,0,20240701,20240728
+```
+
+**[`roster_positions.txt`](/docs/spec.md#roster_positionstxt)**
+
+```csv
+roster_position_id,start_date,end_date,monday_service_id,monday_run_id,tuesday_service_id,tuesday_run_id,wednesday_service_id,wednesday_run_id,thursday_service_id,thursday_run_id,friday_service_id,friday_run_id,saturday_service_id,saturday_run_id,sunday_service_id,sunday_run_id
+POSITION-A-WEEK-1,20240701,20240728,weekday,100,weekday,100,weekday,100,,,,,,,,
+POSITION-A-WEEK-2,20240701,20240728,weekday,100,weekday,100,,,,,,,,,,
+POSITION-B-WEEK-1,20240701,20240728,,,,,,,weekday,100,weekday,100,,,,
+POSITION-B-WEEK-2,20240701,20240728,,,,,weekday,100,weekday,100,weekday,100,,,,
+```
+
+**[`employee_roster.txt`](/docs/spec.md#employee_rostertxt)**
+
+```csv
+roster_position_id,start_date,end_date,employee_id
+POSITION-A-WEEK1,20240701,20240707,EMPLOYEE-A
+POSITION-B-WEEK1,20240701,20240707,EMPLOYEE-B
+POSITION-A-WEEK2,20240707,20240714,EMPLOYEE-A
+POSITION-B-WEEK2,20240707,20240714,EMPLOYEE-B
+POSITION-A-WEEK1,20240715,20240721,EMPLOYEE-A
+POSITION-B-WEEK1,20240715,20240721,EMPLOYEE-B
+POSITION-A-WEEK2,20240722,20240728,EMPLOYEE-A
+POSITION-B-WEEK2,20240722,20240728,EMPLOYEE-B
+```
+
+## Rotating assignments
+
+At some agencies (especially in the UK), employees rotate among all roster positions over many weeks. In TODS, this can be represented by assigning each employee to a new roster position each week.
+
+In this example, there are 5 employees and 5 roster positions. Over a calendar of 3 weeks, each employee will work 3 of the 5 positions.
+
+**[`calendar.txt`](https://gtfs.org/documentation/schedule/reference/#calendartxt)**
+
+```csv
+service_id,monday,tuesday,wednesday,thursday,friday,saturday,start_date,end_date
+weekday,1,1,1,1,1,0,0,20240701,20240721
+```
+
+**[`roster_positions.txt`](/docs/spec.md#roster_positionstxt)**
+
+```csv
+roster_position_id,start_date,end_date,monday_service_id,monday_run_id,tuesday_service_id,tuesday_run_id,wednesday_service_id,wednesday_run_id,thursday_service_id,thursday_run_id,friday_service_id,friday_run_id,saturday_service_id,saturday_run_id,sunday_service_id,sunday_run_id
+POSITION-1,20240701,20240721,weekday,101,weekday,101,weekday,101,weekday,101,weekday,101,,,,
+POSITION-2,20240701,20240721,weekday,102,weekday,102,weekday,102,weekday,102,weekday,102,,,,
+POSITION-3,20240701,20240721,weekday,103,weekday,103,weekday,103,weekday,103,weekday,103,,,,
+POSITION-4,20240701,20240721,weekday,104,weekday,104,weekday,104,weekday,104,weekday,104,,,,
+POSITION-5,20240701,20240721,weekday,105,weekday,105,weekday,105,weekday,105,weekday,105,,,,
+```
+
+**[`employee_roster.txt`](/docs/spec.md#employee_rostertxt)**
+
+```csv
+roster_position_id,start_date,end_date,employee_id
+POSITION-1,20240701,20240707,EMPLOYEE-A
+POSITION-2,20240701,20240707,EMPLOYEE-B
+POSITION-3,20240701,20240707,EMPLOYEE-C
+POSITION-4,20240701,20240707,EMPLOYEE-D
+POSITION-5,20240701,20240707,EMPLOYEE-E
+POSITION-1,20240708,20240714,EMPLOYEE-E
+POSITION-2,20240708,20240714,EMPLOYEE-A
+POSITION-3,20240708,20240714,EMPLOYEE-B
+POSITION-4,20240708,20240714,EMPLOYEE-C
+POSITION-5,20240708,20240714,EMPLOYEE-D
+POSITION-1,20240715,20240721,EMPLOYEE-D
+POSITION-2,20240715,20240721,EMPLOYEE-E
+POSITION-3,20240715,20240721,EMPLOYEE-A
+POSITION-4,20240715,20240721,EMPLOYEE-B
+POSITION-5,20240715,20240721,EMPLOYEE-C
+```
+
+## Minor schedule adjustment
+
+In this example, due to track work, the schedule has been minorly changed one day. There are new trip times, trip IDs, run event times, and a new service ID.
+
+If the roster files specify service IDs (which is recommended), then either [`roster_dates.txt`](/docs/spec.md#roster_datestxt) or [`employee_run_dates.txt`](/docs/spec.md#employee_run_datestxt) must be used to assign the roster position or employee to the new `(service_id, run_id)` pair, i.e. `(trackwork, 100)` instead of `(weekday, 100)`.
+
+However, in this example, the roster does not specify the service ID in [`roster_positions.txt`](/docs/spec.md#roster_positionstxt). Therefore, the employee is assigned to run 100 on whichever service is active. Since both the regular and replacement runs have run ID `100`, on the day of the exception, the employee will be implicitly assigned to `(trackwork, 100)` without needing to specify that in the roster files.
+
+The spec describes this situation in [Service IDs in Rosters](/docs/spec/index.md#service-ids-in-rosters).
+
+**[`calendar.txt`](https://gtfs.org/documentation/schedule/reference/#calendartxt)**
+
+```csv
+service_id,monday,tuesday,wednesday,thursday,friday,saturday,start_date,end_date
+weekday,1,1,1,1,1,0,0,20250201,20240728
+```
+
+**[`calendar_dates.txt`](https://gtfs.org/documentation/schedule/reference/#calendar_datestxt)**
+
+The trackwork is on Friday, February 7.
+
+```csv
+service_id,date,exception_type
+weekday,20250207,2
+trackwork,20250207,1
+```
+
+**[`run_events.txt`](/docs/spec.md#run_eventstxt)**
+
+Because of the track work, travel is slower and the times are adjusted.
+
+```csv
+service_id,run_id,event_sequence,event_type,trip_id,start_location,start_time,end_location,end_time
+weekday,100,1,Operator,1001,suburb,08:00:00,downtown,09:00:00
+weekday,100,2,Operator,1002,downtown,17:00:00,suburb,18:00:00
+trackwork,100,1,Operator,2001,suburb,08:00:00,downtown,09:30:00
+trackwork,100,2,Operator,2002,downtown,16:30:00,suburb,18:00:00
+```
+
+**[`roster_positions.txt`](/docs/spec.md#roster_positionstxt)**
+
+Note that the service ID fields are left blank.
+
+```csv
+roster_position_id,start_date,end_date,monday_service_id,monday_run_id,tuesday_service_id,tuesday_run_id,wednesday_service_id,wednesday_run_id,thursday_service_id,thursday_run_id,friday_service_id,friday_run_id,saturday_service_id,saturday_run_id,sunday_service_id,sunday_run_id
+POSITION,20250201,20250228,,100,,100,,100,,100,,100,,,,,
+```
+
+**[`employee_roster.txt`](/docs/spec.md#employee_rostertxt)**
+
+```csv
+roster_position_id,start_date,end_date,employee_id
+POSITION,20250201,20250228,EMPLOYEE
+```
diff --git a/docs/spec/index.md b/docs/spec/index.md
index b9fef7f..a12bc94 100644
--- a/docs/spec/index.md
+++ b/docs/spec/index.md
@@ -12,6 +12,8 @@ There are two types of files used in the TODS standard:
- **Supplement files**, used to add, modify, and delete information from public GTFS files to model the operational service for internal purposes (with a `_supplement` filename suffix).
- **TODS-Specific files**, used to model operational elements not currently defined in the GTFS standard.
+All files are optional.
+
### Files
| **File Name** | **Type** | **Description** |
@@ -21,6 +23,10 @@ There are two types of files used in the TODS standard:
| stop_times_supplement.txt | Supplement | Supplements and modifies GTFS [stop_times.txt](https://github.com/google/transit/blob/master/gtfs/spec/en/reference.md#stop_timestxt) with non-public times at which trips stop at locations, `stop_times` entries for non-public trips, and related information. |
| routes_supplement.txt | Supplement | Supplements and modifies GTFS [routes.txt](https://github.com/google/transit/blob/master/gtfs/spec/en/reference.md#routestxt) with internal route identifiers and other non-public route identification. |
| run_events.txt | TODS-Specific | Lists all trips and other scheduled activities to be performed by a member of personnel during a run. |
+| employee_run_dates.txt | TODS-Specific | Assigns employees to runs. Or, if rosters are used, gives exceptions to roster assignments on specific dates. |
+| roster.txt | TODS-Specific | Lists the runs that a roster position is assigned to work on a typical week. |
+| roster_dates.txt | TODS-Specific | Lists the runs that a roster position is assigned to work on specific dates. |
+| employee_roster.txt | TODS-Specific | Lists which employee is assigned to which roster position. |
_The use of the Supplement standard to modify other GTFS files is not yet formally adopted into the specification and remains subject to change. Other files may be formally adopted in the future._
@@ -139,3 +145,98 @@ Because some events may overlap in time, it may not be possible to choose a sing
- Events may have gaps between the end time of one event and the start time of the next. e.g. if an operator's layovers aren't represented by an event.
- `start_time` may equal `end_time` for an event that's a single point in time (such as a report time) without any duration.
- Recommended sort order: `service_id`, `run_id`, `event_sequence`.
+
+### `employee_run_dates.txt`
+
+Describes which employees are scheduled to which runs on which dates.
+
+If [`employee_roster.txt`](#employee_rostertxt) is used, then describes exceptions to that schedule.
+
+If a feed doesn't represent roster positions, it can still assign employees to runs by putting every run for every date in this file. In that case, the `exception_type` column can be omitted because every row would be adding a date, which is the default when the column is blank. [Example](./examples/rostering.md#simplest-example-employee_run_datestxt-only).
+
+Primary Key: `*`
+
+| **Field Name** | **Type** | **Required** | **Description** |
+| --- | --- | --- | --- |
+| `date` | Date | Required | |
+| `employee_id` | ID | Required | References an agency's external systems. Employee IDs are not used elsewhere in TODS. |
+| `exception_type` | Enum | Optional | `1` or blank - The run is assigned to this employee on the specified date.
`2` - On this date, the employee will not work on runs assigned via the `employee_roster.txt`, `roster.txt` and `roster_dates.txt`. The employee may still have runs assigned via other rows in this file. |
+| `service_id` | ID referencing `run_events.txt` | Conditionally Required | Part of the Run ID, which is refered to as `(service_id, run_id)`.
If `exception_type` is `1` or blank, then `service_id` is optional and recommended. It's required in some cases to avoid ambiguity. See [Service IDs in Rosters](#service-ids-in-rosters).
If `exception_type` is `2`, then `service_id` is forbidden. |
+| `run_id` | ID referencing `run_events.txt` | Conditionally Required | If `exception_type` is `1`, then `run_id` is required and is the run that's added to this employee's schedule.
If `exception_type` is `2`, then `run_id` is forbidden. |
+
+### `roster.txt`
+
+This file defines roster positions, groupings of work across multiple runs on multiple dates that an employee can be assigned to all at once.
+
+Exceptions to these dates may be listed in [`roster_dates.txt`](#roster_datestxt).
+
+Employees are assigned to these roster positions in [`employee_roster.txt`](#employee_rostertxt).
+
+Primary Key: `roster_position_id`
+
+| **Field Name** | **Type** | **Required** | **Description** |
+| --- | --- | --- | --- |
+| `roster_position_id` | Unique ID | Required | Unique within dataset |
+| `start_date` | Date | Required | First service day that the roster position works. |
+| `end_date` | Date | Required | Last service day tha the roster position works. This day is included in the interval. |
+| `monday_service_id` | ID referencing `run_events.txt` | Conditionally Required | Identifies the run this roster does on Mondays. Runs are identified by the pair `(service_id, run_id)`. Forbidden if `monday_run_id` is blank. Optional and recommended if `monday_run_id` is present. Required in some cases to avoid ambiguity. See [Service IDs in Rosters](#service-ids-in-rosters). |
+| `monday_run_id` | ID referencing `run_events.txt` | Optional | Identifies the run this roster does on Mondays. If blank, this roster does not work on Mondays. |
+| `tuesday_service_id` | ID referencing `run_events.txt` | Conditionally Required | |
+| `tuesday_run_id` | ID referencing `run_events.txt` | Optional | |
+| `wednesday_service_id` | ID referencing `run_events.txt` | Conditionally Required | |
+| `wednesday_run_id` | ID referencing `run_events.txt` | Optional | |
+| `thursday_service_id` | ID referencing `run_events.txt` | Conditionally Required | |
+| `thursday_run_id` | ID referencing `run_events.txt` | Optional | |
+| `friday_service_id` | ID referencing `run_events.txt` | Conditionally Required | |
+| `friday_run_id` | ID referencing `run_events.txt` | Optional | |
+| `saturday_service_id` | ID referencing `run_events.txt` | Conditionally Required | |
+| `saturday_run_id` | ID referencing `run_events.txt` | Optional | |
+| `sunday_service_id` | ID referencing `run_events.txt` | Conditionally Required | |
+| `sunday_run_id` | ID referencing `run_events.txt` | Optional | |
+
+#### Service IDs in Rosters
+
+Run IDs may be non-unique, they may be duplicated across services. E.g. there may be a "Run 100" on both Weekday and Weekend services. So a run as described in [`run_events.txt`](#run_eventstxt) is uniquely identified by a `(service_id, run_id)` pair. It is recommeded that producers include both a Service ID and Run ID when identifying a run in `roster.txt`, [`roster_dates.txt`](#roster_datestxt), and [`employee_run_dates.txt`](#employee_run_datestxt).
+
+If a Run ID is included but a Service ID isn't, then the roster position or employee is assigned to work on whichever run in [`run_events.txt`](#run_eventstxt) has that Run ID and is on a service that is active that service day, according to GTFS [`calendar.txt`](https://gtfs.org/documentation/schedule/reference/#calendartxt)/[`calendar_dates.txt`](https://gtfs.org/documentation/schedule/reference/#calendar_datestxt). (If this would be ambiguous because there are mutliple runs with the same `run_id` active on the same day, and therefore multiple `(service_id, run_id)` pairs it could refer to, then the Service ID is required.)
+
+This is allowed as a shortcut for producers to reduce the level of duplication in the roster file if a roster position works runs with the same ID on different days. For example, if there's a minor schedule change one day due to track work, that day must be on a different Service ID to give new trip and run times. But if a roster position works "Run 100" on both the regular and modified service, and the roster file leaves the Service ID field blank, then the roster file doesn't need an exception for that day because it refers to "Run 100" on whichever service is happening.
+
+More in-depth examples and instructions on how to look up which run an employee is doing are given in [the examples](./examples/rostering.md#given-an-date-and-employee-look-up-which-trips-theyre-doing-that-day).
+
+### `roster_dates.txt`
+
+Defines exceptions to [`roster.txt`](#rostertxt), similar to how [`calendar_dates.txt`](https://gtfs.org/documentation/schedule/reference/#calendar_datestxt) defines exceptions to [`calendar.txt`](https://gtfs.org/documentation/schedule/reference/#calendartxt).
+
+This can be used to define holidays, vacations that are built into the roster position, or other exceptions.
+
+Dates may be added before the `start_date` or after the `end_date` defined in [`roster.txt`](#rostertxt).
+
+After evaluating [`roster.txt`](#rostertxt) and `roster_dates.txt`, each run can only be assigned to one roster position on each date. A roster position may be scheduled to do multiple runs on the same date.
+
+This file may be used even when [`roster.txt`](#rostertxt) is not defined, in which case each roster position is made up of the dates added in this file. This may be useful for agencies whose rosters are very irregular. In this case, the `exception_type` column can be omitted because every row is adding a date, which is the default when the column is blank.
+
+Primary Key: `*`
+
+| **Field Name** | **Type** | **Required** | **Description** |
+| --- | --- | --- | --- |
+| `roster_position_id` | ID referencing [`roster.txt`](#rostertxt) or ID | Required | If `exception_type` is `1`, then the ID does not have to appear in [`roster.txt`](#rostertxt). This file may define new roster positions. |
+| `date` | Date | Required | Date when exception occurs. |
+| `exception_type` | Enum | Optional | `1` (or blank) - The run is added to this roster for the specified date.
`2` - The roster will not work its regular run on this date. |
+| `service_id` | ID referencing `run_events.txt` | Conditionally Required | Part of the Run ID, which is refered to as `(service_id, run_id)`.
If `exception_type` is `1` or blank, then `service_id` is optional and recommended. It's required in some cases to avoid ambiguity. See [Service IDs in Rosters](#service-ids-in-rosters).
If `exception_type` is `2`, then `service_id` is forbidden. |
+| `run_id` | ID referencing `run_events.txt` | Conditionally Required | If `exception_type` is `1`, then `run_id` is required and is the run that's added to this roster position.
If `exception_type` is `2`, then `run_id` is forbidden. |
+
+### `employee_roster.txt`
+
+Describes which employees are scheduled to which roster positions on which dates.
+
+Primary Key: `(roster_position_id, start_date)`
+
+| **Field Name** | **Type** | **Required** | **Description** |
+| --- | --- | --- | --- |
+| `roster_position_id` | ID referencing `roster.txt` or `roster_dates.txt` | Required | |
+| `start_date` | Date | Required | |
+| `end_date` | Date | Required | Included in the interval. |
+| `employee_id` | ID | Required | |
+
+Each roster position can only be assigned to one employee on each date. Employees may be scheduled to more than one roster position on the same date.