From 66d4d58dd15f8cd1e9f7e37ea5f2cb508b394fd5 Mon Sep 17 00:00:00 2001 From: sbriat Date: Wed, 23 Aug 2023 14:32:17 +0200 Subject: [PATCH] almost full coverage for graphhopper georouter --- .../geography/core/domain/route.types.ts | 3 +- src/modules/geography/geography.di-tokens.ts | 1 + src/modules/geography/geography.module.ts | 11 +- .../geography/infrastructure/geodesic.ts | 27 + .../infrastructure/graphhopper-georouter.ts | 226 +++++++- .../unit/infrastructure/geodesic.spec.ts | 14 + .../graphhopper-georouter.spec.ts | 486 ++++++++++++++++-- 7 files changed, 718 insertions(+), 50 deletions(-) create mode 100644 src/modules/geography/infrastructure/geodesic.ts create mode 100644 src/modules/geography/tests/unit/infrastructure/geodesic.spec.ts diff --git a/src/modules/geography/core/domain/route.types.ts b/src/modules/geography/core/domain/route.types.ts index ba47f02..5860261 100644 --- a/src/modules/geography/core/domain/route.types.ts +++ b/src/modules/geography/core/domain/route.types.ts @@ -32,7 +32,8 @@ export type Route = { fwdAzimuth: number; backAzimuth: number; distanceAzimuth: number; - points: SpacetimePoint[] | Coordinates[]; + points: Coordinates[]; + spacetimeWaypoints: SpacetimePoint[]; }; export type Path = { diff --git a/src/modules/geography/geography.di-tokens.ts b/src/modules/geography/geography.di-tokens.ts index 98e9456..101e426 100644 --- a/src/modules/geography/geography.di-tokens.ts +++ b/src/modules/geography/geography.di-tokens.ts @@ -1,3 +1,4 @@ export const PARAMS_PROVIDER = Symbol('PARAMS_PROVIDER'); export const DIRECTION_ENCODER = Symbol('DIRECTION_ENCODER'); export const GEOROUTER = Symbol('GEOROUTER'); +export const GEODESIC = Symbol('GEODESIC'); diff --git a/src/modules/geography/geography.module.ts b/src/modules/geography/geography.module.ts index e1a0c46..8c2df42 100644 --- a/src/modules/geography/geography.module.ts +++ b/src/modules/geography/geography.module.ts @@ -1,10 +1,15 @@ import { Module, Provider } from '@nestjs/common'; 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 { PostgresDirectionEncoder } from './infrastructure/postgres-direction-encoder'; import { GetBasicRouteController } from './interface/controllers/get-basic-route.controller'; import { RouteMapper } from './route.mapper'; +import { Geodesic } from './infrastructure/geodesic'; const mappers: Provider[] = [RouteMapper]; @@ -17,6 +22,10 @@ const adapters: Provider[] = [ provide: DIRECTION_ENCODER, useClass: PostgresDirectionEncoder, }, + { + provide: GEODESIC, + useClass: Geodesic, + }, GetBasicRouteController, ]; diff --git a/src/modules/geography/infrastructure/geodesic.ts b/src/modules/geography/infrastructure/geodesic.ts new file mode 100644 index 0000000..a0f1e76 --- /dev/null +++ b/src/modules/geography/infrastructure/geodesic.ts @@ -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 }; + }; +} diff --git a/src/modules/geography/infrastructure/graphhopper-georouter.ts b/src/modules/geography/infrastructure/graphhopper-georouter.ts index a6f6f3a..ce67939 100644 --- a/src/modules/geography/infrastructure/graphhopper-georouter.ts +++ b/src/modules/geography/infrastructure/graphhopper-georouter.ts @@ -2,15 +2,21 @@ import { Inject, Injectable } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { GeorouterPort } from '../core/application/ports/georouter.port'; 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 { PARAMS_PROVIDER } from '../geography.di-tokens'; +import { GEODESIC, PARAMS_PROVIDER } from '../geography.di-tokens'; import { catchError, lastValueFrom, map } from 'rxjs'; import { AxiosError, AxiosResponse } from 'axios'; import { GeorouterUnavailableException, RouteNotFoundException, } from '../core/domain/route.errors'; +import { GeodesicPort } from '../core/application/ports/geodesic.port'; @Injectable() export class GraphhopperGeorouter implements GeorouterPort { @@ -21,8 +27,12 @@ export class GraphhopperGeorouter implements GeorouterPort { private readonly httpService: HttpService, @Inject(PARAMS_PROVIDER) 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 ( @@ -31,17 +41,7 @@ export class GraphhopperGeorouter implements GeorouterPort { ): Promise => { this.setDefaultUrlArgs(); this.setSettings(settings); - return [ - { - type: PathType.DRIVER, - distance: 1000, - duration: 1000, - fwdAzimuth: 280, - backAzimuth: 100, - distanceAzimuth: 900, - points: [], - }, - ]; + return this.getRoutes(paths); }; private setDefaultUrlArgs = (): void => { @@ -99,7 +99,197 @@ export class GraphhopperGeorouter implements GeorouterPort { private createRoute = ( response: AxiosResponse, 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 = { @@ -113,7 +303,7 @@ type GraphhopperResponse = { points: GraphhopperCoordinates; snapped_waypoints: GraphhopperCoordinates; details: { - time: number[]; + time: [[number, number, number]]; }; instructions: GraphhopperInstruction[]; }, @@ -121,14 +311,14 @@ type GraphhopperResponse = { }; type GraphhopperCoordinates = { - coordinates: number[]; + coordinates: [[number, number]]; }; type GraphhopperInstruction = { distance: number; heading: number; sign: GraphhopperSign; - interval: number[]; + interval: [number, number]; text: string; }; diff --git a/src/modules/geography/tests/unit/infrastructure/geodesic.spec.ts b/src/modules/geography/tests/unit/infrastructure/geodesic.spec.ts new file mode 100644 index 0000000..a71df2e --- /dev/null +++ b/src/modules/geography/tests/unit/infrastructure/geodesic.spec.ts @@ -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); + }); +}); diff --git a/src/modules/geography/tests/unit/infrastructure/graphhopper-georouter.spec.ts b/src/modules/geography/tests/unit/infrastructure/graphhopper-georouter.spec.ts index adda4e8..c167240 100644 --- a/src/modules/geography/tests/unit/infrastructure/graphhopper-georouter.spec.ts +++ b/src/modules/geography/tests/unit/infrastructure/graphhopper-georouter.spec.ts @@ -1,33 +1,259 @@ import { DefaultParamsProviderPort } from '@modules/geography/core/application/ports/default-params-provider.port'; +import { GeodesicPort } from '@modules/geography/core/application/ports/geodesic.port'; import { - Path, - PathType, - Route, -} from '@modules/geography/core/domain/route.types'; -import { PARAMS_PROVIDER } from '@modules/geography/geography.di-tokens'; + GeorouterUnavailableException, + RouteNotFoundException, +} from '@modules/geography/core/domain/route.errors'; +import { PathType, Route } from '@modules/geography/core/domain/route.types'; +import { + GEODESIC, + PARAMS_PROVIDER, +} from '@modules/geography/geography.di-tokens'; import { GraphhopperGeorouter } from '@modules/geography/infrastructure/graphhopper-georouter'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; +import { AxiosError } from 'axios'; +import { of, throwError } from 'rxjs'; -const driverPath: Path = { - type: PathType.DRIVER, - points: [ - { - lon: 6, - lat: 47, - }, - { - lon: 6.1, - lat: 47.1, - }, - { - lon: 6.2, - lat: 47.2, - }, - ], +const mockHttpService = { + get: jest + .fn() + .mockImplementationOnce(() => { + return throwError( + () => new AxiosError('Axios error', AxiosError.ERR_BAD_REQUEST), + ); + }) + .mockImplementationOnce(() => { + return throwError(() => 'Router unavailable'); + }) + .mockImplementationOnce(() => { + return of({ + 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 = { getParams: jest.fn().mockImplementation(() => ({ @@ -51,6 +277,10 @@ describe('Graphhopper Georouter', () => { provide: PARAMS_PROVIDER, useValue: mockDefaultParamsProvider, }, + { + provide: GEODESIC, + useValue: mockGeodesic, + }, ], }).compile(); @@ -62,13 +292,209 @@ describe('Graphhopper Georouter', () => { expect(graphhopperGeorouter).toBeDefined(); }); - it('should return basic driver routes', async () => { - const paths: Path[] = [driverPath]; - const driverRoutes: Route[] = await graphhopperGeorouter.routes(paths, { - detailedDistance: false, - detailedDuration: false, - points: true, - }); - expect(driverRoutes.length).toBe(1); + it('should fail if route is not found', 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(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); }); });