← back to Charge And Explore

backend/src/recommend.ts

99 lines

// End-to-end recommendation pipeline — ties the three core primitives together:
// charge estimate -> stop score (with route convenience + charger quality) ->
// activity match for the *conservative* window. This is what the /recommend API
// endpoint will call once the provider + routing layers are wired.

import { estimateChargeRange } from "./core/charge-estimate.ts";
import {
  scoreStop,
  routeConvenience,
  chargerQuality,
  type ChargerQualityInput,
  type StopScoreComponents,
  type ScoredStop,
} from "./core/stop-score.ts";
import {
  listActivities,
  type ActivityCandidate,
  type AnnotatedActivity,
} from "./core/activity-match.ts";
import type { ChargeEstimateInput } from "./core/types.ts";

export interface StopCandidate {
  stationId: string;
  name: string;
  detourMinutes: number;
  charger: ChargerQualityInput;
  /** Directly-observed component signals (0–100). */
  amenity: number;
  nighttime: number;
  walkability: number;
  basicNeeds: number;
  /** 0–1: how complete the data for this stop is (drives confidence label). */
  dataCompleteness: number;
  charge: ChargeEstimateInput;
  activities: ActivityCandidate[];
  /** Absolute time the driver is expected back at the car. */
  expectedReturnAt: Date;
}

export interface RecommendedStop {
  stationId: string;
  name: string;
  stop: ScoredStop;
  chargeMinutes: { low: number; mid: number; high: number };
  /** EVERY nearby activity, annotated with distance + whether it fits the window. */
  activities: AnnotatedActivity[];
  /** True when nothing fits — UI should suggest a plain break, not a bad pick. */
  noActivityFits: boolean;
}

/**
 * Rank stop candidates best-first. Uses the LOW bound of the charge estimate as
 * the activity window (returning early beats a full car blocking a stall), and
 * folds route + charger into the transparent Stop Score. `timeFit` rewards a
 * charge window long enough to actually do something (~15+ min) without being an
 * endless wait.
 */
export function recommendStops(candidates: StopCandidate[]): RecommendedStop[] {
  return candidates
    .map((c) => {
      const chargeMinutes = estimateChargeRange(c.charge);
      const windowMinutes = chargeMinutes.low;
      const activities = listActivities(c.activities, windowMinutes, c.expectedReturnAt);
      const fitting = activities.filter((a) => a.fits);

      const components: StopScoreComponents = {
        route: routeConvenience(c.detourMinutes),
        charger: chargerQuality(c.charger),
        amenity: c.amenity,
        timeFit: timeFitScore(windowMinutes, fitting.length),
        nighttime: c.nighttime,
        walkability: c.walkability,
        basicNeeds: c.basicNeeds,
      };

      return {
        stationId: c.stationId,
        name: c.name,
        stop: scoreStop(components, c.dataCompleteness),
        chargeMinutes,
        activities,
        noActivityFits: fitting.length === 0,
      };
    })
    .sort((a, b) => b.stop.score - a.stop.score);
}

/**
 * A window is "well fit" when it's long enough to do something (>= 15 min) and
 * at least one activity fits; a too-short window or no fitting activity scores
 * lower. Very long windows (> 90 min) taper — that's a lot of dead time.
 */
function timeFitScore(windowMinutes: number, fittingActivities: number): number {
  if (windowMinutes < 10) return 20;
  const hasActivity = fittingActivities > 0 ? 1 : 0.5;
  const sweetSpot = 100 * Math.exp(-Math.abs(windowMinutes - 35) / 40);
  return Math.round(sweetSpot * hasActivity);
}