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