diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index e07cbbd7..00000000 --- a/src/index.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('sum test', () => { - it('adds 1 + 2 to equal 3', () => { - expect(1 + 2).toBe(3); - }); -}); diff --git a/src/lib/api.ts b/src/lib/api.ts index ded802f0..ae967769 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -67,6 +67,26 @@ export const addAvailability = async (vehicleId: number, from: Date, to: Date) = }); }; +export type BookingRequestParameters = { + userChosen: Coordinates; + busStops: Coordinates[]; + startFixed: boolean; + timeStamps: Date[][]; + numPassengers: number; + numWheelchairs: number; + numBikes: number; + luggage: number; +}; + +export const whitelisting = async (r: BookingRequestParameters) => { + return await fetch('/api/whitelisting', { + method: 'POST', + body: JSON.stringify({ + r + }) + }); +}; + export const booking = async ( from: Location, to: Location, diff --git a/src/lib/capacities.ts b/src/lib/capacities.ts new file mode 100644 index 00000000..b6b1ab0b --- /dev/null +++ b/src/lib/capacities.ts @@ -0,0 +1,91 @@ +import type { Event } from '$lib/compositionTypes.js'; + +export type Capacity = { + wheelchairs: number; + bikes: number; + passengers: number; + luggage: number; +}; + +export type Range = { + earliestPickup: number; + latestDropoff: number; +}; + +export class CapacitySimulation { + constructor( + bikeCapacity: number, + wheelchairCapacity: number, + seats: number, + storageSpace: number + ) { + this.bikeCapacity = bikeCapacity; + this.wheelchairCapacity = wheelchairCapacity; + this.seats = seats; + this.storageSpace = storageSpace; + this.bikes = 0; + this.wheelchairs = 0; + this.passengers = 0; + this.luggage = 0; + } + private bikeCapacity: number; + private wheelchairCapacity: number; + private seats: number; + private storageSpace: number; + private bikes: number; + private wheelchairs: number; + private passengers: number; + private luggage: number; + + private adjustValues(event: Event | Capacity) { + if (event instanceof Capacity || event.is_pickup) { + this.bikes += event.bikes; + this.wheelchairs += event.wheelchairs; + this.passengers += event.passengers; + this.luggage += event.luggage; + } else { + this.bikes -= event.bikes; + this.wheelchairs -= event.wheelchairs; + this.passengers -= event.passengers; + this.luggage -= event.luggage; + } + } + + private isValid(): boolean { + return ( + this.bikeCapacity >= this.bikes && + this.wheelchairCapacity >= this.wheelchairs && + this.storageSpace + this.seats >= this.luggage + this.passengers && + this.seats >= this.passengers + ); + } + + getPossibleInsertionRanges = (events: Event[], toInsert: Capacity): Range[] => { + this.reset(); + const possibleInsertions: Range[] = []; + this.adjustValues(toInsert); + let start: number | undefined = undefined; + for (let i = 0; i != events.length; i++) { + this.adjustValues(events[i]); + if (!this.isValid()) { + if (start != undefined) { + possibleInsertions.push({ earliestPickup: start, latestDropoff: i }); + start = undefined; + } + continue; + } + start = start == undefined ? i : start; + } + if (start != undefined) { + possibleInsertions.push({ earliestPickup: start, latestDropoff: events.length }); + } + return possibleInsertions; + }; + + private reset() { + this.bikes = 0; + this.wheelchairs = 0; + this.passengers = 0; + this.luggage = 0; + } +} diff --git a/src/lib/compositionTypes.ts b/src/lib/compositionTypes.ts new file mode 100644 index 00000000..4f3f6952 --- /dev/null +++ b/src/lib/compositionTypes.ts @@ -0,0 +1,37 @@ +import type { Interval } from './interval'; +import type { Coordinates } from './location'; + +export type Company = { + id: number; + coordinates: Coordinates; + vehicles: Vehicle[]; + zoneId: number; +}; +export type Vehicle = { + id: number; + bike_capacity: number; + storage_space: number; + wheelchair_capacity: number; + seats: number; + tours: Tour[]; + availabilities: Interval[]; +}; + +export type Tour = { + departure: Date; + arrival: Date; + id: number; + events: Event[]; +}; + +export type Event = { + passengers: number; + luggage: number; + wheelchairs: number; + bikes: number; + is_pickup: boolean; + time: Interval; + id: number; + coordinates: Coordinates; + tourId: number; +}; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index c87571a0..cf49418c 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,5 +1,8 @@ -import { hoursToMs } from './time_utils'; +import { hoursToMs, minutesToMs } from './time_utils'; export const TZ = 'Europe/Berlin'; export const MIN_PREP_MINUTES = 30; export const MAX_TRAVEL_DURATION = hoursToMs(1); +export const MAX_PASSENGER_WAITING_TIME = minutesToMs(10); +export const SRID = 4326; +export const SEARCH_INTERVAL_SIZE = minutesToMs(30); diff --git a/src/lib/interval.ts b/src/lib/interval.ts index 596d7ddb..d2385954 100644 --- a/src/lib/interval.ts +++ b/src/lib/interval.ts @@ -91,4 +91,25 @@ export class Interval { merged.push(unmerged.pop()!); return merged; }; + + intersect(other:Interval):Interval|undefined{ + if(this.overlaps(other)){ + return new Interval(new Date(Math.max(this.startTime.getTime(), other.startTime.getTime())), new Date(Math.min(this.endTime.getTime(), other.endTime.getTime()))); + } + return undefined; + }; + + static intersect = (many: Interval[], one: Interval): Interval[] => { + const result: Interval[] = []; + for(let i=0;i!=many.length;++i){ + if(one.startTime.getTime() > many[i].endTime.getTime()){ + break; + } + if(!many[i].overlaps(one)){ + continue; + } + result.push(many[i].intersect(one)!); + } + return result; + } } diff --git a/src/lib/sqlHelpers.ts b/src/lib/sqlHelpers.ts index 3f8276fb..e8e47f8e 100644 --- a/src/lib/sqlHelpers.ts +++ b/src/lib/sqlHelpers.ts @@ -1,4 +1,8 @@ -import { db } from '$lib/database'; +import { sql, type SelectQueryBuilder } from 'kysely'; +import { db } from './database'; +import type { Coordinates } from './location'; +import type { Database } from './types'; +import { SRID } from './constants'; export const queryCompletedTours = async (companyId: number | undefined) => { return await db @@ -21,3 +25,46 @@ export const queryCompletedTours = async (companyId: number | undefined) => { ]) .execute(); }; + +export enum ZoneType { + Any, + Community, + CompulsoryArea +} + +export const selectZonesContainingCoordinates = ( + coordinates: Coordinates, + coordinates2: Coordinates | undefined, + zoneType: ZoneType +) => { + return db + .selectFrom('zone') + .$if(zoneType != ZoneType.Any, (qb) => + qb.where('zone.is_community', '=', zoneType == ZoneType.Community ? true : false) + ) + .$if(coordinates2 != undefined, (qb) => + qb.where( + sql`ST_Covers(zone.area, ST_SetSRID(ST_MakePoint(${coordinates2!.lng}, ${coordinates2!.lat}), ${SRID}))` + ) + ) + .where( + sql`ST_Covers(zone.area, ST_SetSRID(ST_MakePoint(${coordinates.lng}, ${coordinates.lat}), ${SRID}))` + ); +}; + +export const joinInitializedCompaniesOnZones = ( + query: SelectQueryBuilder +) => { + return query + .innerJoin('company', 'company.zone', 'zone.id') + .where((eb) => + eb.and([ + eb('company.latitude', 'is not', null), + eb('company.longitude', 'is not', null), + eb('company.address', 'is not', null), + eb('company.name', 'is not', null), + eb('company.zone', 'is not', null), + eb('company.community_area', 'is not', null) + ]) + ); +}; diff --git a/src/routes/api/whitelist/+server.ts b/src/routes/api/whitelist/+server.ts new file mode 100644 index 00000000..477f902f --- /dev/null +++ b/src/routes/api/whitelist/+server.ts @@ -0,0 +1,188 @@ +import { oneToMany, Direction, type BookingRequestParameters } from '$lib/api.js'; +import { Coordinates, Location } from '$lib/location.js'; +import { error, json } from '@sveltejs/kit'; +import {} from '$lib/utils.js'; +import { minutesToMs, secondsToMs } from '$lib/time_utils.js'; +import { MAX_TRAVEL_DURATION, MIN_PREP_MINUTES } from '$lib/constants.js'; +import { + areChosenCoordinatesInsideAnyZone, + bookingApiQuery, + doesVehicleWithCapacityExist, + type BookingApiQueryResult +} from './queries'; +import { TourScheduler } from './tourScheduler'; +import { computeSearchIntervals } from './searchInterval'; +import type { Capacity } from '$lib/capacities'; + +export type ReturnType = { + status: number; + message: string; +}; + +export type SimpleEvent = { + location: Location; + time: Date; +}; + +export const POST = async (event) => { + const customer = event.locals.user; + if (!customer) { + return error(403); + } + const parameters: BookingRequestParameters[] = JSON.parse(await event.request.json()); + const requests = parameters.length; + if (requests == 0) { + return json({ status: 1, message: 'Es wurden keine Daten übermittelt.' }, { status: 400 }); + } + if(requests>2){ + return json({ status: 1, message: 'Die API erwartet ein Array mit entweder einem oder zwei Einträgen, für die erste und letzte Meile.' }, { status: 400 }); + } + getValidBookings(parameters[0]); + getValidBookings(parameters[1]); +}; + +const getValidBookings = async ( + p: BookingRequestParameters +) => { + const requiredCapacity: Capacity = { + bikes: p.numBikes, + luggage: p.luggage, + wheelchairs: p.numWheelchairs, + passengers: p.numPassengers + }; + const userChosen: Coordinates = p.userChosen; + if (p.busStops.length == 0) { + return json({ status: 1, message: 'Es wurden keine Haltestellen angegeben.' }, { status: 400 }); + } + + let travelDurations = []; + try { + travelDurations = (await oneToMany(userChosen, p.busStops, Direction.Forward)).map((res) => + secondsToMs(res.duration) + ); + } catch (e) { + return json({ status: 500 }); + } + + if (travelDurations.find((d) => d <= MAX_TRAVEL_DURATION) == undefined) { + return json( + { + status: 2, + message: + 'Das Straßenrouting war nicht erfolgreich. Mögliche Gründe: (1) Die angegebenen Koordinaten wurden nicht in den Open Street Map Daten gefunden, (2) Die Reisezeit überschreitet eine Stunde.' + }, + { status: 400 } + ); + } + const results = new Array(travelDurations.length); + + const maxIntervals = computeSearchIntervals( + p.startFixed, + p.timeStamps, + Math.max(...travelDurations) + ); + const earliestValidStartTime = new Date(Date.now() + minutesToMs(MIN_PREP_MINUTES)); + if (earliestValidStartTime > maxIntervals.startTimes.endTime) { + return json( + { + status: 3, + message: 'Die Anfrage verletzt die minimale Vorlaufzeit für alle Haltestellen.' + }, + { status: 400 } + ); + } + + const dbResult: BookingApiQueryResult = await bookingApiQuery( + userChosen, + requiredCapacity, + maxIntervals.expandedSearchInterval, + p.busStops + ); + if (dbResult.companies.length == 0) { + return determineError(userChosen, requiredCapacity); + } + + for (let index = 0; index != travelDurations.length; ++index) { + const travelDuration = travelDurations[index]; + if (travelDuration > MAX_TRAVEL_DURATION) { + results[index] = { + status: 6, + message: + 'Die Koordinaten der Haltestelle konnten in den Open Street Map Daten nicht zugeordnet werden.' + }; + continue; + } + const intervals = computeSearchIntervals(p.startFixed, p.timeStamps, travelDuration); + const possibleStartTimes = intervals.startTimes; + if (earliestValidStartTime > possibleStartTimes.endTime) { + results[index] = { + status: 7, + message: 'Die Anfrage verletzt die minimale Vorlaufzeit für diese Haltestelle.' + }; + continue; + } + const busStopZones = dbResult.busStopZoneIds.get(index); + if (busStopZones == undefined) { + return json({ status: 500 }); + } + const currentCompanies = dbResult.companies.filter( + (c) => busStopZones.find((zId) => zId == c.zoneId) != undefined + ); + if (currentCompanies.length == 0) { + results[index] = { + status: 7, + message: + 'Diese Haltestelle liegt nicht im selben Pflichtfahrgebiet wie die ausgewählten Koordinaten.' + }; + continue; + } + if (earliestValidStartTime > possibleStartTimes.startTime) { + possibleStartTimes.startTime = earliestValidStartTime; + } + } + + const tourConcatenations = new TourScheduler( + p.startFixed, + p.userChosen, + p.busStops, + p.timeStamps, + travelDurations, + dbResult.companies, + requiredCapacity + ); + tourConcatenations.createTourConcatenations(); +}; + +const determineError = async ( + start: Coordinates, + requiredCapacity: Capacity +): Promise => { + if (!(await areChosenCoordinatesInsideAnyZone(start))) { + return json( + { + status: 4, + message: + 'Die angegebenen Koordinaten sind in keinem Pflichtfahrgebiet das vom PrimaÖV-Projekt bedient wird enthalten.' + }, + { status: 400 } + ); + } + if (!(await doesVehicleWithCapacityExist(start, requiredCapacity))) { + return json( + { + status: 5, + message: + 'Kein Unternehmen im relevanten Pflichtfahrgebiet hat ein Fahrzeug mit ausreichend Kapazität.' + }, + { status: 400 } + ); + } + return json( + { + status: 6, + message: + 'Kein Unternehmen im relevanten Pflichtfahrgebiet hat ein Fahrzeug mit ausreichend Kapazität, das zwischen Start und Ende der Anfrage verfügbar ist.' + }, + { status: 400 } + ); +}; diff --git a/src/routes/api/whitelist/queries.ts b/src/routes/api/whitelist/queries.ts new file mode 100644 index 00000000..f8309379 --- /dev/null +++ b/src/routes/api/whitelist/queries.ts @@ -0,0 +1,292 @@ +import { Coordinates } from '$lib/location.js'; +import { Interval } from '$lib/interval.js'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; +import { sql } from 'kysely'; +import { db } from '$lib/database'; +import type { ExpressionBuilder } from 'kysely'; +import type { Database } from '$lib/types'; +import { SRID } from '$lib/constants'; +import { groupBy } from '$lib/collection_utils'; +import type { Capacity } from '$lib/capacities'; +import { + joinInitializedCompaniesOnZones, + selectZonesContainingCoordinates, + ZoneType +} from '$lib/sqlHelpers'; +import type { Company } from '$lib/compositionTypes'; + +export type BookingApiQueryResult = { + companies: Company[]; + busStopZoneIds: Map; +}; + +const selectAvailabilities = (eb: ExpressionBuilder, interval: Interval) => { + return jsonArrayFrom( + eb + .selectFrom('availability') + .whereRef('availability.vehicle', '=', 'vehicle.id') + .where((eb) => + eb.and([ + eb('availability.start_time', '<=', interval.endTime), + eb('availability.end_time', '>=', interval.startTime) + ]) + ) + .select(['availability.start_time', 'availability.end_time']) + ).as('availabilities'); +}; + +const selectEvents = (eb: ExpressionBuilder) => { + return jsonArrayFrom( + eb + .selectFrom('request') + .whereRef('request.tour', '=', 'tour.id') + .innerJoin('event', 'request.id', 'event.request') + .select([ + 'event.id', + 'event.communicated_time', + 'event.scheduled_time', + 'event.latitude', + 'event.longitude', + 'request.passengers', + 'request.bikes', + 'request.luggage', + 'request.wheelchairs', + 'event.is_pickup' + ]) + ).as('events'); +}; + +const selectTours = (eb: ExpressionBuilder, interval: Interval) => { + return jsonArrayFrom( + eb + .selectFrom('tour') + .whereRef('tour.vehicle', '=', 'vehicle.id') + .where((eb) => + eb.and([ + eb('tour.departure', '<=', interval.endTime), + eb('tour.arrival', '>=', interval.startTime) + ]) + ) + .select((eb) => ['tour.id', 'tour.departure', 'tour.arrival', selectEvents(eb)]) + ).as('tours'); +}; + +const selectVehicles = ( + eb: ExpressionBuilder, + interval: Interval, + requiredCapacities: Capacity +) => { + return jsonArrayFrom( + eb + .selectFrom('vehicle') + .whereRef('vehicle.company', '=', 'company.id') + .where((eb) => + eb.and([ + eb('vehicle.wheelchair_capacity', '>=', requiredCapacities.wheelchairs), + eb('vehicle.bike_capacity', '>=', requiredCapacities.bikes), + eb('vehicle.seats', '>=', requiredCapacities.passengers), + eb( + 'vehicle.storage_space', + '>=', + sql`cast(${requiredCapacities.passengers} as integer) + cast(${requiredCapacities.luggage} as integer) - ${eb.ref('vehicle.seats')}` + ) + ]) + ) + .select((eb) => [ + 'vehicle.id', + 'vehicle.bike_capacity', + 'vehicle.storage_space', + 'vehicle.wheelchair_capacity', + 'vehicle.seats', + selectTours(eb, interval), + selectAvailabilities(eb, interval) + ]) + ).as('vehicles'); +}; + +const selectCompanies = ( + eb: ExpressionBuilder, + interval: Interval, + requiredCapacities: Capacity +) => { + return jsonArrayFrom( + eb + .selectFrom('company') + .whereRef('company.zone', '=', 'zone.id') + .where((eb) => + eb.and([ + eb('company.latitude', 'is not', null), + eb('company.longitude', 'is not', null), + eb('company.address', 'is not', null), + eb('company.name', 'is not', null), + eb('company.zone', 'is not', null), + eb('company.community_area', 'is not', null) + ]) + ) + .select([ + 'company.latitude', + 'company.longitude', + 'company.id', + 'company.zone', + selectVehicles(eb, interval, requiredCapacities) + ]) + ).as('companies'); +}; + +export const bookingApiQuery = async ( + start: Coordinates, + requiredCapacities: Capacity, + expandedSearchInterval: Interval, + busStops: Coordinates[] +): Promise => { + interface CoordinateTable { + index: number; + longitude: number; + latitude: number; + } + + const dbResult = await db + .with('busStops', (db) => { + const cteValues = busStops.map( + (busStop, i) => + sql`SELECT cast(${i} as integer) AS index, ${busStop.lat} AS latitude, ${busStop.lng} AS longitude` + ); + return db + .selectFrom( + sql`(${sql.join(cteValues, sql` UNION ALL `)})`.as('cte') + ) + .selectAll(); + }) + .selectFrom('zone') + .where('zone.is_community', '=', false) + .where( + sql`ST_Covers(zone.area, ST_SetSRID(ST_MakePoint(${start.lng}, ${start.lat}), ${SRID}))` + ) + .select((eb) => [ + selectCompanies(eb, expandedSearchInterval, requiredCapacities), + jsonArrayFrom( + eb + .selectFrom('busStops') + .where( + sql`ST_Covers(zone.area, ST_SetSRID(ST_MakePoint(cast(busStops.longitude as float), cast(busStops.latitude as float)), ${SRID}))` + ) + .select(['busStops.index as busStopIndex', 'zone.id as zoneId']) + ).as('busStopZone') + ]) + .executeTakeFirst(); + + if (dbResult == undefined) { + return { companies: [], busStopZoneIds: new Map() }; + } + + const companies = dbResult.companies + .map((c) => { + return { + id: c.id, + coordinates: new Coordinates(c.latitude!, c.longitude!), + zoneId: c.zone!, + vehicles: c.vehicles + .filter((v) => v.availabilities.length != 0) + .map((v) => { + return { + ...v, + availabilities: Interval.merge( + v.availabilities.map((a) => new Interval(a.start_time, a.end_time)) + ), + tours: v.tours.map((t) => { + return { + ...t, + events: t.events.map((e) => { + const scheduled: Date = new Date(e.scheduled_time); + const communicated: Date = new Date(e.communicated_time); + return { + tourId: t.id, + ...e, + coordinates: new Coordinates(e.latitude, e.longitude), + time: new Interval( + new Date(Math.min(scheduled.getTime(), communicated.getTime())), + new Date(Math.max(scheduled.getTime(), communicated.getTime())) + ) + }; + }) + }; + }) + }; + }) + }; + }) + .filter((c) => c.vehicles.length != 0); + companies.forEach((c) => + c.vehicles.forEach((v) => { + v.tours.sort((t1, t2) => t1.departure.getTime() - t2.departure.getTime()); + v.tours.forEach((t) => + t.events.sort((e1, e2) => e1.time.startTime.getTime() - e2.time.startTime.getTime()) + ); + }) + ); + const a = groupBy( + dbResult.busStopZone, + (b) => b.busStopIndex, + (b) => b.zoneId + ); + const zoneContainsBusStop: (boolean)[][] = []; + for(let busStopIdx=0;busStopIdx!=busStops.length;++busStopIdx){ + const buffer=new Array(dbResult.companies.length); + const busStopZones=a.get(busStopIdx); + if(busStopZones!=undefined){ + for(let companyIdx=0;companyIdx!=dbResult.companies.length;++companyIdx){ + buffer[companyIdx]=busStopZones.find((z)=>z==dbResult.companies[companyIdx].zone)!=undefined; + } + zoneContainsBusStop[busStopIdx]=buffer; + }else{ + zoneContainsBusStop[busStopIdx]=[]; + } + } + return { + companies, + busStopZoneIds: groupBy( + dbResult.busStopZone, + (b) => b.busStopIndex, + (b) => b.zoneId + ) + }; +}; + +export const areChosenCoordinatesInsideAnyZone = async ( + coordinates: Coordinates +): Promise => { + return ( + (await selectZonesContainingCoordinates(coordinates, undefined, ZoneType.CompulsoryArea) + .selectAll() + .executeTakeFirst()) != undefined + ); +}; + +export const doesVehicleWithCapacityExist = async ( + coordinates: Coordinates, + requiredCapacities: Capacity +): Promise => { + return ( + (await joinInitializedCompaniesOnZones( + selectZonesContainingCoordinates(coordinates, undefined, ZoneType.CompulsoryArea) + ) + .innerJoin( + (eb) => + eb + .selectFrom('vehicle') + .selectAll() + .where((eb) => + eb.and([ + eb('vehicle.wheelchair_capacity', '>=', requiredCapacities.wheelchairs), + eb('vehicle.bike_capacity', '>=', requiredCapacities.bikes), + eb('vehicle.seats', '>=', requiredCapacities.passengers), + eb('vehicle.storage_space', '>=', requiredCapacities.luggage) + ]) + ) + .as('vehicle'), + (join) => join.onRef('vehicle.company', '=', 'company.id') + ) + .selectAll() + .executeTakeFirst()) == undefined + ); +}; diff --git a/src/routes/api/whitelist/searchInterval.test.ts b/src/routes/api/whitelist/searchInterval.test.ts new file mode 100644 index 00000000..7fb38526 --- /dev/null +++ b/src/routes/api/whitelist/searchInterval.test.ts @@ -0,0 +1,62 @@ +// import { computeSearchIntervals } from './searchInterval'; +import { db } from '$lib/database'; +import { describe, it, expect, beforeAll } from 'vitest'; + +let plate = 1; + +const addCompany = async (): Promise => { + return (await db + .insertInto('company') + .values({ address: null }) + .returning('id') + .executeTakeFirst())!.id; +}; + +const addTaxi = async (company: number): Promise => { + ++plate; + return (await db + .insertInto('vehicle') + .values({ + license_plate: String(plate), + company, + seats: 3, + wheelchair_capacity: 0, + bike_capacity: 0, + storage_space: 0 + }) + .returning('id') + .executeTakeFirst())!.id; +}; + +const clearDatabase = async () => { + await Promise.all([ + db.deleteFrom('company').execute(), + db.deleteFrom('vehicle').execute(), + db.deleteFrom('tour').execute(), + db.deleteFrom('availability').execute(), + db.deleteFrom('auth_user').execute(), + db.deleteFrom('user_session').execute(), + db.deleteFrom('event').execute(), + db.deleteFrom('address').execute(), + db.deleteFrom('request').execute() + ]); +}; + +describe('sum test', () => { + beforeAll(async () => { + await clearDatabase(); + }); + + it('adds 1 + 2 to equal 3', async () => { + const company = await addCompany(); + const taxi1 = await addTaxi(company); + const taxi2 = await addTaxi(company); + + console.log(taxi1, taxi2); + + // await setAvailability(taxi1, ['2024-09-23T17:00', '2024-09-23T18:00']); + // await setAvailability(taxi2, ['2024-09-23T17:30', '2024-09-23T18:30']); + + // await addBooking(); + }); +}); diff --git a/src/routes/api/whitelist/searchInterval.ts b/src/routes/api/whitelist/searchInterval.ts new file mode 100644 index 00000000..f40f2f63 --- /dev/null +++ b/src/routes/api/whitelist/searchInterval.ts @@ -0,0 +1,27 @@ +import { Interval } from '$lib/interval.js'; +import { MAX_TRAVEL_DURATION, SEARCH_INTERVAL_SIZE } from '$lib/constants.js'; + +export const computeSearchIntervals = ( + startFixed: boolean, + times: Date[][], + travelDuration: number +): { + startTimes: Interval; + searchInterval: Interval; + expandedSearchInterval: Interval; +} => { + const time = times.flatMap((t) => t)[0]; + const possibleStartTimes = new Interval( + startFixed ? time : new Date(time.getTime() - SEARCH_INTERVAL_SIZE - travelDuration), + startFixed + ? new Date(time.getTime() + SEARCH_INTERVAL_SIZE) + : new Date(time.getTime() - travelDuration) + ); + const searchInterval = possibleStartTimes.expand(0, travelDuration); + const expandedSearchInterval = searchInterval.expand(MAX_TRAVEL_DURATION, MAX_TRAVEL_DURATION); + return { + startTimes: possibleStartTimes, + searchInterval, + expandedSearchInterval + }; +}; diff --git a/src/routes/api/whitelist/tourScheduler.ts b/src/routes/api/whitelist/tourScheduler.ts new file mode 100644 index 00000000..8ec5c473 --- /dev/null +++ b/src/routes/api/whitelist/tourScheduler.ts @@ -0,0 +1,476 @@ +import { Interval } from '$lib/interval.js'; +import { Coordinates } from '$lib/location.js'; +import { Capacity, CapacitySimulation, type Range } from '$lib/capacities.js'; +import { type Company, type Event } from '$lib/compositionTypes.js'; +import type { SimpleEvent } from './+server.js'; +import { Direction, oneToMany } from '$lib/api.js'; +import { MAX_PASSENGER_WAITING_TIME } from '$lib/constants.js'; + +enum InsertionType { + CONNECT, + APPEND, + PREPEND, + INSERT +} + +enum Timing { + BEFORE, + AFTER +} + +class EventInsertion { + constructor( + startFixed: boolean, + fromUserChosenDuration: number, + toUserChosenDuration: number, + fromBusStopDurations: number[], + toBusStopDurations: number[], + travelDurations: number[], + window: Interval, + busStopTimes: Interval[][], + availabilities: Interval[], + type: InsertionType + ) { + console.assert(fromBusStopDurations.length == toBusStopDurations.length); + console.assert(fromBusStopDurations.length == travelDurations.length); + this.busStops = new Array(busStopTimes.length); + this.both = new Array(busStopTimes.length); + + const availabilitiesInWindow: Interval[] = + type != InsertionType.INSERT ? [window] : Interval.intersect(availabilities, window); + + this.userChosen = availabilitiesInWindow.filter( + (a) => a.getDurationMs() >= fromUserChosenDuration + toUserChosenDuration! + ); + for (let i = 0; i != travelDurations.length; ++i) { + const duration = fromBusStopDurations[i] + toBusStopDurations[i]; + const bothDuration = + (startFixed ? fromBusStopDurations[i] : fromUserChosenDuration) + + travelDurations[i] + + (startFixed ? toUserChosenDuration : toBusStopDurations[i]); + for (let j = 0; j != busStopTimes[i].length; ++j) { + this.busStops[i][j] = Interval.intersect( + availabilitiesInWindow.filter((a) => a.getDurationMs() >= duration), + busStopTimes[i][j] + ); + this.both[i][j] = Interval.intersect( + availabilitiesInWindow.filter((a) => a.getDurationMs() >= bothDuration), + busStopTimes[i][j] + ); + } + } + } + userChosen: Interval[]; + busStops: Interval[][][]; + both: Interval[][][]; +} + +type Answer = { + companyId: number; + vehicleId: number; + pickupAfterEventId: number | undefined; + dropoffAfterEventId: number | undefined; + type: InsertionType; +}; + +export class TourScheduler { + constructor( + startFixed: boolean, + userChosen: Coordinates, + busStops: Coordinates[], + busStopTimes: Date[][], + travelDurations: number[], + companies: Company[], + required: Capacity, + companyMayServeBusStop: boolean[][] + ) { + this.companyMayServeBusStop = companyMayServeBusStop; + this.busStopTimes = busStopTimes.map((times) => + times.map( + (t) => + new Interval( + startFixed ? t : new Date(t.getTime() - MAX_PASSENGER_WAITING_TIME), + startFixed ? new Date(t.getTime() + MAX_PASSENGER_WAITING_TIME) : t + ) + ) + ); + this.required = required; + this.companies = companies; + this.travelDurations = travelDurations; + this.startFixed = startFixed; + this.userChosen = userChosen; + this.busStops = busStops; + + this.possibleInsertionsByVehicle = new Map(); + + this.userChosenMany = new Array(2); + this.busStopMany = new Array(2); + + this.userChosenDuration = new Array(2); + this.busStopDurations = new Array(2); + + this.insertDurations = new Array(companies.length); + + this.insertionIndexesUserChosenDurationIndexes = []; + this.insertionIndexesBusStopDurationIndexes = []; + + this.companyIndexesUserChosenDurationIndexes = new Array(companies.length); + this.companyIndexesBusStopDurationIndexes = new Array(companies.length); + + this.answers = new Array(busStops.length); + } + companyMayServeBusStop: boolean[][]; + busStopTimes: Interval[][]; + required: Capacity; + companies: Company[]; + startFixed: boolean; + travelDurations: number[]; + userChosen: Coordinates; + busStops: Coordinates[]; + + possibleInsertionsByVehicle: Map; + + userChosenMany: Coordinates[][]; + busStopMany: Coordinates[][][]; + + userChosenDuration: number[][]; + busStopDurations: number[][][]; + + insertionIndexesUserChosenDurationIndexes: number[][][][]; + insertionIndexesBusStopDurationIndexes: number[][][][][]; + + companyIndexesUserChosenDurationIndexes: number[][]; + companyIndexesBusStopDurationIndexes: number[][][]; + + insertDurations: EventInsertion[][][][]; + + answers: Answer[][]; + + createTourConcatenations = async () => { + this.simulateCapacities(); + this.gatherRoutingCoordinates(); + this.routing(); + this.computeTravelDurations(); + this.createInsertionPairs(); + }; + + private simulateCapacities() { + this.companies.forEach((c) => { + c.vehicles.forEach((v) => { + const simulation = new CapacitySimulation( + v.bike_capacity, + v.wheelchair_capacity, + v.seats, + v.storage_space + ); + this.possibleInsertionsByVehicle.set( + v.id, + simulation.getPossibleInsertionRanges( + v.tours.flatMap((t) => t.events), + this.required + ) + ); + }); + }); + } + + private gatherRoutingCoordinates() { + this.companies.forEach((c, companyIdx) => { + this.addCompanyCoordinates(c.coordinates, companyIdx); + c.vehicles.forEach((v, vehicleIdx) => { + const allEvents = v.tours.flatMap((t) => t.events); + const insertions = this.possibleInsertionsByVehicle.get(v.id)!; + forEachInsertion(insertions, (insertionIdx) => { + this.addCoordinates( + allEvents[insertionIdx].coordinates, + allEvents[insertionIdx + 1].coordinates, + companyIdx, + vehicleIdx, + insertionIdx + ); + }); + }); + }); + } + + private addCompanyCoordinates(c: Coordinates, companyIdx: number) { + for (let busStopIdx = 0; busStopIdx != this.busStops.length; ++busStopIdx) { + if (!this.companyMayServeBusStop[busStopIdx][companyIdx]) { + continue; + } + this.busStopMany[Timing.BEFORE][busStopIdx].push(c); + this.companyIndexesBusStopDurationIndexes[Timing.BEFORE][companyIdx][busStopIdx] = + this.busStopMany.length; + this.busStopMany[Timing.AFTER][busStopIdx].push(c); + this.companyIndexesBusStopDurationIndexes[Timing.AFTER][companyIdx][busStopIdx] = + this.busStopMany.length; + } + this.userChosenMany[Timing.BEFORE].push(c); + this.userChosenMany[Timing.AFTER].push(c); + } + + private addCoordinates( + prev: Coordinates, + next: Coordinates, + companyIdx: number, + vehicleIdx: number, + insertionIdx: number + ) { + for (let busStopIdx = 0; busStopIdx != this.busStops.length; ++busStopIdx) { + if (!this.companyMayServeBusStop[busStopIdx][companyIdx]) { + continue; + } + this.busStopMany[Timing.BEFORE][busStopIdx].push(prev); + this.insertionIndexesBusStopDurationIndexes[Timing.BEFORE][companyIdx][vehicleIdx][ + insertionIdx + ][busStopIdx] = this.busStopMany[busStopIdx].length; + this.busStopMany[Timing.AFTER][busStopIdx].push(next); + this.insertionIndexesBusStopDurationIndexes[Timing.AFTER][companyIdx][vehicleIdx][ + insertionIdx + ][busStopIdx] = this.busStopMany[busStopIdx].length; + } + this.userChosenMany[Timing.BEFORE].push(prev); + this.insertionIndexesUserChosenDurationIndexes[Timing.BEFORE][companyIdx][vehicleIdx][ + insertionIdx + ] = this.busStopMany.length; + this.userChosenMany[Timing.AFTER].push(next); + this.insertionIndexesUserChosenDurationIndexes[Timing.AFTER][companyIdx][vehicleIdx][ + insertionIdx + ] = this.busStopMany.length; + } + + private async routing() { + this.userChosenDuration[Timing.BEFORE] = ( + await oneToMany(this.userChosen, this.userChosenMany[Timing.BEFORE], Direction.Backward) + ).map((r) => r.duration); + this.userChosenDuration[Timing.AFTER] = ( + await oneToMany(this.userChosen, this.userChosenMany[Timing.AFTER], Direction.Forward) + ).map((r) => r.duration); + for (let busStopIdx = 0; busStopIdx != this.busStops.length; ++busStopIdx) { + this.busStopDurations[Timing.BEFORE][busStopIdx] = ( + await oneToMany( + this.busStops[busStopIdx], + this.busStopMany[Timing.BEFORE][busStopIdx], + Direction.Backward + ) + ).map((r) => r.duration); + this.busStopDurations[Timing.AFTER][busStopIdx] = ( + await oneToMany( + this.busStops[busStopIdx], + this.busStopMany[Timing.AFTER][busStopIdx], + Direction.Forward + ) + ).map((r) => r.duration); + } + } + + private computeTravelDurations() { + const cases = [ + InsertionType.CONNECT, + InsertionType.APPEND, + InsertionType.PREPEND, + InsertionType.INSERT + ]; + this.companies.forEach((c, companyIdx) => { + this.insertDurations[companyIdx] = new Array(c.vehicles.length); + c.vehicles.forEach((v, vehicleIdx) => { + const allEvents = v.tours.flatMap((t) => t.events); + const insertions = this.possibleInsertionsByVehicle.get(v.id)!; + if (insertions.length == 0) { + return; + } + const lastInsertionIdx = insertions[insertions.length - 1].latestDropoff; + this.insertDurations[companyIdx][vehicleIdx] = new Array( + lastInsertionIdx + ); + forEachInsertion(insertions, (insertionIdx) => { + this.insertDurations[companyIdx][vehicleIdx][insertionIdx] = new Array(4); + const prev = allEvents[insertionIdx]; + const next = allEvents[insertionIdx + 1]; + const departure = v.tours.find((t) => t.id == next.tourId)!.departure; + const arrival = v.tours.find((t) => t.id == prev.tourId)!.arrival; + cases.forEach((type) => { + if ((prev.tourId == next.tourId) != (type == InsertionType.INSERT)) { + return; + } + const isAppend = type === InsertionType.CONNECT || type === InsertionType.APPEND; + const isPrepend = type === InsertionType.CONNECT || type === InsertionType.PREPEND; + const interv = new Interval( + isAppend ? arrival : prev.time.startTime, + isPrepend ? departure : next.time.endTime + ); + const p = isAppend ? companyIdx : insertionIdx; + const n = isPrepend ? companyIdx : insertionIdx; + this.insertDurations[companyIdx][vehicleIdx][insertionIdx][type] = new EventInsertion( + this.startFixed, + this.userChosenDuration[Timing.BEFORE][p], + this.userChosenDuration[Timing.AFTER][n], + this.busStopDurations[Timing.BEFORE][p], + this.busStopDurations[Timing.AFTER][n], + this.travelDurations, + interv, + this.busStopTimes, + v.availabilities, + type + ); + }); + }); + }); + }); + } + + private createInsertionPairs() { + this.companies.forEach((c, companyIdx) => { + c.vehicles.forEach((v, vehicleIdx) => { + const insertions = this.possibleInsertionsByVehicle.get(v.id)!; + const allEvents = v.tours.flatMap((t) => t.events); + insertions.forEach((insertion) => { + for ( + let pickupIdx = insertion.earliestPickup; + pickupIdx != insertion.latestDropoff; + ++pickupIdx + ) { + for (let dropoffIdx = pickupIdx; dropoffIdx != insertion.latestDropoff; ++dropoffIdx) { + const prevPickup = allEvents[pickupIdx]; + const nextPickup = allEvents[pickupIdx + 1]; + const prevDropoff = allEvents[dropoffIdx]; + const nextDropoff = allEvents[dropoffIdx + 1]; + const pickupTimeDifference = + nextPickup.time.startTime.getTime() - prevPickup.time.endTime.getTime(); + if (nextPickup.tourId != prevDropoff.tourId) { + break; + } + if (prevPickup.tourId == nextDropoff.tourId) { + if (prevPickup.id == prevDropoff.id) { + this.busStops.forEach((_, busStopIdx) => { + const duration = + this.insertDurations[InsertionType.INSERT][companyIdx][vehicleIdx][pickupIdx] + .bothDurations[busStopIdx]; + if (duration != undefined && duration <= pickupTimeDifference) { + this.answers[busStopIdx].push({ + companyId: c.id, + vehicleId: v.id, + pickupAfterEventId: prevPickup.id, + dropoffAfterEventId: prevDropoff.id, + type: InsertionType.INSERT + }); + } + }); + } else { + } + continue; + } + if (prevPickup.tourId == nextPickup.tourId) { + continue; + } + if (prevDropoff.tourId == nextDropoff.tourId) { + continue; + } + } + } + }); + }); + }); + } + + private createInsertionPair( + allEvents: Event[], + pickupIdx: number, + dropoffIdx: number, + companyIdx: number, + companyId: number, + vehicleIdx: number, + vehicleId: number + ) { + const prevPickup = allEvents[pickupIdx]; + const nextPickup = allEvents[pickupIdx + 1]; + const prevDropoff = allEvents[dropoffIdx]; + const nextDropoff = allEvents[dropoffIdx + 1]; + const eventTimeDifference = + nextPickup.time.startTime.getTime() - prevPickup.time.endTime.getTime(); + this.busStops.forEach((_, busStopIdx) => { + let duration: number | undefined = undefined; + let connectDuration: number | undefined = undefined; + let appendDuration: number | undefined = undefined; + let prependDuration: number | undefined = undefined; + if (pickupIdx == dropoffIdx) { + if (prevPickup.tourId != nextPickup.tourId) { + connectDuration = + this.insertDurations[InsertionType.CONNECT][companyIdx][vehicleIdx][pickupIdx] + .bothDurations[busStopIdx]; + appendDuration = + this.insertDurations[InsertionType.APPEND][companyIdx][vehicleIdx][pickupIdx] + .bothDurations[busStopIdx]; + prependDuration = + this.insertDurations[InsertionType.PREPEND][companyIdx][vehicleIdx][pickupIdx] + .bothDurations[busStopIdx]; + } else { + duration = + this.insertDurations[InsertionType.INSERT][companyIdx][vehicleIdx][pickupIdx] + .bothDurations[busStopIdx]; + } + } else { + if (prevPickup.tourId != nextPickup.tourId) { + } else { + const busStopDuration = + this.insertDurations[InsertionType.INSERT][companyIdx][vehicleIdx][ + this.startFixed ? pickupIdx : dropoffIdx + ].busStopDurations[busStopIdx]; + const userChosenDuration = + this.insertDurations[InsertionType.INSERT][companyIdx][vehicleIdx][ + this.startFixed ? dropoffIdx : pickupIdx + ].userChosenDuration; + } + } + + if (duration != undefined && duration <= eventTimeDifference) { + this.answers[busStopIdx].push({ + companyId, + vehicleId, + pickupAfterEventId: prevPickup.id, + dropoffAfterEventId: prevDropoff.id, + type: InsertionType.INSERT + }); + } + if (connectDuration != undefined && connectDuration <= eventTimeDifference) { + this.answers[busStopIdx].push({ + companyId, + vehicleId, + pickupAfterEventId: prevPickup.id, + dropoffAfterEventId: prevDropoff.id, + type: InsertionType.CONNECT + }); + } + if (appendDuration != undefined && appendDuration <= eventTimeDifference) { + this.answers[busStopIdx].push({ + companyId, + vehicleId, + pickupAfterEventId: prevPickup.id, + dropoffAfterEventId: prevDropoff.id, + type: InsertionType.APPEND + }); + } + if (prependDuration != undefined && prependDuration <= eventTimeDifference) { + this.answers[busStopIdx].push({ + companyId, + vehicleId, + pickupAfterEventId: prevPickup.id, + dropoffAfterEventId: prevDropoff.id, + type: InsertionType.PREPEND + }); + } + }); + } +} + +function forEachInsertion(insertions: Range[], fn: (insertionIdx: number) => T) { + insertions.forEach((insertion) => { + for (let i = insertion.earliestPickup; i != insertion.latestDropoff; ++i) { + fn(i); + } + }); +} + +function beelineCheck(insertion: EventInsertion, se: SimpleEvent): boolean { + return true; //TODO +}