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