Skip to content

Update handling of vehicles and consists - #272

Open
jlstpaul wants to merge 19 commits into
developfrom
feature/issue-269-vehicles-changes
Open

Update handling of vehicles and consists#272
jlstpaul wants to merge 19 commits into
developfrom
feature/issue-269-vehicles-changes

Conversation

@jlstpaul

@jlstpaul jlstpaul commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Pull Request

Summary

This PR updates the handling of vehicles and vehicles assignments in TIDES as follows

  • Normalize attributes of vehicles in a new vehicle_groups table
  • Repurpose vehicles table to represent the static/slowly changing list of physical vehicles (buses and train cars)
  • Create a new vehicle_assignments table to represent the vehicles (buses and train cars) and consists that are available to be assigned each date.
  • Remove the existing train_cars and vehicle_train_cars tables and add a new consist_vehicles table to document that cars that are assigned to each consist (with their order and orientation)
  • Add a vehicle_direction field to the operational event and summary data tables to represent the direction that a train is moving during operation.

This is a breaking change.

Resolves #269

Schema Tables / Terminology

  • vehicle_groups are collections of vehicles that share the same characteristics.  A vehicle group may represent a group of individual vehicles (e.g., buses of the same type) or planned consist types (e.g., two-car trains of type 3 LRVs)
    vehicles

  • vehicles are single physical vehicles, a bus or a train car. A vehicle has a start date and end date when it is in service, along with unique identifying information such as the label on the vehicle and license plate. Other attributes are referenced from vehicle_groups

  • vehicle_assignments are the daily record of either a single vehicle (such as a bus or train car) or a consist (i.e., a train) that can have data associated with it. This allows for the fact that rail operations systems may associate data with either an entire train or each individual car of a train. There is some flexibility in whether and how the vehicle_assignments table is used. In the simplest context, it is and optional table. And in some contexts, vehicle assignments may stretch over multiple days instead of being for a single date.

  • consist_vehicles are the individual train cars that make up a consist. The cars of the consists refer to vehicle records and have both an order and an orientation.

Files Changed

Removed:

  • spec/train_cars.schema.json
  • spec/vehicle_train_cars.schema.json
  • samples/template/TIDES/train_cars.csv
  • samples/template/TIDES/vehicle_train_cars.csv

New:

  • spec/vehicle_groups.schema.json - new table schema
  • spec/vehicle_assignments.schema.json - new table schema
  • spec/consists_vehicles.schema.json - new table schema
  • samples/template/TIDES/vehicle_groups.csv - template csv file
  • samples/template/TIDES/vehicle_assignments.csv - template csv file
  • samples/template/TIDES/consist_vehicles.csv - template csv file

Modified:

  • spec/vehicles.schema.json
  • spec/tides-schema.jsopn
  • samples/template/TIDES/vehicles.csv
  • spec/tides-datapackage-profile.json
  • samples/template/TIDES/datapackage.json
  • CHANGELOG.md - documented new table

_- See related discussion in #269 and discussion document Vehicles and Crew in GTFS / TODS / TIDES

Usage Notes

Outstanding Tasks

Review checklist

Per change management policy, the following must be met before feature branch changes can merge to develop branch:

  • All JSON files validate
  • Reviewed and approved by 2+ contributors or board members

Community review status (updated August 27, 2026)

How community review works: Per the TIDES Change Management Policy, a proposal moves forward once at least three TIDES Contributors outside the originating working group publicly comment with a score: Accepted, Accepted with minor changes, or Substantially revised. A few sentences with your read of the proposal is a complete review.

Originating working group (July 1, 2026 vehicles session): Christopher Yamas, John Levin, Laurie Merrell, Gabriel Sánchez Martínez, Joey Reid. Their work is reflected in the proposal itself; community review comes from Contributors beyond this group.

Review so far: @doconnoronca and @bijustrada360 have contributed to the design discussion, and the join-semantics and worked-example questions raised this week are being worked through in the comments. Scored reviews from Contributors are welcome to complete community review.

Community review remains open, and reviews are welcome while we finalize v2.0. If you have not yet weighed in, a short comment closing with where you land (Accepted / Accepted with minor changes / Substantially revised) is all we need.

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

Data Validation Report

Sample Status
./samples/template/TIDES ⚠️

@chrisyamas
chrisyamas changed the base branch from main to develop July 23, 2026 14:31
@github-actions

Copy link
Copy Markdown
Contributor

Data Validation Report

Sample Status
./samples/template/TIDES ⚠️

@chrisyamas
chrisyamas marked this pull request as ready for review July 23, 2026 17:10
@chrisyamas
chrisyamas requested review from a team as code owners July 23, 2026 17:10
@github-actions

Copy link
Copy Markdown
Contributor

Data Validation Report

Sample Status
./samples/template/TIDES ⚠️

@jlstpaul

jlstpaul commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@lauriemerrell does Option 1 in #272 (comment) resolve your question regarding foreign keys between operational tables and vehicle_assignments table? My inclination is to leave vehicle_assignment_id as a unique/key value. This does make joins between between [operational table].vehicle_id and vehicle_assignments.vehicle_id more complex.

@jlstpaul

jlstpaul commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Given discussion in #273, there is an open question of how to represent attributes in vehicle_groups (as dedicated table fields or in a key/value structure).

Do we want to leave this detail for a future minor, non-breaking version? 

There is also the question of how to handle the existing attribute fields from vehicles and train_cars

  • model_name
  • facility_name
  • capacity_seated
  • capacity_wheelchair
  • capacity_bike
  • bike_rack
  • capacity_standing
  • train_car_type (on train_cars)

I see three options:

Option 1: Temporarily remove these fields from the spec. Leave all attributes out of the new vehicle_groups table Replace these fields in a future release as attributes of vehicle_groups. 


Option 2: Include these existing fields as attributes in vehicle_groups. A future change to how attributes are handled or to the names of these fields would be a breaking change.

Option 1A: Remove the fields, with the recommendation to current users of these fields that they include them as local extensions to the vehicle_groups table with the expectation that a future minor release might require local modifications.

My preference is Option 1A. Remove these fields for now, and re-add them with a future minor release that fully defines the approach to vehicle_group attributes.

@lauriemerrell

Copy link
Copy Markdown
Contributor

@jlstpaul -- yes, I guess Option 1 works. I guess it's like service_id in GTFS where it can be defined in calendar, calendar_dates, or both. I think that's fine from the data modeling perspective.

My concern was more that I was wondering if there are cases where in practice the operational system will emit the unique vehicle_assignment_id (the one that's dynamically generated per instance of the consist) so that would need to be the join key. I'm just not sure how common that is and whether we need to focus on that at this point

@chrisyamas

Copy link
Copy Markdown
Contributor

@jabhij appreciate the thorough review on #251. If you have bandwidth for one more before the window closes tomorrow, #272 is the one that could most use another set of eyes. It restructures vehicles into vehicle_groups, vehicle_assignments, and consist_vehicles, and the open questions there (join semantics, how attributes get represented) are similar territory to what you were digging into on #251.

Any level of review helps, and a comment with a score (Accepted / Accepted with minor changes / Substantially revised) counts toward its community review.

@jabhij

jabhij commented Aug 25, 2026

Copy link
Copy Markdown

QQ: Could we make the identifier semantics of the operational vehicle_id more explicit?

I understand the intent behind using vehicle_assignments as the bridge for both physical vehicles and consists. However, it seems that vehicle_id in operational tables no longer necessarily references vehicles.vehicle_id — for a consist, it may instead reference a vehicle_assignments.vehicle_id that has no corresponding record in vehicles.

That makes vehicle_id somewhat polymorphic from a downstream user's perspective. Would it be worth explicitly documenting the expected join path and/or naming the operational field differently if its semantics are intentionally broader than a physical vehicle ID?

For example, an end user should be able to determine unambiguously whether:

  1. operational.vehicle_id → vehicle_assignments.vehicle_id → vehicles.vehicle_id
  2. or whether operational.vehicle_id can directly reference either a physical vehicle or a consist.

This seems particularly important for those agencies building relational data models on top of TIDES.

@jabhij

jabhij commented Aug 25, 2026

Copy link
Copy Markdown

Suggestion: Can we add a concrete example showing the expected values of vehicle_assignment_id and vehicle_id for both a physical vehicle and a consist?
The current definitions are technically clear, but the two identifiers can be easy to conflate. The core purpose is to improve documentation, not necessarily a schema change.

@chrisyamas

Copy link
Copy Markdown
Contributor

Good questions, and they land on ground this thread has been chewing on for a while. The intended join path is what John laid out in #272 (comment), his example with the bus, two LRVs, and consist CON0099 covers the physical-vehicle vs. consist situation you're describing. Short version, vehicle_assignment_id stays truly unique, and the operational vehicle_id resolves through vehicle_assignments, which for a consist can carry an ID that has no record in vehicles at all.

And yes, that makes the operational field broader than a plain physical vehicle ID. Downstream users shouldn't have to figure that out on their own. I agree the worked example belongs in the documentation. @jlstpaul what do you think about lifting your example into the table docs as part of this PR, or into the implementation documentation alongside the trip_stop_sequence example we moved there?

@jabhij

jabhij commented Aug 26, 2026

Copy link
Copy Markdown

@chrisyamas Thanks for the clarification. That makes the intended relationship much clearer, especially the distinction between vehicle_assignment_id as the unique assignment identifier and vehicle_id in operational tables as the identifier that resolves through vehicle_assignments.

Additionally, I can add a worked example to make the intended relationship very clear. Sounds good?

@jabhij

jabhij commented Aug 26, 2026

Copy link
Copy Markdown

Workd Example:
(for documentation)

Physical vehicle
vehicle_assignment_id = BUS001
vehicle_id = BUS001
type = vehicle

vehicle_assignments.vehicle_id resolves directly to vehicles.vehicle_id

Consist
vehicle_assignment_id = VA_CON0099_20260825_01
vehicle_id = CON0099
type = consist

→ CON0099 does not exist in vehicles; instead, it resolves through consist_vehicles to the physical vehicles LRV001 and LRV002.

The operational join can be shown as:
operational.vehicle_id vehicle_assignments.vehicle_id

and, depending on the assignment type:
vehiclevehicles.vehicle_id
or
consistconsist_vehicles.consist_id vehicles.vehicle_id

I believe showing both cases would help prevent downstream users from assuming that every operational vehicle_id is a physical vehicle ID.

@jabhij

jabhij commented Aug 26, 2026

Copy link
Copy Markdown

QQ: How should downstream users resolve the applicable vehicle_assignment when a vehicle_id has multiple assignment records?

I understand from the discussion that operational records should resolve through vehicle_assignments. One remaining question for me is how the correct assignment is selected when the same vehicle_id appears in multiple assignments over time.

For example:
BUS001 → assignment A → 2026-08-25
BUS001 → assignment B → 2026-08-26

An operational record containing vehicle_id = BUS001 would match both assignments unless the temporal context is also considered.

Should downstream users use service_date and/or assignment_start_time / assignment_end_time to resolve the applicable assignment? If so, I think documenting that temporal join would be valuable alongside the physical-vehicle/consist example.

(thinking from a data modeling perspective)

@bijustrada360

bijustrada360 commented Aug 26, 2026

Copy link
Copy Markdown

Re: polymorphic vehicle_id in operational tables

On following on the discussion between @lauriemerrell and @jabhij about vehicle_id semantics, rather than resolving this by choosing between the two vehicle_assignment_id uniqueness options, would it be worth considering a terminology shift that removes the ambiguity at the source?

Proposal: redefine "vehicle" as the operating unit, not the physical unit.

  • vehicle = the thing that actually operates in service: a bus (trivially 1 physical unit) or a train (1+ physical units coupled together). This becomes the only thing vehicle_id in operational tables ever references, no branching logic required.
  • physical unit (currently vehicles) = rename to something like vehicle_units, a single bus body or single rail car. Static roster, unchanged in content from what's already in this PR.
  • consist_vehicles = rename to vehicle_composition (or similar) links a vehicle to its 1+ constituent physical units, with order/orientation. For a bus, this is always exactly one row.
  • vehicle_assignments = daily record of a vehicle (generic, per the new definition). No type discriminator needed, since "vehicle" always means the same granularity regardless of mode.

Why this helps: the join operational.vehicle_id → vehicle_assignments.vehicle_id → vehicle becomes single-hop and non-branching for every mode. Today's design requires downstream consumers to check a type field and follow different paths for vehicle vs. consist, that's exactly what @jabhij flagged as making vehicle_id "polymorphic from a downstream user's perspective." Under this redefinition, there's no polymorphism to document , a bus is just a degenerate case (composition of 1) of the same concept as a train.

Trade-off, to be upfront about it: this does mean another rename on an already-breaking PR, and every bus-only agency ends up with a trivially one-row vehicle_composition table it doesn't strictly need. But it seems like a one-time, mechanical cost versus the alternative, which is permanent type-branching logic that every downstream integrator has to re-implement and document correctly.

@botanize

Copy link
Copy Markdown
Contributor

@bijustrada360 if vehicle_id refers to the operating (logical) unit, what do I do with consists that report at the physical unit level?

For example, in a vehicle_locations table I've got individual vehicles in consists reporting their own locations (3 vehicles in a consist, three distinct locations), and I'd also like to aggregate those individual vehicle locations to a consist location (e.g., location of front vehicle). Should I treat those train-car vehicle_locations as tide formatted but non-compliant because they're using a vehicle_id that refers to a physical unit, not a logical one?

@botanize

Copy link
Copy Markdown
Contributor

Picking up on @jabhij's #272 (comment),

Maybe I'm misunderstanding, but from John's comment on this issue, either:

  1. vehicle_assignment_id is unique and you join [ops].vehicle_id = vehicle_assignments.vehicle_id, then you might have multiple rows that need to be filtered, but because neither service_date nor assignment start/end times are required you have no reliable way of doing that join in a single step.
  2. vehicle_assignment_id is not unique and you join [ops].vehicle_id = vehicle_assignments.vehicle_assignment_id. Now vehicle_id changes meaning, it's no longer an internal identifier of a vehicle (physical or logical), it's now a vehicle assignment which might be time limited. For example, say you retire a vehicle and re-use its ID, it needs a new vehicle_group_id, so it requires another row in vehicle assignments. You can add another row with the same vehicle_assignment_id and a new vehicle_group_id, but again, you'll need to include service_date or assignment start/end time in your join to get a unique row back.

In either case it seems like you're likely to need to use additional join clauses to get information about the actual vehicle.
However, if those date and datetime fields are optional then the joins become complex. An assignment with null service_date or assignment times won't be picked up by a join that includes any of those fields, e.g.,

-- NOTE: this won't work if both service_date and either of assignment_start_time and assignment_end_time are null
select
*
from vehicle_locations
inner join vehicle_assignments
on
  locations.vehicle_id = vehicle_assignments.vehicle_id and
  (
    (
      locations.event_timestamp between 
        vehicle_assignments.assignment_start_time and 
        assignment_end_time
    ) or
    (
     locations.service_date = vehicle_assignments.service_date
    )
 ) 

And if both the service_date and the assignment start/end times are null, you're still out of luck.

I think that means you are pretty much forced to supply either service_date or assignment start/end times for all vehicles, making all three of those fields conditionally required. This is obviously undesirable for service date because it requires a new set of rows for each bus on each date! But I don't particularly like conditionally required fields in these situations.

An alternative is to remove service_date, and require assignment_start_time and assignment_end_time (or valid_from and valid_to). For physical vehicles with vehicle_ids that are never re-used these values could default to something like, 1900-01-01T00:00:00Z and 2999-12-31T23:59:59Z respectively. Or assignment_start_time would be the date the vehicle was commissioned. For consists you'd use the times the consist was formed (and use the distant future placeholder until you know when the consist is dissolved). I think that because consist_vehicles links to vehicle_assignments on vehicle_assignment_id, that you'd have to create a new row in vehicle_assignments with the vehicle_assignment_id linking to the consist with the correct direction and the start and end times for that assignment every time the consist reverses directions?

Example:

vehicle_assignment_id vehicle_id type assignment_start_time assignment_end_time
BUS001 BUS001 vehicle 2020-03-01T00:00:00Z 2999-12-31T23:59:59Z
CON001-F CON001 consist 2026-08-26T00:00:00Z 2026-08-26T00:59:00Z
CON001-R CON001 consist 2026-08-26T01:00:00Z 2026-08-26T01:59:00Z
CON001-F CON001 consist 2026-08-26T02:00:00Z 2999-12-31T23:59:59Z
LRV001 LRV001 vehicle 2023-01-18T00:00:00Z 2999-12-31T23:59:59Z
BUS002 BUS002 vehicle 2017-03-01T00:00:00Z 2024-10-14T00:00:00Z
BUS002-1 BUS002 vehicle 2025-03-02T00:00:00Z 2999-12-31T23:59:59Z

Now joins are [ops].vehicle_id = vehicle_assignments.vehicle_id and [ops].event_timestamp between vehicle_assignments.assignment_start_time and vehicle_assignments.assignment_end_time, and you always resolve a single row, which means I can fairly easily track a single consist as it changes direction (because the vehicle_id doesn't change), but I can also find out which order the vehicles were in at any point in time (if I've created the appropriate entries in vehicle_assignments).

@bijustrada360

bijustrada360 commented Aug 26, 2026

Copy link
Copy Markdown

Good catch @botanize I missed that; this is a real gap in the "vehicle = logical unit" framing as I described it, not just a semantics question. Given per-car GPS is the common case for most rail systems, vehicle_locations needs a small schema change to support it cleanly, not just a documentation note.

Proposal:

Add vehicle_unit_id as a required field alongside vehicle_id in vehicle_locations, and make the composite (vehicle_id, vehicle_unit_id, timestamp) the effective key (replacing whatever single-vehicle_id uniqueness exists today).

  • vehicle_id - the logical unit (train or bus), always resolves through vehicle_assignmentsvehicle with no branching. Unchanged from the redefinition proposal.
  • vehicle_unit_id - the physical unit that produced this specific location reading, references vehicle_units (formerly vehicles).
  • For a bus, vehicle_unit_id is just its own physical unit id, same value pattern as vehicle_id, since a bus is a degenerate one-unit consist. This keeps the rule uniform across modes rather than special-casing "buses don't need this field."
  • For a 3-car train with per-car GPS, you'd get 3 rows per timestamp, same vehicle_id, distinct vehicle_unit_id per car, exactly your example.

We'd deliberately make vehicle_unit_id required rather than nullable, even though train-wide (non-per-car) GPS setups exist. A nullable field inside a uniqueness key doesn't reliably prevent duplicate rows in most SQL engines (NULL ≠ NULL in most constraint implementations), so keeping it required avoids that edge case. For a genuinely train-wide GPS unit, the agency would just repeat the same vehicle_unit_id value across all cars in the consist (or use a designated "lead unit" convention) - this is a data-population choice, not a schema branch.

One thing I would recommend leaving out of scope: aggregating per-car locations into a single "consist location" (e.g., front-of-train). That's a derived value, and computing it correctly requires orientation/direction-of-travel data that already lives in consist_vehicles/vehicle_composition, better left to downstream consumers than standardized in vehicle_locations itself, since "front" conventions could reasonably vary by agency (direction of travel vs. physical car order, etc.).

Net effect: this is a contained change - it only touches vehicle_locations's key structure, doesn't ripple into vehicle_assignments or consist_vehicles.

@bijustrada360

bijustrada360 commented Aug 26, 2026

Copy link
Copy Markdown

I want to start by saying I hate to stir the pot this late in the review window, but on a second look getting this table (vehicle_assignment) right matters more than getting it merged on schedule, this is exactly the kind of structural decision that's expensive to walk back once agencies build against it. So its a detailed response please bear with me.

Proposal: restructure vehicle_assignments as an event log

Building on the discussion between @lauriemerrell, @jabhij, and @botanize about join semantics; rather than resolving the optional-temporal-fields problem by making assignment_start_time/assignment_end_time required with sentinel defaults, consider restructuring the table as a pullout/pullin event log instead of an interval table.

Definitions, to make sure we're aligned before getting into the schema

  • Assignment = the work a vehicle performs in a single continuous span from pullout to pullin, corresponding to one block. A vehicle can perform multiple blocks and therefore have multiple assignments within a single service day (e.g. split shifts, AM/PM-only vehicles, mid-day relief), but has at most one active assignment at any given moment: assignments for the same vehicle do not overlap in time.
  • timestamp is the actual clock time of a real pullout or pullin event, not a configuration validity boundary, and not synonymous with service_date. This is a deliberate departure from the current schema's assignment_start_time/assignment_end_time, which describe when an assignment record is considered valid rather than an actual operational event. I'm intentionally not reusing those field names here to avoid carrying that ambiguity forward.
  • assigned = a pullout event: the vehicle begins working the referenced block_id.
  • unassigned = a pullin event, or a mid-block vehicle swap-out: the vehicle stops working the referenced block_id, whether because the block finished or because the vehicle was replaced.
  • service_date = the service day this assignment belongs to, fixed at the moment of pullout (the assigned event) using the agency's operating-day convention (24-hour+ clock, matching GTFS's extended-time convention for trips past midnight). Every row associated with the same block, including its eventual unassigned/pullin event, even if that occurs after physical midnight carries this same service_date; it's inherited from the block's pullout and held constant for the block's duration, not independently derived per-row. This matters because, per trips_performed.schema.json's own note, block_id is not unique across days the same block_id value on a different service day is a distinct block so service_date is needed to disambiguate which block a given assignment row actually refers to.

Under this model there is no separate "start time" and "end time" field on a single row, each row is one event, and a full assignment period is reconstructed as the span between one assigned row and the next unassigned row for the same vehicle_id and block_id.

Proposed schema

Field Type Required
vehicle_id string → vehicles.vehicle_id yes
block_id string → block reference (TIDES already has block_id on trips_performed, following GTFS's block definition) yes
service_date date yes
timestamp datetime yes
assignment_status enum: assigned / unassigned yes

Primary key: (vehicle_id, timestamp, block_id).

block_id is included in the key, not just carried as data, because a vehicle finishing one block and starting another can plausibly log both events at the same timestamp (e.g. an instantaneous relief/swap) — (vehicle_id, timestamp) alone would collide in that case, while adding block_id correctly treats them as two distinct rows.

service_date is not part of the key it's carried on every row for direct, unambiguous joinability to trips_performed on (service_date, block_id), without downstream consumers having to re-derive it themselves.

Why this resolves the current issues

  • No optional fields, no sentinel values. Every field is mandatory and every row is a real, meaningful timestamp, no placeholder dates (1900-01-01/2999-12-31) needed for open-ended cases, and no ambiguity about which rows a join will or won't match.
  • Deterministic resolution. "What block is vehicle X on at time T" becomes an as-of lookup, most recent row for that vehicle_id at or before T (e.g. ROW_NUMBER() OVER (PARTITION BY vehicle_id ORDER BY timestamp DESC)) rather than a BETWEEN against an interval with nullable bounds.
  • Handles vehicle swaps naturally. A vehicle that breaks down mid-block and is replaced produces an unassigned row for the failed vehicle and an assigned row for its replacement, same block_id and service_date, near-simultaneous or identical timestamps, no special-casing needed.
  • Unambiguous block resolution. Since block_id alone can repeat across service days, carrying service_date on every row (fixed at pullout, per above) removes any guesswork about which occurrence of a block an assignment row refers to.
  • Drops fields that were compensating for the interval design's gaps: vehicle_assignment_id (a surrogate key whose own current description concedes it's typically redundant with vehicle_id), type (vehicle-vs-consist distinction, orthogonal to this table's purpose), vehicle_group_id (resolvable via vehicle_id → vehicles → vehicle_groups, doesn't need to be duplicated here), and operational_status.

Validation rule worth stating explicitly

For a given vehicle_id, rows should strictly alternate assigned → unassigned → assigned → unassigned... in timestamp order. Two consecutive assigned rows (or two consecutive unassigned rows) for the same vehicle with no opposite-status row between them indicates malformed data; this is a cheap, concrete integrity check implementers can run against the table.

Known limitation: near-duplicate events

The key and alternation rule above catch structural problems, but they can't catch a subtler case: two rows with the same vehicle_id, block_id, and assignment_status, at different timestamps, are structurally valid either way, as a legitimate re-assignment to the same block later in the day, or as an accidental duplicate emitted by an upstream AVL/CAD system (dropped-ack retry, batch reprocessing, etc.). No schema constraint can distinguish these; it's a semantic question the data alone can't answer.

This ambiguity is compounded by the fact that not all agencies operate the same way. Some agencies do run the same vehicle on the same block twice in a service day (e.g. interlined or split work), so a second assigned row for an identical (vehicle_id, block_id) pair later in the day is entirely legitimate for them. But an agency whose operating model never re-assigns the same vehicle to the same block within a service day would see that exact same pattern, same vehicle, same block, later timestamp and have good reason to treat it as a duplicate or ingestion error rather than a real event. Whether a repeated (vehicle_id, block_id) pair on the same service_date is legitimate or a data quality problem depends on the agency's own operating model, not on anything visible in the row data itself. I dont believe the schema should try to encode this, a hard constraint against repeats would silently break agencies who do legitimately reuse vehicle/block pairs. I'd recommend the spec leave this as agency-specific validation guidance: agencies should document, and their own downstream tooling should encode, whether repeated vehicle/block pairs within a service day are expected or should be flagged.

Enforcing the alternation rule

The alternation rule above can't be expressed as a JSON Table Schema constraint - frictionless/Table Schema only supports single-row constraints (required, unique, enum, foreign keys), with no mechanism for cross-row sequencing checks. I'd recommend documenting this as a normative rule plus a recommended validation query, so implementers have something concrete to run rather than just a sentence to interpret:

WITH ordered AS (
  SELECT
    vehicle_id,
    timestamp,
    block_id,
    assignment_status,
    LAG(assignment_status) OVER (
      PARTITION BY vehicle_id ORDER BY timestamp
    ) AS prev_status
  FROM vehicle_assignments
)
SELECT *
FROM ordered
WHERE assignment_status = prev_status

Any row returned here is a violation, two consecutive same-status events for the same vehicle with nothing alternating between them.

Rail note: vehicle swaps vs. composition changes

This design already supports a block being handed off between different vehicles mid-service, e.g. a stalled train pulled from service and a replacement train dispatched to finish its block using the same swap pattern described above (an unassigned row for the outgoing vehicle, an assigned row for the incoming one, same block_id and service_date). This works cleanly because vehicle_id here refers to the logical operating unit (a full train/consist), not a physical car, per the earlier redefinition in this thread.

This is distinct from a consist recomposition cars added to or removed from a train that keeps the same vehicle_id throughout. That's not an assignment-level event at all; it's tracked entirely in vehicle_composition/consist_vehicles, independent of vehicle_assignments.

Summary: this design handles both transit modes

Buses and trains are handled by the same four fields and the same rules, with no mode-specific branching anywhere in the schema:

  • Bus: vehicle_id = the bus itself. One assigned/unassigned pair per piece of work; a mechanical failure mid-block is handled by the vehicle-swap pattern (outgoing bus unassigned, replacement bus assigned, same block_id).
  • Rail: vehicle_id = the train/consist (per the logical-unit redefinition upthread). The exact same swap pattern covers a stalled train being pulled and a replacement train picking up its block. Changes to a train's physical composition (cars added/removed) never touch this table at all, that's entirely vehicle_composition's job, and vehicle_assignments doesn't need to know or care how many cars are behind a given vehicle_id.

The only thing that differs between modes is what vehicle_id resolves to underneath, a single physical unit for a bus, a composed set of units for a train, and that distinction is handled entirely upstream, in vehicles/vehicle_composition. vehicle_assignments itself stays mode-agnostic throughout, which is what lets a single schema, a single key, and a single validation rule serve both without exceptions.

Scope note on operational_status

This table would only contain rows for vehicles actively available for assignment (i.e., roadworthy). Vehicle condition/readiness (e.g. under maintenance, out of service) is a separate concern from work assignment and arguably belongs in its own table rather than as a field here.

Hope I've covered every possible critical scenario, but if not do let me know.

@jabhij

jabhij commented Aug 26, 2026

Copy link
Copy Markdown

I think this discussion has surfaced an important temporal integrity issue with vehicle_assignments.

I agree that the physical-unit vs. logical/consist distinction needs to be handled, but before changing the terminology/model more broadly, I think we should make sure the assignment model provides a deterministic way to resolve an operational record to the correct assignment.

With Option 1, operational.vehicle_idvehicle_assignments.vehicle_id, the same vehicle_id can legitimately have multiple assignment records over time. However, service_date, assignment_start_time, and assignment_end_time are currently optional.

If an operational record has vehicle_id = BUS001, but the assignment table contains multiple BUS001 records and none of the temporal fields are populated, there does not appear to be a deterministic way to identify the applicable assignment.

Could we define a normative rule for this?
For example, should an assignment used to resolve operational data always have either a service_date or a valid start/end interval, with the event timestamp/service date determining the applicable record?

I think this is important to resolve before v2.0 because otherwise two downstream implementations could legitimately resolve the same operational record to different assignments.

@jabhij

jabhij commented Aug 27, 2026

Copy link
Copy Markdown

QQ: Could we reconcile the vehicle_id foreign-reference definition with the agreed Option 1 semantics?

The discussion describes Option 1 as having vehicle_assignments.vehicle_id = CON0099 for a consist,
where CON0099 does not exist in vehicles. However, the current schema still defines vehicle_id as a reference to vehicles.vehicle_id, while also describing it as omitted for type=consist.

Could the schema and foreign-reference definition be updated to reflect the agreed Option 1 behavior?
Otherwise, a conforming consist record could appear to violate the schema's foreign-key definition.

@bijustrada360

Copy link
Copy Markdown

@jabhij both of these are already resolved by the event-log restructuring proposed above ( ##272 (comment)), rather than needing a new normative rule layered onto the current interval design:

On your first question (deterministic resolution): the event-log model doesn't need a rule mandating service_date or a valid interval be populated, because there's no interval at all, every row is a single, mandatory, real timestamp (assigned/unassigned). Resolving "what's BUS001's assignment at time T" becomes an as-of lookup (most recent row at or before T), which is deterministic by construction rather than by a normative rule that has to be enforced separately. So rather than defining a rule for the current schema, I'd suggest we settle whether the event-log restructuring itself is the right direction, it removes the class of problem you're flagging rather than patching it.

On your second question (reconciling vehicle_id's foreign-key definition with the consist case): this is also resolved upstream by the "vehicle = logical operating unit" redefinition from earlier in this thread ( ##272 (comment)). Under that model, a consist is a vehicle_id with a normal row in vehicles, there's no more type=consist case where vehicle_id doesn't resolve to vehicles.vehicle_id, so the foreign-key definition doesn't need special-casing at all.

Given the review window closes today, wanted to flag directly so we're not tracking two parallel fixes to the same underlying issue.

@gabriel-korbato

Copy link
Copy Markdown
Contributor

Building on the discussion between @lauriemerrell, @jabhij, and @botanize about join semantics; rather than resolving the optional-temporal-fields problem by making assignment_start_time/assignment_end_time required with sentinel defaults, consider restructuring the table as a pullout/pullin event log instead of an interval table.

Field Type Required
block_id string → block reference (TIDES already has block_id on trips_performed, following GTFS's block definition) yes

Primary key: (vehicle_id, timestamp, block_id).

block_id is included in the key, not just carried as data, because a vehicle finishing one block and starting another can plausibly log both events at the same timestamp (e.g. an instantaneous relief/swap) — (vehicle_id, timestamp) alone would collide in that case, while adding block_id correctly treats them as two distinct rows.

@bijustrada360 By requiring block_id, this proposal does not handle unscheduled trips that lack a block_id, such as for run-as-directed operations, or the recording of operations that are unscheduled or when the schedule is unavailable. The data structure needs to be able to record vehicle movements (real-life observations) even in the absence of a schedule (the plan).

@bijustrada360

Copy link
Copy Markdown

@gabriel-korbato I think this is a good example of where precise transit-domain definitions matter a lot for getting this schema right, and it's worth summarizing the rules explicitly here since the audience following this thread may be missing that context.

Worth noting upfront: these are conventions informally followed by most leading service planning/scheduling systems and CAD/AVL vendors. I say informally because there's no formal standard defining them, they're industry practice rather than a documented spec.

  1. Every trip belongs to a block.
  2. A block can contain zero or more trips, blocks with zero trips are spares/extras, used as operational buffer.
  3. Blocks belong to a scheduling unit/service type (Weekday, Saturday, Sunday, special day, etc.), which is what gets placed on the service calendar. Not every scheduling unit is on the calendar, those that aren't represent unscheduled service, but CAD/AVL systems still ingest them in advance so they're available to activate on the day of operation.
  4. On service day, an unscheduled block sits unassigned until dispatch actively assigns it to a vehicle. At the moment of that assignment, every trip in the block becomes real, operational service for that day, the same as any originally-scheduled block. (Some agencies instead reassign individual trips from an unassigned block into an existing assigned block, extending it or replacing cancelled trips, that's scenario-specific, but still block-mediated.)
  5. CAD/AVL/dispatch systems can also create genuinely ad-hoc blocks on the day of operation itself, for situations with no pre-existing plan at all (e.g. a bus bridge stood up in response to a rail disruption).
  6. block_id always refers to a real block under one of the above there's no case where legitimate vehicle movement lacks a block_id. This is primarily because on-board systems rely on block_id to communicate effectively with CAD/AVL, without it, the on-board system has no reference to associate its reports (location, status, trip progress) with the correct unit of work, so every vehicle movement needs to be tied to a block for that link to function.

Given that, whether a block appears in trips_performed follows from what happens to it, not from where it originated:

  • A block with trips that gets assigned to a vehicle → those trips are operated → they appear in trips_performedblock_id joins cleanly.
  • A block with zero trips (spare, extra, or an unactivated ad-hoc buffer) → nothing was operated → no rows in trips_performed, regardless of whether it was ever assigned.

So vehicle_assignments needs no special handling for unscheduled or ad-hoc blocks, block_id stays required as originally proposed, and trips_performed resolution is a natural consequence of trip content plus assignment, not something the schema needs to branch on. The original event-log proposal holds as-is.

@botanize

Copy link
Copy Markdown
Contributor

6. there's no case where legitimate vehicle movement lacks a block_id. This is primarily because on-board systems rely on block_id to communicate effectively with CAD/AVL, without it, the on-board system has no reference to associate its reports (location, status, trip progress) with the correct unit of work, so every vehicle movement needs to be tied to a block for that link to function.

Unfortunately, legitimate vehicle movement without a block_id is fairly common in our system. It's not unusual in certain circumstances for a vehicle to fail to pickup a duty/block assignment and yet, the operator continues to do the intended work.

@gabriel-korbato

gabriel-korbato commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

@bijustrada360 I agree that leading scheduling and CAD/AVL systems follow the convention of having blocks, but TIDES is meant to capture a variety of scenarios, including of smaller operations that don't have a leading CAD/AVL system. For example, we have worked with systems that are operated more loosely and don't have a proper CAD/AVL system, just GPS tracking attached to the vehicle. In some of these agencies there is a rough service plan without blocks, and certainly nothing about a schedule comes in through the AVL data... this must be figured out when the data are processed. It's worth preserving the ability to describe a physical observable fact (e.g. vehicle v operated a trip on route r, direction d, starting at time t, and the observed times at each stop) even when there is no associated schedule.

I've also seen what @botanize reports about buses not having a block because the driver doesn't properly log on at more than one agency.

@bijustrada360

Copy link
Copy Markdown
  1. there's no case where legitimate vehicle movement lacks a block_id. This is primarily because on-board systems rely on block_id to communicate effectively with CAD/AVL, without it, the on-board system has no reference to associate its reports (location, status, trip progress) with the correct unit of work, so every vehicle movement needs to be tied to a block for that link to function.

Unfortunately, legitimate vehicle movement without a block_id is fairly common in our system. It's not unusual in certain circumstances for a vehicle to fail to pickup a duty/block assignment and yet, the operator continues to do the intended work.

@botanize, thanks for the real-world example, before we build this into the standard, I want to understand the nature of the gap a bit better, since it changes how we should handle it.

Is a failed block pickup, where the operator proceeds with the intended work anyway, a limitation specific to the systems you're currently working with, and are there any plans on your end to close that gap? Asking because TIDES is meant to be a standard that works above system limitations rather than baking in permanent workarounds for gaps that could reasonably be resolved at the system level.

That said, regardless of the answer, I think there's a solution that lets block_id stay required with no schema change: reserving a prefixed range of block_ids (e.g. 999-001 through 999-100) for trips that couldn't be matched to a real block. Each unresolved case gets its own distinct identifier, so there's no collision even with multiple vehicles simultaneously unresolved, and the only constraint is the same one that already applies to any block, trips assigned to it shouldn't overlap in time. This stays entirely within "every block_id refers to a real block," so it wouldn't require weakening the field's requirement in vehicle_assignments.

I've recommended this exact pattern to an agency before; they were seeing real downstream problems from unresolved block assignments (issues with their CAD/AVL, trip prediction algorithm, and radio communications), and the reserved-range approach resolved it without requiring any change to their core assignment model.

So even if this does turn out to be something inherent that every implementation has to handle, I think we can document this reserved-range convention as the recommended pattern rather than changing the schema itself.

@gabriel-korbato

Copy link
Copy Markdown
Contributor

Asking because TIDES is meant to be a standard that works above system limitations rather than baking in permanent workarounds for gaps that could reasonably be resolved at the system level.

TIDES is meant to be flexible enough to work with many different types of systems, and we do not want to require that an agency have a schedule to be able to use TIDES to represent their operations.

Another point is that TIDES data is meant to be used at various steps in the data lifecycle, so we would want to be able to represent blockless movements if blocks are missing in the raw AVL data. Later that data might get processed and matched to a schedule, at which point most trips could be assigned trips and blocks (if there is a schedule). The TIDES format is not meant to be used only for the final/processed/clean data; it must also accommodate common imperfections in raw data to allow adopters to easily convert vendor-formats into TIDES without having to include complex cleansing processes.

@bijustrada360

Copy link
Copy Markdown

TIDES is meant to be flexible enough to work with many different types of systems, and we do not want to require that an agency have a schedule to be able to use TIDES to represent their operations.

Another point is that TIDES data is meant to be used at various steps in the data lifecycle, so we would want to be able to represent blockless movements if blocks are missing in the raw AVL data. Later that data might get processed and matched to a schedule, at which point most trips could be assigned trips and blocks (if there is a schedule). The TIDES format is not meant to be used only for the final/processed/clean data; it must also accommodate common imperfections in raw data to allow adopters to easily convert vendor-formats into TIDES without having to include complex cleansing processes.

That wasn't my understanding of TIDES's intent, apologies, and thanks for the correction.

That's an important distinction: vehicle_id, timestamp, and assignment_status describe a raw, observed fact (a vehicle left or returned to the yard at a specific moment), while block_id is enrichment that depends on a schedule existing and, likely, a separate matching process. Requiring it on every row assumes every adopter has both at capture time, which isn't reasonable for a format meant to represent raw and unprocessed data, not just fully processed data.

Revised proposal:

  • block_id becomes optional. Populated when known, either because a schedule exists and is available at capture time, or added later as a downstream step once raw AVL data is matched to a schedule. The event log itself (vehicle going into/out of active status) doesn't depend on it.
  • Primary key becomes (vehicle_id, timestamp, assignment_status), dropping block_id. A nullable field shouldn't sit inside a key, that reopens the same ambiguity already ruled out earlier for assignment_start_time/assignment_end_time. This still resolves the original reason block_id was in the key (a vehicle finishing one block and starting another at the identical timestamp): one of those two rows is always assigned and the other unassigned, and those can't collide under the alternation rule already established, so assignment_status disambiguates it without needing block_id at all.

Limitation worth flagging: two genuinely identical rows, same vehicle_id, timestamp, and assignment_status, would still collide under this key, but that's already covered by the "known limitation: near-duplicate events" section discussed earlier in this thread (a data-quality/deduplication question, not something the schema can resolve structurally).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🚀 feature Adds a new feature - to spec or code 🔄 normative change A normative change requires approval of TIDES Board restructuring For issues such as adding or removing tables, making significant chages to a table, etc. 📄 spec Pertains to the specification itself

Projects

None yet

Development

Successfully merging this pull request may close these issues.

📄🚀 – Modify handling of vehicle attributes and vehicles consists

7 participants