diff --git a/src/modules/ad/core/application/queries/match/algorithm.abstract.ts b/src/modules/ad/core/application/queries/match/algorithm.abstract.ts index 6915366..faeb9bc 100644 --- a/src/modules/ad/core/application/queries/match/algorithm.abstract.ts +++ b/src/modules/ad/core/application/queries/match/algorithm.abstract.ts @@ -20,7 +20,7 @@ export abstract class Algorithm { for (const processor of this.processors) { this.candidates = await processor.execute(this.candidates); } - console.log(JSON.stringify(this.candidates, null, 2)); + // console.log(JSON.stringify(this.candidates, null, 2)); return this.candidates.map((candidate: CandidateEntity) => MatchEntity.create({ adId: candidate.id }), ); diff --git a/src/modules/ad/core/domain/calendar-tools.service.ts b/src/modules/ad/core/domain/calendar-tools.service.ts index 184adc6..933b628 100644 --- a/src/modules/ad/core/domain/calendar-tools.service.ts +++ b/src/modules/ad/core/domain/calendar-tools.service.ts @@ -63,14 +63,16 @@ export class CalendarTools { }; /** - * Returns a date from a date and time as strings, adding optional seconds + * Returns a date from a date (as a date) and a time (as a string), adding optional seconds */ - static datetimeFromString = ( - date: string, + static datetimeWithSeconds = ( + date: Date, time: string, additionalSeconds = 0, ): Date => { - const datetime = new Date(`${date}T${time}:00Z`); + const datetime: Date = new Date(date); + datetime.setUTCHours(parseInt(time.split(':')[0])); + datetime.setUTCMinutes(parseInt(time.split(':')[1])); datetime.setUTCSeconds(additionalSeconds); return datetime; }; @@ -79,7 +81,7 @@ export class CalendarTools { * Returns dates from a day and time based on unix epoch day * (1970-01-01 is day 4) * The method returns an array of dates because for edges (day 0 and 6) - * we need to return 2 possibilities + * we need to return 2 possibilities : one for the previous week, one for the next week */ static epochDaysFromTime = (weekDay: number, time: string): Date[] => { if (weekDay < 0 || weekDay > 6) diff --git a/src/modules/ad/core/domain/candidate.entity.ts b/src/modules/ad/core/domain/candidate.entity.ts index 073dcea..a61369e 100644 --- a/src/modules/ad/core/domain/candidate.entity.ts +++ b/src/modules/ad/core/domain/candidate.entity.ts @@ -46,6 +46,7 @@ export class CandidateEntity extends AggregateRoot { /** * Create the journeys based on the driver schedule (the driver 'drives' the carpool !) + * This is a tedious process : additional information can be found in deeper methods ! */ createJourneys = (): CandidateEntity => { this.props.journeys = this.props.driverSchedule.map( @@ -90,6 +91,13 @@ export class CandidateEntity extends AggregateRoot { this._createJourneyItem(carpoolPathItem, index, driverScheduleItem), ) as JourneyItem[]; + /** + * Create a journey item based on a carpool path item and driver schedule item + * The stepIndex is used to get the duration to reach the carpool path item + * from the steps prop (computed previously by a georouter) + * There MUST be a one/one relation between the carpool path items indexes + * and the steps indexes. + */ private _createJourneyItem = ( carpoolPathItem: CarpoolPathItem, stepIndex: number, @@ -123,42 +131,55 @@ export class CandidateEntity extends AggregateRoot { actor.target == Target.START ? 0 : duration; + const firstDate: Date = CalendarTools.firstDate( + scheduleItem.day, + this.props.dateInterval, + ); + const lastDate: Date = CalendarTools.lastDate( + scheduleItem.day, + this.props.dateInterval, + ); return new ActorTime({ role: actor.role, target: actor.target, - firstDatetime: CalendarTools.datetimeFromString( - this.props.dateInterval.lowerDate, + firstDatetime: CalendarTools.datetimeWithSeconds( + firstDate, scheduleItem.time, effectiveDuration, ), - firstMinDatetime: CalendarTools.datetimeFromString( - this.props.dateInterval.lowerDate, + firstMinDatetime: CalendarTools.datetimeWithSeconds( + firstDate, scheduleItem.time, -scheduleItem.margin + effectiveDuration, ), - firstMaxDatetime: CalendarTools.datetimeFromString( - this.props.dateInterval.lowerDate, + firstMaxDatetime: CalendarTools.datetimeWithSeconds( + firstDate, scheduleItem.time, scheduleItem.margin + effectiveDuration, ), - lastDatetime: CalendarTools.datetimeFromString( - this.props.dateInterval.higherDate, + lastDatetime: CalendarTools.datetimeWithSeconds( + lastDate, scheduleItem.time, effectiveDuration, ), - lastMinDatetime: CalendarTools.datetimeFromString( - this.props.dateInterval.higherDate, + lastMinDatetime: CalendarTools.datetimeWithSeconds( + lastDate, scheduleItem.time, -scheduleItem.margin + effectiveDuration, ), - lastMaxDatetime: CalendarTools.datetimeFromString( - this.props.dateInterval.higherDate, + lastMaxDatetime: CalendarTools.datetimeWithSeconds( + lastDate, scheduleItem.time, scheduleItem.margin + effectiveDuration, ), }); }; + /** + * Get the closest (in time) passenger schedule item for a given driver schedule item + * This is mandatory as we can't rely only on the day of the schedule item : + * items on different days can match when playing with margins around midnight + */ private _closestPassengerScheduleItem = ( driverScheduleItem: ScheduleItem, ): ScheduleItem => @@ -179,8 +200,12 @@ export class CandidateEntity extends AggregateRoot { : currentScheduleItemGap, ).scheduleItem; + /** + * Find the passenger schedule item with the minimum duration between a given date and the dates of the passenger schedule + */ private _minPassengerScheduleItemGapForDate = (date: Date): ScheduleItemGap => this.props.passengerSchedule + // first map the passenger schedule to "real" dates (we use unix epoch date as base) .map( (scheduleItem: ScheduleItem) => { @@ -191,16 +216,21 @@ export class CandidateEntity extends AggregateRoot { ), }, ) + // then compute the duration in seconds to the given date + // for each "real" date computed in step 1 .map((scheduleItemRange: ScheduleItemRange) => ({ scheduleItem: scheduleItemRange.scheduleItem, gap: scheduleItemRange.range + // compute the duration .map((scheduleDate: Date) => Math.round(Math.abs(scheduleDate.getTime() - date.getTime())), ) + // keep the lowest duration .reduce((previousGap: number, currentGap: number) => previousGap < currentGap ? previousGap : currentGap, ), })) + // finally, keep the passenger schedule item with the lowest duration .reduce( ( previousScheduleItemGap: ScheduleItemGap, diff --git a/src/modules/ad/tests/unit/core/calendar-tools.service.spec.ts b/src/modules/ad/tests/unit/core/calendar-tools.service.spec.ts index 9dccb04..67fcf7b 100644 --- a/src/modules/ad/tests/unit/core/calendar-tools.service.spec.ts +++ b/src/modules/ad/tests/unit/core/calendar-tools.service.spec.ts @@ -90,27 +90,26 @@ describe('Calendar tools service', () => { describe('Datetime from string', () => { it('should return a date with time from a string without additional seconds', () => { - const datetime: Date = CalendarTools.datetimeFromString( - '2023-09-01', + const datetime: Date = CalendarTools.datetimeWithSeconds( + new Date('2023-09-01'), '07:12', ); expect(datetime.getUTCMinutes()).toBe(12); }); it('should return a date with time from a string with additional seconds', () => { - const datetime: Date = CalendarTools.datetimeFromString( - '2023-09-01', + const datetime: Date = CalendarTools.datetimeWithSeconds( + new Date('2023-09-01'), '07:12', 60, ); expect(datetime.getUTCMinutes()).toBe(13); }); it('should return a date with time from a string with negative additional seconds', () => { - const datetime: Date = CalendarTools.datetimeFromString( - '2023-09-01', + const datetime: Date = CalendarTools.datetimeWithSeconds( + new Date('2023-09-01'), '07:00', -60, ); - console.log(datetime); expect(datetime.getUTCHours()).toBe(6); expect(datetime.getUTCMinutes()).toBe(59); }); diff --git a/src/modules/ad/tests/unit/core/candidate.entity.spec.ts b/src/modules/ad/tests/unit/core/candidate.entity.spec.ts index 9556e10..bcc7961 100644 --- a/src/modules/ad/tests/unit/core/candidate.entity.spec.ts +++ b/src/modules/ad/tests/unit/core/candidate.entity.spec.ts @@ -1,7 +1,244 @@ import { Role } from '@modules/ad/core/domain/ad.types'; import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; -import { Target } from '@modules/ad/core/domain/candidate.types'; +import { + SpacetimeDetourRatio, + Target, +} from '@modules/ad/core/domain/candidate.types'; import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object'; +import { CarpoolPathItemProps } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object'; +import { Journey } from '@modules/ad/core/domain/value-objects/journey.value-object'; +import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object'; +import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object'; +import { StepProps } from '@modules/ad/core/domain/value-objects/step.value-object'; + +const waypointsSet1: PointProps[] = [ + { + lat: 48.678454, + lon: 6.189745, + }, + { + lat: 48.84877, + lon: 2.398457, + }, +]; + +const waypointsSet2: PointProps[] = [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.8566, + lon: 2.3522, + }, +]; + +const schedule1: ScheduleItemProps[] = [ + { + day: 1, + time: '07:00', + margin: 900, + }, +]; + +const schedule2: ScheduleItemProps[] = [ + { + day: 1, + time: '07:10', + margin: 900, + }, +]; + +const schedule3: ScheduleItemProps[] = [ + { + day: 1, + time: '06:30', + margin: 900, + }, + { + day: 2, + time: '06:30', + margin: 900, + }, + { + day: 3, + time: '06:00', + margin: 900, + }, + { + day: 4, + time: '06:30', + margin: 900, + }, + { + day: 5, + time: '06:30', + margin: 900, + }, +]; + +const schedule4: ScheduleItemProps[] = [ + { + day: 1, + time: '06:50', + margin: 900, + }, + { + day: 2, + time: '06:50', + margin: 900, + }, + { + day: 4, + time: '06:50', + margin: 900, + }, + { + day: 5, + time: '06:50', + margin: 900, + }, +]; + +const schedule5: ScheduleItemProps[] = [ + { + day: 0, + time: '00:10', + margin: 900, + }, + { + day: 1, + time: '07:05', + margin: 900, + }, +]; + +const schedule6: ScheduleItemProps[] = [ + { + day: 1, + time: '23:10', + margin: 900, + }, + { + day: 6, + time: '23:45', + margin: 900, + }, +]; + +const spacetimeDetourRatio: SpacetimeDetourRatio = { + maxDistanceDetourRatio: 0.3, + maxDurationDetourRatio: 0.3, +}; + +const carpoolPath1: CarpoolPathItemProps[] = [ + { + lat: 48.689445, + lon: 6.17651, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.START, + }), + new Actor({ + role: Role.PASSENGER, + target: Target.START, + }), + ], + }, + { + lat: 48.8566, + lon: 2.3522, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.FINISH, + }), + new Actor({ + role: Role.PASSENGER, + target: Target.FINISH, + }), + ], + }, +]; + +const carpoolPath2: CarpoolPathItemProps[] = [ + { + lat: 48.689445, + lon: 6.17651, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.START, + }), + ], + }, + { + lat: 48.678451, + lon: 6.168784, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.NEUTRAL, + }), + new Actor({ + role: Role.PASSENGER, + target: Target.START, + }), + ], + }, + { + lat: 48.848715, + lon: 2.36985, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.NEUTRAL, + }), + new Actor({ + role: Role.PASSENGER, + target: Target.FINISH, + }), + ], + }, + { + lat: 48.8566, + lon: 2.3522, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.FINISH, + }), + ], + }, +]; + +const steps: StepProps[] = [ + { + lat: 48.689445, + lon: 6.17651, + duration: 0, + distance: 0, + }, + { + lat: 48.678451, + lon: 6.168784, + duration: 1254, + distance: 33462, + }, + { + lat: 48.848715, + lon: 2.36985, + duration: 12477, + distance: 343654, + }, + { + lat: 48.8566, + lon: 2.3522, + duration: 13548, + distance: 350145, + }, +]; describe('Candidate entity', () => { it('should create a new candidate entity', () => { @@ -12,46 +249,13 @@ describe('Candidate entity', () => { lowerDate: '2023-08-28', higherDate: '2023-08-28', }, - driverWaypoints: [ - { - lat: 48.678454, - lon: 6.189745, - }, - { - lat: 48.84877, - lon: 2.398457, - }, - ], - passengerWaypoints: [ - { - lat: 48.689445, - lon: 6.17651, - }, - { - lat: 48.8566, - lon: 2.3522, - }, - ], + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, driverDistance: 350145, driverDuration: 13548, - driverSchedule: [ - { - day: 0, - time: '07:00', - margin: 900, - }, - ], - passengerSchedule: [ - { - day: 0, - time: '07:10', - margin: 900, - }, - ], - spacetimeDetourRatio: { - maxDistanceDetourRatio: 0.3, - maxDurationDetourRatio: 0.3, - }, + driverSchedule: schedule1, + passengerSchedule: schedule2, + spacetimeDetourRatio, }); expect(candidateEntity.id.length).toBe(36); }); @@ -64,76 +268,14 @@ describe('Candidate entity', () => { lowerDate: '2023-08-28', higherDate: '2023-08-28', }, - driverWaypoints: [ - { - lat: 48.689445, - lon: 6.17651, - }, - { - lat: 48.8566, - lon: 2.3522, - }, - ], - passengerWaypoints: [ - { - lat: 48.689445, - lon: 6.17651, - }, - { - lat: 48.8566, - lon: 2.3522, - }, - ], + driverWaypoints: waypointsSet2, + passengerWaypoints: waypointsSet2, driverDistance: 350145, driverDuration: 13548, - driverSchedule: [ - { - day: 0, - time: '07:00', - margin: 900, - }, - ], - passengerSchedule: [ - { - day: 0, - time: '07:10', - margin: 900, - }, - ], - spacetimeDetourRatio: { - maxDistanceDetourRatio: 0.3, - maxDurationDetourRatio: 0.3, - }, - }).setCarpoolPath([ - { - lat: 48.689445, - lon: 6.17651, - actors: [ - new Actor({ - role: Role.DRIVER, - target: Target.START, - }), - new Actor({ - role: Role.PASSENGER, - target: Target.START, - }), - ], - }, - { - lat: 48.8566, - lon: 2.3522, - actors: [ - new Actor({ - role: Role.DRIVER, - target: Target.FINISH, - }), - new Actor({ - role: Role.PASSENGER, - target: Target.FINISH, - }), - ], - }, - ]); + driverSchedule: schedule1, + passengerSchedule: schedule2, + spacetimeDetourRatio, + }).setCarpoolPath(carpoolPath1); expect(candidateEntity.getProps().carpoolPath).toHaveLength(2); }); @@ -145,46 +287,13 @@ describe('Candidate entity', () => { lowerDate: '2023-08-28', higherDate: '2023-08-28', }, - driverWaypoints: [ - { - lat: 48.678454, - lon: 6.189745, - }, - { - lat: 48.84877, - lon: 2.398457, - }, - ], - passengerWaypoints: [ - { - lat: 48.689445, - lon: 6.17651, - }, - { - lat: 48.8566, - lon: 2.3522, - }, - ], + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, driverDistance: 350145, driverDuration: 13548, - driverSchedule: [ - { - day: 0, - time: '07:00', - margin: 900, - }, - ], - passengerSchedule: [ - { - day: 0, - time: '07:10', - margin: 900, - }, - ], - spacetimeDetourRatio: { - maxDistanceDetourRatio: 0.3, - maxDurationDetourRatio: 0.3, - }, + driverSchedule: schedule1, + passengerSchedule: schedule2, + spacetimeDetourRatio, }).setMetrics(352688, 14587); expect(candidateEntity.getProps().distance).toBe(352688); expect(candidateEntity.getProps().duration).toBe(14587); @@ -199,46 +308,13 @@ describe('Candidate entity', () => { lowerDate: '2023-08-28', higherDate: '2023-08-28', }, - driverWaypoints: [ - { - lat: 48.678454, - lon: 6.189745, - }, - { - lat: 48.84877, - lon: 2.398457, - }, - ], - passengerWaypoints: [ - { - lat: 48.849445, - lon: 6.68651, - }, - { - lat: 47.18746, - lon: 2.89742, - }, - ], + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, driverDistance: 350145, driverDuration: 13548, - driverSchedule: [ - { - day: 0, - time: '07:00', - margin: 900, - }, - ], - passengerSchedule: [ - { - day: 0, - time: '07:10', - margin: 900, - }, - ], - spacetimeDetourRatio: { - maxDistanceDetourRatio: 0.3, - maxDurationDetourRatio: 0.3, - }, + driverSchedule: schedule1, + passengerSchedule: schedule2, + spacetimeDetourRatio, }).setMetrics(458690, 13980); expect(candidateEntity.isDetourValid()).toBeFalsy(); }); @@ -250,52 +326,120 @@ describe('Candidate entity', () => { lowerDate: '2023-08-28', higherDate: '2023-08-28', }, - driverWaypoints: [ - { - lat: 48.678454, - lon: 6.189745, - }, - { - lat: 48.84877, - lon: 2.398457, - }, - ], - passengerWaypoints: [ - { - lat: 48.849445, - lon: 6.68651, - }, - { - lat: 47.18746, - lon: 2.89742, - }, - ], + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, driverDistance: 350145, driverDuration: 13548, - driverSchedule: [ - { - day: 0, - time: '07:00', - margin: 900, - }, - ], - passengerSchedule: [ - { - day: 0, - time: '07:10', - margin: 900, - }, - ], - spacetimeDetourRatio: { - maxDistanceDetourRatio: 0.3, - maxDurationDetourRatio: 0.3, - }, + driverSchedule: schedule1, + passengerSchedule: schedule2, + spacetimeDetourRatio, }).setMetrics(352368, 18314); expect(candidateEntity.isDetourValid()).toBeFalsy(); }); }); describe('Journeys', () => { - it('should create journeys', () => {}); + it('should create journeys for a single date', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule1, + passengerSchedule: schedule2, + spacetimeDetourRatio, + }) + .setCarpoolPath(carpoolPath2) + .setSteps(steps) + .createJourneys(); + expect(candidateEntity.getProps().journeys).toHaveLength(1); + }); + it('should create journeys for multiple dates', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + dateInterval: { + lowerDate: '2023-09-01', + higherDate: '2024-09-01', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule3, + passengerSchedule: schedule4, + spacetimeDetourRatio, + }) + .setCarpoolPath(carpoolPath2) + .setSteps(steps) + .createJourneys(); + expect(candidateEntity.getProps().journeys).toHaveLength(5); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[0].firstDate.getDate(), + ).toBe(4); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[0].journeyItems[1].actorTimes[1].firstMinDatetime.getDate(), + ).toBe(4); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[1].journeyItems[1].actorTimes[1].firstMinDatetime.getDate(), + ).toBe(5); + }); + it('should create journeys for multiple dates, including week edges (saturday/sunday)', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + dateInterval: { + lowerDate: '2023-09-01', + higherDate: '2024-09-01', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule5, + passengerSchedule: schedule6, + spacetimeDetourRatio, + }) + .setCarpoolPath(carpoolPath2) + .setSteps(steps) + .createJourneys(); + expect(candidateEntity.getProps().journeys).toHaveLength(2); + expect( + (candidateEntity.getProps().journeys as Journey[])[0].journeyItems[1] + .actorTimes[0].target, + ).toBe(Target.NEUTRAL); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[0].journeyItems[1].actorTimes[0].firstDatetime.getUTCHours(), + ).toBe(0); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[0].journeyItems[1].actorTimes[0].firstDatetime.getUTCMinutes(), + ).toBe(30); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[0].journeyItems[1].actorTimes[1].firstMinDatetime.getUTCHours(), + ).toBe(23); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[0].journeyItems[1].actorTimes[1].firstMinDatetime.getUTCMinutes(), + ).toBe(30); + }); }); });