Rene Fichtmueller 280bf8f50a feat: calibrate regional adoption model with research-backed parameters
Update REGIONAL_LAGS with data from LightCounting, vendor earnings,
OFC market sessions, and Chinese IPO prospectuses. Add price index
per region and segment mix (hyperscaler/telco/enterprise) for
more accurate regional revenue modeling.
2026-03-28 02:34:29 +13:00

274 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Hype Cycle Data Enrichment — Real metrics from scraped data
*
* Computes PhaseMetrics overrides from actual database observations:
* - vendorCount: How many vendors sell this speed class
* - price trends: ASP decline rate from price_observations
* - catalog density: Number of SKUs per speed class (market maturity signal)
* - product diversity: Form factor and reach variety
*/
import { pool } from "../db/client";
import type { PhaseMetrics } from "./norton-bass";
interface SpeedClassMetrics {
speedGbps: number;
vendorCount: number;
skuCount: number;
avgPrice?: number;
minPrice?: number;
maxPrice?: number;
priceCount: number;
formFactors: string[];
reachVariants: number;
}
/**
* Query real vendor/product counts per speed class from the database.
*/
export async function getSpeedClassMetrics(): Promise<ReadonlyArray<SpeedClassMetrics>> {
const result = await pool.query(`
SELECT
t.speed_gbps,
COUNT(DISTINCT t.vendor_id) AS vendor_count,
COUNT(DISTINCT t.id) AS sku_count,
ARRAY_AGG(DISTINCT t.form_factor) FILTER (WHERE t.form_factor IS NOT NULL AND t.form_factor != '') AS form_factors,
COUNT(DISTINCT t.reach_label) FILTER (WHERE t.reach_label IS NOT NULL AND t.reach_label != '') AS reach_variants
FROM transceivers t
WHERE t.speed_gbps > 0
GROUP BY t.speed_gbps
ORDER BY t.speed_gbps
`);
const priceResult = await pool.query(`
SELECT
t.speed_gbps,
AVG(po.price) AS avg_price,
MIN(po.price) AS min_price,
MAX(po.price) AS max_price,
COUNT(*) AS price_count
FROM price_observations po
JOIN transceivers t ON t.id = po.transceiver_id
WHERE t.speed_gbps > 0
GROUP BY t.speed_gbps
`);
const priceMap = new Map<number, { avg: number; min: number; max: number; count: number }>();
for (const row of priceResult.rows) {
priceMap.set(Number(row.speed_gbps), {
avg: parseFloat(row.avg_price),
min: parseFloat(row.min_price),
max: parseFloat(row.max_price),
count: parseInt(row.price_count),
});
}
return result.rows.map((row) => {
const speedGbps = Number(row.speed_gbps);
const priceData = priceMap.get(speedGbps);
return {
speedGbps,
vendorCount: parseInt(row.vendor_count),
skuCount: parseInt(row.sku_count),
avgPrice: priceData?.avg,
minPrice: priceData?.min,
maxPrice: priceData?.max,
priceCount: priceData?.count ?? 0,
formFactors: row.form_factors || [],
reachVariants: parseInt(row.reach_variants),
};
});
}
/**
* Convert raw speed-class metrics into PhaseMetrics overrides.
* These override the model-estimated values with real data.
*/
export function metricsToPhaseOverrides(
metrics: SpeedClassMetrics,
totalMarketSkus: number,
): Partial<PhaseMetrics> {
const overrides: Partial<PhaseMetrics> = {};
// Vendor count — direct from data
overrides.vendorCount = metrics.vendorCount;
// Vendor trend — estimate from catalog density
// More SKUs + more vendors = increasing; few = decreasing
if (metrics.vendorCount >= 4 && metrics.skuCount > 50) {
overrides.vendorTrend = "stable";
} else if (metrics.vendorCount >= 2) {
overrides.vendorTrend = "increasing";
} else {
overrides.vendorTrend = "decreasing";
}
// Shipment share proxy — SKU count relative to total market
overrides.shipmentShare = Math.min(0.5, metrics.skuCount / Math.max(1, totalMarketSkus));
// Interop level — more reach variants and form factors = better interop
const ffDiversity = metrics.formFactors.length;
const reachDiversity = metrics.reachVariants;
overrides.interopLevel = Math.min(100, ffDiversity * 15 + reachDiversity * 8);
return overrides;
}
/**
* Get enriched PhaseMetrics for all speed classes.
* Returns a map of speedGbps -> partial PhaseMetrics overrides.
*/
export async function getDataDrivenOverrides(): Promise<Map<number, Partial<PhaseMetrics>>> {
const allMetrics = await getSpeedClassMetrics();
const totalSkus = allMetrics.reduce((sum, m) => sum + m.skuCount, 0);
const overridesMap = new Map<number, Partial<PhaseMetrics>>();
for (const metrics of allMetrics) {
overridesMap.set(metrics.speedGbps, metricsToPhaseOverrides(metrics, totalSkus));
}
return overridesMap;
}
/**
* Revenue lifecycle prediction per speed class.
*
* Uses scraped price data + Bass diffusion to estimate:
* - Peak revenue year
* - Revenue duration (years above 50% of peak)
* - Current revenue trajectory
*/
export interface RevenueLifecycle {
speedGbps: number;
technology: string;
currentAvgPrice?: number;
estimatedPeakRevenueYear: number;
estimatedDeclineStartYear: number;
revenueHalfLifeYears: number;
currentPhase: "growing" | "peaking" | "declining" | "legacy";
revenueIndex: number; // 0-100, relative to estimated peak
}
export function computeRevenueLifecycle(
speedGbps: number,
techName: string,
introYear: number,
peakYear: number,
currentYear: number,
avgPrice?: number,
): RevenueLifecycle {
// Revenue = Price × Volume. Price declines while volume grows.
// Peak revenue happens ~2 years before peak volume (when price×volume is maximized)
const peakRevenueYear = Math.round(peakYear - 2);
const declineStartYear = peakYear + 2;
const halfLife = Math.round((peakYear - introYear) * 0.7);
const yearsFromPeak = currentYear - peakRevenueYear;
let currentPhase: RevenueLifecycle["currentPhase"];
if (currentYear < peakRevenueYear - 2) currentPhase = "growing";
else if (currentYear <= peakRevenueYear + 2) currentPhase = "peaking";
else if (currentYear <= declineStartYear + halfLife) currentPhase = "declining";
else currentPhase = "legacy";
// Revenue index: bell curve centered on peakRevenueYear
const sigma = halfLife / 1.5;
const revenueIndex = Math.round(100 * Math.exp(-0.5 * Math.pow(yearsFromPeak / sigma, 2)));
return {
speedGbps,
technology: techName,
currentAvgPrice: avgPrice,
estimatedPeakRevenueYear: peakRevenueYear,
estimatedDeclineStartYear: declineStartYear,
revenueHalfLifeYears: halfLife,
currentPhase,
revenueIndex,
};
}
/**
* Regional adoption model.
* Applies lag coefficients per region based on industry research.
*/
export interface RegionalAdoption {
region: string;
lagYears: number;
marketSharePct: number;
adoptionPhase: string;
estimatedPeakYear: number;
}
/**
* Regional lag coefficients calibrated from research (2026-03-28).
* Sources: LightCounting, vendor earnings, OFC market sessions, Chinese IPO prospectuses.
* Lag in years (converted from quarters: NA=0, CN=0.5Q≈0.125yr, EU=4Q=1yr, etc.)
*/
const REGIONAL_LAGS: ReadonlyArray<{
region: string;
lagYears: number;
marketSharePct: number;
priceIndex: number;
segmentMix: { hyperscaler: number; telco: number; enterprise: number };
}> = [
{
region: "North America (Hyperscale)",
lagYears: 0,
marketSharePct: 32,
priceIndex: 1.0,
segmentMix: { hyperscaler: 0.65, telco: 0.20, enterprise: 0.15 },
},
{
region: "China (BAT/Hyperscale)",
lagYears: 0.125, // 0.5 quarters = near-parity, sometimes leads on volume
marketSharePct: 38,
priceIndex: 0.58, // 42% cheaper domestically
segmentMix: { hyperscaler: 0.50, telco: 0.35, enterprise: 0.15 },
},
{
region: "APAC (ex-China)",
lagYears: 0.625, // 2.5 quarters
marketSharePct: 13,
priceIndex: 1.0,
segmentMix: { hyperscaler: 0.35, telco: 0.40, enterprise: 0.25 },
},
{
region: "Europe",
lagYears: 1.0, // 4 quarters — telco procurement cycles, regulatory compliance
marketSharePct: 14,
priceIndex: 1.12, // 12% premium (CE/RoHS compliance, smaller volumes, channel markup)
segmentMix: { hyperscaler: 0.25, telco: 0.45, enterprise: 0.30 },
},
{
region: "Rest of World",
lagYears: 1.5, // 6 quarters
marketSharePct: 3,
priceIndex: 1.05,
segmentMix: { hyperscaler: 0.20, telco: 0.50, enterprise: 0.30 },
},
];
export function computeRegionalAdoption(
techPeakYear: number,
currentYear: number,
techName: string,
): ReadonlyArray<RegionalAdoption> {
return REGIONAL_LAGS.map(({ region, lagYears, marketSharePct }) => {
const regionalPeak = techPeakYear + lagYears;
const yearsToPeak = regionalPeak - currentYear;
let adoptionPhase: string;
if (yearsToPeak > 5) adoptionPhase = "Pre-adoption";
else if (yearsToPeak > 2) adoptionPhase = "Early Adoption";
else if (yearsToPeak > -1) adoptionPhase = "Growth";
else if (yearsToPeak > -4) adoptionPhase = "Mature";
else adoptionPhase = "Declining";
return {
region,
lagYears,
marketSharePct,
adoptionPhase,
estimatedPeakYear: Math.round(regionalPeak * 2) / 2, // Round to half-year
};
});
}