File

src/trips/optimizer/route.optimizer.ts

Index

Properties

Properties

address
address: string
Type : string
id
id: string
Type : string
lat
lat: number
Type : number
lng
lng: number
Type : number
type
type: string
Type : string
import { Injectable, Logger } from '@nestjs/common';

export interface Stop {
  id: string;
  lat: number;
  lng: number;
  type: string; // 'pickup' | 'delivery'
  address: string;
}

export interface OptimizedRoute {
  stops: Stop[];
  totalDistanceKm: number;
  estimatedDurationMin: number;
}

@Injectable()
export class RouteOptimizer {
  private readonly logger = new Logger(RouteOptimizer.name);

  /**
   * Optimize stop sequence using nearest-neighbor heuristic + 2-opt improvement.
   */
  optimizeRoute(
    stops: Stop[],
    startLat?: number,
    startLng?: number,
  ): OptimizedRoute {
    if (stops.length <= 2) {
      return {
        stops,
        totalDistanceKm: this.calculateTotalDistance(stops),
        estimatedDurationMin: this.estimateDuration(
          this.calculateTotalDistance(stops),
        ),
      };
    }

    // Nearest neighbor
    let route = this.nearestNeighbor(stops, startLat, startLng);

    // 2-opt improvement
    route = this.twoOpt(route);

    const totalKm = this.calculateTotalDistance(route);
    return {
      stops: route,
      totalDistanceKm: Math.round(totalKm * 10) / 10,
      estimatedDurationMin: Math.round(this.estimateDuration(totalKm)),
    };
  }

  /**
   * Calculate ETA for a trip given current position and remaining stops.
   */
  calculateETA(
    currentLat: number,
    currentLng: number,
    remainingStops: Stop[],
  ): Array<{ stopId: string; etaMinutes: number; distanceKm: number }> {
    const etas: Array<{
      stopId: string;
      etaMinutes: number;
      distanceKm: number;
    }> = [];
    let prevLat = currentLat;
    let prevLng = currentLng;
    let cumulativeMin = 0;

    for (const stop of remainingStops) {
      const distKm = this.haversineDistance(
        prevLat,
        prevLng,
        stop.lat,
        stop.lng,
      );
      const durationMin = this.estimateDuration(distKm);
      cumulativeMin += durationMin + 15; // 15 min dwell time per stop
      etas.push({
        stopId: stop.id,
        etaMinutes: Math.round(cumulativeMin),
        distanceKm: Math.round(distKm * 10) / 10,
      });
      prevLat = stop.lat;
      prevLng = stop.lng;
    }

    return etas;
  }

  private nearestNeighbor(
    stops: Stop[],
    startLat?: number,
    startLng?: number,
  ): Stop[] {
    const unvisited = [...stops];
    const route: Stop[] = [];
    let currentLat = startLat || unvisited[0].lat;
    let currentLng = startLng || unvisited[0].lng;

    while (unvisited.length > 0) {
      let nearestIdx = 0;
      let nearestDist = Infinity;
      for (let i = 0; i < unvisited.length; i++) {
        const dist = this.haversineDistance(
          currentLat,
          currentLng,
          unvisited[i].lat,
          unvisited[i].lng,
        );
        if (dist < nearestDist) {
          nearestDist = dist;
          nearestIdx = i;
        }
      }
      const nearest = unvisited.splice(nearestIdx, 1)[0];
      route.push(nearest);
      currentLat = nearest.lat;
      currentLng = nearest.lng;
    }

    return route;
  }

  private twoOpt(route: Stop[]): Stop[] {
    let improved = true;
    let best = [...route];

    while (improved) {
      improved = false;
      for (let i = 1; i < best.length - 1; i++) {
        for (let j = i + 1; j < best.length; j++) {
          const newRoute = [
            ...best.slice(0, i),
            ...best.slice(i, j + 1).reverse(),
            ...best.slice(j + 1),
          ];
          if (
            this.calculateTotalDistance(newRoute) <
            this.calculateTotalDistance(best)
          ) {
            best = newRoute;
            improved = true;
          }
        }
      }
    }

    return best;
  }

  private calculateTotalDistance(stops: Stop[]): number {
    let total = 0;
    for (let i = 1; i < stops.length; i++) {
      total += this.haversineDistance(
        stops[i - 1].lat,
        stops[i - 1].lng,
        stops[i].lat,
        stops[i].lng,
      );
    }
    return total;
  }

  private estimateDuration(distKm: number): number {
    // Assume average 60 km/h for East African roads
    return (distKm / 60) * 60;
  }

  private haversineDistance(
    lat1: number,
    lon1: number,
    lat2: number,
    lon2: number,
  ): number {
    const R = 6371;
    const dLat = ((lat2 - lat1) * Math.PI) / 180;
    const dLon = ((lon2 - lon1) * Math.PI) / 180;
    const a =
      Math.sin(dLat / 2) ** 2 +
      Math.cos((lat1 * Math.PI) / 180) *
        Math.cos((lat2 * Math.PI) / 180) *
        Math.sin(dLon / 2) ** 2;
    return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  }
}

results matching ""

    No results matching ""