50 lines
1.5 KiB
TypeScript
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),
|
|
}));
|
|
}
|