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>, weights: Record, ): 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[], weights: Record, ): GridCell[] { return rows.map((row) => ({ ...row, score: compositeScore(row.categoryScores, weights), })); }