import type { FacilityStatus, FacilityType } from "./types.js"; export function slugify(s: string): string { return s .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .toLowerCase() .replace(/&/g, " and ") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 96); } const NOISE_WORDS = new Set(["data", "center", "centre", "datacenter", "datacentre", "dc", "facility", "campus", "site", "the", "of", "inc", "llc", "ltd", "limited", "corp", "corporation", "gmbh", "sa", "ag", "bv", "nv", "plc", "co", "company", "group", "holdings", "s.a.", "s.a", "srl", "sas", "pty", "kk", "k.k."]); /** Aggressive name normalization for entity resolution: lowercase, strip punctuation, drop noise tokens, sort-independent. */ export function normalizeName(name: string): string { return name .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .toLowerCase() .replace(/\(.*?\)/g, " ") .replace(/[^a-z0-9]+/g, " ") .split(" ") .filter((t) => t && !NOISE_WORDS.has(t)) .join(" ") .trim(); } /** Lighter normalization keeping identifiers like "DC12", "FR5", "AM3". */ export function normalizeNameKeepCodes(name: string): string { return name .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .toLowerCase() .replace(/\b(data\s*cent(er|re)s?|datacent(er|re)s?)\b/g, " ") .replace(/[^a-z0-9]+/g, " ") .replace(/\s+/g, " ") .trim(); } /** * Words that precede a number without naming a facility ("Tier 3", "Route 7", "Suite 200") — never a facility code. * Units and address vocabulary are excluded so that "50 MW" or "Highway 9" never become identifiers. */ export const FACILITY_CODE_STOPWORDS: ReadonlySet = new Set([ "TIER", "LEVEL", "FLOOR", "SUITE", "UNIT", "ROUTE", "HIGHWAY", "HWY", "ROAD", "RD", "STREET", "ST", "AVENUE", "AVE", "BOULEVARD", "BLVD", "NO", "TOP", "MW", "KW", "GW", "PUE", "RACK", "RACKS", "ZONE", "REGION", "PHONE", "TEL", "FAX", "BOX", "PO", "KM", "MI", "SQ", "SQM", "SQFT", "ACRE", "ACRES", "PHASE", // prepositions / verbs that precede a figure in prose ("with 50 MW", "opened 2019", "built in 1998") "WITH", "OF", "AND", "IN", "AT", "TO", "THE", "OVER", "THAN", "ABOUT", "UP", "FOR", "BY", "ON", "FROM", "OR", "NEAR", "PLUS", "AROUND", "APPROX", "CIRCA", "SINCE", "BUILT", "EST", "ESTABLISHED", "OPENED", "OPEN", "OPENING", "YEAR", "NR", "NUM", "NUMBER", "TOTAL", "ONLY", "SOME", "ALL", "PER", ]); /** A figure immediately followed by a unit is a quantity, not a code ("50 MW", "3 acres", "12%"). */ const UNIT_AFTER_RE = /^\s?(MW|GW|KW|MWH|KWH|TW|SQ|SQM|SQFT|M2|FT|ACRES?|HA|KM|MI|%|MILLION|BILLION|MILLIONS|RACKS?|STOREYS?|STORIES|FLOORS?|BUILDINGS?|HALLS?|PHASES?|YEARS?)(?![A-Z])/; /** * Extract facility codes: `DC12`, `FR5`, `LD8`, `SV1`, `LONDON6`, `LON6`, `DC-12`, `FRA01A`, `IAD12`, `SYD01`, `SC-2`, * `S3`, `Inzai 3`, `Fremont 2`, `Las Vegas 8` (→ `VEGAS8`). A single-letter prefix is only accepted when attached * (`S3`, `M1`), a word prefix may be separated by a space or a hyphen. Leading zeros are dropped (`SYD01` = `SYD1`) * so that a code compares equal across sources. Stop-words (`Tier 3`, `Route 7`, `50 MW`) never yield a code. */ export function extractFacilityCodes(name: string): string[] { const out = new Set(); const up = name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toUpperCase(); const add = (prefix: string, digits: string, suffix: string, rest: string) => { if (FACILITY_CODE_STOPWORDS.has(prefix) || UNIT_AFTER_RE.test(rest)) return; const n = digits.replace(/^0+(?=\d)/, ""); out.add(`${prefix}${n}${suffix}`); }; // attached: DC12, FRA01A, LONDON6, S3, MEL01D for (const m of up.matchAll(/(? { const m = new Map>(); for (const c of codes) { const p = facilityCodePrefix(c); if (!m.has(p)) m.set(p, new Set()); m.get(p)!.add(c); } return m; }; const pa = byPrefix(a), pb = byPrefix(b); // `SY5` and `SY5B` agree (a hall of the same building); `SY5A` vs `SY5B` do not const base = (c: string) => c.replace(/(\d)[A-Z]$/, "$1"); const agree = (x: string, y: string) => x === y || (base(x) === base(y) && (x === base(x) || y === base(y))); let shared = false, conflict = false; for (const [prefix, ca] of pa) { const cb = pb.get(prefix); if (!cb) continue; if ([...ca].some((c) => [...cb].some((d) => agree(c, d)))) shared = true; else conflict = true; } if (conflict) return 0; return shared ? 1 : null; } export function tokenSet(s: string): Set { return new Set(normalizeName(s).split(" ").filter(Boolean)); } export function jaccard(a: Set, b: Set): number { if (!a.size && !b.size) return 1; let inter = 0; for (const t of a) if (b.has(t)) inter++; return inter / (a.size + b.size - inter); } /** Parse a power figure in MW from free text like "36 MW", "36MW", "120 megawatts", "1.2 GW", "36,000 kW". Returns MW. */ export function parseMw(text: string | null | undefined): number | null { if (!text) return null; const s = text.replace(/ /g, " "); // "40+MW", "150+ MW" (at least) parse as the stated figure const m = s.match(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\+?\s*(gigawatts?|gw|megawatts?|mw|kilowatts?|kw)\b/i); if (!m) return null; let num = m[1]!; // "36,000" → 36000 ; "1,2" (fr decimal) → 1.2 ; "1.2" → 1.2 ; "0.779" → 0.779 (a decimal, never 779) ; // "1.500.000" (two dot groups, European thousands) → 1500000. A single dot followed by three digits is a // decimal in every English-language capacity figure ("0.779MW", "1.250 MW") — DataBank MSP4 was stored as 779 MW. if (/^\d{1,3}(,\d{3})+$/.test(num)) num = num.replace(/,/g, ""); else if (/^\d{1,3}(\.\d{3}){2,}$/.test(num)) num = num.replace(/\./g, ""); else num = num.replace(",", "."); const v = Number(num); if (!Number.isFinite(v)) return null; const unit = m[2]!.toLowerCase(); if (unit.startsWith("g")) return round(v * 1000, 3); if (unit.startsWith("k")) return round(v / 1000, 3); return round(v, 3); } /** All MW figures in a text, deduplicated, descending. */ export function parseAllMw(text: string): number[] { const out = new Set(); for (const m of text.matchAll(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\+?\s*(gigawatts?|gw|megawatts?|mw)\b/gi)) { const v = parseMw(m[0]); if (v != null && v > 0 && v < 100_000) out.add(v); } return [...out].sort((a, b) => b - a); } /** Square feet / square metres → m². */ export function parseAreaSqm(text: string | null | undefined): number | null { if (!text) return null; const m = text.replace(/ /g, " ").match(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:\.\d+)?)\s*(million\s+)?(sq\.?\s*ft|square\s*feet|sqft|ft²|sq\.?\s*m|square\s*met(?:er|re)s?|sqm|m²|m2)/i); if (!m) return null; let v = Number(m[1]!.replace(/,/g, "").replace(/\.(?=\d{3}\b)/g, "")); if (m[2]) v *= 1_000_000; if (!Number.isFinite(v)) return null; const unit = m[3]!.toLowerCase(); return /ft|feet/.test(unit) ? round(v * 0.09290304, 1) : round(v, 1); } /** Acres / hectares → hectares. */ export function parseAreaHa(text: string | null | undefined): number | null { if (!text) return null; const m = text.match(/(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)\s*(acres?|hectares?|ha)\b/i); if (!m) return null; const v = Number(m[1]!.replace(/,/g, "")); if (!Number.isFinite(v)) return null; return /acre/i.test(m[2]!) ? round(v * 0.40468564, 2) : round(v, 2); } /** Money in USD from "$1.2 billion", "US$450 million", "€800m", "£2bn". Non-USD is returned as-is with currency flag. */ export function parseMoney(text: string | null | undefined): { amount: number; currency: string } | null { if (!text) return null; const m = text.replace(/ /g, " ").match(/(US\$|USD|\$|CA\$|C\$|€|EUR|£|GBP|A\$|AUD|¥|JPY|S\$|SGD|₹|INR|CHF|SEK|NOK|DKK)\s?(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)\s*(trillion|billion|million|thousand|bn|m|k|b|tn)?\b/i); if (!m) return null; let v = Number(m[2]!.replace(/,/g, "")); const mult = (m[3] ?? "").toLowerCase(); if (mult === "billion" || mult === "bn" || mult === "b") v *= 1e9; else if (mult === "million" || mult === "m") v *= 1e6; else if (mult === "thousand" || mult === "k") v *= 1e3; else if (mult === "trillion" || mult === "tn") v *= 1e12; const cur = m[1]!.toUpperCase(); const currency = cur === "$" || cur === "US$" || cur === "USD" ? "USD" : cur === "CA$" || cur === "C$" ? "CAD" : cur === "€" || cur === "EUR" ? "EUR" : cur === "£" || cur === "GBP" ? "GBP" : cur === "A$" || cur === "AUD" ? "AUD" : cur === "¥" || cur === "JPY" ? "JPY" : cur === "S$" || cur === "SGD" ? "SGD" : cur === "₹" || cur === "INR" ? "INR" : cur; return { amount: Math.round(v), currency }; } const MONTHS: Record = { january: "01", february: "02", march: "03", april: "04", june: "06", july: "07", august: "08", september: "09", october: "10", november: "11", december: "12", jan: "01", feb: "02", mar: "03", apr: "04", may: "05", jun: "06", jul: "07", aug: "08", sep: "09", sept: "09", oct: "10", nov: "11", dec: "12", janvier: "01", février: "02", fevrier: "02", mars: "03", avril: "04", mai: "05", juin: "06", juillet: "07", août: "08", aout: "08", septembre: "09", octobre: "10", novembre: "11", décembre: "12", decembre: "12" }; /** * Parse a (possibly partial) date into ISO-like partial: "2027", "2027-Q2", "2027-06", "2027-06-15", "2027-H1". * Accepts "Q2 2027", "second half of 2027", "mid-2027", "June 2027", "2027-06-15T…", "15 June 2027". */ export function parsePartialDate(text: string | null | undefined): string | null { if (!text) return null; const s = text.trim(); let m = s.match(/^(\d{4})-(\d{2})-(\d{2})/); if (m) return `${m[1]}-${m[2]}-${m[3]}`; m = s.match(/^(\d{4})-(\d{2})$/); if (m) return `${m[1]}-${m[2]}`; m = s.match(/\b(?:Q|quarter\s*)([1-4])\s*(?:of\s*)?(\d{4})\b/i) ?? s.match(/\b(\d{4})\s*-?\s*Q([1-4])\b/i); if (m) { const [q, y] = /^\d{4}/.test(m[1]!) ? [m[2], m[1]] : [m[1], m[2]]; return `${y}-Q${q}`; } m = s.match(/\b(first|second|1st|2nd)\s+half\s+(?:of\s+)?(\d{4})\b/i) ?? s.match(/\bH([12])\s*(\d{4})\b/i); if (m) { const h = /^(first|1st|1)$/i.test(m[1]!) ? 1 : 2; return `${m[2]}-H${h}`; } m = s.match(/\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-zéû]+)\.?\s+(\d{4})\b/); if (m && MONTHS[m[2]!.toLowerCase()]) return `${m[3]}-${MONTHS[m[2]!.toLowerCase()]}-${m[1]!.padStart(2, "0")}`; m = s.match(/\b([A-Za-zéû]+)\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})\b/); if (m && MONTHS[m[1]!.toLowerCase()]) return `${m[3]}-${MONTHS[m[1]!.toLowerCase()]}-${m[2]!.padStart(2, "0")}`; m = s.match(/\b([A-Za-zéû]+)\.?\s+(\d{4})\b/); if (m && MONTHS[m[1]!.toLowerCase()]) return `${m[2]}-${MONTHS[m[1]!.toLowerCase()]}`; m = s.match(/\b(?:mid|early|late|end of|by|in)?-?\s*(20\d{2}|19\d{2})\b/); if (m) return m[1]!; return null; } /** Sortable numeric key for a partial date (for ordering timelines): YYYY.fraction */ export function partialDateSortKey(d: string | null | undefined): number { if (!d) return Number.POSITIVE_INFINITY; const y = Number(d.slice(0, 4)); if (!Number.isFinite(y)) return Number.POSITIVE_INFINITY; let frac = 0.5; const rest = d.slice(5); if (/^Q[1-4]/.test(rest)) frac = (Number(rest[1]) - 0.5) / 4; else if (/^H[12]/.test(rest)) frac = rest[1] === "1" ? 0.25 : 0.75; else if (/^\d{2}/.test(rest)) { const mo = Number(rest.slice(0, 2)); const day = /^\d{2}-\d{2}/.test(rest) ? Number(rest.slice(3, 5)) : 15; frac = (mo - 1 + day / 31) / 12; } return y + frac; } export function formatPartialDate(d: string | null | undefined): string { if (!d) return "—"; const y = d.slice(0, 4); const rest = d.slice(5); if (!rest) return y; if (/^[QH]\d/.test(rest)) return `${rest} ${y}`; const mo = Number(rest.slice(0, 2)); const names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const mn = names[mo - 1] ?? rest.slice(0, 2); if (/^\d{2}-\d{2}/.test(rest)) return `${mn} ${Number(rest.slice(3, 5))}, ${y}`; return `${mn} ${y}`; } const STATUS_PATTERNS: Array<[RegExp, FacilityStatus]> = [ [/\b(cancel+ed|scrapped|abandoned|withdrawn)\b/i, "cancelled"], [/\b(closed|decommissioned|shut\s?down|ceased operations)\b/i, "closed"], [/\b(delayed|postponed|on hold|paused)\b/i, "delayed"], [/\b(partially operational|phase\s*1\s*(is\s*)?(live|operational|complete))\b/i, "partially_operational"], [/\b(under construction|being built|construction (is )?under ?way|breaks? ground|breaking ground|broke ground|groundbreaking|topped out|topping out|construction (has )?(started|began|begins|commenced|starts))\b/i, "under_construction"], [/\b(approved|approval granted|granted planning|received approval|rezoning approved|permit(s)? (were |was )?(granted|issued))\b/i, "approved"], [/\b(planning application|permit application|rezoning (request|application)|seeking approval|applied for|filed plans|submitted plans|under review by)\b/i, "permitting"], [/\b(announced|announces|unveils?|unveiled|plans to build|plans? for|will build|to build|build of|to develop|to construct|set to build|reveals? plans|launch(es|ed)? plans|confirms? (the )?(build|plans?|development)|enters? [A-Z]\w+ with|doubles down)\b/i, "announced"], [/\b(proposed|proposal)\b/i, "proposed"], [/\b(rumou?red|reportedly|is said to|considering|exploring|in talks)\b/i, "rumored"], [/\b(expansion|expanding|expands|adds? capacity|adds? \d+ ?\+?(MW|GW)|new phase|additional \d+ ?\+?MW)\b/i, "expansion"], [/\b(now open|opened|opens|operational|live|online|commissioned|inaugurat(ed|es|ion)|ready for service|RFS)\b/i, "operational"], ]; export function inferStatus(text: string | null | undefined): FacilityStatus | null { if (!text) return null; for (const [re, st] of STATUS_PATTERNS) if (re.test(text)) return st; return null; } export function normalizeStatus(raw: string | null | undefined): FacilityStatus | null { if (!raw) return null; const s = raw.toLowerCase().trim().replace(/[\s-]+/g, "_"); const direct: Record = { operational: "operational", live: "operational", active: "operational", open: "operational", ok: "operational", operating: "operational", existing: "operational", under_construction: "under_construction", construction: "under_construction", building: "under_construction", planned: "announced", plan: "announced", announced: "announced", proposed: "proposed", rumored: "rumored", rumoured: "rumored", permitting: "permitting", approved: "approved", delayed: "delayed", cancelled: "cancelled", canceled: "cancelled", closed: "closed", decommissioned: "closed", expansion: "expansion", partially_operational: "partially_operational", partial: "partially_operational", coming_soon: "announced", future: "announced", in_development: "announced", development: "announced", }; return direct[s] ?? inferStatus(raw); } export function inferFacilityType(text: string | null | undefined): FacilityType | null { if (!text) return null; const s = text.toLowerCase(); if (/\b(internet exchange|ixp?\b|peering exchange)/.test(s)) return "internet_exchange"; if (/\b(carrier hotel|meet-?me room|interconnection hub)/.test(s)) return "carrier_hotel"; if (/\b(ai (data ?center|campus|factory|cluster|infrastructure)|gpu (cluster|campus|farm)|accelerated computing|training cluster|inference)/.test(s)) return "ai"; if (/\b(supercomput|hpc|high[- ]performance computing|exascale|petaflop)/.test(s)) return "hpc"; if (/\b(sovereign cloud|sovereign region)/.test(s)) return "sovereign_cloud"; if (/\b(cloud region|availability zone|aws region|azure region|gcp region)/.test(s)) return "cloud_region"; if (/\b(hyperscale|hyperscaler)/.test(s)) return "hyperscale"; if (/\b(edge (data ?center|site|node|facility)|micro data ?center)/.test(s)) return "edge"; if (/\b(wholesale|build[- ]to[- ]suit|powered shell)/.test(s)) return "wholesale"; if (/\b(government|federal|ministry|public sector|municipal)/.test(s)) return "government"; if (/\b(university|research|laboratory|national lab)/.test(s)) return "research"; if (/\b(colocation|colo\b|retail colocation|interconnection)/.test(s)) return "colocation"; return null; } export function round(v: number, digits = 2): number { const f = 10 ** digits; return Math.round(v * f) / f; } export function clamp(v: number, lo: number, hi: number): number { return Math.min(hi, Math.max(lo, v)); } export function cleanText(s: string | null | undefined): string | null { if (s == null) return null; const t = s.replace(/\s+/g, " ").trim(); return t || null; } /** Country ISO-2 from common names / ISO-3 / demonyms found in text. */ export const COUNTRY_ALIASES: Record = { "united states": "US", usa: "US", "u.s.": "US", "u.s.a.": "US", "united states of america": "US", america: "US", canada: "CA", "united kingdom": "GB", uk: "GB", "u.k.": "GB", britain: "GB", "great britain": "GB", england: "GB", scotland: "GB", wales: "GB", germany: "DE", deutschland: "DE", france: "FR", netherlands: "NL", "the netherlands": "NL", holland: "NL", ireland: "IE", spain: "ES", portugal: "PT", italy: "IT", sweden: "SE", norway: "NO", denmark: "DK", finland: "FI", iceland: "IS", belgium: "BE", luxembourg: "LU", switzerland: "CH", austria: "AT", poland: "PL", czechia: "CZ", "czech republic": "CZ", hungary: "HU", romania: "RO", bulgaria: "BG", greece: "GR", turkey: "TR", türkiye: "TR", russia: "RU", ukraine: "UA", japan: "JP", singapore: "SG", australia: "AU", "new zealand": "NZ", india: "IN", china: "CN", "hong kong": "HK", taiwan: "TW", "south korea": "KR", korea: "KR", malaysia: "MY", indonesia: "ID", thailand: "TH", vietnam: "VN", philippines: "PH", "united arab emirates": "AE", uae: "AE", "saudi arabia": "SA", qatar: "QA", bahrain: "BH", oman: "OM", kuwait: "KW", israel: "IL", "south africa": "ZA", nigeria: "NG", kenya: "KE", egypt: "EG", morocco: "MA", brazil: "BR", mexico: "MX", chile: "CL", argentina: "AR", colombia: "CO", peru: "PE", uruguay: "UY", estonia: "EE", latvia: "LV", lithuania: "LT", slovakia: "SK", slovenia: "SI", croatia: "HR", serbia: "RS", cyprus: "CY", malta: "MT", pakistan: "PK", bangladesh: "BD", "sri lanka": "LK", kazakhstan: "KZ", ghana: "GH", tanzania: "TZ", uganda: "UG", rwanda: "RW", ethiopia: "ET", angola: "AO", "côte d'ivoire": "CI", "ivory coast": "CI", senegal: "SN", tunisia: "TN", algeria: "DZ", jordan: "JO", panama: "PA", "costa rica": "CR", "puerto rico": "PR", "dominican republic": "DO", ecuador: "EC", bolivia: "BO", paraguay: "PY", venezuela: "VE", guatemala: "GT", cambodia: "KH", laos: "LA", myanmar: "MM", nepal: "NP", mongolia: "MN", macau: "MO", macao: "MO", brunei: "BN", fiji: "FJ", "papua new guinea": "PG", georgia: "GE", armenia: "AM", azerbaijan: "AZ", uzbekistan: "UZ", belarus: "BY", moldova: "MD", albania: "AL", "north macedonia": "MK", "bosnia and herzegovina": "BA", montenegro: "ME", kosovo: "XK", usa_: "US", }; export function countryFromText(text: string | null | undefined): string | null { if (!text) return null; const s = text.toLowerCase(); const keys = Object.keys(COUNTRY_ALIASES).sort((a, b) => b.length - a.length); for (const k of keys) if (new RegExp(`(^|[^a-z])${k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z]|$)`, "i").test(s)) return COUNTRY_ALIASES[k]!; return null; }