Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Query options:
| ----------------------- | --------- | ---------------------------------------------------------------------- |
| `maxTransfers` | `5` | Maximum number of transfers |
| `minTransferTime` | `2 min` | Fallback minimum transfer time |
| `maxDuration` | unlimited | Maximum total journey duration from the query departure time |
| `maxInitialWaitingTime` | unlimited | Maximum wait for the first vehicle after arriving at the boarding stop |
| `transportModes` | all | Restrict to a subset of GTFS route types |

Expand Down
6 changes: 4 additions & 2 deletions src/cli/perf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ export const loadQueriesFromJson = (
.from(fromStop.id)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.to(new Set(toStops.map((stop) => stop!.id)))
.departureTime(timeFromString(serializedQuery.departureTime));
.departureTime(timeFromString(serializedQuery.departureTime))
.maxDuration(6 * 60);

if (serializedQuery.maxTransfers !== undefined) {
queryBuilder.maxTransfers(serializedQuery.maxTransfers);
Expand Down Expand Up @@ -180,7 +181,8 @@ export const loadRangeQueriesFromJson = (
.to(new Set(toStops.map((stop) => stop!.id)))
.departureTime(timeFromString(serializedQuery.departureTime))
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.lastDepartureTime(timeFromString(serializedQuery.lastDepartureTime!));
.lastDepartureTime(timeFromString(serializedQuery.lastDepartureTime!))
.maxDuration(6 * 60);

if (serializedQuery.maxTransfers !== undefined) {
queryBuilder.maxTransfers(serializedQuery.maxTransfers);
Expand Down
72 changes: 49 additions & 23 deletions src/cli/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ export const startRepl = (stopsPath: string, timetablePath: string) => {
this.displayPrompt();
},
});
const routeSyntax =
'.route from <stop> to <stop> at <HH:mm> [before <HH:mm>] [with <N> transfers] [wait max <N> minutes]';

replServer.defineCommand('route', {
help: 'Find a route using .route from <stop> to <stop> at <HH:mm> [before <HH:mm>] [with <N> transfers]',
help: `Find a route using ${routeSyntax}`,
action(routeQuery: string) {
this.clearBufferedCommand();
const parts = routeQuery.split(' ').filter(Boolean);
Expand All @@ -60,29 +63,34 @@ export const startRepl = (stopsPath: string, timetablePath: string) => {
const atIndex = parts.indexOf('at');
const beforeIndex = parts.indexOf('before');
const withIndex = parts.indexOf('with');
const waitIndex = parts.indexOf('wait');
const routeClauseIndexes = [beforeIndex, withIndex, waitIndex].filter(
(index) => index !== -1,
);

if (fromIndex === -1 || toIndex === -1 || atIndex === -1) {
console.log(
'Usage: .route from <stop> to <stop> at <HH:mm> [before <HH:mm>] [with <N> transfers]',
);
console.log(`Usage: ${routeSyntax}`);
this.displayPrompt();
return;
}

const fromId = parts.slice(fromIndex + 1, toIndex).join(' ');
const toId = parts.slice(toIndex + 1, atIndex).join(' ');

// atTime ends at 'before', 'with', or the end of the input.
const atTimeEnd =
beforeIndex !== -1
? beforeIndex
: withIndex !== -1
? withIndex
: parts.length;
// atTime ends at 'before', 'with', 'wait', or the end of the input.
const atTimeEnd = Math.min(
...routeClauseIndexes.filter((index) => index > atIndex),
parts.length,
);
const atTime = parts.slice(atIndex + 1, atTimeEnd).join(' ');

// beforeTime is only present when the 'before' keyword appears.
const beforeTimeEnd = withIndex !== -1 ? withIndex : parts.length;
const beforeTimeEnd = Math.min(
...[withIndex, waitIndex].filter(
(index) => index !== -1 && index > beforeIndex,
),
parts.length,
);
const beforeTime =
beforeIndex !== -1
? parts.slice(beforeIndex + 1, beforeTimeEnd).join(' ')
Expand All @@ -92,11 +100,23 @@ export const startRepl = (stopsPath: string, timetablePath: string) => {
withIndex !== -1 && parts[withIndex + 1] !== undefined
? parseInt(parts[withIndex + 1] as string)
: 4;

if (!fromId || !toId || !atTime) {
console.log(
'Usage: .route from <stop> to <stop> at <HH:mm> [before <HH:mm>] [with <N> transfers]',
);
const maxInitialWaitingTime =
waitIndex !== -1 &&
parts[waitIndex + 1] === 'max' &&
parts[waitIndex + 2] !== undefined
? Number(parts[waitIndex + 2])
: undefined;
const waitUnit = parts[waitIndex + 3];
const hasInvalidWaitClause =
waitIndex !== -1 &&
(parts[waitIndex + 1] !== 'max' ||
maxInitialWaitingTime === undefined ||
!Number.isFinite(maxInitialWaitingTime) ||
maxInitialWaitingTime < 0 ||
(waitUnit !== 'minute' && waitUnit !== 'minutes'));

if (!fromId || !toId || !atTime || hasInvalidWaitClause) {
console.log(`Usage: ${routeSyntax}`);
this.displayPrompt();
return;
}
Expand Down Expand Up @@ -131,13 +151,16 @@ export const startRepl = (stopsPath: string, timetablePath: string) => {
const router = new Router(timetable, stopsIndex);
if (beforeTime !== undefined) {
const lastDepartureTime = timeFromString(beforeTime);
const query = new RangeQuery.Builder()
const queryBuilder = new RangeQuery.Builder()
.from(fromStop.id)
.to(toStop.id)
.departureTime(departureTime)
.lastDepartureTime(lastDepartureTime)
.maxTransfers(maxTransfers)
.build();
.maxTransfers(maxTransfers);
if (maxInitialWaitingTime !== undefined) {
queryBuilder.maxInitialWaitingTime(maxInitialWaitingTime);
}
const query = queryBuilder.build();

const result = router.rangeRoute(query);

Expand All @@ -160,12 +183,15 @@ export const startRepl = (stopsPath: string, timetablePath: string) => {
});
}
} else {
const query = new Query.Builder()
const queryBuilder = new Query.Builder()
.from(fromStop.id)
.to(toStop.id)
.departureTime(departureTime)
.maxTransfers(maxTransfers)
.build();
.maxTransfers(maxTransfers);
if (maxInitialWaitingTime !== undefined) {
queryBuilder.maxInitialWaitingTime(maxInitialWaitingTime);
}
const query = queryBuilder.build();

const result = router.route(query);
const arrivalTime = result.arrivalAt(toStop.id);
Expand Down
3 changes: 3 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Plotter } from './routing/plotter.js';
import type { QueryOptions, RangeQueryOptions } from './routing/query.js';
import { Query, RangeQuery } from './routing/query.js';
import { Result } from './routing/result.js';
import type { Leg, Transfer, VehicleLeg } from './routing/route.js';
Expand Down Expand Up @@ -40,6 +41,8 @@ export type {
Leg,
LocationType,
ParetoRun,
QueryOptions,
RangeQueryOptions,
RouteType,
ServiceRouteInfo,
SourceStopId,
Expand Down
22 changes: 22 additions & 0 deletions src/routing/__tests__/plainRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,28 @@ describe('PlainRouter', () => {
// Route 0 arrives at stop3 at 08:35
assert.strictEqual(timeToStop3?.arrival, timeFromHM(8, 35));
});

it('should not return journeys arriving after maxDuration', () => {
const tooShort = new Query.Builder()
.from(0)
.to(2)
.departureTime(timeFromHM(8, 0))
.maxDuration(34)
.build();

assert.strictEqual(router.route(tooShort).bestRoute(), undefined);

const justEnough = new Query.Builder()
.from(0)
.to(2)
.departureTime(timeFromHM(8, 0))
.maxDuration(35)
.build();

const route = router.route(justEnough).bestRoute();
assert(route);
assert.strictEqual(route.arrivalTime(), timeFromHM(8, 35));
});
});

describe('with a route change', () => {
Expand Down
22 changes: 22 additions & 0 deletions src/routing/__tests__/rangeRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,28 @@ describe('RangeRouter', () => {
assert.strictEqual(result.size, 0);
assert.strictEqual(result.bestRoute(), undefined);
});

it('filters runs whose arrivals exceed maxDuration for their departure slot', () => {
const tooShort = new RangeQuery.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(8, 0))
.lastDepartureTime(timeFromHM(8, 30))
.maxDuration(29)
.build();

assert.strictEqual(router.rangeRoute(tooShort).size, 0);

const justEnough = new RangeQuery.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(8, 0))
.lastDepartureTime(timeFromHM(8, 30))
.maxDuration(30)
.build();

assert.strictEqual(router.rangeRoute(justEnough).size, 2);
});
});

describe('same-stop query (origin equals destination)', () => {
Expand Down
142 changes: 142 additions & 0 deletions src/routing/__tests__/raptor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,148 @@ describe('Raptor', () => {
});
});

describe('maxDuration', () => {
it('filters vehicle arrivals after the maxDuration cutoff and allows the exact boundary', () => {
const timetable = new Timetable(
stopsAdjacency,
[route0, route1],
serviceRoutes,
);
const raptor = new Raptor(timetable);
const tooShortOptions: QueryOptions = {
maxTransfers: 5,
minTransferTime: 2,
transportModes: ALL_TRANSPORT_MODES,
maxDuration: 29,
};

const tooShort = new RoutingState(
timeFromHM(8, 0),
[1],
[{ fromStopId: 0, toStopId: 0, duration: 0 }],
NB_STOPS,
tooShortOptions.maxTransfers + 1,
tooShortOptions.maxDuration,
);
raptor.run(tooShortOptions, tooShort);
assert.strictEqual(tooShort.getArrival(1), undefined);

const justEnoughOptions: QueryOptions = {
maxTransfers: 5,
minTransferTime: 2,
transportModes: ALL_TRANSPORT_MODES,
maxDuration: 30,
};
const justEnough = new RoutingState(
timeFromHM(8, 0),
[1],
[{ fromStopId: 0, toStopId: 0, duration: 0 }],
NB_STOPS,
justEnoughOptions.maxTransfers + 1,
justEnoughOptions.maxDuration,
);
raptor.run(justEnoughOptions, justEnough);
assert.strictEqual(justEnough.getArrival(1)?.arrival, timeFromHM(8, 30));
});

it('keeps intermediate stops reachable while filtering later vehicle arrivals', () => {
const timetable = new Timetable(
stopsAdjacency,
[route0, route1],
serviceRoutes,
);
const options: QueryOptions = {
maxTransfers: 5,
minTransferTime: 2,
transportModes: ALL_TRANSPORT_MODES,
maxDuration: 45,
};
const state = new RoutingState(
timeFromHM(8, 0),
[2],
[{ fromStopId: 0, toStopId: 0, duration: 0 }],
NB_STOPS,
options.maxTransfers + 1,
options.maxDuration,
);
const raptor = new Raptor(timetable);
raptor.run(options, state);
assert.strictEqual(state.getArrival(1)?.arrival, timeFromHM(8, 30));
assert.strictEqual(state.getArrival(2), undefined);
});

it('filters timed walking transfers after the maxDuration cutoff', () => {
const timetable = new Timetable(
stopsAdjacencyWithTransfer,
[route0],
[serviceRoutes[0]!],
);
const raptor = new Raptor(timetable);
const tooShortOptions: QueryOptions = {
maxTransfers: 5,
minTransferTime: 2,
transportModes: ALL_TRANSPORT_MODES,
maxDuration: 34,
};

const tooShort = new RoutingState(
timeFromHM(8, 0),
[2],
[{ fromStopId: 0, toStopId: 0, duration: 0 }],
NB_STOPS,
tooShortOptions.maxTransfers + 1,
tooShortOptions.maxDuration,
);
raptor.run(tooShortOptions, tooShort);
assert.strictEqual(tooShort.getArrival(1)?.arrival, timeFromHM(8, 30));
assert.strictEqual(tooShort.getArrival(2), undefined);

const justEnoughOptions: QueryOptions = {
maxTransfers: 5,
minTransferTime: 2,
transportModes: ALL_TRANSPORT_MODES,
maxDuration: 35,
};
const justEnough = new RoutingState(
timeFromHM(8, 0),
[2],
[{ fromStopId: 0, toStopId: 0, duration: 0 }],
NB_STOPS,
justEnoughOptions.maxTransfers + 1,
justEnoughOptions.maxDuration,
);
raptor.run(justEnoughOptions, justEnough);
assert.strictEqual(justEnough.getArrival(2)?.arrival, timeFromHM(8, 35));
});

it('filters in-seat continuation arrivals after the maxDuration cutoff', () => {
const timetable = new Timetable(
stopsAdjacency,
[route0, route1],
serviceRoutes,
tripContinuations,
);
const options: QueryOptions = {
maxTransfers: 5,
minTransferTime: 2,
transportModes: ALL_TRANSPORT_MODES,
maxDuration: 45,
};
const state = new RoutingState(
timeFromHM(8, 0),
[2],
[{ fromStopId: 0, toStopId: 0, duration: 0 }],
NB_STOPS,
options.maxTransfers + 1,
options.maxDuration,
);
const raptor = new Raptor(timetable);
raptor.run(options, state);
assert.strictEqual(state.getArrival(1)?.arrival, timeFromHM(8, 30));
assert.strictEqual(state.getArrival(2), undefined);
});
});

describe('early termination', () => {
it('exits when no trips are catchable', () => {
// Departing at 09:00 — all trips have already left (route 0 at 08:10,
Expand Down
1 change: 1 addition & 0 deletions src/routing/plainRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class PlainRouter {
accessLegs,
this.timetable.nbStops(),
query.options.maxTransfers + 1,
query.options.maxDuration,
);

this.raptor.run(query.options, routingState);
Expand Down
Loading
Loading