diff --git a/cron/crontab b/cron/crontab index f5ade5f51..4e98db502 100644 --- a/cron/crontab +++ b/cron/crontab @@ -6,3 +6,6 @@ # Cron job to remind customers and companies of imminent journeys */5 * * * * /usr/local/bin/node /app/scripts/sendReminder.js >> /var/log/cron.log 2>&1 + +#Cron job to create statistics of driven distances +30 3 * * * /usr/local/bin/node /app/scripts/createStatistics.js >> /var/log/cron.log 2>&1 diff --git a/cron/scripts/createStatistics.js b/cron/scripts/createStatistics.js new file mode 100644 index 000000000..d71578351 --- /dev/null +++ b/cron/scripts/createStatistics.js @@ -0,0 +1,7 @@ +try { + await fetch('http://prima:3000/apiInternal/createStatistics', { + method: 'POST' + }); +} catch (err) { + console.error('Error:', err); +} diff --git a/migrations/2026-01-26.js b/migrations/2026-01-26.js new file mode 100644 index 000000000..a515494a0 --- /dev/null +++ b/migrations/2026-01-26.js @@ -0,0 +1,17 @@ +export async function up(db) { + await db.schema + .alterTable('ride_share_tour') + .addColumn('approach_and_return_m', 'bigint') + .addColumn('fully_payed_m', 'bigint') + .addColumn('occupied_m', 'bigint') + .execute(); + + await db.schema + .alterTable('tour') + .addColumn('approach_and_return_m', 'bigint') + .addColumn('fully_payed_m', 'bigint') + .addColumn('occupied_m', 'bigint') + .execute(); +} + +export async function down() { } \ No newline at end of file diff --git a/package.json b/package.json index e2ffbddc2..ce8aa6a58 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,9 @@ "repeataddh": "npm run rr && npm run addrs && npm run healthrs", "repeatbookrsh": "npm run rr && npm run bookrs && npm run healthrs", "flc": "npm run format && npm run lint && npm run check", - "updateDurations": "pnpx vite-node --options.transformMode.ssr='/.*/' scripts/updateDatabaseDurations.ts" + "updateDurations": "pnpx vite-node --options.transformMode.ssr='/.*/' scripts/updateDatabaseDurations.ts", + "stats": "pnpx vite-node --options.transformMode.ssr='/.*/' scripts/createStatistics.ts", + "vstats": "pnpx vite-node --options.transformMode.ssr='/.*/' scripts/viewStatistics.ts" }, "devDependencies": { "@eslint/compat": "^1.2.5", diff --git a/scripts/createStatistics.ts b/scripts/createStatistics.ts new file mode 100644 index 000000000..e3853e2ad --- /dev/null +++ b/scripts/createStatistics.ts @@ -0,0 +1,10 @@ +import { createStatistics } from '../src/lib/createStatistics'; + +async function main(): Promise { + createStatistics(); +} + +// Run the main function +main().catch((error) => { + console.error('Error in main function:', error); +}); diff --git a/scripts/viewStatistics.ts b/scripts/viewStatistics.ts new file mode 100644 index 000000000..c2c18ca75 --- /dev/null +++ b/scripts/viewStatistics.ts @@ -0,0 +1,63 @@ +import { db } from '../src/lib/server/db/index.js'; + +async function getTours() { + return await db + .selectFrom('tour') + .where('tour.approachAndReturnM', 'is not', null) + .selectAll() + .execute(); +} + +async function getRsTours() { + return await db + .selectFrom('rideShareTour') + .where('rideShareTour.approachAndReturnM', 'is not', null) + .selectAll() + .execute(); +} + +type Tours = Awaited>; +type RsTours = Awaited>; + +async function viewStatistics() { + const tours = await getTours(); + const tourEntries = { + all: createEntries(tours), + cancelled: createEntries(tours.filter((t) => t.cancelled)), + uncancelledTours: createEntries(tours.filter((t) => !t.cancelled)) + }; + + const rsTours = await getRsTours(); + const rsTourEntries = { + all: createRsEntries(rsTours), + cancelled: createRsEntries(rsTours.filter((t) => t.cancelled)), + uncancelledTours: createRsEntries(rsTours.filter((t) => !t.cancelled)) + }; + console.log('TAXI'); + console.log(JSON.stringify(tourEntries, null, 2)); + console.log(); + console.log('RIDE SHARE'); + console.log(JSON.stringify(rsTourEntries, null, 2)); +} + +function createEntries(tours: Tours) { + return { + count: tours.length, + approachAndReturnM: tours.reduce((prev, curr) => (prev += curr.approachAndReturnM!), 0), + fullyPayedM: tours.reduce((prev, curr) => (prev += curr.fullyPayedM!), 0), + occupiedM: tours.reduce((prev, curr) => (prev += curr.occupiedM!), 0) + }; +} + +function createRsEntries(tours: RsTours) { + return { + count: tours.length, + approachAndReturnM: tours.reduce((prev, curr) => (prev += curr.approachAndReturnM!), 0), + fullyPayedM: tours.reduce((prev, curr) => (prev += curr.fullyPayedM!), 0), + occupiedM: tours.reduce((prev, curr) => (prev += curr.occupiedM!), 0) + }; +} + +viewStatistics().catch((error) => { + console.error('Error in main function:', error); +}); diff --git a/src/lib/createStatistics.test.ts b/src/lib/createStatistics.test.ts new file mode 100644 index 000000000..07c213a02 --- /dev/null +++ b/src/lib/createStatistics.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { haversineDistance } from './createStatistics'; + +describe('haversineDistance', () => { + it('returns 0 for identical coordinates', () => { + const berlin = { lat: 52.52, lng: 13.405 }; + + expect(haversineDistance(berlin, berlin)).toBe(0); + }); + + it('is symmetric', () => { + const berlin = { lat: 52.52, lng: 13.405 }; + const paris = { lat: 48.8566, lng: 2.3522 }; + + const aToB = haversineDistance(berlin, paris); + const bToA = haversineDistance(paris, berlin); + + expect(aToB).toBeCloseTo(bToA, 8); + }); + + it('calculates Berlin to Paris distance in meters', () => { + const berlin = { lat: 52.52, lng: 13.405 }; + const paris = { lat: 48.8566, lng: 2.3522 }; + + const distance = haversineDistance(berlin, paris); + + // Great-circle distance is roughly 877 km + expect(distance).toBeGreaterThan(875_000); + expect(distance).toBeLessThan(879_000); + }); + + it('calculates short distances reasonably', () => { + const a = { lat: 52.52, lng: 13.405 }; + const b = { lat: 52.5201, lng: 13.405 }; + + const distance = haversineDistance(a, b); + + // 0.0001 degrees latitude is about 11.1 meters + expect(distance).toBeGreaterThan(11); + expect(distance).toBeLessThan(12); + }); + + it('handles crossing the antimeridian', () => { + const a = { lat: 0, lng: 179.9 }; + const b = { lat: 0, lng: -179.9 }; + + const distance = haversineDistance(a, b); + + // 0.2 degrees at equator ≈ 22.2 km + expect(distance).toBeGreaterThan(22_000); + expect(distance).toBeLessThan(22_300); + }); +}); \ No newline at end of file diff --git a/src/lib/createStatistics.ts b/src/lib/createStatistics.ts new file mode 100644 index 000000000..193d94532 --- /dev/null +++ b/src/lib/createStatistics.ts @@ -0,0 +1,159 @@ +import { jsonArrayFrom } from 'kysely/helpers/postgres'; +import { db } from '$lib/server/db'; +import type { Coordinates } from '$lib/util/Coordinates'; +import { carRouting } from '$lib/util/carRouting'; +import type { Itinerary, Leg } from '$lib/openapi'; +import { polyLineToLatLngArray } from '$lib/util/polylineToGeoJSON'; + +export async function createStatistics() { + await computeAndPersistStatistics('tour'); + await computeAndPersistStatistics('rideShareTour'); +} + +async function tourQuery() { + return await db + .selectFrom('tour') + .innerJoin('request', 'request.tour', 'tour.id') + .innerJoin('vehicle', 'vehicle.id', 'tour.vehicle') + .innerJoin('company', 'company.id', 'vehicle.company') + .where('tour.arrival', '<', Date.now()) + .where('tour.approachAndReturnM', 'is', null) + .where('tour.cancelled', '=', false) + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom('request') + .innerJoin('event', 'event.request', 'request.id') + .innerJoin('eventGroup', 'event.eventGroupId', 'eventGroup.id') + .where('request.cancelled', '=', false) + .whereRef('request.tour', '=', 'tour.id') + .selectAll(['event', 'eventGroup']) + .select('request.passengers') + ).as('events'), + 'company.lat', + 'company.lng', + 'tour.id' + ]) + .execute(); +} + +async function rideShareTourQuery() { + return ( + await db + .selectFrom('rideShareTour as tour') + .where('tour.approachAndReturnM', 'is', null) + .where('tour.cancelled', '=', false) + .where('tour.latestEnd', '<', Date.now()) + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom('request') + .innerJoin('event', 'event.request', 'request.id') + .innerJoin('eventGroup', 'event.eventGroupId', 'eventGroup.id') + .where('request.cancelled', '=', false) + .whereRef('request.tour', '=', 'tour.id') + .selectAll(['event', 'eventGroup']) + .select('request.passengers') + ).as('events') + ]) + .execute() + ).map((t) => ({ ...t, lat: 0, lng: 0, id: 0 })); +} + +type Tours = Awaited>; +type Tour = Tours[0]; +type Event = Tour['events'][0]; + +async function computeStatistics(events: Event[], company?: Coordinates) { + const routingQueries = new Array>(events.length); + for (let i = 0; i < events.length - 1; ++i) { + routingQueries[i] = carRouting(events[i], events[i + 1]); + } + const routingResults = await Promise.all(routingQueries); + const distances = routingResults.map((r) => legsToTravelDistance(r?.legs)); + + let currentPassengers = 0; + let occupiedM = 0; + let fullyPayedM = 0; + for (let i = 0; i != events.length; ++i) { + const curr = events[i]; + currentPassengers += curr.isPickup ? curr.passengers : -curr.passengers; + if (currentPassengers !== 0) { + occupiedM += distances[i]; + } + fullyPayedM += distances[i]; + } + + let approachPlusReturn = 0; + if (company !== undefined) { + const routingResultApproach = await carRouting(company, events[0]); + const routingResultReturn = await carRouting(events[events.length - 1], company); + approachPlusReturn = + legsToTravelDistance(routingResultApproach?.legs) + + legsToTravelDistance(routingResultReturn?.legs); + } + return { + approachPlusReturn, + fullyPayedM, + occupiedM + }; +} + +export function haversineDistance(c1: Coordinates, c2: Coordinates) { + function toRad(deg: number) { + return (deg * Math.PI) / 180; + } + const R = 6371000; // Earth radius in meters + + const dLat = toRad(c2.lat - c1.lat); + const dLon = toRad(c2.lng - c1.lng); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(c1.lat)) * Math.cos(toRad(c2.lat)) * Math.sin(dLon / 2) ** 2; + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +function legToTravelDistance(leg: Leg): number { + const legGeometry = polyLineToLatLngArray(leg.legGeometry.points).map((v) => ({ + lat: v[0], + lng: v[1] + })); + let sum = 0; + for (let i = 1; i != legGeometry.length; ++i) { + const prev = legGeometry[i - 1]; + const curr = legGeometry[i]; + sum += haversineDistance(prev, curr); + } + return sum; +} + +function legsToTravelDistance(legs: Leg[] | undefined) { + return legs === undefined + ? 0 + : legs.reduce((prev, curr) => (prev += legToTravelDistance(curr)), 0); +} + +async function computeAndPersistStatistics(type: 'tour' | 'rideShareTour') { + const tours = type === 'tour' ? await tourQuery() : await rideShareTourQuery(); + const stats = await Promise.all( + tours.map( + async (t) => + await computeStatistics( + t.events.sort((e1, e2) => e1.scheduledTimeStart - e2.scheduledTimeStart), + type === 'tour' ? { lat: t.lat!, lng: t.lng! } : undefined + ) + ) + ); + for (let i = 0; i != tours.length; ++i) { + await db + .updateTable(type) + .set({ + fullyPayedM: Math.round(stats[i].fullyPayedM), + approachAndReturnM: Math.round(stats[i].approachPlusReturn), + occupiedM: Math.round(stats[i].occupiedM) + }) + .where('id', '=', tours[i].id) + .execute(); + } +} diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index e20bf72b3..38ff21a53 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -64,6 +64,9 @@ export interface Database { directDuration: number | null; cancelled: boolean; message: string | null; + approachAndReturnM: number | null; + fullyPayedM: number | null; + occupiedM: number | null; }; availability: { id: Generated; @@ -137,6 +140,9 @@ export interface Database { communicatedEnd: number; earliestStart: number; latestEnd: number; + approachAndReturnM: number | null; + fullyPayedM: number | null; + occupiedM: number | null; }; rideShareVehicle: { id: Generated; diff --git a/src/lib/util/polylineToGeoJSON.ts b/src/lib/util/polylineToGeoJSON.ts index f56a7a5d2..7ca164ccc 100644 --- a/src/lib/util/polylineToGeoJSON.ts +++ b/src/lib/util/polylineToGeoJSON.ts @@ -3,8 +3,12 @@ import polyline from '@mapbox/polyline'; export function polylineToGeoJSON(encodedPolyline: string): GeoJSON.LineString { return { type: 'LineString', - coordinates: polyline - .decode(encodedPolyline, 7) // - .map(([lng, lat]) => [lat, lng]) + coordinates: polyLineToLatLngArray(encodedPolyline) }; } + +export function polyLineToLatLngArray(encodedPolyline: string) { + return polyline + .decode(encodedPolyline, 7) // + .map(([lng, lat]) => [lat, lng]); +} diff --git a/src/routes/apiInternal/createStatistics/+server.ts b/src/routes/apiInternal/createStatistics/+server.ts new file mode 100644 index 000000000..dbacf14f2 --- /dev/null +++ b/src/routes/apiInternal/createStatistics/+server.ts @@ -0,0 +1,7 @@ +import { createStatistics } from '$lib/createStatistics'; +import { json, type RequestEvent } from '@sveltejs/kit'; + +export const POST = async (_: RequestEvent) => { + await createStatistics(); + return json({}); +};