Skip to content
Draft

Stats #576

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
3 changes: 3 additions & 0 deletions cron/crontab
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions cron/scripts/createStatistics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
try {
await fetch('http://prima:3000/apiInternal/createStatistics', {
method: 'POST'
});
} catch (err) {
console.error('Error:', err);
}
17 changes: 17 additions & 0 deletions migrations/2026-01-26.js
Original file line number Diff line number Diff line change
@@ -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() { }
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions scripts/createStatistics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createStatistics } from '../src/lib/createStatistics';

async function main(): Promise<void> {
createStatistics();
}

// Run the main function
main().catch((error) => {
console.error('Error in main function:', error);
});
63 changes: 63 additions & 0 deletions scripts/viewStatistics.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof getTours>>;
type RsTours = Awaited<ReturnType<typeof getRsTours>>;

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);
});
53 changes: 53 additions & 0 deletions src/lib/createStatistics.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
159 changes: 159 additions & 0 deletions src/lib/createStatistics.ts
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could also be interesting for cancelled tours?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. So far I avoided touching this since, #582 means there might be tours for which it is not trivial to tell which requests belonged to it before it was cancelled. So I will probably look at this issue before merging this.

.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<ReturnType<typeof tourQuery>>;
type Tour = Tours[0];
type Event = Tour['events'][0];

async function computeStatistics(events: Event[], company?: Coordinates) {
const routingQueries = new Array<Promise<Itinerary | undefined>>(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();
}
}
6 changes: 6 additions & 0 deletions src/lib/server/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>;
Expand Down Expand Up @@ -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<number>;
Expand Down
10 changes: 7 additions & 3 deletions src/lib/util/polylineToGeoJSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
7 changes: 7 additions & 0 deletions src/routes/apiInternal/createStatistics/+server.ts
Original file line number Diff line number Diff line change
@@ -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({});
};