← 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);
}