← back to Charge And Explore
backend/src/core/stop-score.ts
120 lines
// Stop scoring.
//
// The score is deliberately TRANSPARENT and decomposable: every recommendation
// must be explainable to the user ("3-min detour, 250 kW, verified restroom").
// We intentionally avoid a black-box "safety score" derived from crime data —
// it invites bias, false assurance, and geographic redlining. Instead we expose
// observable "nighttime comfort" signals and an explicit confidence level.
/** Route convenience: smooth exponential decay on detour minutes (0–100). */
export function routeConvenience(detourMinutes: number): number {
const d = Math.max(0, detourMinutes);
return 100 * Math.exp(-d / 12);
}
export interface ChargerQualityInput {
/** Normalized charging power 0–100 (station kW vs a reference max). */
power: number;
/** Stall-count adequacy 0–100. */
stalls: number;
/** Operational reliability 0–100. */
reliability: number;
/** Dynamic availability 0–100 — ONLY when from an authorized, fresh feed. */
dynamicAvailability: number;
}
/** Charger quality sub-score (0–100). */
export function chargerQuality(c: ChargerQualityInput): number {
return (
0.5 * clamp01to100(c.power) +
0.25 * clamp01to100(c.stalls) +
0.15 * clamp01to100(c.reliability) +
0.1 * clamp01to100(c.dynamicAvailability)
);
}
export interface StopScoreComponents {
/** R — route convenience (0–100). */
route: number;
/** C — charger quality (0–100). */
charger: number;
/** A — amenity quality (0–100). */
amenity: number;
/** F — charging-time fit (0–100). */
timeFit: number;
/** N — nighttime comfort/access signals (0–100). */
nighttime: number;
/** W — walkability (0–100). */
walkability: number;
/** B — basic needs, e.g. restrooms (0–100). */
basicNeeds: number;
}
export const STOP_SCORE_WEIGHTS = {
route: 0.25,
charger: 0.2,
amenity: 0.2,
timeFit: 0.15,
nighttime: 0.1,
walkability: 0.05,
basicNeeds: 0.05,
} as const;
export type Confidence = "high" | "limited" | "low";
export interface ScoredStop {
score: number;
confidence: Confidence;
breakdown: Record<keyof StopScoreComponents, number>;
}
/**
* Combine the weighted components into a final 0–100 stop score plus a
* decomposable breakdown. `dataCompleteness` (0–1) drives the confidence label
* so the UI can say "84 — Good stop · Limited data".
*/
export function scoreStop(
components: StopScoreComponents,
dataCompleteness: number,
): ScoredStop {
const w = STOP_SCORE_WEIGHTS;
const c = components;
const score =
w.route * clamp01to100(c.route) +
w.charger * clamp01to100(c.charger) +
w.amenity * clamp01to100(c.amenity) +
w.timeFit * clamp01to100(c.timeFit) +
w.nighttime * clamp01to100(c.nighttime) +
w.walkability * clamp01to100(c.walkability) +
w.basicNeeds * clamp01to100(c.basicNeeds);
return {
score: Math.round(score),
confidence: confidenceFrom(dataCompleteness),
breakdown: {
route: c.route,
charger: c.charger,
amenity: c.amenity,
timeFit: c.timeFit,
nighttime: c.nighttime,
walkability: c.walkability,
basicNeeds: c.basicNeeds,
},
};
}
function confidenceFrom(dataCompleteness: number): Confidence {
const x = clamp01(dataCompleteness);
if (x >= 0.8) return "high";
if (x >= 0.5) return "limited";
return "low";
}
function clamp01(x: number): number {
return Math.min(1, Math.max(0, x));
}
function clamp01to100(x: number): number {
return Math.min(100, Math.max(0, x));
}