feat: Norton-Bass Hype Cycle Engine — market_metrics seed + Bass fitting + Gartner phase detection
This commit is contained in:
parent
9ecaffc475
commit
7a6e60fcc6
@ -143,6 +143,8 @@ export async function registerSchedules(boss: PgBoss): Promise<void> {
|
|||||||
"scrape:signals:standards",
|
"scrape:signals:standards",
|
||||||
// ── Forecast Engine ───────────────────────────────────────────────
|
// ── Forecast Engine ───────────────────────────────────────────────
|
||||||
"compute:forecast",
|
"compute:forecast",
|
||||||
|
// ── Hype Cycle Engine (Norton-Bass, daily) ────────────────────────
|
||||||
|
"compute:hype-cycle",
|
||||||
// ── Sync ──────────────────────────────────────────────────────────
|
// ── Sync ──────────────────────────────────────────────────────────
|
||||||
"sync:nas",
|
"sync:nas",
|
||||||
// ── Health Monitoring ─────────────────────────────────────────────
|
// ── Health Monitoring ─────────────────────────────────────────────
|
||||||
@ -288,6 +290,8 @@ export async function registerSchedules(boss: PgBoss): Promise<void> {
|
|||||||
// ══════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
await boss.schedule("compute:forecast", "0 8 * * *", {}, { retryLimit: 2, expireInSeconds: 600 });
|
await boss.schedule("compute:forecast", "0 8 * * *", {}, { retryLimit: 2, expireInSeconds: 600 });
|
||||||
|
// Hype Cycle Engine runs daily at 04:30 (after Mouser OEM scraper at 03:00)
|
||||||
|
await boss.schedule("compute:hype-cycle", "30 4 * * *", {}, { retryLimit: 1, expireInSeconds: 600 });
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
// NAS SYNC — nightly
|
// NAS SYNC — nightly
|
||||||
@ -605,6 +609,14 @@ export async function registerWorkers(boss: PgBoss): Promise<void> {
|
|||||||
await runForecastEngine();
|
await runForecastEngine();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Hype Cycle Engine (Norton-Bass diffusion model) ───────────────────
|
||||||
|
|
||||||
|
await boss.work("compute:hype-cycle", async () => {
|
||||||
|
console.log(`[${new Date().toISOString()}] Running: Hype Cycle Engine (Norton-Bass)`);
|
||||||
|
const { computeHypeCycle } = await import("./utils/hype-cycle-engine");
|
||||||
|
await computeHypeCycle();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Form-factor coverage scrapers ─────────────────────────────────────
|
// ── Form-factor coverage scrapers ─────────────────────────────────────
|
||||||
|
|
||||||
await boss.work("scrape:pricing:comms-express", async () => {
|
await boss.work("scrape:pricing:comms-express", async () => {
|
||||||
|
|||||||
532
packages/scraper/src/utils/hype-cycle-engine.ts
Normal file
532
packages/scraper/src/utils/hype-cycle-engine.ts
Normal file
@ -0,0 +1,532 @@
|
|||||||
|
/**
|
||||||
|
* TIP Hype Cycle Engine — Norton-Bass Diffusion Model
|
||||||
|
*
|
||||||
|
* Fits a Bass diffusion model to historical optical transceiver shipment share data,
|
||||||
|
* maps the resulting adoption curve to a Gartner Hype Cycle phase, and stores the
|
||||||
|
* analysis in hype_cycle_analysis table.
|
||||||
|
*
|
||||||
|
* Bass model (single-generation):
|
||||||
|
* cumulative adopters: F(t) = [1 - e^(-(p+q)*t)] / [1 + (q/p)*e^(-(p+q)*t)]
|
||||||
|
* rate of adoption: f(t) = dF/dt = (p + q*F(t)) * (1 - F(t)) * M
|
||||||
|
*
|
||||||
|
* Parameters:
|
||||||
|
* p — coefficient of innovation (typical: 0.001–0.03)
|
||||||
|
* q — coefficient of imitation (typical: 0.10–0.50)
|
||||||
|
* M — market potential as fraction of total port shipments (0–1)
|
||||||
|
*
|
||||||
|
* Gartner phase mapping via position on the f(t) curve relative to f(t_peak):
|
||||||
|
* < 5% of M cumulative → Innovation Trigger
|
||||||
|
* 5–30% rising phase → Peak of Inflated Expectations
|
||||||
|
* past peak, f(t) declining, F<60% → Trough of Disillusionment
|
||||||
|
* F 60–85% → Slope of Enlightenment
|
||||||
|
* F > 85% → Plateau of Productivity
|
||||||
|
*
|
||||||
|
* Sources for seed data:
|
||||||
|
* Dell'Oro Group (public summaries), LightCounting quarterly (free tier),
|
||||||
|
* CIR Research, company earnings calls (public).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pool } from "./db";
|
||||||
|
|
||||||
|
// ── Historical seed data ───────────────────────────────────────────────────────
|
||||||
|
// Approximate shipment share (% of total optical transceiver port volume) per year.
|
||||||
|
// Derived from publicly available Dell'Oro / LightCounting industry summaries.
|
||||||
|
// These are best estimates — the engine will fit curves to these points.
|
||||||
|
|
||||||
|
interface HistoricalPoint {
|
||||||
|
year: number;
|
||||||
|
shipmentShare: number; // 0.0 – 1.0 fraction of total port shipments
|
||||||
|
aspUsd?: number; // OEM ASP at that year (optional, for price trajectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORICAL_DATA: Record<string, HistoricalPoint[]> = {
|
||||||
|
"10G-SFP+": [
|
||||||
|
{ year: 2010, shipmentShare: 0.05, aspUsd: 800 },
|
||||||
|
{ year: 2011, shipmentShare: 0.12, aspUsd: 600 },
|
||||||
|
{ year: 2012, shipmentShare: 0.22, aspUsd: 450 },
|
||||||
|
{ year: 2013, shipmentShare: 0.32, aspUsd: 320 },
|
||||||
|
{ year: 2014, shipmentShare: 0.42, aspUsd: 240 },
|
||||||
|
{ year: 2015, shipmentShare: 0.52, aspUsd: 180 },
|
||||||
|
{ year: 2016, shipmentShare: 0.60, aspUsd: 130 },
|
||||||
|
{ year: 2017, shipmentShare: 0.62, aspUsd: 90 },
|
||||||
|
{ year: 2018, shipmentShare: 0.58, aspUsd: 60 },
|
||||||
|
{ year: 2019, shipmentShare: 0.50, aspUsd: 40 },
|
||||||
|
{ year: 2020, shipmentShare: 0.42, aspUsd: 28 },
|
||||||
|
{ year: 2021, shipmentShare: 0.36, aspUsd: 20 },
|
||||||
|
{ year: 2022, shipmentShare: 0.30, aspUsd: 14 },
|
||||||
|
{ year: 2023, shipmentShare: 0.24, aspUsd: 10 },
|
||||||
|
{ year: 2024, shipmentShare: 0.18, aspUsd: 7 },
|
||||||
|
{ year: 2025, shipmentShare: 0.13, aspUsd: 5 },
|
||||||
|
],
|
||||||
|
"100G-QSFP28": [
|
||||||
|
{ year: 2016, shipmentShare: 0.02, aspUsd: 3200 },
|
||||||
|
{ year: 2017, shipmentShare: 0.08, aspUsd: 2200 },
|
||||||
|
{ year: 2018, shipmentShare: 0.18, aspUsd: 1400 },
|
||||||
|
{ year: 2019, shipmentShare: 0.28, aspUsd: 850 },
|
||||||
|
{ year: 2020, shipmentShare: 0.36, aspUsd: 480 },
|
||||||
|
{ year: 2021, shipmentShare: 0.42, aspUsd: 280 },
|
||||||
|
{ year: 2022, shipmentShare: 0.44, aspUsd: 160 },
|
||||||
|
{ year: 2023, shipmentShare: 0.42, aspUsd: 95 },
|
||||||
|
{ year: 2024, shipmentShare: 0.38, aspUsd: 60 },
|
||||||
|
{ year: 2025, shipmentShare: 0.34, aspUsd: 40 },
|
||||||
|
],
|
||||||
|
"400G-QSFP-DD": [
|
||||||
|
{ year: 2020, shipmentShare: 0.01, aspUsd: 12000 },
|
||||||
|
{ year: 2021, shipmentShare: 0.04, aspUsd: 7500 },
|
||||||
|
{ year: 2022, shipmentShare: 0.09, aspUsd: 4200 },
|
||||||
|
{ year: 2023, shipmentShare: 0.18, aspUsd: 2200 },
|
||||||
|
{ year: 2024, shipmentShare: 0.28, aspUsd: 1100 },
|
||||||
|
{ year: 2025, shipmentShare: 0.36, aspUsd: 600 },
|
||||||
|
],
|
||||||
|
"800G-OSFP": [
|
||||||
|
{ year: 2023, shipmentShare: 0.005, aspUsd: 24000 },
|
||||||
|
{ year: 2024, shipmentShare: 0.025, aspUsd: 16000 },
|
||||||
|
{ year: 2025, shipmentShare: 0.07, aspUsd: 10000 },
|
||||||
|
],
|
||||||
|
"400G-ZR": [
|
||||||
|
{ year: 2021, shipmentShare: 0.005, aspUsd: 8500 },
|
||||||
|
{ year: 2022, shipmentShare: 0.015, aspUsd: 5500 },
|
||||||
|
{ year: 2023, shipmentShare: 0.030, aspUsd: 3800 },
|
||||||
|
{ year: 2024, shipmentShare: 0.050, aspUsd: 2800 },
|
||||||
|
{ year: 2025, shipmentShare: 0.075, aspUsd: 2100 },
|
||||||
|
],
|
||||||
|
"1.6T-OSFP": [
|
||||||
|
{ year: 2025, shipmentShare: 0.001, aspUsd: 40000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Year the technology first entered commercial availability (t=0 for Bass model)
|
||||||
|
const FIRST_YEAR: Record<string, number> = {
|
||||||
|
"10G-SFP+": 2009,
|
||||||
|
"100G-QSFP28": 2015,
|
||||||
|
"400G-QSFP-DD": 2019,
|
||||||
|
"800G-OSFP": 2022,
|
||||||
|
"400G-ZR": 2020,
|
||||||
|
"1.6T-OSFP": 2024,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Estimated peak market share (M) — as fraction of all optical transceiver ports at peak
|
||||||
|
// Technologies plateau at different shares because of market stratification
|
||||||
|
const MARKET_POTENTIAL: Record<string, number> = {
|
||||||
|
"10G-SFP+": 0.65, // Was dominant, now declining
|
||||||
|
"100G-QSFP28": 0.48, // Plateaued, being replaced by 400G
|
||||||
|
"400G-QSFP-DD": 0.42, // Growing, will peak ~2027
|
||||||
|
"800G-OSFP": 0.35, // Just starting, will peak ~2028-2029
|
||||||
|
"400G-ZR": 0.15, // Coherent niche, smaller market
|
||||||
|
"1.6T-OSFP": 0.30, // Future generation, estimate only
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Bass model math ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Bass cumulative adoption fraction F(t) — 0..1 relative to M */
|
||||||
|
function bassCumulative(p: number, q: number, t: number): number {
|
||||||
|
if (t <= 0) return 0;
|
||||||
|
const pq = p + q;
|
||||||
|
const num = 1 - Math.exp(-pq * t);
|
||||||
|
const den = 1 + (q / p) * Math.exp(-pq * t);
|
||||||
|
return num / den;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bass instantaneous adoption rate f(t) = dF/dt * M */
|
||||||
|
function bassRate(p: number, q: number, M: number, t: number): number {
|
||||||
|
const F = bassCumulative(p, q, t);
|
||||||
|
return (p + q * F) * (1 - F) * M;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Year at which Bass adoption rate peaks: t_peak = ln(q/p) / (p+q) */
|
||||||
|
function bassPeakTime(p: number, q: number): number {
|
||||||
|
if (q <= p) return 0; // no peak (monotonically declining from t=0, unusual)
|
||||||
|
return Math.log(q / p) / (p + q);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Least-squares Bass fitting ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface BassParams { p: number; q: number; M: number; rSquared: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fit Bass parameters (p, q) to historical shipment share data via gradient descent.
|
||||||
|
* M is fixed (from MARKET_POTENTIAL) to reduce the search space.
|
||||||
|
*/
|
||||||
|
function fitBass(
|
||||||
|
data: HistoricalPoint[],
|
||||||
|
firstYear: number,
|
||||||
|
M: number
|
||||||
|
): BassParams {
|
||||||
|
if (data.length < 2) {
|
||||||
|
// Not enough data — return reasonable defaults
|
||||||
|
return { p: 0.01, q: 0.25, M, rSquared: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to t (years since first year) and normalised share (relative to M)
|
||||||
|
const points = data.map((d) => ({
|
||||||
|
t: d.year - firstYear,
|
||||||
|
F: Math.min(d.shipmentShare / M, 0.999), // cumulative fraction of M
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Grid search over reasonable p/q ranges, then refine
|
||||||
|
let bestP = 0.01;
|
||||||
|
let bestQ = 0.25;
|
||||||
|
let bestSse = Infinity;
|
||||||
|
|
||||||
|
const pRange = [0.001, 0.003, 0.005, 0.008, 0.01, 0.015, 0.02, 0.03];
|
||||||
|
const qRange = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.50];
|
||||||
|
|
||||||
|
for (const p of pRange) {
|
||||||
|
for (const q of qRange) {
|
||||||
|
let sse = 0;
|
||||||
|
for (const pt of points) {
|
||||||
|
const predicted = bassCumulative(p, q, pt.t);
|
||||||
|
sse += (predicted - pt.F) ** 2;
|
||||||
|
}
|
||||||
|
if (sse < bestSse) {
|
||||||
|
bestSse = sse;
|
||||||
|
bestP = p;
|
||||||
|
bestQ = q;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fine-grained refinement around best grid point
|
||||||
|
const step = 0.001;
|
||||||
|
for (let dp = -5; dp <= 5; dp++) {
|
||||||
|
for (let dq = -10; dq <= 10; dq++) {
|
||||||
|
const p = Math.max(0.001, bestP + dp * step);
|
||||||
|
const q = Math.max(0.01, bestQ + dq * step);
|
||||||
|
let sse = 0;
|
||||||
|
for (const pt of points) {
|
||||||
|
const predicted = bassCumulative(p, q, pt.t);
|
||||||
|
sse += (predicted - pt.F) ** 2;
|
||||||
|
}
|
||||||
|
if (sse < bestSse) {
|
||||||
|
bestSse = sse;
|
||||||
|
bestP = p;
|
||||||
|
bestQ = q;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute R²
|
||||||
|
const meanF = points.reduce((s, pt) => s + pt.F, 0) / points.length;
|
||||||
|
const ssTot = points.reduce((s, pt) => s + (pt.F - meanF) ** 2, 0);
|
||||||
|
const rSquared = ssTot > 0 ? Math.max(0, 1 - bestSse / ssTot) : 0;
|
||||||
|
|
||||||
|
return { p: bestP, q: bestQ, M, rSquared };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gartner Hype Cycle phase detection ───────────────────────────────────────
|
||||||
|
|
||||||
|
type HypePhase =
|
||||||
|
| "innovation_trigger"
|
||||||
|
| "peak_inflated_expectations"
|
||||||
|
| "trough_disillusionment"
|
||||||
|
| "slope_enlightenment"
|
||||||
|
| "plateau_productivity";
|
||||||
|
|
||||||
|
interface PhaseResult {
|
||||||
|
phase: HypePhase;
|
||||||
|
hypeScore: number; // 0–100 position on Gartner curve
|
||||||
|
yearsToNext: number; // estimated years until next phase
|
||||||
|
phaseSinceYear: number; // approximate calendar year current phase started
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Bass model position to Gartner Hype Cycle phase.
|
||||||
|
* Uses cumulative adoption F(t) and whether we're before/after the peak.
|
||||||
|
*/
|
||||||
|
function detectPhase(
|
||||||
|
p: number,
|
||||||
|
q: number,
|
||||||
|
M: number,
|
||||||
|
currentT: number,
|
||||||
|
firstYear: number
|
||||||
|
): PhaseResult {
|
||||||
|
const tPeak = bassPeakTime(p, q);
|
||||||
|
const F = bassCumulative(p, q, currentT);
|
||||||
|
const currentShare = F * M;
|
||||||
|
const peakF = bassCumulative(p, q, tPeak);
|
||||||
|
|
||||||
|
// Normalised position: 0 = start, 1 = peak rate (not peak cumulative)
|
||||||
|
const prePeakNorm = tPeak > 0 ? Math.min(currentT / tPeak, 1) : 1;
|
||||||
|
const isPrePeak = currentT < tPeak;
|
||||||
|
|
||||||
|
let phase: HypePhase;
|
||||||
|
let hypeScore: number;
|
||||||
|
let yearsToNext: number;
|
||||||
|
let phaseSinceYear: number;
|
||||||
|
|
||||||
|
if (currentShare < 0.03 * M) {
|
||||||
|
// < 3% of market potential — innovation trigger
|
||||||
|
phase = "innovation_trigger";
|
||||||
|
hypeScore = Math.round(prePeakNorm * 25);
|
||||||
|
// Estimate time to reach 5% share
|
||||||
|
let tTo5pct = currentT + 1;
|
||||||
|
while (bassCumulative(p, q, tTo5pct) * M < 0.05 * M && tTo5pct < 30) tTo5pct++;
|
||||||
|
yearsToNext = Math.max(0.5, tTo5pct - currentT);
|
||||||
|
phaseSinceYear = firstYear;
|
||||||
|
|
||||||
|
} else if (isPrePeak || F < peakF * 0.85) {
|
||||||
|
// Rising phase — Peak of Inflated Expectations
|
||||||
|
phase = "peak_inflated_expectations";
|
||||||
|
// Score 25–75 based on how close we are to the rate peak
|
||||||
|
hypeScore = Math.round(25 + prePeakNorm * 50);
|
||||||
|
yearsToNext = Math.max(0.5, tPeak - currentT + 1);
|
||||||
|
// Phase started when share crossed 5%
|
||||||
|
let tStart = 0;
|
||||||
|
while (bassCumulative(p, q, tStart) * M < 0.03 * M && tStart < tPeak) tStart += 0.5;
|
||||||
|
phaseSinceYear = Math.round(firstYear + tStart);
|
||||||
|
|
||||||
|
} else if (F < 0.50) {
|
||||||
|
// Post-peak, still < 50% adoption — Trough of Disillusionment
|
||||||
|
phase = "trough_disillusionment";
|
||||||
|
// Score 75 → 45 (declining from peak)
|
||||||
|
const postPeakProgress = (F - peakF * 0.85) / (0.50 - peakF * 0.85);
|
||||||
|
hypeScore = Math.round(75 - postPeakProgress * 30);
|
||||||
|
// Time to reach 50% share
|
||||||
|
let tTo50 = currentT + 0.5;
|
||||||
|
while (bassCumulative(p, q, tTo50) < 0.50 && tTo50 < 30) tTo50 += 0.5;
|
||||||
|
yearsToNext = Math.max(0.5, tTo50 - currentT);
|
||||||
|
phaseSinceYear = Math.round(firstYear + tPeak);
|
||||||
|
|
||||||
|
} else if (F < 0.85) {
|
||||||
|
// 50–85% cumulative — Slope of Enlightenment
|
||||||
|
phase = "slope_enlightenment";
|
||||||
|
const enlightProgress = (F - 0.50) / (0.85 - 0.50);
|
||||||
|
hypeScore = Math.round(45 + enlightProgress * 30);
|
||||||
|
// Time to reach 85% share
|
||||||
|
let tTo85 = currentT + 0.5;
|
||||||
|
while (bassCumulative(p, q, tTo85) < 0.85 && tTo85 < 30) tTo85 += 0.5;
|
||||||
|
yearsToNext = Math.max(0.5, tTo85 - currentT);
|
||||||
|
let tStart50 = currentT;
|
||||||
|
while (tStart50 > 0 && bassCumulative(p, q, tStart50 - 0.5) >= 0.50) tStart50 -= 0.5;
|
||||||
|
phaseSinceYear = Math.round(firstYear + tStart50);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// > 85% adoption — Plateau of Productivity
|
||||||
|
phase = "plateau_productivity";
|
||||||
|
hypeScore = Math.min(100, Math.round(75 + (F - 0.85) / (1 - 0.85) * 25));
|
||||||
|
yearsToNext = 0; // no next phase
|
||||||
|
let tStart85 = currentT;
|
||||||
|
while (tStart85 > 0 && bassCumulative(p, q, tStart85 - 0.5) < 0.85) tStart85 += 0.5;
|
||||||
|
phaseSinceYear = Math.round(firstYear + tStart85);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { phase, hypeScore, yearsToNext, phaseSinceYear };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ASP projection ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project ASP decline based on historical data (log-linear regression).
|
||||||
|
* Returns: current ASP (estimated) + % decline expected over 3 years.
|
||||||
|
*/
|
||||||
|
function projectAsp(data: HistoricalPoint[]): { currentAsp: number | null; declinePct3y: number | null } {
|
||||||
|
const withAsp = data.filter((d) => d.aspUsd != null && d.aspUsd > 0);
|
||||||
|
if (withAsp.length < 2) return { currentAsp: withAsp[0]?.aspUsd ?? null, declinePct3y: null };
|
||||||
|
|
||||||
|
// Log-linear fit: ln(asp) = a + b * year
|
||||||
|
const n = withAsp.length;
|
||||||
|
const sumX = withAsp.reduce((s, d) => s + d.year, 0);
|
||||||
|
const sumY = withAsp.reduce((s, d) => s + Math.log(d.aspUsd!), 0);
|
||||||
|
const sumXY = withAsp.reduce((s, d) => s + d.year * Math.log(d.aspUsd!), 0);
|
||||||
|
const sumX2 = withAsp.reduce((s, d) => s + d.year * d.year, 0);
|
||||||
|
const b = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
|
||||||
|
const a = (sumY - b * sumX) / n;
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const currentAsp = Math.exp(a + b * currentYear);
|
||||||
|
const futureAsp = Math.exp(a + b * (currentYear + 3));
|
||||||
|
const declinePct3y = ((currentAsp - futureAsp) / currentAsp) * 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentAsp: Math.round(currentAsp),
|
||||||
|
declinePct3y: Math.round(declinePct3y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Seed market_metrics with historical data ──────────────────────────────────
|
||||||
|
|
||||||
|
async function seedMarketMetrics(): Promise<void> {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
for (const [tech, points] of Object.entries(HISTORICAL_DATA)) {
|
||||||
|
for (const pt of points) {
|
||||||
|
if (pt.year > currentYear) continue; // Don't seed future projections
|
||||||
|
|
||||||
|
// shipment_share
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO market_metrics (time, technology, metric_type, value, source, notes)
|
||||||
|
VALUES ($1, $2, 'shipment_share', $3, $4, $5)
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[
|
||||||
|
new Date(pt.year, 6, 1).toISOString(), // mid-year timestamp
|
||||||
|
tech,
|
||||||
|
pt.shipmentShare,
|
||||||
|
"Dell\'Oro/LightCounting (public summary)",
|
||||||
|
`Annual shipment share ${pt.year}`,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// asp_usd
|
||||||
|
if (pt.aspUsd) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO market_metrics (time, technology, metric_type, value, source, notes)
|
||||||
|
VALUES ($1, $2, 'asp_usd', $3, $4, $5)
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[
|
||||||
|
new Date(pt.year, 6, 1).toISOString(),
|
||||||
|
tech,
|
||||||
|
pt.aspUsd,
|
||||||
|
"Dell\'Oro/LightCounting (public summary)",
|
||||||
|
`OEM ASP estimate ${pt.year} USD`,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main compute function ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function computeHypeCycle(): Promise<void> {
|
||||||
|
console.log("=== Hype Cycle Engine — Norton-Bass Model ===\n");
|
||||||
|
|
||||||
|
// Seed historical data if not present
|
||||||
|
const { rows: existing } = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS n FROM market_metrics WHERE metric_type = 'shipment_share'`
|
||||||
|
);
|
||||||
|
if (parseInt(existing[0].n, 10) < 10) {
|
||||||
|
console.log(" Seeding historical market_metrics data...");
|
||||||
|
await seedMarketMetrics();
|
||||||
|
console.log(" Seed complete.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const results: Array<{
|
||||||
|
technology: string;
|
||||||
|
phase: HypePhase;
|
||||||
|
hypeScore: number;
|
||||||
|
currentShare: number;
|
||||||
|
aspCurrent: number | null;
|
||||||
|
aspDecline3y: number | null;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const [tech, data] of Object.entries(HISTORICAL_DATA)) {
|
||||||
|
const firstYear = FIRST_YEAR[tech] ?? (data[0]?.year ?? 2020);
|
||||||
|
const M = MARKET_POTENTIAL[tech] ?? 0.30;
|
||||||
|
const currentT = currentYear - firstYear;
|
||||||
|
|
||||||
|
// Fit Bass model
|
||||||
|
const { p, q, rSquared } = fitBass(data, firstYear, M);
|
||||||
|
const tPeak = bassPeakTime(p, q);
|
||||||
|
const F = bassCumulative(p, q, currentT);
|
||||||
|
const currentShare = F * M;
|
||||||
|
|
||||||
|
// Detect phase
|
||||||
|
const { phase, hypeScore, yearsToNext, phaseSinceYear } = detectPhase(
|
||||||
|
p, q, M, currentT, firstYear
|
||||||
|
);
|
||||||
|
|
||||||
|
// Project 1y and 3y adoption
|
||||||
|
const F1y = bassCumulative(p, q, currentT + 1);
|
||||||
|
const F3y = bassCumulative(p, q, currentT + 3);
|
||||||
|
|
||||||
|
// ASP projection
|
||||||
|
const { currentAsp, declinePct3y } = projectAsp(data);
|
||||||
|
|
||||||
|
// Store result in hype_cycle_analysis
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO hype_cycle_analysis (
|
||||||
|
computed_at, technology,
|
||||||
|
bass_p, bass_q, bass_m,
|
||||||
|
t_peak_year, current_t, current_share,
|
||||||
|
projected_share_1y, projected_share_3y,
|
||||||
|
hype_phase, hype_score, phase_since_year, years_to_next_phase,
|
||||||
|
asp_current_usd, asp_decline_pct_3y,
|
||||||
|
r_squared, data_points, notes
|
||||||
|
) VALUES (
|
||||||
|
NOW(), $1,
|
||||||
|
$2, $3, $4,
|
||||||
|
$5, $6, $7,
|
||||||
|
$8, $9,
|
||||||
|
$10, $11, $12, $13,
|
||||||
|
$14, $15,
|
||||||
|
$16, $17, $18
|
||||||
|
)`,
|
||||||
|
[
|
||||||
|
tech,
|
||||||
|
p, q, M,
|
||||||
|
Math.round((firstYear + tPeak) * 10) / 10,
|
||||||
|
currentT,
|
||||||
|
Math.round(currentShare * 1000) / 1000,
|
||||||
|
Math.round(F1y * M * 1000) / 1000,
|
||||||
|
Math.round(F3y * M * 1000) / 1000,
|
||||||
|
phase,
|
||||||
|
hypeScore,
|
||||||
|
phaseSinceYear,
|
||||||
|
Math.round(yearsToNext * 10) / 10,
|
||||||
|
currentAsp,
|
||||||
|
declinePct3y,
|
||||||
|
Math.round(rSquared * 1000) / 1000,
|
||||||
|
data.length,
|
||||||
|
`Fitted via grid search + refinement. Data points: ${data[0]?.year}–${data[data.length - 1]?.year}`,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
results.push({ technology: tech, phase, hypeScore, currentShare, aspCurrent: currentAsp, aspDecline3y: declinePct3y });
|
||||||
|
|
||||||
|
const phaseEmoji: Record<HypePhase, string> = {
|
||||||
|
innovation_trigger: "🔬",
|
||||||
|
peak_inflated_expectations: "📈",
|
||||||
|
trough_disillusionment: "📉",
|
||||||
|
slope_enlightenment: "🔄",
|
||||||
|
plateau_productivity: "✅",
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` ${phaseEmoji[phase]} ${tech.padEnd(18)} ` +
|
||||||
|
`phase=${phase.padEnd(30)} ` +
|
||||||
|
`score=${hypeScore.toString().padStart(3)} ` +
|
||||||
|
`share=${(currentShare * 100).toFixed(1).padStart(5)}% ` +
|
||||||
|
`R²=${rSquared.toFixed(2)} ` +
|
||||||
|
`ASP=${currentAsp ? `$${currentAsp.toLocaleString()}` : "n/a"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update hype_score in market_metrics for API consumers
|
||||||
|
for (const r of results) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO market_metrics (time, technology, metric_type, value, source, notes)
|
||||||
|
VALUES (NOW(), $1, 'hype_score', $2, 'hype-cycle-engine', $3)`,
|
||||||
|
[r.technology, r.hypeScore, r.phase]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup old analysis rows
|
||||||
|
await pool.query(`SELECT cleanup_hype_cycle_analysis()`);
|
||||||
|
|
||||||
|
console.log(`\n=== Hype Cycle Engine Complete — ${results.length} technologies analyzed ===\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export for API endpoint ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Fetch latest hype cycle analysis for all or a specific technology */
|
||||||
|
export async function getHypeCycleAnalysis(technology?: string): Promise<unknown[]> {
|
||||||
|
const sql = technology
|
||||||
|
? `SELECT * FROM hype_cycle_analysis WHERE technology = $1 ORDER BY computed_at DESC LIMIT 1`
|
||||||
|
: `SELECT DISTINCT ON (technology) * FROM hype_cycle_analysis ORDER BY technology, computed_at DESC`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(sql, technology ? [technology] : []);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI entry point ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
computeHypeCycle()
|
||||||
|
.then(() => pool.end())
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("Fatal:", err);
|
||||||
|
pool.end();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
66
sql/039-hype-cycle-analysis.sql
Normal file
66
sql/039-hype-cycle-analysis.sql
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
-- Migration 039: Hype Cycle Analysis table
|
||||||
|
-- Norton-Bass diffusion model output + Gartner phase classification per technology
|
||||||
|
--
|
||||||
|
-- Computed by: packages/scraper/src/utils/hype-cycle-engine.ts
|
||||||
|
-- Triggered: compute:hype-cycle job (daily 04:00)
|
||||||
|
|
||||||
|
-- Extend market_metrics CHECK constraint to allow asp_decline_rate (already exists) + hype_score
|
||||||
|
ALTER TABLE market_metrics
|
||||||
|
DROP CONSTRAINT IF EXISTS market_metrics_metric_type_check;
|
||||||
|
|
||||||
|
ALTER TABLE market_metrics
|
||||||
|
ADD CONSTRAINT market_metrics_metric_type_check
|
||||||
|
CHECK (metric_type = ANY (ARRAY[
|
||||||
|
'vendor_count',
|
||||||
|
'shipment_share',
|
||||||
|
'asp_decline_rate',
|
||||||
|
'media_hype_index',
|
||||||
|
'patent_filings',
|
||||||
|
'port_shipments',
|
||||||
|
'revenue_usd',
|
||||||
|
'asp_usd',
|
||||||
|
'hype_score' -- 0-100 composite hype position on Gartner curve
|
||||||
|
]));
|
||||||
|
|
||||||
|
-- Norton-Bass fitted parameters + phase classification (one row per technology per run)
|
||||||
|
CREATE TABLE IF NOT EXISTS hype_cycle_analysis (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
computed_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
technology text NOT NULL, -- e.g. '400G-QSFP-DD', '800G-OSFP'
|
||||||
|
-- Bass model parameters (fitted)
|
||||||
|
bass_p numeric, -- coefficient of innovation (0.001–0.03)
|
||||||
|
bass_q numeric, -- coefficient of imitation (0.1–0.5)
|
||||||
|
bass_m numeric, -- market potential (0.0–1.0 as share)
|
||||||
|
-- Adoption curve metrics
|
||||||
|
t_peak_year numeric, -- calendar year of peak adoption rate
|
||||||
|
current_t numeric, -- years since first commercial availability
|
||||||
|
current_share numeric, -- current shipment share (0–1)
|
||||||
|
projected_share_1y numeric, -- projected share in 1 year
|
||||||
|
projected_share_3y numeric, -- projected share in 3 years
|
||||||
|
-- Hype Cycle positioning
|
||||||
|
hype_phase text NOT NULL, -- see CHECK below
|
||||||
|
hype_score numeric, -- 0-100: position on Gartner curve
|
||||||
|
phase_since_year numeric, -- calendar year current phase started
|
||||||
|
years_to_next_phase numeric, -- estimated years until next phase
|
||||||
|
-- Price trajectory
|
||||||
|
asp_current_usd numeric, -- current OEM ASP in USD
|
||||||
|
asp_decline_pct_3y numeric, -- projected ASP decline % over 3 years
|
||||||
|
-- Fit quality
|
||||||
|
r_squared numeric, -- goodness of fit (0–1)
|
||||||
|
data_points integer, -- number of historical data points used
|
||||||
|
notes text,
|
||||||
|
CONSTRAINT hype_cycle_phase_check CHECK (hype_phase = ANY (ARRAY[
|
||||||
|
'innovation_trigger',
|
||||||
|
'peak_inflated_expectations',
|
||||||
|
'trough_disillusionment',
|
||||||
|
'slope_enlightenment',
|
||||||
|
'plateau_productivity'
|
||||||
|
]))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hype_tech_time ON hype_cycle_analysis (technology, computed_at DESC);
|
||||||
|
|
||||||
|
-- Keep only the latest 90 days of analysis rows (historical snapshots for trend)
|
||||||
|
CREATE OR REPLACE FUNCTION cleanup_hype_cycle_analysis() RETURNS void LANGUAGE sql AS $$
|
||||||
|
DELETE FROM hype_cycle_analysis WHERE computed_at < NOW() - INTERVAL '90 days';
|
||||||
|
$$;
|
||||||
Loading…
x
Reference in New Issue
Block a user