almost full coverage for graphhopper georouter

This commit is contained in:
sbriat 2023-08-23 14:32:17 +02:00
parent 158b12b150
commit 66d4d58dd1
7 changed files with 718 additions and 50 deletions

View File

@ -32,7 +32,8 @@ export type Route = {
fwdAzimuth: number; fwdAzimuth: number;
backAzimuth: number; backAzimuth: number;
distanceAzimuth: number; distanceAzimuth: number;
points: SpacetimePoint[] | Coordinates[]; points: Coordinates[];
spacetimeWaypoints: SpacetimePoint[];
}; };
export type Path = { export type Path = {

View File

@ -1,3 +1,4 @@
export const PARAMS_PROVIDER = Symbol('PARAMS_PROVIDER'); export const PARAMS_PROVIDER = Symbol('PARAMS_PROVIDER');
export const DIRECTION_ENCODER = Symbol('DIRECTION_ENCODER'); export const DIRECTION_ENCODER = Symbol('DIRECTION_ENCODER');
export const GEOROUTER = Symbol('GEOROUTER'); export const GEOROUTER = Symbol('GEOROUTER');
export const GEODESIC = Symbol('GEODESIC');

View File

@ -1,10 +1,15 @@
import { Module, Provider } from '@nestjs/common'; import { Module, Provider } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs'; import { CqrsModule } from '@nestjs/cqrs';
import { DIRECTION_ENCODER, PARAMS_PROVIDER } from './geography.di-tokens'; import {
DIRECTION_ENCODER,
GEODESIC,
PARAMS_PROVIDER,
} from './geography.di-tokens';
import { DefaultParamsProvider } from './infrastructure/default-params-provider'; import { DefaultParamsProvider } from './infrastructure/default-params-provider';
import { PostgresDirectionEncoder } from './infrastructure/postgres-direction-encoder'; import { PostgresDirectionEncoder } from './infrastructure/postgres-direction-encoder';
import { GetBasicRouteController } from './interface/controllers/get-basic-route.controller'; import { GetBasicRouteController } from './interface/controllers/get-basic-route.controller';
import { RouteMapper } from './route.mapper'; import { RouteMapper } from './route.mapper';
import { Geodesic } from './infrastructure/geodesic';
const mappers: Provider[] = [RouteMapper]; const mappers: Provider[] = [RouteMapper];
@ -17,6 +22,10 @@ const adapters: Provider[] = [
provide: DIRECTION_ENCODER, provide: DIRECTION_ENCODER,
useClass: PostgresDirectionEncoder, useClass: PostgresDirectionEncoder,
}, },
{
provide: GEODESIC,
useClass: Geodesic,
},
GetBasicRouteController, GetBasicRouteController,
]; ];

View File

@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { Geodesic as Geolib, GeodesicClass } from 'geographiclib-geodesic';
import { GeodesicPort } from '../core/application/ports/geodesic.port';
@Injectable()
export class Geodesic implements GeodesicPort {
private geod: GeodesicClass;
constructor() {
this.geod = Geolib.WGS84;
}
inverse = (
lon1: number,
lat1: number,
lon2: number,
lat2: number,
): { azimuth: number; distance: number } => {
const { azi2: azimuth, s12: distance } = this.geod.Inverse(
lat1,
lon1,
lat2,
lon2,
);
return { azimuth, distance };
};
}

View File

@ -2,15 +2,21 @@ import { Inject, Injectable } from '@nestjs/common';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { GeorouterPort } from '../core/application/ports/georouter.port'; import { GeorouterPort } from '../core/application/ports/georouter.port';
import { GeorouterSettings } from '../core/application/types/georouter-settings.type'; import { GeorouterSettings } from '../core/application/types/georouter-settings.type';
import { Path, PathType, Route } from '../core/domain/route.types'; import {
Path,
PathType,
Route,
SpacetimePoint,
} from '../core/domain/route.types';
import { DefaultParamsProviderPort } from '../core/application/ports/default-params-provider.port'; import { DefaultParamsProviderPort } from '../core/application/ports/default-params-provider.port';
import { PARAMS_PROVIDER } from '../geography.di-tokens'; import { GEODESIC, PARAMS_PROVIDER } from '../geography.di-tokens';
import { catchError, lastValueFrom, map } from 'rxjs'; import { catchError, lastValueFrom, map } from 'rxjs';
import { AxiosError, AxiosResponse } from 'axios'; import { AxiosError, AxiosResponse } from 'axios';
import { import {
GeorouterUnavailableException, GeorouterUnavailableException,
RouteNotFoundException, RouteNotFoundException,
} from '../core/domain/route.errors'; } from '../core/domain/route.errors';
import { GeodesicPort } from '../core/application/ports/geodesic.port';
@Injectable() @Injectable()
export class GraphhopperGeorouter implements GeorouterPort { export class GraphhopperGeorouter implements GeorouterPort {
@ -21,8 +27,12 @@ export class GraphhopperGeorouter implements GeorouterPort {
private readonly httpService: HttpService, private readonly httpService: HttpService,
@Inject(PARAMS_PROVIDER) @Inject(PARAMS_PROVIDER)
private readonly defaultParamsProvider: DefaultParamsProviderPort, private readonly defaultParamsProvider: DefaultParamsProviderPort,
@Inject(GEODESIC) private readonly geodesic: GeodesicPort,
) { ) {
this.url = defaultParamsProvider.getParams().GEOROUTER_URL; this.url = [
defaultParamsProvider.getParams().GEOROUTER_URL,
'/route?',
].join('');
} }
routes = async ( routes = async (
@ -31,17 +41,7 @@ export class GraphhopperGeorouter implements GeorouterPort {
): Promise<Route[]> => { ): Promise<Route[]> => {
this.setDefaultUrlArgs(); this.setDefaultUrlArgs();
this.setSettings(settings); this.setSettings(settings);
return [ return this.getRoutes(paths);
{
type: PathType.DRIVER,
distance: 1000,
duration: 1000,
fwdAzimuth: 280,
backAzimuth: 100,
distanceAzimuth: 900,
points: [],
},
];
}; };
private setDefaultUrlArgs = (): void => { private setDefaultUrlArgs = (): void => {
@ -99,7 +99,197 @@ export class GraphhopperGeorouter implements GeorouterPort {
private createRoute = ( private createRoute = (
response: AxiosResponse<GraphhopperResponse>, response: AxiosResponse<GraphhopperResponse>,
type: PathType, type: PathType,
): Route => undefined; ): Route => {
const route = {} as Route;
route.type = type;
if (response.data.paths && response.data.paths[0]) {
const shortestPath = response.data.paths[0];
route.distance = shortestPath.distance ?? 0;
route.duration = shortestPath.time ? shortestPath.time / 1000 : 0;
if (shortestPath.points && shortestPath.points.coordinates) {
route.points = shortestPath.points.coordinates.map((coordinate) => ({
lon: coordinate[0],
lat: coordinate[1],
}));
const inverse = this.geodesic.inverse(
route.points[0].lon,
route.points[0].lat,
route.points[route.points.length - 1].lon,
route.points[route.points.length - 1].lat,
);
route.fwdAzimuth =
inverse.azimuth >= 0
? inverse.azimuth
: 360 - Math.abs(inverse.azimuth);
route.backAzimuth =
route.fwdAzimuth > 180
? route.fwdAzimuth - 180
: route.fwdAzimuth + 180;
route.distanceAzimuth = inverse.distance;
if (
shortestPath.details &&
shortestPath.details.time &&
shortestPath.snapped_waypoints &&
shortestPath.snapped_waypoints.coordinates
) {
let instructions: GraphhopperInstruction[] = [];
if (shortestPath.instructions)
instructions = shortestPath.instructions;
route.spacetimeWaypoints = this.generateSpacetimePoints(
shortestPath.points.coordinates,
shortestPath.snapped_waypoints.coordinates,
shortestPath.details.time,
instructions,
);
}
}
}
return route;
};
private generateSpacetimePoints = (
points: [[number, number]],
snappedWaypoints: [[number, number]],
durations: [[number, number, number]],
instructions: GraphhopperInstruction[],
): SpacetimePoint[] => {
const indices = this.getIndices(points, snappedWaypoints);
const times = this.getTimes(durations, indices);
const distances = this.getDistances(instructions, indices);
return indices.map((index) => ({
lon: points[index][1],
lat: points[index][0],
distance: distances.find((distance) => distance.index == index)?.distance,
duration: times.find((time) => time.index == index)?.duration,
}));
};
private getIndices = (
points: [[number, number]],
snappedWaypoints: [[number, number]],
): number[] => {
const indices = snappedWaypoints.map((waypoint) =>
points.findIndex(
(point) => point[0] == waypoint[0] && point[1] == waypoint[1],
),
);
if (indices.find((index) => index == -1) === undefined) return indices;
const missedWaypoints = indices
.map(
(value, index) =>
<
{
index: number;
originIndex: number;
waypoint: number[];
nearest: number;
distance: number;
}
>{
index: value,
originIndex: index,
waypoint: snappedWaypoints[index],
nearest: undefined,
distance: 999999999,
},
)
.filter((element) => element.index == -1);
for (const index in points) {
for (const missedWaypoint of missedWaypoints) {
const inverse = this.geodesic.inverse(
missedWaypoint.waypoint[0],
missedWaypoint.waypoint[1],
points[index][0],
points[index][1],
);
if (inverse.distance < missedWaypoint.distance) {
missedWaypoint.distance = inverse.distance;
missedWaypoint.nearest = parseInt(index);
}
}
}
for (const missedWaypoint of missedWaypoints) {
indices[missedWaypoint.originIndex] = missedWaypoint.nearest;
}
return indices;
};
private getTimes = (
durations: [[number, number, number]],
indices: number[],
): Array<{ index: number; duration: number }> => {
const times: Array<{ index: number; duration: number }> = [];
let duration = 0;
for (const [origin, destination, stepDuration] of durations) {
let indexFound = false;
const indexAsOrigin = indices.find((index) => index == origin);
if (
indexAsOrigin !== undefined &&
times.find((time) => origin == time.index) == undefined
) {
times.push({
index: indexAsOrigin,
duration: Math.round(stepDuration / 1000),
});
indexFound = true;
}
if (!indexFound) {
const indexAsDestination = indices.find(
(index) => index == destination,
);
if (
indexAsDestination !== undefined &&
times.find((time) => destination == time.index) == undefined
) {
times.push({
index: indexAsDestination,
duration: Math.round((duration + stepDuration) / 1000),
});
indexFound = true;
}
}
if (!indexFound) {
const indexInBetween = indices.find(
(index) => origin < index && index < destination,
);
if (indexInBetween !== undefined) {
times.push({
index: indexInBetween,
duration: Math.round((duration + stepDuration / 2) / 1000),
});
}
}
duration += stepDuration;
}
return times;
};
private getDistances = (
instructions: GraphhopperInstruction[],
indices: number[],
): Array<{ index: number; distance: number }> => {
let distance = 0;
const distances: Array<{ index: number; distance: number }> = [
{
index: 0,
distance,
},
];
for (const instruction of instructions) {
distance += instruction.distance;
if (
(instruction.sign == GraphhopperSign.SIGN_WAYPOINT ||
instruction.sign == GraphhopperSign.SIGN_FINISH) &&
indices.find((index) => index == instruction.interval[0]) !== undefined
) {
distances.push({
index: instruction.interval[0],
distance: Math.round(distance),
});
}
}
return distances;
};
} }
type GraphhopperResponse = { type GraphhopperResponse = {
@ -113,7 +303,7 @@ type GraphhopperResponse = {
points: GraphhopperCoordinates; points: GraphhopperCoordinates;
snapped_waypoints: GraphhopperCoordinates; snapped_waypoints: GraphhopperCoordinates;
details: { details: {
time: number[]; time: [[number, number, number]];
}; };
instructions: GraphhopperInstruction[]; instructions: GraphhopperInstruction[];
}, },
@ -121,14 +311,14 @@ type GraphhopperResponse = {
}; };
type GraphhopperCoordinates = { type GraphhopperCoordinates = {
coordinates: number[]; coordinates: [[number, number]];
}; };
type GraphhopperInstruction = { type GraphhopperInstruction = {
distance: number; distance: number;
heading: number; heading: number;
sign: GraphhopperSign; sign: GraphhopperSign;
interval: number[]; interval: [number, number];
text: string; text: string;
}; };

View File

@ -0,0 +1,14 @@
import { Geodesic } from '@modules/geography/infrastructure/geodesic';
describe('Matcher geodesic', () => {
it('should be defined', () => {
const geodesic: Geodesic = new Geodesic();
expect(geodesic).toBeDefined();
});
it('should get inverse values', () => {
const geodesic: Geodesic = new Geodesic();
const inv = geodesic.inverse(0, 0, 1, 1);
expect(Math.round(inv.azimuth)).toBe(45);
expect(Math.round(inv.distance)).toBe(156900);
});
});

View File

@ -1,33 +1,259 @@
import { DefaultParamsProviderPort } from '@modules/geography/core/application/ports/default-params-provider.port'; import { DefaultParamsProviderPort } from '@modules/geography/core/application/ports/default-params-provider.port';
import { GeodesicPort } from '@modules/geography/core/application/ports/geodesic.port';
import { import {
Path, GeorouterUnavailableException,
PathType, RouteNotFoundException,
Route, } from '@modules/geography/core/domain/route.errors';
} from '@modules/geography/core/domain/route.types'; import { PathType, Route } from '@modules/geography/core/domain/route.types';
import { PARAMS_PROVIDER } from '@modules/geography/geography.di-tokens'; import {
GEODESIC,
PARAMS_PROVIDER,
} from '@modules/geography/geography.di-tokens';
import { GraphhopperGeorouter } from '@modules/geography/infrastructure/graphhopper-georouter'; import { GraphhopperGeorouter } from '@modules/geography/infrastructure/graphhopper-georouter';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AxiosError } from 'axios';
import { of, throwError } from 'rxjs';
const driverPath: Path = { const mockHttpService = {
type: PathType.DRIVER, get: jest
points: [ .fn()
{ .mockImplementationOnce(() => {
lon: 6, return throwError(
lat: 47, () => new AxiosError('Axios error', AxiosError.ERR_BAD_REQUEST),
}, );
{ })
lon: 6.1, .mockImplementationOnce(() => {
lat: 47.1, return throwError(() => 'Router unavailable');
}, })
{ .mockImplementationOnce(() => {
lon: 6.2, return of({
lat: 47.2, status: 200,
}, data: {
], paths: [
{
distance: 50000,
time: 1800000,
snapped_waypoints: {
coordinates: [
[0, 0],
[10, 10],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
[6, 6],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[10, 10],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
[6, 6],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
details: {
time: [
[0, 1, 180000],
[1, 2, 180000],
[2, 3, 180000],
[3, 4, 180000],
[4, 5, 180000],
[5, 6, 180000],
[6, 7, 180000],
[7, 9, 360000],
[9, 10, 180000],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[10, 10],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[5, 5],
[10, 10],
],
},
details: {
time: [
[0, 1, 180000],
[1, 2, 180000],
[2, 3, 180000],
[3, 4, 180000],
[4, 7, 540000],
[7, 9, 360000],
[9, 10, 180000],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
[6, 6],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[5, 5],
[10, 10],
],
},
details: {
time: [
[0, 1, 180000],
[1, 2, 180000],
[2, 3, 180000],
[3, 4, 180000],
[4, 7, 540000],
[7, 9, 360000],
[9, 10, 180000],
],
},
instructions: [
{
distance: 25000,
sign: 0,
interval: [0, 5],
text: 'Some instructions',
time: 900000,
},
{
distance: 0,
sign: 5,
interval: [5, 5],
text: 'Waypoint 1',
time: 0,
},
{
distance: 25000,
sign: 2,
interval: [5, 10],
text: 'Some instructions',
time: 900000,
},
{
distance: 0.0,
sign: 4,
interval: [10, 10],
text: 'Arrive at destination',
time: 0,
},
],
},
],
},
});
}),
}; };
const mockHttpService = {}; const mockGeodesic: GeodesicPort = {
inverse: jest.fn().mockImplementation(() => ({
azimuth: 45,
distance: 50000,
})),
};
const mockDefaultParamsProvider: DefaultParamsProviderPort = { const mockDefaultParamsProvider: DefaultParamsProviderPort = {
getParams: jest.fn().mockImplementation(() => ({ getParams: jest.fn().mockImplementation(() => ({
@ -51,6 +277,10 @@ describe('Graphhopper Georouter', () => {
provide: PARAMS_PROVIDER, provide: PARAMS_PROVIDER,
useValue: mockDefaultParamsProvider, useValue: mockDefaultParamsProvider,
}, },
{
provide: GEODESIC,
useValue: mockGeodesic,
},
], ],
}).compile(); }).compile();
@ -62,13 +292,209 @@ describe('Graphhopper Georouter', () => {
expect(graphhopperGeorouter).toBeDefined(); expect(graphhopperGeorouter).toBeDefined();
}); });
it('should return basic driver routes', async () => { it('should fail if route is not found', async () => {
const paths: Path[] = [driverPath]; await expect(
const driverRoutes: Route[] = await graphhopperGeorouter.routes(paths, { graphhopperGeorouter.routes(
detailedDistance: false, [
detailedDuration: false, {
points: true, type: PathType.DRIVER,
}); points: [
expect(driverRoutes.length).toBe(1); {
lon: 0,
lat: 0,
},
{
lon: 1,
lat: 1,
},
],
},
],
{
detailedDistance: false,
detailedDuration: false,
points: false,
},
),
).rejects.toBeInstanceOf(RouteNotFoundException);
});
it('should fail if georouter is unavailable', async () => {
await expect(
graphhopperGeorouter.routes(
[
{
type: PathType.DRIVER,
points: [
{
lon: 0,
lat: 0,
},
{
lon: 1,
lat: 1,
},
],
},
],
{
detailedDistance: false,
detailedDuration: false,
points: false,
},
),
).rejects.toBeInstanceOf(GeorouterUnavailableException);
});
it('should create a basic route', async () => {
const routes: Route[] = await graphhopperGeorouter.routes(
[
{
type: PathType.DRIVER,
points: [
{
lon: 0,
lat: 0,
},
{
lon: 10,
lat: 10,
},
],
},
],
{
detailedDistance: false,
detailedDuration: false,
points: false,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].distance).toBe(50000);
});
it('should create one route with points', async () => {
const routes = await graphhopperGeorouter.routes(
[
{
type: PathType.DRIVER,
points: [
{
lat: 0,
lon: 0,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
detailedDistance: false,
detailedDuration: false,
points: true,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].distance).toBe(50000);
expect(routes[0].duration).toBe(1800);
expect(routes[0].fwdAzimuth).toBe(45);
expect(routes[0].backAzimuth).toBe(225);
expect(routes[0].points).toHaveLength(11);
});
it('should create one route with points and time', async () => {
const routes = await graphhopperGeorouter.routes(
[
{
type: PathType.DRIVER,
points: [
{
lat: 0,
lon: 0,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
detailedDistance: false,
detailedDuration: true,
points: true,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].spacetimeWaypoints).toHaveLength(2);
expect(routes[0].spacetimeWaypoints[1].duration).toBe(1800);
expect(routes[0].spacetimeWaypoints[1].distance).toBeUndefined();
});
it('should create one route with points and missed waypoints extrapolations', async () => {
const routes = await graphhopperGeorouter.routes(
[
{
type: PathType.DRIVER,
points: [
{
lat: 0,
lon: 0,
},
{
lat: 5,
lon: 5,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
detailedDistance: false,
detailedDuration: true,
points: true,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].spacetimeWaypoints).toHaveLength(3);
expect(routes[0].distance).toBe(50000);
expect(routes[0].duration).toBe(1800);
expect(routes[0].fwdAzimuth).toBe(45);
expect(routes[0].backAzimuth).toBe(225);
expect(routes[0].points.length).toBe(9);
});
it('should create one route with points, time and distance', async () => {
const routes = await graphhopperGeorouter.routes(
[
{
type: PathType.DRIVER,
points: [
{
lat: 0,
lon: 0,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
detailedDistance: true,
detailedDuration: true,
points: true,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].spacetimeWaypoints.length).toBe(3);
expect(routes[0].spacetimeWaypoints[1].duration).toBe(990);
expect(routes[0].spacetimeWaypoints[1].distance).toBe(25000);
}); });
}); });