274 lines
8.8 KiB
TypeScript
274 lines
8.8 KiB
TypeScript
/**
|
||
* 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,
|
||
): { -readonly [K in keyof PhaseMetrics]?: PhaseMetrics[K] } {
|
||
const overrides: { -readonly [K in keyof PhaseMetrics]?: PhaseMetrics[K] } = {};
|
||
|
||
// 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, { -readonly [K in keyof PhaseMetrics]?: PhaseMetrics[K] }>> {
|
||
const allMetrics = await getSpeedClassMetrics();
|
||
const totalSkus = allMetrics.reduce((sum, m) => sum + m.skuCount, 0);
|
||
|
||
const overridesMap = new Map<number, { -readonly [K in keyof PhaseMetrics]?: PhaseMetrics[K] }>();
|
||
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
|
||
};
|
||
});
|
||
}
|