Update: 2026-07-06 18:23:46

This commit is contained in:
Hamza-Ayed
2026-07-06 18:23:46 +03:00
parent 9d257c5d7d
commit d6ab09c19b
7 changed files with 505 additions and 202 deletions
+112 -33
View File
@@ -12,26 +12,35 @@ export interface EngineOptions {
cleanOutliers?: boolean;
/**
* Surge threshold as a fraction of the median price (e.g. 0.05 = 5%).
* Default: 0.05 (5% of median ride price).
* The absolute threshold is computed dynamically per dataset so it scales
* correctly across currencies (JOD, SYP, IQD, etc.).
* Computed dynamically per dataset so it scales across currencies.
*/
surgeThresholdFraction?: number;
tierCount?: number;
/**
* Known receipts to validate against — used to sanity-check the formula.
* Each entry is a real fare that the engine's formula should be able to predict.
*/
knownReceipts?: Array<{
label: string;
distanceKm: number;
durationMin: number;
actualPrice: number;
}>;
}
/**
* Main pricing analysis engine.
* Orchestrates the full pipeline: fetch → clean → cluster → regress → surge → zone.
* Pipeline: fetch → clean → cluster → regress → surge → zone → validate.
*/
export async function runAnalysis(
samples: RideSample[],
options: EngineOptions = {}
): Promise<AnalysisReport> {
const {
cleanOutliers = true,
cleanOutliers = true,
surgeThresholdFraction = 0.05,
tierCount = 3,
tierCount = 3,
knownReceipts = [],
} = options;
if (samples.length < 5) {
@@ -43,10 +52,9 @@ export async function runAnalysis(
// Step 1: Remove statistical outliers (MAD on PPK)
const cleanSamples = cleanOutliers ? removeOutliers(samples) : samples;
// Step 2: Compute dynamic surge threshold — 5% of median price, clamped to [0.05, 2.0]
// This ensures the threshold scales correctly for high-denomination currencies.
const allPrices = cleanSamples.map(s => s.price);
const medianPrice = median(allPrices);
// Step 2: Compute dynamic surge threshold (5% of median price)
const allPrices = cleanSamples.map(s => s.price);
const medianPrice = median(allPrices);
const surgeThreshold = Math.min(Math.max(medianPrice * surgeThresholdFraction, 0.05), 2.0);
// Step 3: Group by route and extract base (non-surge) prices
@@ -56,42 +64,54 @@ export async function runAnalysis(
// Step 4: Cluster into pricing tiers by PPK
const rawTiers = clusterTiers(cleanSamples, tierCount);
// Step 5: Run regression on each tier
// Step 5: Run regression on each tier (two-stage + robust-MLR, best wins)
const analyzedTiers = analyzeAllTiers(rawTiers);
// Step 6: Detect surge patterns
const surgePatterns = detectSurge(cleanSamples, surgeThreshold);
const surgeHours = aggregateSurgeHours(surgePatterns);
const surgeHours = aggregateSurgeHours(surgePatterns);
// Step 7: Zone analysis
const zones = analyzeByZone(cleanSamples);
const zones = analyzeByZone(cleanSamples);
const zoneTypes = analyzeByZoneType(cleanSamples);
// Build report
const report: AnalysisReport = {
competitorName: firstSample.competitorName,
countryCode: firstSample.countryCode,
tiers: analyzedTiers,
countryCode: firstSample.countryCode,
tiers: analyzedTiers,
surgePatterns,
zones,
totalSamples: samples.length,
analyzedAt: new Date().toISOString(),
totalSamples: samples.length,
analyzedAt: new Date().toISOString(),
};
// Print summary
printSummary(report, baseSamples, surgeHours, zoneTypes, surgeThreshold);
// Print full summary including receipt validation
printSummary(report, baseSamples, surgeHours, zoneTypes, surgeThreshold, knownReceipts);
return report;
}
// ─────────────────────────────────────────────────────────────
// Summary printing
// ─────────────────────────────────────────────────────────────
function currencyCode(countryCode: string): string {
const map: Record<string, string> = { JO: 'JOD', SY: 'SYP', IQ: 'IQD', SA: 'SAR', AE: 'AED', EG: 'EGP' };
return map[countryCode] ?? 'CUR';
}
function printSummary(
report: AnalysisReport,
baseSamples: RideSample[],
surgeHours: ReturnType<typeof aggregateSurgeHours>,
zoneTypes: ReturnType<typeof analyzeByZoneType>,
surgeThreshold: number
report: AnalysisReport,
baseSamples: RideSample[],
surgeHours: ReturnType<typeof aggregateSurgeHours>,
zoneTypes: ReturnType<typeof analyzeByZoneType>,
surgeThreshold: number,
knownReceipts: NonNullable<EngineOptions['knownReceipts']>
): void {
const sep = '═══════════════════════════════════════════════════════';
const cur = currencyCode(report.countryCode);
console.log(`\n${sep}`);
console.log(` 📊 Pricing Analysis Report — ${report.competitorName} (${report.countryCode})`);
console.log(` ${report.totalSamples} total samples, ${baseSamples.length} base-price samples`);
@@ -99,28 +119,32 @@ function printSummary(
console.log(` Analyzed at: ${report.analyzedAt}`);
console.log(sep);
// Tiers
// ── Tiers ───────────────────────────────────
console.log(`\n📦 PRICING TIERS:`);
for (const tier of report.tiers) {
const reg = tier.regression;
if (reg) {
const tierIcon = tier.label === 'economy' ? '💰' : tier.label === 'standard' ? '🚗' : '💎';
console.log(` ${tierIcon} ${tier.label.toUpperCase()}:`);
console.log(` Base Fare: ${reg.baseFare.toFixed(3)} ${report.countryCode === 'JO' ? 'JOD' : report.countryCode === 'SY' ? 'SYP' : report.countryCode === 'IQ' ? 'IQD' : 'CUR'}`);
const icon = tier.label === 'economy' ? '💰' : tier.label === 'standard' ? '🚗' : '💎';
console.log(` ${icon} ${tier.label.toUpperCase()}: [model: ${reg.modelName}]`);
console.log(` Flag Fall: ${reg.baseFare.toFixed(3)} ${cur}`);
console.log(` Per KM: ${reg.kmRate.toFixed(3)}`);
console.log(` Per Min: ${reg.minRate.toFixed(3)}`);
console.log(` Per Min: ${reg.minRate.toFixed(3)}${reg.minRate < 0.005 ? ' ⚠️ (near-zero — may need more data)' : ''}`);
console.log(` Min Fare: ${reg.minFare.toFixed(3)} ${reg.hasMinFare ? '✅ active' : ''}`);
console.log(` RMSE: ${reg.rmse.toFixed(4)}`);
console.log(` R²: ${reg.rSquared.toFixed(4)}`);
console.log(` Samples: ${reg.sampleCount}`);
console.log(` PPK range: ${tier.ppkRange[0].toFixed(3)} – ${tier.ppkRange[1].toFixed(3)}`);
// Formula preview
const formula = buildFormulaString(reg.baseFare, reg.kmRate, reg.minRate, reg.minFare, cur);
console.log(` Formula: ${formula}`);
} else {
console.log(` 📄 ${tier.label.toUpperCase()}: ${tier.samples.length} samples (insufficient for regression)`);
}
console.log('');
}
// Surge
// ── Surge ───────────────────────────────────
if (surgeHours.length > 0) {
console.log(`⚡ SURGE PATTERNS (by hour-of-day):`);
for (const sh of surgeHours) {
@@ -130,7 +154,7 @@ function printSummary(
console.log(`\nℹ️ No significant surge patterns detected.`);
}
// Zones
// ── Zones ───────────────────────────────────
if (zoneTypes.length > 0) {
console.log(`\n📍 ZONE TYPE ANALYSIS:`);
for (const zt of zoneTypes) {
@@ -138,14 +162,69 @@ function printSummary(
}
}
// Surge route details
// ── Top Surge Routes ────────────────────────
if (report.surgePatterns.length > 0) {
console.log(`\n🔍 TOP SURGE ROUTES:`);
for (const sr of report.surgePatterns.slice(0, 5)) {
console.log(` ${sr.distanceKm.toFixed(1)}km → base ${sr.basePrice.toFixed(2)}, peak ${(sr.basePrice * sr.maxMultiplier).toFixed(2)} JOD (${sr.maxMultiplier.toFixed(3)}x)`);
console.log(` ${sr.distanceKm.toFixed(1)}km → base ${sr.basePrice.toFixed(2)}, peak ${(sr.basePrice * sr.maxMultiplier).toFixed(2)} ${cur} (${sr.maxMultiplier.toFixed(3)}x)`);
}
}
// ── Receipt Validation ──────────────────────
if (knownReceipts.length > 0) {
console.log(`\n🧾 RECEIPT VALIDATION:`);
for (const receipt of knownReceipts) {
console.log(`\n 📄 ${receipt.label}`);
console.log(` Route: ${receipt.distanceKm} km / ${receipt.durationMin.toFixed(2)} min`);
console.log(` Actual price: ${receipt.actualPrice.toFixed(3)} ${cur}`);
for (const tier of report.tiers) {
const reg = tier.regression;
if (!reg) continue;
const predicted =
reg.baseFare +
reg.kmRate * receipt.distanceKm +
reg.minRate * receipt.durationMin;
const effective = reg.hasMinFare && reg.minFare > 0
? Math.max(predicted, reg.minFare)
: predicted;
const error = effective - receipt.actualPrice;
const errorPct = (error / receipt.actualPrice) * 100;
const sign = error >= 0 ? '+' : '';
const flag = Math.abs(errorPct) <= 5 ? '✅' : Math.abs(errorPct) <= 15 ? '⚠️' : '❌';
console.log(` [${tier.label.padEnd(8)}] predicted: ${effective.toFixed(3)} ${cur} error: ${sign}${errorPct.toFixed(1)}% ${flag}`);
}
}
console.log('');
}
console.log(sep);
console.log('');
}
/**
* Build a human-readable formula string for display.
* e.g. "price = 0.440 + 0.220 × km + 0.040 × min (min fare: 0.800)"
*/
function buildFormulaString(
baseFare: number,
kmRate: number,
minRate: number,
minFare: number,
cur: string
): string {
const parts: string[] = [];
if (baseFare > 0.001) parts.push(`${baseFare.toFixed(3)}`);
parts.push(`${kmRate.toFixed(3)} × km`);
if (minRate > 0.001) parts.push(`${minRate.toFixed(3)} × min`);
let formula = `price = ${parts.join(' + ')}`;
if (minFare > 0.001) formula += ` (min fare: ${minFare.toFixed(3)} ${cur})`;
return formula;
}
@@ -1,15 +1,68 @@
import { PricingTier, RegressionResult, RideSample } from './types';
import { PricingTier, RideSample, RegressionResult } from './types';
import {
twoStageRegression,
robustMultipleLinearRegression,
calcRMSE,
calcRSquared,
detectMinimumFare,
} from '../utils/math';
import { mean } from 'simple-statistics';
type RawModel = { baseFare: number; kmRate: number; minRate: number };
/** Evaluate RMSE of a model against the actual samples. */
function evalRMSE(model: RawModel, samples: RideSample[]): number {
const actual = samples.map(s => s.price);
const predicted = samples.map(s =>
model.baseFare + model.kmRate * s.distance_km + model.minRate * s.duration_min
);
return calcRMSE(actual, predicted);
}
/**
* Run multiple linear regression on each pricing tier.
* Detects minimum fare and computes RMSE/R².
* Select the best model from two candidates.
*
* Strategy:
* 1. Always run both two-stage and robust-MLR.
* 2. Primary criterion: lower RMSE wins.
* 3. Tie-break: if both models have similar RMSE (within 5%), prefer the one
* with a positive minRate — this respects the domain knowledge that time
* is always part of the taxi pricing formula.
*/
function selectBestModel(
modelA: RawModel | null, // two-stage
modelB: RawModel | null, // robust-MLR
samples: RideSample[]
): { model: RawModel; name: string } | null {
if (!modelA && !modelB) return null;
if (!modelA) return { model: modelB!, name: 'robust-MLR' };
if (!modelB) return { model: modelA, name: 'two-stage' };
const rmseA = evalRMSE(modelA, samples);
const rmseB = evalRMSE(modelB, samples);
// If RMSE difference is within 5%, prefer the model with a time component
const tolerance = Math.min(rmseA, rmseB) * 0.05;
if (Math.abs(rmseA - rmseB) <= tolerance) {
const aHasTime = modelA.minRate > 0.005;
const bHasTime = modelB.minRate > 0.005;
if (aHasTime && !bHasTime) return { model: modelA, name: 'two-stage' };
if (bHasTime && !aHasTime) return { model: modelB, name: 'robust-MLR' };
}
return rmseA <= rmseB
? { model: modelA, name: 'two-stage' }
: { model: modelB, name: 'robust-MLR' };
}
/**
* Run regression on a single pricing tier.
*
* Tries two approaches and picks the best:
* A) Two-stage regression — estimates flag fall first, then km + min rates
* B) Robust MLR — iterative outlier removal on full 3-parameter model
*
* The winner is chosen by RMSE, with a 5% tie-break that prefers models
* with a positive per-minute rate (time is always a component in real meters).
*/
export function analyzeTier(tier: PricingTier): PricingTier {
const samples = tier.samples;
@@ -18,78 +71,78 @@ export function analyzeTier(tier: PricingTier): PricingTier {
return tier;
}
// Primary model: price = baseFare + kmRate * dist + minRate * dur
// We use robust regression to strip out surge outliers and find the floor price
const mlrResult = robustMultipleLinearRegression(
const input: Array<{ distance_km: number; duration_min: number; price: number }> =
samples.map(s => ({
distance_km: s.distance_km,
distance_km: s.distance_km,
duration_min: s.duration_min,
price: s.price,
}))
);
price: s.price,
}));
if (!mlrResult) {
// Run both models
const modelA = twoStageRegression(input); // Stage 1: flag fall | Stage 2: km + min
const modelB = robustMultipleLinearRegression(input); // Current robust approach
const best = selectBestModel(modelA, modelB, samples);
if (!best) {
tier.regression = null;
return tier;
}
// Predict and compute RMSE/R²
const actualPrices = samples.map(s => s.price);
const { model: mlrResult, name: modelName } = best;
// Compute final metrics using the winning model
const actualPrices = samples.map(s => s.price);
const predictedPrices = samples.map(s =>
mlrResult.baseFare +
mlrResult.kmRate * s.distance_km +
mlrResult.minRate * s.duration_min
mlrResult.baseFare + mlrResult.kmRate * s.distance_km + mlrResult.minRate * s.duration_min
);
const rmse = calcRMSE(actualPrices, predictedPrices);
const rmse = calcRMSE(actualPrices, predictedPrices);
const rSquared = calcRSquared(actualPrices, predictedPrices);
// Detect minimum fare
// Detect minimum fare (floor charge for very short trips)
const minFare = detectMinimumFare(
samples.map(s => s.distance_km),
samples.map(s => s.price),
mlrResult.kmRate
);
// If minFare is detected and the short-ride residuals improve,
// apply minFare-adjusted model
let hasMinFare = false;
let adjustedRMSE = rmse;
let adjustedRMSE = rmse;
let adjustedRSquared = rSquared;
if (minFare && minFare > 0) {
const adjustedPredicted = samples.map(s => {
const raw = mlrResult.baseFare + mlrResult.kmRate * s.distance_km + mlrResult.minRate * s.duration_min;
const raw = mlrResult.baseFare +
mlrResult.kmRate * s.distance_km +
mlrResult.minRate * s.duration_min;
return Math.max(raw, minFare);
});
const adjRmse = calcRMSE(actualPrices, adjustedPredicted);
const adjRsq = calcRSquared(actualPrices, adjustedPredicted);
const adjRsq = calcRSquared(actualPrices, adjustedPredicted);
// If minimum fare improves the fit, use it
if (adjRmse < rmse) {
hasMinFare = true;
adjustedRMSE = adjRmse;
hasMinFare = true;
adjustedRMSE = adjRmse;
adjustedRSquared = adjRsq;
}
}
tier.regression = {
baseFare: mlrResult.baseFare,
kmRate: mlrResult.kmRate,
minRate: mlrResult.minRate,
minFare: minFare || 0,
rmse: adjustedRMSE,
rSquared: adjustedRSquared,
baseFare: mlrResult.baseFare,
kmRate: mlrResult.kmRate,
minRate: mlrResult.minRate,
minFare: minFare || 0,
rmse: adjustedRMSE,
rSquared: adjustedRSquared,
sampleCount: samples.length,
hasMinFare,
modelName, // carry through for display
};
return tier;
}
/**
* Run regression on all tiers.
*/
/** Run regression on all tiers. */
export function analyzeAllTiers(tiers: PricingTier[]): PricingTier[] {
return tiers.map(tier => analyzeTier(tier));
}
@@ -58,6 +58,8 @@ export interface RegressionResult {
rSquared: number;
sampleCount: number;
hasMinFare: boolean;
/** Which regression model was selected: 'two-stage' | 'robust-MLR' */
modelName: string;
}
export interface SurgeResult {
+16 -3
View File
@@ -183,12 +183,25 @@ async function processCompetitor(
countryCode: row.country_code,
}));
// Known receipts for formula validation.
// Add real receipts here as they are collected — the engine will print
// predicted vs actual with % error so you can judge formula quality at a glance.
const knownReceipts = comp.competitor_name === 'com.taxif.passenger' ? [
{
label: 'TaxiF receipt 2026-06-11 (Amman)',
distanceKm: 2.17,
durationMin: 6 + 38 / 60, // 6 min 38 sec
actualPrice: 1.15, // 1.18 JOD total − 0.03 BookingFee
},
] : [];
const report = await runAnalysis(samples, {
competitorName: comp.competitor_name,
countryCode: comp.country_code,
cleanOutliers: true,
countryCode: comp.country_code,
cleanOutliers: true,
// surgeThresholdFraction defaults to 0.05 (5% of median price) — currency-agnostic
tierCount: 3,
tierCount: 3,
knownReceipts,
});
// --- Compute peak hours once, reuse in both formulas and surge insights ---
+228 -114
View File
@@ -1,5 +1,9 @@
import { median, mean } from 'simple-statistics';
// ─────────────────────────────────────────────
// Internal helpers
// ─────────────────────────────────────────────
function pearsonCorr(x: number[], y: number[]): number {
const n = Math.min(x.length, y.length);
if (n < 3) return 0;
@@ -17,90 +21,6 @@ function pearsonCorr(x: number[], y: number[]): number {
return denom === 0 ? 0 : num / denom;
}
export function multipleLinearRegression(
samples: Array<{ distance_km: number; duration_min: number; price: number }>
): { baseFare: number; kmRate: number; minRate: number } | null {
const n = samples.length;
if (n < 3) return null;
const dists = samples.map(s => s.distance_km);
const durs = samples.map(s => s.duration_min);
const prices = samples.map(s => s.price);
const corr = pearsonCorr(dists, durs);
const lambda = Math.abs(corr) > 0.85 ? 0.5 : 0.01;
let sumX1 = 0, sumX2 = 0, sumY = 0;
let sumX1Sq = 0, sumX2Sq = 0, sumX1X2 = 0;
let sumX1Y = 0, sumX2Y = 0;
for (const s of samples) {
const x1 = s.distance_km, x2 = s.duration_min, y = s.price;
sumX1 += x1; sumX2 += x2; sumY += y;
sumX1Sq += x1 * x1; sumX2Sq += x2 * x2; sumX1X2 += x1 * x2;
sumX1Y += x1 * y; sumX2Y += x2 * y;
}
const A = [
[n, sumX1, sumX2],
[sumX1, sumX1Sq + lambda, sumX1X2],
[sumX2, sumX1X2, sumX2Sq + lambda],
];
const B = [sumY, sumX1Y, sumX2Y];
try {
const beta = gaussianElimination(A, B);
let baseFare = Math.max(0, beta[0]);
let kmRate = Math.max(0, beta[1]);
let minRate = Math.max(0, beta[2]);
const predFull = samples.map(s => baseFare + kmRate * s.distance_km + minRate * s.duration_min);
const rmseFull = calcRMSE(prices, predFull);
const ratioK = dists.reduce((a, d, i) => d > 0 ? a + prices[i] / d : a, 0) / dists.filter(d => d > 0).length;
if (!isFinite(ratioK)) return { baseFare, kmRate, minRate };
const predDistOnly = dists.map(d => ratioK * d);
const rmseDist = calcRMSE(prices, predDistOnly);
if (rmseDist <= rmseFull * 1.10) {
return { baseFare: 0, kmRate: Math.round(ratioK * 1000) / 1000, minRate: 0 };
}
return { baseFare, kmRate, minRate };
} catch {
return null;
}
}
export function robustMultipleLinearRegression(
samples: Array<{ distance_km: number; duration_min: number; price: number }>,
maxIterations: number = 4
): { baseFare: number; kmRate: number; minRate: number } | null {
let currentSamples = [...samples];
let bestModel = multipleLinearRegression(currentSamples);
if (!bestModel) return null;
const prices = samples.map(s => s.price).sort((a, b) => a - b);
const medianPrice = prices[Math.floor(prices.length / 2)];
const fixedThreshold = Math.max(medianPrice * 0.3, 0.1);
for (let i = 0; i < maxIterations; i++) {
const predicted = currentSamples.map(
s => bestModel!.baseFare + bestModel!.kmRate * s.distance_km + bestModel!.minRate * s.duration_min
);
const actual = currentSamples.map(s => s.price);
const inliers = currentSamples.filter((_, idx) => actual[idx] - predicted[idx] < fixedThreshold);
if (inliers.length < Math.max(5, samples.length * 0.3)) break;
if (inliers.length === currentSamples.length) break;
currentSamples = inliers;
const newModel = multipleLinearRegression(currentSamples);
if (!newModel) break;
bestModel = newModel;
}
return bestModel;
}
function gaussianElimination(A: number[][], B: number[]): number[] {
const n = A.length;
const a = A.map(row => [...row]);
@@ -130,10 +50,208 @@ function gaussianElimination(A: number[][], B: number[]): number[] {
return x;
}
// ─────────────────────────────────────────────
// Core regression — full 3-parameter model
// price = baseFare + kmRate × dist + minRate × dur
// ─────────────────────────────────────────────
export function multipleLinearRegression(
samples: Array<{ distance_km: number; duration_min: number; price: number }>
): { baseFare: number; kmRate: number; minRate: number } | null {
const n = samples.length;
if (n < 3) return null;
const dists = samples.map(s => s.distance_km);
const durs = samples.map(s => s.duration_min);
const prices = samples.map(s => s.price);
const corr = pearsonCorr(dists, durs);
// Stronger ridge when predictors are collinear (typical in taxi data)
const lambda = Math.abs(corr) > 0.85 ? 1.5 : 0.05;
let sumX1 = 0, sumX2 = 0, sumY = 0;
let sumX1Sq = 0, sumX2Sq = 0, sumX1X2 = 0;
let sumX1Y = 0, sumX2Y = 0;
for (const s of samples) {
const x1 = s.distance_km, x2 = s.duration_min, y = s.price;
sumX1 += x1; sumX2 += x2; sumY += y;
sumX1Sq += x1 * x1; sumX2Sq += x2 * x2; sumX1X2 += x1 * x2;
sumX1Y += x1 * y; sumX2Y += x2 * y;
}
const A = [
[n, sumX1, sumX2 ],
[sumX1, sumX1Sq + lambda, sumX1X2 ],
[sumX2, sumX1X2, sumX2Sq + lambda],
];
const B = [sumY, sumX1Y, sumX2Y];
try {
const beta = gaussianElimination(A, B);
return {
baseFare: Math.max(0, beta[0]),
kmRate: Math.max(0, beta[1]),
minRate: Math.max(0, beta[2]),
};
} catch {
return null;
}
}
// ─────────────────────────────────────────────
// Two-Stage Regression
// Stage 1: estimate flag fall from shortest rides
// Stage 2: regress residuals on (dist, dur) with no intercept
// ─────────────────────────────────────────────
/**
* Stage 1 — Estimate the flag fall (فتحة العداد / meter opening charge).
*
* Takes the shortest 20% of rides by distance (min 5 samples) and fits
* a simple linear model: price ~ intercept + slope × dist.
* The intercept is the flag fall estimate.
*
* Clamped to [0, 65% of median price] to avoid unreasonable values.
*/
export function estimateFlagFall(
samples: Array<{ distance_km: number; duration_min: number; price: number }>
): number {
if (samples.length < 5) return 0;
const sorted = [...samples].sort((a, b) => a.distance_km - b.distance_km);
const shortCount = Math.max(5, Math.floor(sorted.length * 0.20));
const shortRides = sorted.slice(0, shortCount);
// Simple OLS: price ~ a + b × dist on short rides only
const n = shortRides.length;
const sumX = shortRides.reduce((s, r) => s + r.distance_km, 0);
const sumY = shortRides.reduce((s, r) => s + r.price, 0);
const sumXX = shortRides.reduce((s, r) => s + r.distance_km ** 2, 0);
const sumXY = shortRides.reduce((s, r) => s + r.distance_km * r.price, 0);
const denom = n * sumXX - sumX * sumX;
if (Math.abs(denom) < 1e-10) {
// Degenerate case — return a safe lower-bound estimate
return Math.min(...shortRides.map(r => r.price)) * 0.4;
}
const slope = (n * sumXY - sumX * sumY) / denom;
const intercept = (sumY - slope * sumX) / n;
const medianPrice = median(samples.map(s => s.price));
return Math.max(0, Math.min(intercept, medianPrice * 0.65));
}
/**
* Stage 2 — Two-variable regression with no intercept.
* Fits: (price − fixedBase) ~ kmRate × dist + minRate × dur
*
* Uses ridge regularization (lambda = 2.0 when dist/dur are collinear)
* to distribute the effect between km and min rather than collapsing to one.
*/
function twoVarNoIntercept(
samples: Array<{ distance_km: number; duration_min: number; price: number }>,
fixedBase: number
): { kmRate: number; minRate: number } | null {
const n = samples.length;
if (n < 3) return null;
const dists = samples.map(s => s.distance_km);
const durs = samples.map(s => s.duration_min);
const corr = pearsonCorr(dists, durs);
// Higher ridge when predictors are correlated — forces balance between km and min
const lambda = Math.abs(corr) > 0.85 ? 2.0 : 0.5;
let s11 = 0, s22 = 0, s12 = 0, s1y = 0, s2y = 0;
for (let i = 0; i < n; i++) {
const x1 = dists[i], x2 = durs[i];
const y = samples[i].price - fixedBase;
s11 += x1 * x1; s22 += x2 * x2; s12 += x1 * x2;
s1y += x1 * y; s2y += x2 * y;
}
// Solve 2×2 ridge system:
// [ s11+λ s12 ] [ kmRate ] [ s1y ]
// [ s12 s22+λ ] [ minRate ] = [ s2y ]
const a = s11 + lambda, b = s12, d = s22 + lambda;
const det = a * d - b * b;
if (Math.abs(det) < 1e-12) return null;
return {
kmRate: Math.max(0, (s1y * d - s2y * b) / det),
minRate: Math.max(0, (a * s2y - b * s1y) / det),
};
}
/**
* Two-Stage Regression (main entry point for the engine).
*
* Properly decomposes taxi pricing into three components:
* price = baseFare (flag fall) + kmRate × dist + minRate × dur
*
* Stage 1 fixes baseFare from shortest rides.
* Stage 2 fits kmRate and minRate on residuals.
*
* This avoids the distance/duration collinearity problem by removing
* the constant component first.
*/
export function twoStageRegression(
samples: Array<{ distance_km: number; duration_min: number; price: number }>
): { baseFare: number; kmRate: number; minRate: number } | null {
if (samples.length < 5) return null;
const baseFare = estimateFlagFall(samples);
const rates = twoVarNoIntercept(samples, baseFare);
if (!rates) return null;
return { baseFare, kmRate: rates.kmRate, minRate: rates.minRate };
}
// ─────────────────────────────────────────────
// Robust regression (iterative outlier removal)
// ─────────────────────────────────────────────
export function robustMultipleLinearRegression(
samples: Array<{ distance_km: number; duration_min: number; price: number }>,
maxIterations: number = 4
): { baseFare: number; kmRate: number; minRate: number } | null {
let currentSamples = [...samples];
let bestModel = multipleLinearRegression(currentSamples);
if (!bestModel) return null;
const prices = samples.map(s => s.price).sort((a, b) => a - b);
const medianPrice = prices[Math.floor(prices.length / 2)];
const fixedThreshold = Math.max(medianPrice * 0.3, 0.1);
for (let i = 0; i < maxIterations; i++) {
const predicted = currentSamples.map(
s => bestModel!.baseFare + bestModel!.kmRate * s.distance_km + bestModel!.minRate * s.duration_min
);
const actual = currentSamples.map(s => s.price);
const inliers = currentSamples.filter((_, idx) => actual[idx] - predicted[idx] < fixedThreshold);
if (inliers.length < Math.max(5, samples.length * 0.3)) break;
if (inliers.length === currentSamples.length) break;
currentSamples = inliers;
const newModel = multipleLinearRegression(currentSamples);
if (!newModel) break;
bestModel = newModel;
}
return bestModel;
}
// ─────────────────────────────────────────────
// Statistics utilities
// ─────────────────────────────────────────────
export function calcRMSE(actual: number[], predicted: number[]): number {
const n = Math.min(actual.length, predicted.length);
if (n === 0) return Infinity;
return Math.sqrt(actual.reduce((sum, a, i) => i < predicted.length ? sum + (a - predicted[i]) ** 2 : sum, 0) / n);
return Math.sqrt(
actual.reduce((sum, a, i) => i < predicted.length ? sum + (a - predicted[i]) ** 2 : sum, 0) / n
);
}
export function calcRSquared(actual: number[], predicted: number[]): number {
@@ -149,27 +267,26 @@ export function findInliersMAD(values: number[], threshold: number = 3.5): numbe
const med = median(values);
const mad = median(values.map(v => Math.abs(v - med)));
if (mad === 0) return values.map((_, i) => i);
return values.map((v, i) => ({ v, i, z: 0.6745 * Math.abs(v - med) / mad }))
.filter(x => x.z < threshold).map(x => x.i);
return values
.map((v, i) => ({ v, i, z: 0.6745 * Math.abs(v - med) / mad }))
.filter(x => x.z < threshold)
.map(x => x.i);
}
/**
* Compute K-Means inertia (sum of squared distances from each point to its centroid).
* Lower inertia = better clustering.
*/
// ─────────────────────────────────────────────
// K-Means clustering (multi-run for stability)
// ─────────────────────────────────────────────
function calcInertia(values: number[], assignments: number[], centroids: number[]): number {
return values.reduce((sum, v, i) => sum + (v - centroids[assignments[i]]) ** 2, 0);
}
/**
* Single K-Means run. Returns assignments, centroids, and inertia.
*/
function kMeansOnce(
values: number[],
k: number,
maxIterations: number
): { assignments: number[]; centroids: number[]; inertia: number } {
// K-Means++ seeding for better initialisation
// K-Means++ seeding
const centroids: number[] = [];
centroids.push(values[Math.floor(Math.random() * values.length)]);
for (let c = 1; c < k; c++) {
@@ -180,7 +297,6 @@ function kMeansOnce(
r -= dists[i];
if (r <= 0) { centroids.push(values[i]); break; }
}
// Fallback: if loop exits without pushing (floating point edge case)
if (centroids.length < c + 1) centroids.push(values[values.length - 1]);
}
@@ -202,14 +318,12 @@ function kMeansOnce(
}
}
const inertia = calcInertia(values, assignments, centroids);
return { assignments, centroids, inertia };
return { assignments, centroids, inertia: calcInertia(values, assignments, centroids) };
}
/**
* K-Means clustering with multiple restarts.
* Runs `runs` times and returns the assignment with the lowest inertia,
* eliminating randomness instability across different executions.
* K-Means with multiple restarts — picks the run with lowest inertia
* to eliminate randomness instability across executions.
*/
export function kMeans(
values: number[],
@@ -219,30 +333,30 @@ export function kMeans(
): number[] {
if (values.length < k) return values.map(() => 0);
let bestResult: { assignments: number[]; centroids: number[]; inertia: number } | null = null;
let best: ReturnType<typeof kMeansOnce> | null = null;
for (let run = 0; run < runs; run++) {
const result = kMeansOnce(values, k, maxIterations);
if (bestResult === null || result.inertia < bestResult.inertia) {
bestResult = result;
}
if (!best || result.inertia < best.inertia) best = result;
}
// Re-order cluster indices so label 0 = lowest centroid (economy), etc.
const { assignments, centroids } = bestResult!;
const { assignments, centroids } = best!;
const order = centroids.map((c, i) => ({ c, i })).sort((a, b) => a.c - b.c);
const map = new Map(order.map((item, idx) => [item.i, idx]));
const map = new Map(order.map((item, idx) => [item.i, idx]));
return assignments.map(a => map.get(a)!);
}
// ─────────────────────────────────────────────
// Minimum fare detection
// ─────────────────────────────────────────────
export function detectMinimumFare(distances: number[], prices: number[], kmRate: number): number | null {
if (distances.length < 5) return null;
const pairs = distances.map((d, i) => ({ d, p: prices[i] })).sort((a, b) => a.d - b.d);
const pairs = distances.map((d, i) => ({ d, p: prices[i] })).sort((a, b) => a.d - b.d);
const shortCount = Math.max(5, Math.floor(pairs.length * 0.3));
const shortRides = pairs.slice(0, shortCount);
const residuals = shortRides.map(({ d, p }) => p - kmRate * d).sort((a, b) => a - b);
const trimIdx = Math.max(0, Math.floor(residuals.length * 0.1));
const trimmed = residuals.slice(trimIdx, residuals.length - trimIdx);
const estimate = trimmed.length > 0 ? Math.max(...trimmed) : 0;
const residuals = shortRides.map(({ d, p }) => p - kmRate * d).sort((a, b) => a - b);
const trimIdx = Math.max(0, Math.floor(residuals.length * 0.1));
const trimmed = residuals.slice(trimIdx, residuals.length - trimIdx);
const estimate = trimmed.length > 0 ? Math.max(...trimmed) : 0;
return estimate > 0 ? Math.round(estimate * 100) / 100 : null;
}
+19 -1
View File
@@ -120,7 +120,25 @@ try {
// Fixed-price types, Speed & Awfar: use quoted price as-is
$fixedPriceTypes = ['Speed', 'Fixed Price', 'Awfar Car'];
if (in_array($carType, $fixedPriceTypes)) {
$finalPrice = $quotedPrice;
$finalPrice = $quotedPrice; // Fallback if Redis fails
// 🆕 Immutable Fare Lock: Force use of Redis locked price
try {
global $redis;
if ($redis) {
$lockedPrice = $redis->get("ride_locked_price_{$rideId}");
if ($lockedPrice !== false) {
$finalPrice = floatval($lockedPrice);
error_log("[finish_ride_updates] Using Redis Locked Price for ride {$rideId}: {$finalPrice}");
} else {
error_log("[finish_ride_updates] Redis Locked Price not found for ride {$rideId}. Using DB quoted price.");
}
} else {
error_log("[finish_ride_updates] Global Redis instance not available, using DB quoted price.");
}
} catch (Exception $e) {
error_log("[finish_ride_updates] Redis Error (reading locked price): " . $e->getMessage());
}
} else {
// Variable pricing: calculate from actual distance
$cleanDist = preg_replace('/[^0-9.]/', '', $actualDistance);
+39 -15
View File
@@ -36,6 +36,29 @@ try {
$stmtMainRide = $con->prepare("UPDATE ride SET status = ?, rideTimeStart = NOW() WHERE id = ?");
$stmtMainRide->execute([$status, $ride_id]);
// 🆕 Immutable Fare Lock: Save agreed fixed price in Redis
try {
$stmtRideInfo = $con->prepare("SELECT price, car_type FROM ride WHERE id = ? LIMIT 1");
$stmtRideInfo->execute([$ride_id]);
$rideInfo = $stmtRideInfo->fetch(PDO::FETCH_ASSOC);
if ($rideInfo) {
$agreedPrice = floatval($rideInfo['price']);
$carTypeStr = $rideInfo['car_type'] ?? 'Fixed Price';
$fixedPriceTypes = ['Speed', 'Fixed Price', 'Awfar Car'];
if (in_array($carTypeStr, $fixedPriceTypes)) {
global $redis;
if ($redis) {
$redis->setex("ride_locked_price_{$ride_id}", 86400, $agreedPrice);
error_log("[start_ride] Locked Fixed Price for ride {$ride_id}: {$agreedPrice}");
} else {
error_log("[start_ride] Failed to lock price: Global Redis instance not available.");
}
}
}
} catch (Exception $e) {
error_log("[start_ride] Redis Error (locking price): " . $e->getMessage());
}
// تحديث أو إدخال في جدول Driver Orders
$checkSql = "SELECT `order_id` FROM `driver_orders` WHERE `order_id` = ?";
$checkStmt = $con->prepare($checkSql);
@@ -61,21 +84,22 @@ try {
// 2.5 تصفير الدين من Redis عند بدء الرحلة (كما طلبت)
if ($passenger_id) {
try {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redisKey = "passenger_debt_" . $passenger_id;
// قراءة الدين الحالي من Redis قبل الحذف (إن لزم الأمر للتسجيل مستقبلاً)
$currentDebt = (float) $redis->get($redisKey);
// تصفير / حذف الدين
$redis->del($redisKey);
// يمكنك هنا أيضاً إدراج حركة معاكسة في جدول passengerWallet إذا أردت تسوية قاعدة البيانات
if ($currentDebt < 0) {
$positiveOffset = abs($currentDebt);
$stmtWallet = $con->prepare("INSERT INTO `passengerWallet` (passenger_id, balance) VALUES (?, ?)");
$stmtWallet->execute([$passenger_id, $positiveOffset]);
global $redis;
if ($redis) {
$redisKey = "passenger_debt_" . $passenger_id;
// قراءة الدين الحالي من Redis قبل الحذف (إن لزم الأمر للتسجيل مستقبلاً)
$currentDebt = (float) $redis->get($redisKey);
// تصفير / حذف الدين
$redis->del($redisKey);
// يمكنك هنا أيضاً إدراج حركة معاكسة في جدول passengerWallet إذا أردت تسوية قاعدة البيانات
if ($currentDebt < 0) {
$positiveOffset = abs($currentDebt);
$stmtWallet = $con->prepare("INSERT INTO `passengerWallet` (passenger_id, balance) VALUES (?, ?)");
$stmtWallet->execute([$passenger_id, $positiveOffset]);
}
}
} catch (Exception $e) {
error_log("Redis Error (zeroing debt): " . $e->getMessage());