← back to Charge And Explore

backend/src/core/activity-match.ts

130 lines

// Activity matching.
//
// For each candidate place near a stop, decide whether the driver can walk out,
// enjoy it, and walk back with a SAFE buffer before the car is done. The golden
// rule: use the *lower bound* of the charging-time estimate (pessimistic), never
// the optimistic midpoint — returning early is always safer than a full car
// blocking a stall while the owner is off-site.

import { clamp } from "./charge-estimate.ts";

export interface ActivityCandidate {
  placeId: string;
  /** Pedestrian-routed walking distance from the charger, in meters. */
  distanceMeters: number;
  /** Pedestrian-routed minutes out (NOT straight-line distance). */
  walkOutMinutes: number;
  /** Pedestrian-routed minutes back. */
  walkBackMinutes: number;
  suggestedVisitMinutes: number;
  /** When the place stops being usable (kitchen/close), or null if unknown. */
  openThrough: Date | null;
  preferenceScore: number;
  qualityScore: number;
  accessibilityScore: number;
  weatherFitScore: number;
  dataFreshnessScore: number;
}

/** Conservative return buffer: at least 5 min, or 20% of the charge window. */
export function returnBuffer(conservativeWindowMinutes: number): number {
  return Math.max(5, Math.ceil(conservativeWindowMinutes * 0.2));
}

/**
 * Returns a 0–100 fit score, or `null` when the activity does NOT safely fit
 * the window (too long, or closes before the expected return time).
 */
export function scoreActivity(
  candidate: ActivityCandidate,
  conservativeWindowMinutes: number,
  expectedReturnAt: Date,
): number | null {
  const buffer = returnBuffer(conservativeWindowMinutes);
  const required =
    candidate.walkOutMinutes +
    candidate.suggestedVisitMinutes +
    candidate.walkBackMinutes +
    buffer;

  if (required > conservativeWindowMinutes) return null;
  if (candidate.openThrough && candidate.openThrough < expectedReturnAt) {
    return null;
  }

  // Best fit is when required time is close to the available window (not a
  // 3-minute activity crammed into a 40-minute charge).
  const fitScore =
    100 *
    (1 - Math.abs(conservativeWindowMinutes - required) / conservativeWindowMinutes);

  return (
    0.35 * clamp(fitScore, 0, 100) +
    0.2 * candidate.preferenceScore +
    0.15 * candidate.qualityScore +
    0.1 * candidate.accessibilityScore +
    0.1 * candidate.weatherFitScore +
    0.1 * candidate.dataFreshnessScore
  );
}

export interface AnnotatedActivity {
  placeId: string;
  /** Walking distance from the charger, meters. */
  distanceMeters: number;
  /** One-way walk time from the charger, minutes. */
  walkMinutes: number;
  /** Does it safely fit the conservative window? */
  fits: boolean;
  /** Fit score when it fits, else null. */
  score: number | null;
}

/**
 * List EVERY activity near the charger annotated with distance + whether it
 * safely fits the window — fitting ones first (best score), then the rest by
 * distance. The UI shows the full list so the driver sees all options, not just
 * the ones we picked.
 */
export function listActivities(
  candidates: ActivityCandidate[],
  conservativeWindowMinutes: number,
  expectedReturnAt: Date,
): AnnotatedActivity[] {
  return candidates
    .map((c) => {
      const score = scoreActivity(c, conservativeWindowMinutes, expectedReturnAt);
      return {
        placeId: c.placeId,
        distanceMeters: c.distanceMeters,
        walkMinutes: c.walkOutMinutes,
        fits: score !== null,
        score,
      };
    })
    .sort((a, b) => {
      if (a.fits !== b.fits) return a.fits ? -1 : 1;
      if (a.fits && b.fits) return (b.score ?? 0) - (a.score ?? 0);
      return a.distanceMeters - b.distanceMeters;
    });
}

/**
 * Rank all fitting candidates best-first. Returns only the ones that safely
 * fit; when the result is empty the UI should say so and suggest an in-car
 * break or restroom-only stop rather than forcing a bad recommendation.
 */
export function rankActivities(
  candidates: ActivityCandidate[],
  conservativeWindowMinutes: number,
  expectedReturnAt: Date,
): Array<{ placeId: string; score: number }> {
  return candidates
    .map((c) => ({
      placeId: c.placeId,
      score: scoreActivity(c, conservativeWindowMinutes, expectedReturnAt),
    }))
    .filter((r): r is { placeId: string; score: number } => r.score !== null)
    .sort((a, b) => b.score - a.score);
}