spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1/**2 * Pure reconciliation logic (no database): facility candidate scoring, thresholds, field-merge policy,3 * completeness and confidence. Everything here is unit-tested; facilities.ts wires it to Postgres.4 * Algorithm + thresholds are documented in docs/RECONCILIATION.md.5 */6import {7 compareFacilityCodes,8 computeConfidence,9 COUNTRY_ALIASES,10 extractFacilityCodes,11 haversineKm,12 jaccard,13 matchRadiusKm,14 normalizeName,15 normalizeNameKeepCodes,16 PIPELINE_STATUSES,17 PRECISION_RANK,18 validLatLng,19 type ConfidenceLevel,20 type GeoPrecision,21 type SourceKind,22} from "@dci/core";23import { authority, isPrimaryKind } from "./common.js";24import { findCanonicalOperator } from "./canonical-operators.js";2526export const AUTO_MERGE_THRESHOLD = 0.92;27export const PENDING_THRESHOLD = 0.6;2829/** Minimal projection of a stored facility used for scoring. */30export interface FacilityCandidate {31 id: string;32 name: string;33 normalizedName: string;34 aliases?: string[];35 operatorId?: string | null;36 /** canonical operator name when known — lets the name signal ignore operator tokens ("Colt DCS", "Sabey") */37 operatorName?: string | null;38 countryIso2?: string | null;39 city?: string | null;40 address?: string | null;41 lat?: number | null;42 lng?: number | null;43 geoPrecision?: string | null;44 externalIds?: Record<string, string | number> | null;45}4647/** Incoming record projection. */48export interface FacilityProbe {49 name: string;50 aliases?: string[];51 operatorId?: string | null;52 operatorName?: string | null;53 countryIso2?: string | null;54 city?: string | null;55 address?: string | null;56 lat?: number | null;57 lng?: number | null;58 geoPrecision?: GeoPrecision | null;59}6061export interface MatchScore {62 score: number;63 reasons: string[];64 blocked: boolean; // different known operators without exact name+address65 distanceKm: number | null;66}6768export type MatchDecision = "merge" | "pending" | "create";6970export function decide(score: number): MatchDecision {71 if (score >= AUTO_MERGE_THRESHOLD) return "merge";72 if (score >= PENDING_THRESHOLD) return "pending";73 return "create";74}7576export function normalizeAddress(a: string | null | undefined): string {77 if (!a) return "";78 return a79 .normalize("NFKD")80 .replace(/[̀-ͯ]/g, "")81 .toLowerCase()82 .replace(/\b(street|st)\b/g, "st")83 .replace(/\b(road|rd)\b/g, "rd")84 .replace(/\b(avenue|ave)\b/g, "ave")85 .replace(/\b(boulevard|blvd)\b/g, "blvd")86 .replace(/\b(drive|dr)\b/g, "dr")87 .replace(/\b(court|ct)\b/g, "ct")88 .replace(/\b(lane|ln)\b/g, "ln")89 .replace(/\b(place|pl)\b/g, "pl")90 .replace(/\b(parkway|pkwy)\b/g, "pkwy")91 .replace(/\b(highway|hwy)\b/g, "hwy")92 .replace(/\b(north|n|south|s|east|e|west|w)\b/g, (m) => m[0]!)93 .replace(/\b(suite|ste|unit|floor|fl)\b\s*\S+/g, " ")94 .replace(/[^a-z0-9]+/g, " ")95 .replace(/\s+/g, " ")96 .trim();97}9899function addressSimilarity(a: string | null | undefined, b: string | null | undefined): number | null {100 const na = normalizeAddress(a);101 const nb = normalizeAddress(b);102 if (!na || !nb) return null;103 if (na === nb) return 1;104 const ta = new Set(na.split(" "));105 const tb = new Set(nb.split(" "));106 const j = jaccard(ta, tb);107 // house number agreement is a strong hint — "21721 Filigree Ct" (US) or "Sägereistrasse 35" (Europe); a leading108 // token is always a candidate, other numeric tokens only when short enough not to be a postal code109 const houseNumbers = (s: string): Set<string> => {110 const toks = s.split(" ");111 const out = new Set<string>();112 for (const [i, t] of toks.entries()) if (/^\d+[a-z]?$/.test(t) && (i === 0 || t.replace(/[a-z]$/, "").length <= 4)) out.add(t);113 return out;114 };115 const ha = houseNumbers(na), hb = houseNumbers(nb);116 if (ha.size && hb.size) {117 const shared = [...ha].some((n) => hb.has(n));118 return shared ? Math.max(j, 0.75) : Math.min(j, 0.4);119 }120 return j;121}122123// ---------------------------------------------------------------------------------------------------------------124// Name comparison125126/** Ordinals / roman numerals are compared as digits: "Campus II" = "Campus 2" = "Campus Two". */127const ORDINAL_TOKENS: Record<string, string> = { one: "1", i: "1", two: "2", ii: "2", three: "3", iii: "3", four: "4", iv: "4", five: "5", v: "5", six: "6", vi: "6", seven: "7", vii: "7", eight: "8", viii: "8", nine: "9", ix: "9", ten: "10", x: "10" };128/** Tokens that distinguish sibling buildings of one site: directions, ordinals, single letters, small numbers. */129const SIBLING_WORDS = new Set(["north", "south", "east", "west", "northern", "southern", "eastern", "western", "a", "b", "c", "d", "e", "f"]);130const isSiblingToken = (t: string): boolean => SIBLING_WORDS.has(t) || /^\d{1,3}$/.test(t);131132/** Canonical token set of a facility name (noise words removed, ordinals as digits). */133export function nameTokens(name: string | null | undefined): Set<string> {134 if (!name) return new Set();135 return new Set(normalizeName(name).split(" ").filter(Boolean).map((t) => ORDINAL_TOKENS[t] ?? t));136}137138const setsEqual = (a: Set<string>, b: Set<string>): boolean => a.size === b.size && [...a].every((t) => b.has(t));139const without = (a: Set<string>, drop: Set<string>): Set<string> => new Set([...a].filter((t) => !drop.has(t)));140141const countryTokenCache = new Map<string, string[][]>();142/** Token sequences naming a country ("united kingdom", "uk", "denmark") — stripped from names before comparison. */143function countryAliasTokens(iso2: string | null | undefined): string[][] {144 if (!iso2) return [];145 const hit = countryTokenCache.get(iso2);146 if (hit) return hit;147 const out: string[][] = [];148 for (const [alias, code] of Object.entries(COUNTRY_ALIASES)) if (code === iso2) { const t = normalizeName(alias).split(" ").filter(Boolean); if (t.length) out.push(t); }149 countryTokenCache.set(iso2, out);150 return out;151}152153/** Token sequences naming an operator (canonical name + curated aliases + the stored name). */154function operatorAliasTokens(names: Array<string | null | undefined>): string[][] {155 const out: string[][] = [];156 for (const n of names) {157 if (!n) continue;158 const canon = findCanonicalOperator(n);159 for (const a of [n, ...(canon ? [canon.name, ...canon.aliases] : [])]) { const t = normalizeName(a).split(" ").filter(Boolean); if (t.length) out.push(t); }160 }161 return out;162}163164/** Remove every alias whose tokens are all present ("united kingdom" is removed only when both tokens are there). */165function stripSequences(tokens: Set<string>, sequences: string[][]): Set<string> {166 let out = tokens;167 for (const seq of sequences) if (seq.every((t) => out.has(t))) out = without(out, new Set(seq));168 return out;169}170171export interface NameSimilarity {172 score: number;173 /** identical names (canonical token sets, or code-preserving normalization) */174 exact: boolean;175 alias: boolean;176 /** the names only differ by sibling tokens: "Telehouse North" / "Telehouse East", "Inzai 3" / "Inzai 4", "Campus I" / "Campus II" */177 siblings: boolean;178 /** identical once operator and country tokens are removed: "Google Fredericia" / "Fredericia, Denmark" (operator Google) */179 residualEqual: boolean;180 /** at least one name is nothing but the operator's name ("Pulsant", "BCX", "Digital Realty Data Center") */181 operatorOnly: boolean;182 /** number of tokens in the shorter full name */183 minTokens: number;184}185186export function nameSimilarity(probe: FacilityProbe, cand: FacilityCandidate): NameSimilarity {187 const pt = nameTokens(probe.name);188 const ct = nameTokens(cand.name);189 const minTokens = Math.min(pt.size, ct.size);190 const none: NameSimilarity = { score: 0, exact: false, alias: false, siblings: false, residualEqual: false, operatorOnly: false, minTokens };191192 // names that normalize to nothing (non-Latin scripts) are compared verbatim — never "equal because both empty"193 if (!pt.size || !ct.size) {194 const rp = probe.name.trim().toLowerCase(), rc = cand.name.trim().toLowerCase();195 return rp && rp === rc ? { ...none, exact: true, score: 1 } : none;196 }197198 const opSeqs = operatorAliasTokens([probe.operatorName, cand.operatorName]);199 const ctySeqs = [...countryAliasTokens(probe.countryIso2), ...countryAliasTokens(cand.countryIso2)];200 const pr = stripSequences(stripSequences(pt, opSeqs), ctySeqs);201 const cr = stripSequences(stripSequences(ct, opSeqs), ctySeqs);202 // a name that is just a brand ("Digital Realty Data Center") says nothing about which building it is203 const brandOnly = (tokens: Set<string>, raw: string) => !tokens.size || !!findCanonicalOperator(raw) || (opSeqs.length === 0 && !!findCanonicalOperator([...tokens].join(" ")));204 const operatorOnly = brandOnly(pr, probe.name) || brandOnly(cr, cand.name);205206 // siblings: identical apart from direction / ordinal / letter tokens, with a shared non-sibling stem207 const diff = new Set([...[...pt].filter((t) => !ct.has(t)), ...[...ct].filter((t) => !pt.has(t))]);208 const stem = [...pt].filter((t) => ct.has(t) && !isSiblingToken(t));209 const siblings = diff.size > 0 && stem.length > 0 && [...diff].every(isSiblingToken);210211 const exact = setsEqual(pt, ct) || (normalizeNameKeepCodes(probe.name) !== "" && normalizeNameKeepCodes(probe.name) === normalizeNameKeepCodes(cand.name));212 if (exact) return { ...none, score: 1, exact: true, operatorOnly };213 if (siblings) return { ...none, score: 0.2, siblings: true, operatorOnly };214215 const residualEqual = pr.size > 0 && cr.size > 0 && setsEqual(pr, cr);216 if (residualEqual) return { ...none, score: 0.9, residualEqual: true, operatorOnly };217218 // alias hit: only aliases that carry more than a brand ("meta" alone must not link every Meta site)219 const meaningful = (a: string) => { const t = nameTokens(a); return t.size >= 2 || (t.size === 1 && [...t][0]!.length >= 5 && !findCanonicalOperator(a)); };220 const probeAliases = [probe.name, ...(probe.aliases ?? [])].filter(meaningful).map((a) => normalizeName(a));221 const candAliases = [cand.name, ...(cand.aliases ?? [])].filter(meaningful).map((a) => normalizeName(a));222 if (probeAliases.some((a) => candAliases.includes(a))) return { ...none, score: 0.9, alias: true, operatorOnly };223224 // token overlap on the residual names (operator / country tokens ignored when known)225 const a = pr.size ? pr : pt, b = cr.size ? cr : ct;226 const j = jaccard(a, b);227 const inter = [...a].filter((t) => b.has(t)).length;228 const containment = a.size && b.size ? inter / Math.min(a.size, b.size) : 0;229 // containment ("Equinix DC12" inside "Equinix DC12 Building B") beats jaccard, but a single shared token is weak230 const cScore = containment * (Math.min(a.size, b.size) >= 2 ? 0.85 : 0.6);231 return { ...none, score: Math.max(j, cScore), operatorOnly };232}233234/** Code prefixes that name a building inside a site rather than the site itself. */235const BUILDING_CODE_PREFIXES = new Set(["DC", "B", "BLDG", "BUILDING", "HALL", "DH", "PH", "PHASE", "BLD", "BLOCK", "H"]);236/** true when one name carries a building code the other lacks, or when exactly one of the two names says "campus". */237function subBuildingCodes(a: string, b: string): boolean {238 const ca = extractFacilityCodes(a), cb = extractFacilityCodes(b);239 const onlyA = ca.filter((c) => !cb.includes(c)), onlyB = cb.filter((c) => !ca.includes(c));240 const building = (codes: string[]) => codes.some((c) => BUILDING_CODE_PREFIXES.has(c.replace(/\d.*$/, "")));241 if ((building(onlyA) && !onlyB.length) || (building(onlyB) && !onlyA.length)) return true;242 const campusA = /\bcampus\b/i.test(a), campusB = /\bcampus\b/i.test(b);243 return campusA !== campusB && (onlyA.length > 0 || onlyB.length > 0);244}245246/** true when the un-attributed side's name starts with the other side's operator ("Google Data Center Fredericia" vs operator Google). */247function operatorImpliedByName(probe: FacilityProbe, cand: FacilityCandidate): boolean {248 const pair = !probe.operatorId && cand.operatorId && cand.operatorName ? [probe.name, cand.operatorName] : probe.operatorId && !cand.operatorId && probe.operatorName ? [cand.name, probe.operatorName] : null;249 if (!pair) return false;250 const tokens = nameTokens(pair[0]);251 return operatorAliasTokens([pair[1]]).some((seq) => seq.every((t) => tokens.has(t)));252}253254/**255 * Weighted score of an incoming record against a stored candidate.256 * Weights: name 0.40, facility codes 0.15, operator 0.20, distance 0.15, address 0.10 — weights for signals257 * that are unavailable on either side are redistributed over the available ones. Rules on top (docs/RECONCILIATION.md):258 * operator+code and operator+site raise the name signal; sibling tokens and conflicting codes are hard negatives259 * (score < pending); an exact multi-token name at the same spot with no operator conflict auto-merges.260 */261export function scoreFacilityMatch(probe: FacilityProbe, cand: FacilityCandidate): MatchScore {262 const reasons: string[] = [];263 const parts: Array<{ w: number; v: number }> = [];264265 const name = nameSimilarity(probe, cand);266 if (name.exact) reasons.push("name:exact");267 else if (name.alias) reasons.push("name:alias");268 else if (name.residualEqual) reasons.push("name:site-equal");269 else if (name.siblings) reasons.push("name:siblings");270 else reasons.push(`name:${name.score.toFixed(2)}`);271 if (name.operatorOnly) reasons.push("name:operator-only");272273 // facility codes (DC12, FR5, LONDON6, Inzai 3…): shared → 1, same prefix with different numbers → 0, else no signal274 const codes = compareFacilityCodes(extractFacilityCodes(probe.name), extractFacilityCodes(cand.name));275 if (codes != null) reasons.push(codes ? "codes:match" : "codes:conflict");276277 // operator278 let op = 0.5;279 let blocked = false;280 if (probe.operatorId && cand.operatorId) {281 if (probe.operatorId === cand.operatorId) {282 op = 1;283 reasons.push("operator:same");284 } else {285 op = 0;286 reasons.push("operator:different");287 const addr = addressSimilarity(probe.address, cand.address);288 blocked = !(name.exact && addr === 1);289 }290 } else if (operatorImpliedByName(probe, cand)) {291 op = 0.9;292 reasons.push("operator:name-implied");293 } else reasons.push("operator:unknown");294295 // distance296 let geo: number | null = null;297 let distanceKm: number | null = null;298 const radius = matchRadiusKm(probe.geoPrecision ?? "unknown", (cand.geoPrecision as GeoPrecision | undefined) ?? "unknown");299 let sameCity = false;300 if (validLatLng(probe.lat, probe.lng) && validLatLng(cand.lat, cand.lng)) {301 distanceKm = haversineKm(probe.lat as number, probe.lng as number, cand.lat as number, cand.lng as number);302 geo = distanceKm <= radius ? 1 - (distanceKm / radius) * 0.4 : Math.max(0, 1 - distanceKm / (radius * 4));303 reasons.push(`distance:${distanceKm.toFixed(2)}km/${radius}km`);304 } else if (probe.city && cand.city && normalizeName(probe.city) === normalizeName(cand.city)) {305 geo = 0.6;306 sameCity = true;307 reasons.push("city:same");308 }309 // "same place": inside twice the match radius, or the same city without coordinates310 const samePlace = distanceKm != null ? distanceKm <= 2 * radius : sameCity;311 const geoCompatible = geo == null || samePlace;312313 const addr = addressSimilarity(probe.address, cand.address);314 if (addr != null) reasons.push(`address:${addr.toFixed(2)}`);315316 let nameScore = name.score;317 // Strong identifier rule: same (or name-implied) operator + same facility code + geographically compatible → exact.318 if (codes === 1 && op >= 0.9 && geoCompatible) {319 nameScore = Math.max(nameScore, 0.95);320 reasons.push("rule:operator+code");321 }322 // Same site rule: names identical once the operator / country words are removed ("Fredericia" = "Fredericia, Denmark").323 if (name.residualEqual && op >= 0.9 && geoCompatible) {324 nameScore = Math.max(nameScore, op === 1 ? 1 : 0.95);325 reasons.push("rule:operator+site");326 }327 // Conflicting codes (DC12 vs DC13, LONDON6 vs LONDON5) and sibling tokens (North vs East, I vs II) are hard negatives.328 if (codes === 0) nameScore = Math.min(nameScore, 0.45);329 if (name.siblings) nameScore = Math.min(nameScore, 0.2);330 // A brand-only name ("Pulsant", "Switch") is not evidence for one particular building of that brand.331 if (name.operatorOnly && !(name.exact && codes === 1)) nameScore = Math.min(nameScore, 0.3);332333 parts.push({ w: 0.4, v: nameScore });334 if (codes != null) parts.push({ w: 0.15, v: codes });335 parts.push({ w: 0.2, v: op });336 if (geo != null) parts.push({ w: 0.15, v: geo });337 if (addr != null) parts.push({ w: 0.1, v: addr });338339 const wsum = parts.reduce((s, p) => s + p.w, 0);340 let score = parts.reduce((s, p) => s + p.w * p.v, 0) / wsum;341342 // Exact multi-token name at the same spot, no operator conflict, no address disagreement → the same building.343 // ("Meta Odense Data Center" ×2 in Odense, "Adeo Datacenter ApS" ×2 at 0 m, "Equinix ZRH5" from two sources.)344 const nameIsSpecific = name.minTokens >= 2 || addr === 1 || (distanceKm != null && distanceKm <= 0.1);345 if (name.exact && !name.operatorOnly && op !== 0 && samePlace && (addr == null || addr >= 0.75) && nameIsSpecific && codes !== 0) {346 score = Math.max(score, op === 1 ? 0.93 : 0.92);347 reasons.push("rule:exact-name+site");348 }349 // An alias may come from a rename or an earlier fold: it can send a pair to review, never auto-merge it.350 if (name.alias && score >= AUTO_MERGE_THRESHOLD) {351 score = AUTO_MERGE_THRESHOLD - 0.01;352 reasons.push("rule:alias-review");353 }354 // Campus vs one of its buildings ("DATA4 MAD1" vs "MAD1-DC01", "Teraco JB3 Isando Campus" vs "JB3 Data Centre"):355 // a shared site code with an extra building code on one side is a containment, not an identity → review.356 if (codes === 1 && subBuildingCodes(probe.name, cand.name) && score >= AUTO_MERGE_THRESHOLD) {357 score = AUTO_MERGE_THRESHOLD - 0.01;358 reasons.push("rule:campus-vs-building");359 }360361 // country mismatch is decisive362 if (probe.countryIso2 && cand.countryIso2 && probe.countryIso2 !== cand.countryIso2) {363 score = Math.min(score, 0.3);364 reasons.push("country:different");365 }366 // far apart with precise coordinates on both sides → not the same building367 if (distanceKm != null && distanceKm > 4 * radius) {368 score = Math.min(score, 0.5);369 reasons.push("distance:too-far");370 }371 if (codes === 0 || name.siblings) {372 score = Math.min(score, PENDING_THRESHOLD - 0.01);373 reasons.push(codes === 0 ? "rule:code-conflict" : "rule:siblings");374 }375 if (blocked) {376 score = Math.min(score, PENDING_THRESHOLD - 0.01);377 reasons.push("blocked:different-operators");378 }379 return { score: Math.round(score * 1000) / 1000, reasons, blocked, distanceKm };380}381382export function bestMatch(probe: FacilityProbe, candidates: FacilityCandidate[]): { candidate: FacilityCandidate; match: MatchScore } | null {383 let best: { candidate: FacilityCandidate; match: MatchScore } | null = null;384 for (const c of candidates) {385 const m = scoreFacilityMatch(probe, c);386 if (!best || m.score > best.match.score) best = { candidate: c, match: m };387 }388 return best;389}390391// ---------------------------------------------------------------------------------------------------------------392// Field merge policy393394export interface FieldObservation {395 value: unknown;396 sourceKind?: string | null;397 confidence?: string | null;398 isEstimate?: boolean | null;399 observedAt?: string | null; // ISO400 sourceId?: string | null;401 url?: string | null;402}403404export type MergeReason = "empty" | "same-source" | "higher-authority" | "equal-authority-stale" | "estimate-vs-measured" | "lower-authority" | "keep" | "equal";405406/** A stored value observed by a peer source within this window is considered fresh and is not churned by equal-authority peers. */407export const STALE_AFTER_DAYS = 30;408409/**410 * Decide whether an incoming observation replaces the stored one.411 * - empty stored → take412 * - the same source re-observing the same page → take (a source may correct itself)413 * - measured beats estimate; higher authority beats lower414 * - equal authority → keep the stored value while it is fresh (< 30 days); replace it once stale.415 * (Two pages of one source describing the same facility must not make values ping-pong on every crawl.)416 */417export function shouldReplace(incoming: FieldObservation, stored: FieldObservation | null): { replace: boolean; reason: MergeReason } {418 if (stored == null || stored.value == null || stored.value === "") return { replace: incoming.value != null, reason: "empty" };419 if (incoming.value == null) return { replace: false, reason: "keep" };420 if (JSON.stringify(incoming.value) === JSON.stringify(stored.value)) return { replace: false, reason: "equal" };421 const sameSource = !!incoming.sourceId && !!stored.sourceId && incoming.sourceId === stored.sourceId;422 const sameUrl = !incoming.url || !stored.url || incoming.url === stored.url;423 if (sameSource && sameUrl) return { replace: true, reason: "same-source" };424 if (!!incoming.isEstimate !== !!stored.isEstimate) return { replace: !incoming.isEstimate, reason: "estimate-vs-measured" };425 const ai = authority(incoming.sourceKind, incoming.confidence, incoming.isEstimate);426 const as = authority(stored.sourceKind, stored.confidence, stored.isEstimate);427 if (ai > as) return { replace: true, reason: "higher-authority" };428 if (ai < as) return { replace: false, reason: "lower-authority" };429 const ti = incoming.observedAt ? Date.parse(incoming.observedAt) : Date.now();430 const ts = stored.observedAt ? Date.parse(stored.observedAt) : 0;431 return ti - ts > STALE_AFTER_DAYS * 86_400_000 ? { replace: true, reason: "equal-authority-stale" } : { replace: false, reason: "keep" };432}433434/** MW policy: same as shouldReplace but only operator/government/filing/utility sources may overwrite a primary-sourced figure. */435export function shouldReplaceMw(incoming: FieldObservation, stored: FieldObservation | null): { replace: boolean; reason: MergeReason } {436 const base = shouldReplace(incoming, stored);437 if (!base.replace || !stored || stored.value == null) return base;438 if (base.reason === "same-source" || base.reason === "estimate-vs-measured") return base;439 if (isPrimaryKind(stored.sourceKind) && !isPrimaryKind(incoming.sourceKind)) return { replace: false, reason: "lower-authority" };440 return base;441}442443/** Geo: the incoming point may only replace a stored one when its precision is at least as good. */444export function shouldReplaceGeo(incomingPrecision: GeoPrecision, storedPrecision: string | null | undefined, storedHasCoords: boolean, incomingIsSameSource = false): boolean {445 if (!storedHasCoords) return true;446 const sp = storedPrecision && storedPrecision in PRECISION_RANK ? PRECISION_RANK[storedPrecision as GeoPrecision] : 0;447 if (incomingIsSameSource && PRECISION_RANK[incomingPrecision] === sp) return true;448 return PRECISION_RANK[incomingPrecision] > sp || (PRECISION_RANK[incomingPrecision] === sp && !incomingIsSameSource);449}450451// ---------------------------------------------------------------------------------------------------------------452// Completeness & confidence453454export interface CompletenessInput {455 geoPrecision?: string | null;456 hasCoords: boolean;457 operatorId?: string | null;458 address?: string | null;459 status?: string | null;460 itCapacityMw?: number | null;461 totalPowerMw?: number | null;462 plannedPowerMw?: number | null;463 mwIsEstimate?: boolean;464 facilityType?: string | null;465 openedOn?: string | null;466 website?: string | null;467 description?: string | null;468 carriersCount?: number | null;469 ixpCount?: number | null;470}471472/** 0–100. Weights: geo 15, operator 10, address 10, status 10, MW 20, type 5, opened 10, website 5, description 5, tenants/IXPs 10. */473export function completenessScore(f: CompletenessInput): number {474 let s = 0;475 if (f.hasCoords) {476 const p = f.geoPrecision ?? "unknown";477 s += p === "exact" || p === "parcel" || p === "street" ? 15 : p === "unknown" ? 4 : 8;478 }479 if (f.operatorId) s += 10;480 if (f.address && f.address.trim().length > 4) s += 10;481 if (f.status && f.status !== "unknown") s += 10;482 const mw = f.itCapacityMw ?? f.totalPowerMw ?? null;483 if (mw != null && mw > 0) s += f.mwIsEstimate ? 14 : 20;484 else if (f.plannedPowerMw != null && f.plannedPowerMw > 0) s += f.mwIsEstimate ? 8 : 12;485 if (f.facilityType && f.facilityType !== "unknown") s += 5;486 if (f.openedOn) s += 10;487 if (f.website) s += 5;488 if (f.description && f.description.trim().length > 40) s += 5;489 if ((f.carriersCount ?? 0) > 0 || (f.ixpCount ?? 0) > 0) s += 10;490 return Math.max(0, Math.min(100, s));491}492493export interface ConfidenceSummaryInput {494 /** distinct source kinds with current provenance for this facility */495 sourceKinds: string[];496 /** number of distinct sources with current provenance (defaults to the number of kinds) */497 sourceCount?: number;498 lastVerifiedIso?: string | null;499 /** true when every MW observation is an estimate and no non-MW primary source exists */500 onlyEstimates?: boolean;501}502503const KIND_ORDER: SourceKind[] = ["operator", "government", "filing", "utility", "cloud_provider", "registry", "dataset", "community", "secondary", "news"];504/** Kinds whose single word is enough for `high`: the operator itself, a government / regulator, a filing, a utility, a cloud provider. */505const AUTHORITATIVE_KINDS: ReadonlySet<string> = new Set<SourceKind>(["operator", "government", "filing", "utility", "cloud_provider"]);506/** Crowd-sourced or second-hand kinds: alone, they are `unverified`; corroborated by a second source, `moderate`. */507const COMMUNITY_KINDS: ReadonlySet<string> = new Set<SourceKind>(["community", "secondary", "news"]);508509/**510 * Facility-level confidence: best source kind as base, corroborated by the number of distinct source kinds.511 * - `high` only when an authoritative kind (operator / government / filing / utility / cloud provider) backs the record;512 * a registry or dataset alone is `moderate`;513 * - a single community / secondary / news source is `unverified` (OpenStreetMap features seen nowhere else);514 * - `verified` needs ≥ 2 independent authoritative kinds.515 */516export function facilityConfidence(i: ConfidenceSummaryInput): ConfidenceLevel {517 const kinds = i.sourceKinds.filter((k): k is SourceKind => (KIND_ORDER as string[]).includes(k));518 if (!kinds.length) return "unverified";519 const best = KIND_ORDER.find((k) => kinds.includes(k))!;520 const sourceCount = i.sourceCount ?? kinds.length;521 if (COMMUNITY_KINDS.has(best) && sourceCount <= 1) return i.onlyEstimates ? "estimated" : "unverified";522 const ageDays = i.lastVerifiedIso ? Math.max(0, (Date.now() - Date.parse(i.lastVerifiedIso)) / 86_400_000) : 0;523 const authoritative = kinds.filter((k) => AUTHORITATIVE_KINDS.has(k));524 // "verified" requires ≥ 2 independent authoritative kinds agreeing on the record525 const corroborations = authoritative.length >= 2 ? authoritative.length : Math.min(1, kinds.length - 1);526 const level = computeConfidence({ sourceKind: best, corroborations, ageDays, isEstimate: !!i.onlyEstimates });527 if (!authoritative.length && (level === "high" || level === "verified")) return "moderate";528 return level;529}530531export function isPipelineStatus(status: string | null | undefined): boolean {532 return !!status && (PIPELINE_STATUSES as readonly string[]).includes(status);533}534535export function bestMw(f: { itCapacityMw?: number | null; totalPowerMw?: number | null; plannedPowerMw?: number | null }): number | null {536 return f.itCapacityMw ?? f.totalPowerMw ?? f.plannedPowerMw ?? null;537}538