Update: 2026-07-06 17:51:16

This commit is contained in:
Hamza-Ayed
2026-07-06 17:51:16 +03:00
parent e21e1f4ec2
commit 9d257c5d7d
8 changed files with 292 additions and 241 deletions
@@ -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';
+25 -10
View File
@@ -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 };
}
+3 -1
View File
@@ -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);
}