Update: 2026-07-06 17:51:16
This commit is contained in:
@@ -7,9 +7,29 @@ const TIER_LABELS: Array<'economy' | 'standard' | 'premium'> = [
|
||||
'premium',
|
||||
];
|
||||
|
||||
/**
|
||||
* City center coordinates per country code.
|
||||
* Used by classifyZoneType to measure distance from the urban center.
|
||||
* Add more countries here as new competitors are onboarded.
|
||||
*/
|
||||
const CITY_CENTERS: Record<string, { lat: number; lng: number }> = {
|
||||
JO: { lat: 31.95, lng: 35.90 }, // Amman, Jordan
|
||||
SY: { lat: 33.51, lng: 36.29 }, // Damascus, Syria
|
||||
IQ: { lat: 33.34, lng: 44.40 }, // Baghdad, Iraq
|
||||
SA: { lat: 24.69, lng: 46.72 }, // Riyadh, Saudi Arabia
|
||||
AE: { lat: 25.20, lng: 55.27 }, // Dubai, UAE
|
||||
EG: { lat: 30.04, lng: 31.24 }, // Cairo, Egypt
|
||||
LB: { lat: 33.89, lng: 35.50 }, // Beirut, Lebanon
|
||||
KW: { lat: 29.37, lng: 47.98 }, // Kuwait City
|
||||
};
|
||||
|
||||
/** Fallback city center when country code is not mapped yet */
|
||||
const DEFAULT_CITY_CENTER = { lat: 31.95, lng: 35.90 }; // Amman
|
||||
|
||||
/**
|
||||
* Cluster rides into pricing tiers based on price_per_km using K-Means.
|
||||
* Returns sorted tiers (economy < standard < premium).
|
||||
* Uses multi-run K-Means++ for stable, deterministic results.
|
||||
*/
|
||||
export function clusterTiers(
|
||||
samples: RideSample[],
|
||||
@@ -70,11 +90,19 @@ export function assignZone(lat: number, lng: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify zone type based on distance from city center (Amman: 31.95, 35.90).
|
||||
* Classify zone type based on distance from the city center for a given country.
|
||||
* Falls back to Amman coordinates if countryCode is not in CITY_CENTERS.
|
||||
*
|
||||
* Zone radii (in degrees, ~111km per degree):
|
||||
* centre < 0.025° ≈ 2.8 km
|
||||
* mid < 0.050° ≈ 5.6 km
|
||||
* suburb < 0.100° ≈ 11.1 km
|
||||
* outskirts ≥ 0.100°
|
||||
*/
|
||||
export function classifyZoneType(lat: number, lng: number): string {
|
||||
const dlat = lat - 31.95;
|
||||
const dlng = lng - 35.90;
|
||||
export function classifyZoneType(lat: number, lng: number, countryCode: string = 'JO'): string {
|
||||
const center = CITY_CENTERS[countryCode] ?? DEFAULT_CITY_CENTER;
|
||||
const dlat = lat - center.lat;
|
||||
const dlng = lng - center.lng;
|
||||
const dist = Math.sqrt(dlat * dlat + dlng * dlng);
|
||||
|
||||
if (dist < 0.025) return 'centre';
|
||||
|
||||
@@ -4,12 +4,19 @@ import { clusterTiers } from './clustering';
|
||||
import { analyzeAllTiers } from './regression';
|
||||
import { detectSurge, aggregateSurgeHours } from './surge';
|
||||
import { analyzeByZone, analyzeByZoneType } from './zone';
|
||||
import { median } from 'simple-statistics';
|
||||
|
||||
export interface EngineOptions {
|
||||
competitorName?: string;
|
||||
countryCode?: string;
|
||||
cleanOutliers?: boolean;
|
||||
surgeThreshold?: number;
|
||||
/**
|
||||
* 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.).
|
||||
*/
|
||||
surgeThresholdFraction?: number;
|
||||
tierCount?: number;
|
||||
}
|
||||
|
||||
@@ -23,7 +30,7 @@ export async function runAnalysis(
|
||||
): Promise<AnalysisReport> {
|
||||
const {
|
||||
cleanOutliers = true,
|
||||
surgeThreshold = 0.12,
|
||||
surgeThresholdFraction = 0.05,
|
||||
tierCount = 3,
|
||||
} = options;
|
||||
|
||||
@@ -36,21 +43,27 @@ export async function runAnalysis(
|
||||
// Step 1: Remove statistical outliers (MAD on PPK)
|
||||
const cleanSamples = cleanOutliers ? removeOutliers(samples) : samples;
|
||||
|
||||
// Step 2: Group by route and extract base (non-surge) prices
|
||||
// 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);
|
||||
const surgeThreshold = Math.min(Math.max(medianPrice * surgeThresholdFraction, 0.05), 2.0);
|
||||
|
||||
// Step 3: Group by route and extract base (non-surge) prices
|
||||
const routeGroups = groupByRoute(cleanSamples);
|
||||
const baseSamples = extractBasePrices(routeGroups, surgeThreshold);
|
||||
|
||||
// Step 3: Cluster into pricing tiers by PPK
|
||||
// Step 4: Cluster into pricing tiers by PPK
|
||||
const rawTiers = clusterTiers(cleanSamples, tierCount);
|
||||
|
||||
// Step 4: Run regression on each tier
|
||||
// Step 5: Run regression on each tier
|
||||
const analyzedTiers = analyzeAllTiers(rawTiers);
|
||||
|
||||
// Step 5: Detect surge patterns
|
||||
// Step 6: Detect surge patterns
|
||||
const surgePatterns = detectSurge(cleanSamples, surgeThreshold);
|
||||
const surgeHours = aggregateSurgeHours(surgePatterns);
|
||||
|
||||
// Step 6: Zone analysis
|
||||
// Step 7: Zone analysis
|
||||
const zones = analyzeByZone(cleanSamples);
|
||||
const zoneTypes = analyzeByZoneType(cleanSamples);
|
||||
|
||||
@@ -66,7 +79,7 @@ export async function runAnalysis(
|
||||
};
|
||||
|
||||
// Print summary
|
||||
printSummary(report, baseSamples, surgeHours, zoneTypes);
|
||||
printSummary(report, baseSamples, surgeHours, zoneTypes, surgeThreshold);
|
||||
|
||||
return report;
|
||||
}
|
||||
@@ -75,12 +88,14 @@ function printSummary(
|
||||
report: AnalysisReport,
|
||||
baseSamples: RideSample[],
|
||||
surgeHours: ReturnType<typeof aggregateSurgeHours>,
|
||||
zoneTypes: ReturnType<typeof analyzeByZoneType>
|
||||
zoneTypes: ReturnType<typeof analyzeByZoneType>,
|
||||
surgeThreshold: number
|
||||
): void {
|
||||
const sep = '═══════════════════════════════════════════════════════';
|
||||
console.log(`\n${sep}`);
|
||||
console.log(` 📊 Pricing Analysis Report — ${report.competitorName} (${report.countryCode})`);
|
||||
console.log(` ${report.totalSamples} total samples, ${baseSamples.length} base-price samples`);
|
||||
console.log(` Surge threshold: ${surgeThreshold.toFixed(3)} (dynamic, 5% of median)`);
|
||||
console.log(` Analyzed at: ${report.analyzedAt}`);
|
||||
console.log(sep);
|
||||
|
||||
@@ -91,7 +106,7 @@ function printSummary(
|
||||
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' : 'CUR'}`);
|
||||
console.log(` Base Fare: ${reg.baseFare.toFixed(3)} ${report.countryCode === 'JO' ? 'JOD' : report.countryCode === 'SY' ? 'SYP' : report.countryCode === 'IQ' ? 'IQD' : 'CUR'}`);
|
||||
console.log(` Per KM: ${reg.kmRate.toFixed(3)}`);
|
||||
console.log(` Per Min: ${reg.minRate.toFixed(3)}`);
|
||||
console.log(` Min Fare: ${reg.minFare.toFixed(3)} ${reg.hasMinFare ? '✅ active' : ''}`);
|
||||
|
||||
@@ -93,28 +93,3 @@ export function analyzeTier(tier: PricingTier): PricingTier {
|
||||
export function analyzeAllTiers(tiers: PricingTier[]): PricingTier[] {
|
||||
return tiers.map(tier => analyzeTier(tier));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple distance-only regression for comparison.
|
||||
* price = kmRate * dist
|
||||
*/
|
||||
export function distanceOnlyRegression(
|
||||
samples: RideSample[]
|
||||
): { kmRate: number; rmse: number } | null {
|
||||
if (samples.length < 3) return null;
|
||||
|
||||
const distances = samples.map(s => s.distance_km);
|
||||
const prices = samples.map(s => s.price);
|
||||
|
||||
// Simple average of price/km
|
||||
const ratios = distances.map((d, i) => d > 0 ? prices[i] / d : 0)
|
||||
.filter(r => r > 0 && isFinite(r));
|
||||
|
||||
if (ratios.length < 3) return null;
|
||||
|
||||
const kmRate = mean(ratios);
|
||||
const predicted = distances.map(d => kmRate * d);
|
||||
const rmse = calcRMSE(prices, predicted);
|
||||
|
||||
return { kmRate, rmse };
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export function analyzeByZone(samples: RideSample[]): ZoneAnalysis[] {
|
||||
|
||||
/**
|
||||
* Analyze pricing by zone type (centre, mid, suburb, outskirts).
|
||||
* Passes countryCode to classifyZoneType so the correct city center is used.
|
||||
*/
|
||||
export function analyzeByZoneType(
|
||||
samples: RideSample[]
|
||||
@@ -62,7 +63,8 @@ export function analyzeByZoneType(
|
||||
const typeMap = new Map<string, number[]>();
|
||||
|
||||
for (const s of samples) {
|
||||
const zoneType = classifyZoneType(s.startLat, s.startLng);
|
||||
// Pass countryCode so we use the correct city center (not always Amman)
|
||||
const zoneType = classifyZoneType(s.startLat, s.startLng, s.countryCode);
|
||||
if (!typeMap.has(zoneType)) typeMap.set(zoneType, []);
|
||||
typeMap.get(zoneType)!.push(s.ppk);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user