fifteen/apps/web/lib/scoring.ts
2026-03-01 21:58:53 +01:00

50 lines
1.5 KiB
TypeScript

import type { CategoryId } from "@transportationer/shared";
import type { GridCell } from "@transportationer/shared";
export interface SigmoidParams {
thresholdSec: number;
k: number;
}
/**
* Sigmoid decay: 1 / (1 + exp(k * (t - threshold)))
* score = 1.0 when t = 0 (POI at doorstep)
* score = 0.5 when t = threshold
* score ≈ 0.02 when t = 2 * threshold
*/
export function sigmoid(travelTimeSec: number, params: SigmoidParams): number {
return 1 / (1 + Math.exp(params.k * (travelTimeSec - params.thresholdSec)));
}
export function defaultSigmoidParams(thresholdMin: number): SigmoidParams {
const thresholdSec = thresholdMin * 60;
return { thresholdSec, k: 4 / thresholdSec };
}
/**
* Weighted composite score, normalized to [0, 1].
* Missing categories contribute 0 to the numerator and their weight to the denominator.
*/
export function compositeScore(
categoryScores: Partial<Record<CategoryId, number>>,
weights: Record<CategoryId, number>,
): number {
const totalWeight = Object.values(weights).reduce((a, b) => a + b, 0);
if (totalWeight === 0) return 0;
let sum = 0;
for (const [cat, w] of Object.entries(weights) as [CategoryId, number][]) {
sum += (w / totalWeight) * (categoryScores[cat] ?? 0);
}
return Math.min(1, Math.max(0, sum));
}
/** Re-apply weights to rows already fetched from PostGIS */
export function reweightCells(
rows: Omit<GridCell, "score">[],
weights: Record<CategoryId, number>,
): GridCell[] {
return rows.map((row) => ({
...row,
score: compositeScore(row.categoryScores, weights),
}));
}