Skip to content

Commit 48bdfe0

Browse files
AyelanderiTzxMigz
authored andcommitted
Implement connection from checkpoint in Path to timepoint in gtfs
1 parent be53b4d commit 48bdfe0

4 files changed

Lines changed: 232 additions & 5 deletions

File tree

packages/transition-backend/src/services/gtfsExport/ScheduleExporter.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const objectToGtfs = (
3939

4040
const periods = schedule.periods;
4141

42-
const pathsById: { [key: string]: { path: Path; distances: number[] } } = {};
42+
const pathsById: { [key: string]: { path: Path; distances: number[]; checkpointNodeIds: Set<string> } } = {};
4343

4444
for (let periodIdx = 0, countI = periods.length; periodIdx < countI; periodIdx++) {
4545
const trips = periods[periodIdx].trips || [];
@@ -52,14 +52,26 @@ const objectToGtfs = (
5252
continue;
5353
}
5454
const newPath = pathCollection.newObject(pathGeojson);
55-
pathsById[trip.path_id] = { path: newPath, distances: newPath.getCoordinatesDistanceTraveledMeters() };
55+
const checkpointNodeIds = new Set<string>();
56+
const checkpoints = newPath.attributes.data.segmentTimesCheckpoints;
57+
if (checkpoints && checkpoints.length > 0) {
58+
for (const cp of checkpoints) {
59+
checkpointNodeIds.add(cp.fromNodeId);
60+
checkpointNodeIds.add(cp.toNodeId);
61+
}
62+
}
63+
pathsById[trip.path_id] = {
64+
path: newPath,
65+
distances: newPath.getCoordinatesDistanceTraveledMeters(),
66+
checkpointNodeIds
67+
};
5668
pathIds[newPath.id] = true;
5769
const nodes = newPath.attributes.nodes;
5870
for (let nodeI = 0, countNodes = nodes.length; nodeI < countNodes; nodeI++) {
5971
nodeIds[nodes[nodeI]] = true;
6072
}
6173
}
62-
const { path, distances: pathDistancesTraveledMeters } = pathsById[trip.path_id];
74+
const { path, distances: pathDistancesTraveledMeters, checkpointNodeIds } = pathsById[trip.path_id];
6375
const pathGeography = path.attributes.geography;
6476
const pathCoordinates = pathGeography.coordinates;
6577
const pathNodeIds = path.attributes.nodes;
@@ -122,7 +134,13 @@ const objectToGtfs = (
122134
continuous_pickup: 1, // optional, TODO: implement other choices (0: continousu pick up, 2: must phone agency, 3: must coordinate with driver)
123135
continuous_drop_off: 1, // optional, TODO: implement other choices (0: continousu drop off, 2: must phone agency, 3: must coordinate with driver)
124136
shape_dist_traveled: Math.round(distanceTraveledKm * 1000) / 1000, // optional
125-
timepoint: 1 as const // optional, TODO: implement approximate: 0 (exact: 1)
137+
timepoint:
138+
checkpointNodeIds.size === 0 ||
139+
k === 0 ||
140+
k === countK - 1 ||
141+
checkpointNodeIds.has(pathNodeIds[k])
142+
? (1 as const)
143+
: (0 as const)
126144
};
127145
gtfsStopTimes.push(stopTime);
128146
}

packages/transition-backend/src/services/gtfsExport/__tests__/ScheduleExporter.test.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,25 @@ const pathAttributes2 = {
5959
nodes: [uuidV4(), uuidV4(), uuidV4(), uuidV4()],
6060
}
6161

62+
// Third path with checkpoints: nodes[0] and nodes[2] are checkpoint boundaries
63+
const pathWithCheckpointsAttributes = {
64+
...pathAttributes,
65+
id: uuidV4(),
66+
nodes: [uuidV4(), uuidV4(), uuidV4(), uuidV4()],
67+
data: {
68+
segmentTimesCheckpoints: [
69+
{ fromNodeId: '', toNodeId: '' } // will be set after node IDs are known
70+
]
71+
},
72+
};
73+
// Set checkpoint to reference nodes[0] → nodes[2]
74+
pathWithCheckpointsAttributes.data.segmentTimesCheckpoints = [
75+
{ fromNodeId: pathWithCheckpointsAttributes.nodes[0], toNodeId: pathWithCheckpointsAttributes.nodes[2] }
76+
];
77+
6278
const path = new Path(pathAttributes, false);
6379
const path2 = new Path(pathAttributes2, false);
80+
const pathWithCheckpoints = new Path(pathWithCheckpointsAttributes, false);
6481
// Convert distances in km, same coordinates for both patsh
6582
const pathDistances = path.getCoordinatesDistanceTraveledMeters().map(dist => Math.round(dist) / 1000);
6683
const lineId = uuidV4();
@@ -239,7 +256,7 @@ const mockReadForLines = schedulesDbQueries.readForLines as jest.MockedFunction<
239256
jest.mock('../../../models/db/transitPaths.db.queries', () => {
240257
return {
241258
geojsonCollection: jest.fn().mockImplementation(async () => {
242-
return { type: 'FeatureCollection', features: [path.toGeojson(), path2.toGeojson()] };
259+
return { type: 'FeatureCollection', features: [path.toGeojson(), path2.toGeojson(), pathWithCheckpoints.toGeojson()] };
243260
})
244261
}
245262
});
@@ -619,3 +636,81 @@ test('Test GTFS compliance - handles >24h times for midnight-crossing schedules'
619636
expect(firstHour).toBeGreaterThanOrEqual(24);
620637
expect(secondHour).toBeGreaterThanOrEqual(24);
621638
});
639+
640+
test('Test checkpoint nodes are exported as timepoints, others as approximate', async () => {
641+
// Path has 4 nodes with checkpoint on nodes[0]→nodes[2]
642+
// So nodes[0] and nodes[2] are checkpoint boundaries → timepoint=1
643+
// nodes[1] is intermediate → timepoint=0
644+
// nodes[3] is last stop → timepoint=1 (GTFS convention)
645+
const scheduleWithCheckpoints: ScheduleAttributes = {
646+
allow_seconds_based_schedules: false,
647+
id: uuidV4(),
648+
integer_id: 10,
649+
line_id: lineId,
650+
service_id: serviceId,
651+
is_frozen: false,
652+
data: {},
653+
periods: [{
654+
schedule_id: 10,
655+
id: uuidV4(),
656+
integer_id: 1,
657+
data: {},
658+
end_at_hour: 12,
659+
interval_seconds: 1800,
660+
outbound_path_id: pathWithCheckpointsAttributes.id,
661+
period_shortname: 'morning',
662+
start_at_hour: 7,
663+
trips: [{
664+
arrival_time_seconds: 27015,
665+
departure_time_seconds: 25200,
666+
id: uuidV4(),
667+
node_arrival_times_seconds: [25200, 25251, 26250, 27015],
668+
node_departure_times_seconds: [25200, 25261, 26260, 27015],
669+
nodes_can_board: [true, true, true, false],
670+
nodes_can_unboard: [false, true, true, true],
671+
path_id: pathWithCheckpointsAttributes.id,
672+
seated_capacity: 20,
673+
total_capacity: 50,
674+
schedule_period_id: 1,
675+
data: {}
676+
}]
677+
}],
678+
periods_group_shortname: 'all_day',
679+
};
680+
681+
mockReadForLines.mockResolvedValueOnce([scheduleWithCheckpoints]);
682+
const response = await exportSchedule([lineId], { directoryPath: 'test', quotesFct: quoteFct, serviceToGtfsId });
683+
expect(response.status).toEqual('success');
684+
685+
const stopTimesOutput = mockWriteStopTimeStream.write.mock.calls[0][0] as string;
686+
const stopTimesLines = stopTimesOutput.split('\n').filter(line => line.trim() && !line.startsWith('"trip_id"'));
687+
688+
expect(stopTimesLines).toHaveLength(4);
689+
690+
// Parse timepoint values (last field in each CSV line)
691+
const timepoints = stopTimesLines.map(line => {
692+
const fields = line.split(',');
693+
return parseInt(fields[fields.length - 1]);
694+
});
695+
696+
// nodes[0] = checkpoint boundary → 1, nodes[1] = intermediate → 0,
697+
// nodes[2] = checkpoint boundary → 1, nodes[3] = last stop → 1
698+
expect(timepoints).toEqual([1, 0, 1, 1]);
699+
});
700+
701+
test('Test path without checkpoints exports all timepoints as 1', async () => {
702+
// pathAttributes has no checkpoints (data: {})
703+
mockReadForLines.mockResolvedValueOnce([scheduleAttributes1]);
704+
const response = await exportSchedule([lineId], { directoryPath: 'test', quotesFct: quoteFct, serviceToGtfsId });
705+
expect(response.status).toEqual('success');
706+
707+
const stopTimesOutput = mockWriteStopTimeStream.write.mock.calls[0][0] as string;
708+
const stopTimesLines = stopTimesOutput.split('\n').filter(line => line.trim() && !line.startsWith('"trip_id"'));
709+
710+
// All timepoints should be 1 when no checkpoints exist
711+
stopTimesLines.forEach(line => {
712+
const fields = line.split(',');
713+
const timepoint = parseInt(fields[fields.length - 1]);
714+
expect(timepoint).toBe(1);
715+
});
716+
});

packages/transition-backend/src/services/gtfsImport/PathImporter.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,31 @@ type PathGroup = {
120120
trips: TripData[];
121121
};
122122

123+
/** Derive checkpoints from GTFS timepoint data on a representative trip's stop_times.
124+
* Only creates checkpoints if some (but not all) stops are timepoints. */
125+
const deriveCheckpointsFromTimepoints = (
126+
newPath: Path,
127+
allStopTimes: StopTime[][],
128+
nodeIds: string[],
129+
importData: GtfsInternalData
130+
): void => {
131+
const representativeStopTimes = allStopTimes[0];
132+
if (!representativeStopTimes || representativeStopTimes.length === 0) return;
133+
134+
const timepointNodeIds = representativeStopTimes
135+
.filter((st) => st.timepoint === 1)
136+
.map((st) => importData.nodeIdsByStopGtfsId[st.stop_id]);
137+
138+
// Only create checkpoints if there's actual segmentation (not all stops are timepoints)
139+
if (timepointNodeIds.length >= 2 && timepointNodeIds.length < nodeIds.length) {
140+
const checkpoints: { fromNodeId: string; toNodeId: string }[] = [];
141+
for (let i = 0; i < timepointNodeIds.length - 1; i++) {
142+
checkpoints.push({ fromNodeId: timepointNodeIds[i], toNodeId: timepointNodeIds[i + 1] });
143+
}
144+
newPath.attributes.data.segmentTimesCheckpoints = checkpoints;
145+
}
146+
};
147+
123148
const generatePathsForLine = (
124149
line: Line,
125150
tripsForLine: TripData[],
@@ -205,6 +230,7 @@ const generatePathsForLine = (
205230
importData,
206231
tripsWithService
207232
);
233+
deriveCheckpointsFromTimepoints(newPath, allStopTimes, nodeIds, importData);
208234
newPaths.push(newPath);
209235
const pathsForShape = pathByShapeId[shapeId] || [];
210236
pathsForShape.push(newPath);
@@ -226,6 +252,7 @@ const generatePathsForLine = (
226252
importData,
227253
tripsWithService
228254
);
255+
deriveCheckpointsFromTimepoints(newPath, allStopTimes, nodeIds, importData);
229256
newPaths.push(newPath);
230257
pathsWithoutShape.push(newPath);
231258
for (const tripData of trips) {

packages/transition-backend/src/services/gtfsImport/__tests__/PathImporter.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,3 +540,90 @@ describe('One line, 3 trips with no shape', () => {
540540
});
541541

542542
});
543+
544+
describe('Timepoint to checkpoint mapping', () => {
545+
beforeEach(() => {
546+
importData.shapeById = {};
547+
importData.shapeById['timepointShape'] = [
548+
{ shape_id: 'timepointShape', shape_pt_lat: 45.538, shape_pt_lon: -73.614, shape_pt_sequence: 0 },
549+
{ shape_id: 'timepointShape', shape_pt_lat: 45.539, shape_pt_lon: -73.613, shape_pt_sequence: 5 },
550+
{ shape_id: 'timepointShape', shape_pt_lat: 45.540, shape_pt_lon: -73.612, shape_pt_sequence: 10 },
551+
{ shape_id: 'timepointShape', shape_pt_lat: 45.541, shape_pt_lon: -73.611, shape_pt_sequence: 20 },
552+
];
553+
importData.stopCoordinatesByStopId = {
554+
stop1: [-73.614, 45.538] as [number, number],
555+
stop2: [-73.613, 45.539] as [number, number],
556+
stop3: [-73.612, 45.540] as [number, number],
557+
stop4: [-73.611, 45.541] as [number, number]
558+
};
559+
});
560+
561+
test('Creates checkpoints from mixed timepoint values', async () => {
562+
const tripId = 'timepointTrip';
563+
const tripsByRouteId = {};
564+
tripsByRouteId[routeId] = [{
565+
trip: { route_id: routeId, service_id: uuidV4(), trip_id: tripId, trip_headsign: 'Test', direction_id: 0, shape_id: 'timepointShape' },
566+
stopTimes: [
567+
{ trip_id: tripId, stop_id: 'stop1', stop_sequence: 1, arrivalTimeSeconds: 36000, departureTimeSeconds: 36000, timepoint: 1 },
568+
{ trip_id: tripId, stop_id: 'stop2', stop_sequence: 2, arrivalTimeSeconds: 36090, departureTimeSeconds: 36100, timepoint: 0 },
569+
{ trip_id: tripId, stop_id: 'stop3', stop_sequence: 3, arrivalTimeSeconds: 36180, departureTimeSeconds: 36200, timepoint: 1 },
570+
{ trip_id: tripId, stop_id: 'stop4', stop_sequence: 4, arrivalTimeSeconds: 36300, departureTimeSeconds: 36300, timepoint: 1 }
571+
]
572+
}];
573+
574+
const result = await PathImporter.generateAndImportPaths(tripsByRouteId, importData, collectionManager) as any;
575+
expect(result.status).toEqual('success');
576+
577+
const createdPathAttribs = (pathsDbQueries.createMultiple as any).mock.calls[0][0][0];
578+
// Timepoint stops: stop1 (nodeId1), stop3 (nodeId3), stop4 (nodeId4)
579+
// → checkpoints: [{nodeId1, nodeId3}, {nodeId3, nodeId4}]
580+
expect(createdPathAttribs.data.segmentTimesCheckpoints).toEqual([
581+
{ fromNodeId: nodeId1, toNodeId: nodeId3 },
582+
{ fromNodeId: nodeId3, toNodeId: nodeId4 }
583+
]);
584+
});
585+
586+
test('Does not create checkpoints when all stops are timepoints', async () => {
587+
const tripId = 'allTimepointTrip';
588+
const tripsByRouteId = {};
589+
tripsByRouteId[routeId] = [{
590+
trip: { route_id: routeId, service_id: uuidV4(), trip_id: tripId, trip_headsign: 'Test', direction_id: 0, shape_id: 'timepointShape' },
591+
stopTimes: [
592+
{ trip_id: tripId, stop_id: 'stop1', stop_sequence: 1, arrivalTimeSeconds: 36000, departureTimeSeconds: 36000, timepoint: 1 },
593+
{ trip_id: tripId, stop_id: 'stop2', stop_sequence: 2, arrivalTimeSeconds: 36090, departureTimeSeconds: 36100, timepoint: 1 },
594+
{ trip_id: tripId, stop_id: 'stop3', stop_sequence: 3, arrivalTimeSeconds: 36180, departureTimeSeconds: 36200, timepoint: 1 },
595+
{ trip_id: tripId, stop_id: 'stop4', stop_sequence: 4, arrivalTimeSeconds: 36300, departureTimeSeconds: 36300, timepoint: 1 }
596+
]
597+
}];
598+
599+
const result = await PathImporter.generateAndImportPaths(tripsByRouteId, importData, collectionManager) as any;
600+
expect(result.status).toEqual('success');
601+
602+
const createdPathAttribs = (pathsDbQueries.createMultiple as any).mock.calls[0][0][0];
603+
expect(createdPathAttribs.data.segmentTimesCheckpoints).toBeUndefined();
604+
});
605+
606+
test('Creates checkpoints from timepoints without shape', async () => {
607+
const tripId = 'noShapeTimepointTrip';
608+
const tripsByRouteId = {};
609+
tripsByRouteId[routeId] = [{
610+
trip: { route_id: routeId, service_id: uuidV4(), trip_id: tripId, trip_headsign: 'Test', direction_id: 0 },
611+
stopTimes: [
612+
{ trip_id: tripId, stop_id: 'stop1', stop_sequence: 1, arrivalTimeSeconds: 36000, departureTimeSeconds: 36000, timepoint: 1 },
613+
{ trip_id: tripId, stop_id: 'stop2', stop_sequence: 2, arrivalTimeSeconds: 36090, departureTimeSeconds: 36100, timepoint: 0 },
614+
{ trip_id: tripId, stop_id: 'stop3', stop_sequence: 3, arrivalTimeSeconds: 36180, departureTimeSeconds: 36200, timepoint: 0 },
615+
{ trip_id: tripId, stop_id: 'stop4', stop_sequence: 4, arrivalTimeSeconds: 36300, departureTimeSeconds: 36300, timepoint: 1 }
616+
]
617+
}];
618+
619+
const result = await PathImporter.generateAndImportPaths(tripsByRouteId, importData, collectionManager) as any;
620+
expect(result.status).toEqual('success');
621+
622+
const createdPathAttribs = (pathsDbQueries.createMultiple as any).mock.calls[0][0][0];
623+
// Timepoint stops: stop1 (nodeId1), stop4 (nodeId4)
624+
// → checkpoints: [{nodeId1, nodeId4}]
625+
expect(createdPathAttribs.data.segmentTimesCheckpoints).toEqual([
626+
{ fromNodeId: nodeId1, toNodeId: nodeId4 }
627+
]);
628+
});
629+
});

0 commit comments

Comments
 (0)