data-centre-nameplates / nameplate-model.js
constructelligence's picture
Upload folder using huggingface_hub
5e2d1eb verified
Raw History Blame Contribute Delete
22.6 kB
// Nameplates: equipment nameplate OCR checked against the equipment schedule.
// On a data centre job the gear is the job (UPS, PDUs, switchgear, gensets,
// CRAHs, chillers), and the costly mistakes are a unit delivered at the wrong
// voltage or rating, or one that never gets onto the asset register. This
// module turns OCR text into nameplate fields, matches a reading to the
// schedule, checks each field, and builds the register CSV.
//
// OCR is Tesseract.js (pretrained, loaded from the CDN like the other models),
// so nothing here is trained: it is parsing and matching.
// Pure functions (tests/nameplate-model.test.mjs).
import { parseCsv } from './schedule-import.js';
export const FIELDS = [
['manufacturer', 'Manufacturer'], ['model', 'Model'], ['serial', 'Serial'], ['voltage', 'Voltage'],
['phase', 'Phase'], ['hz', 'Hz'], ['kva', 'kVA'], ['kw', 'kW'], ['amps', 'Amps'], ['mca', 'MCA'], ['mocp', 'MOCP'],
['refrigerant', 'Refrigerant'], ['mfgDate', 'Mfg date'],
];
export const EQUIPMENT_TYPES = [
['ups', 'UPS', /\bUPS\b|UNINTERRUPTIBLE/],
['generator', 'Generator', /GENERATOR|GENSET|\bEKW\b|STANDBY POWER|PRIME POWER/],
['ats', 'Transfer switch', /TRANSFER SWITCH|\bATS\b|\bSTS\b/],
['switchgear', 'Switchgear / switchboard', /SWITCHGEAR|SWITCHBOARD/],
['transformer', 'Transformer', /TRANSFORMER|\bXFMR\b|PRIMARY.*SECONDARY/],
['pdu', 'PDU / RPP', /POWER DISTRIBUTION UNIT|\bPDU\b|\bRPP\b|REMOTE POWER PANEL/],
['panelboard', 'Panelboard', /PANELBOARD/],
['busway', 'Busway', /BUSWAY|BUS PLUG|BUS DUCT/],
['battery', 'Battery cabinet', /BATTERY|\bVRLA\b|LITHIUM/],
['cdu', 'Coolant distribution unit', /COOLANT DISTRIBUTION|\bCDU\b/],
['chiller', 'Chiller', /CHILLER/],
['crah', 'CRAH / CRAC', /\bCRAH\b|\bCRAC\b|COMPUTER ROOM AIR|AIR HANDL/],
['pump', 'Pump', /\bPUMP\b/],
];
// Makers common on data centre electrical and cooling packages. Matched as
// whole words; anything else falls back to a line that reads like a company.
const MAKERS = [
'Schneider Electric', 'Square D', 'APC', 'Eaton', 'ABB', 'Siemens', 'Vertiv', 'Liebert', 'Caterpillar', 'Cummins',
'Kohler', 'Rolls-Royce', 'MTU', 'Generac', 'Trane', 'Carrier', 'Daikin', 'York', 'Johnson Controls', 'Stulz',
'Munters', 'nVent', 'Legrand', 'Mitsubishi Electric', 'Toshiba', 'General Electric', 'Hubbell', 'Socomec', 'Piller',
'Starline', 'Raritan', 'Server Technology', 'Emerson', 'Russelectric', 'ASCO', 'Hitachi', 'Riello',
'Rittal', 'Motivair', 'CoolIT', 'Hitec', 'EnerSys', 'Leclanché', 'Samsung SDI',
];
const COMPANY_LINE = /\b(INC|CORP|CORPORATION|LTD|LLC|GMBH|CO\.|COMPANY|SYSTEMS|ELECTRIC|POWER|TECHNOLOGIES|INDUSTRIES)\b/;
const NUM = '(\\d{1,5}(?:[.,]\\d+)?)';
// "1,002" is a thousands separator on a US plate; "2,5" is a European decimal.
const num = s => (s == null ? null : parseFloat(/^\d{1,3},\d{3}$/.test(String(s)) ? String(s).replace(',', '') : String(s).replace(',', '.')));
/**
* Nameplate fields from OCR output: a string, or lines as [{ text, confidence }]
* (Tesseract's data.lines). Each field is { value, conf } (conf 0–100, the OCR
* confidence of the line it came from; 100 for plain text).
*/
export function parseNameplate(input) {
const lines = (typeof input === 'string' ? input.split(/\r?\n/).map(text => ({ text, confidence: 100 })) : input)
.map(l => ({ text: ocrFix(String(l.text || '').replace(/\s+/g, ' ').trim()), conf: Math.round(l.confidence ?? 100), bbox: l.bbox || null }))
// Lines Tesseract itself scores near zero are texture read as text (brushed metal, a busy background).
.filter(l => l.text && l.conf >= 20);
const fields = {};
// line: index of the OCR line the value came from, so the page can outline it on the photo.
const set = (key, value, conf, line) => { if (fields[key] == null && value != null && value !== '') fields[key] = { value, conf, line }; };
// Each rule runs over every line in order, so the first line that has the field wins.
const scan = (key, re, map = m => m[1]) => {
for (const [i, l] of lines.entries()) {
const m = up(l.text).match(re);
if (m) { const v = map(m); if (v != null && v !== '') { set(key, v, l.conf, i); return; } }
}
};
const CODE = '([A-Z0-9][A-Z0-9\\-\\/\\.]{2,})';
scan('model', new RegExp(`(?:\\bMODEL\\b|\\bMOD\\.|\\bM\\/N\\b|\\bCAT(?:ALOG)?\\.?\\s*(?:NO\\b\\.?|#|NUMBER)|\\bP\\/N\\b|\\bPART\\s*(?:NO\\b\\.?|#|NUMBER))\\s*(?:NO\\b\\.?|NUMBER\\b|#)?\\s*[:#.]?\\s*${CODE}`), m => cleanCode(m[1]));
scan('serial', new RegExp(`(?:\\bSERIAL\\b|\\bSER\\.|\\bS\\/N\\b|\\b5\\/N\\b|\\bSN\\b)\\s*(?:NO\\b\\.?|NUMBER\\b|#)?\\s*[:#.]?\\s*([A-Z0-9][A-Z0-9\\-]{3,})`), m => cleanCode(m[1]));
scan('refrigerant', /\bR[-\s]?(410A|134A|1234ZE|1234YF|513A|514A|1233ZD|454B|407C|32|22)\b/, m => `R-${m[1]}`);
scan('hz', /\b(50|60)\s*HZ\b|\bHZ\s*[:]?\s*(50|60)\b|\bFREQ(?:UENCY)?\.?\s*[:]?\s*(50|60)\b/, m => +(m[1] || m[2] || m[3]));
scan('phase', /\b([13])\s*-?\s*(?:PH|PHASE|Ø|Φ)\b|\bPH(?:ASE)?\s*[:]?\s*([13])\b/, m => +(m[1] || m[2]));
scan('kva', new RegExp(`${NUM}\\s*KVA\\b|\\bKVA\\s*[:]?\\s*${NUM}`), m => num(m[1] || m[2]));
scan('kw', new RegExp(`${NUM}\\s*E?KW\\b|\\bE?KW\\s*[:]?\\s*${NUM}`), m => num(m[1] || m[2]));
scan('mfgDate', /\b(?:MFG\.?|MANUFACTURED|MFD\.?|BUILD|PROD(?:UCTION)?\.?)\s*(?:DATE)?\s*[:]?\s*(\d{1,2}[/-]\d{2,4}|\d{4}[/-]\d{1,2}(?:[/-]\d{1,2})?|\d{1,2}[/-]\d{1,2}[/-]\d{2,4})/, m => m[1]);
// Voltage: every voltage on the plate (a transformer or UPS carries more than one).
const volts = [];
let voltLine = null;
for (const [i, l] of lines.entries()) {
const found = voltagesIn(up(l.text));
if (found.values.length) {
for (const v of found.values) if (!volts.includes(v)) volts.push(v);
if (!voltLine) voltLine = { text: found.display, conf: l.conf, line: i };
}
}
if (volts.length) fields.voltage = { value: voltLine.text, conf: voltLine.conf, line: voltLine.line, values: volts };
// Amps: a labelled rated current first, then any bare "1002 A". Refrigerants (R-410A) are not amps.
scan('amps', new RegExp(`(?:\\bFLA\\b|\\bRLA\\b|\\bCURRENT\\b|\\bAMPS?\\b|\\bAMPERES\\b)\\s*[:]?\\s*${NUM}`), m => num(m[1]));
if (!fields.amps) scan('amps', new RegExp(`(?<![-\\d.R])${NUM}\\s*(?:A|AMPS?)\\b`), m => num(m[1]));
scan('mca', new RegExp(`\\bMCA\\b\\s*[:]?\\s*${NUM}`), m => num(m[1]));
scan('mocp', new RegExp(`(?:\\bMOCP\\b|\\bMAX\\.?\\s*(?:FUSE|BREAKER|OCP))\\s*(?:SIZE)?\\s*[:]?\\s*${NUM}`), m => num(m[1]));
// Manufacturer: a known maker anywhere, else a line that reads like a company name.
for (const [i, l] of lines.entries()) {
// "New York" on a supplier's address is not York the maker.
const t = up(l.text).replace(/NEW YORK/g, '');
const maker = MAKERS.find(mk => new RegExp(`\\b${escapeRe(up(mk))}\\b`).test(t));
if (maker) { set('manufacturer', maker, l.conf, i); break; }
}
if (!fields.manufacturer) {
const i = lines.findIndex(x => COMPANY_LINE.test(up(x.text)) && !/\d{3}/.test(x.text) && x.text.length <= 48);
if (i >= 0) set('manufacturer', titleCase(lines[i].text.replace(/[^A-Za-z0-9&.,\- ]/g, '').trim()), lines[i].conf, i);
}
const all = up(lines.map(l => l.text).join('\n'));
const type = (EQUIPMENT_TYPES.find(([, , re]) => re.test(all)) || [null])[0];
const conf = lines.length ? Math.round(lines.reduce((s, l) => s + l.conf, 0) / lines.length) : 0;
return { fields, type, conf, text: lines.map(l => l.text).join('\n'), lines: lines.map(l => ({ text: l.text, conf: l.conf, bbox: l.bbox })) };
}
/**
* Undo the misreads Tesseract makes on plate lettering (seen in tests/nameplates-bench.mjs):
* a serifed "V" read as "Vv", "Y" as "¥", codes and decimals split after their punctuation
* ("NPX- 750- 480", "38. 5"), "HZ" as "Hw"/"H2", "KVA" as "XVA".
*/
export function ocrFix(t) {
return t
.replace(/[¥]/g, 'Y')
.replace(/[‘’`]/g, "'")
.replace(/(\d)\s*V[vV]\b/g, '$1 V')
.replace(/([A-Za-z0-9])-\s+(?=[A-Za-z0-9])/g, '$1-')
.replace(/(\d)\.\s+(?=\d)/g, '$1.')
.replace(/\b(50|60)\s*H[wW2]\b/g, '$1 HZ')
// K read as X in KVA / KW / EKW.
.replace(/(\d\s*)XVA\b/g, '$1KVA')
.replace(/(\d\s*)E[KX][WN]\b/g, '$1EKW')
.replace(/(\d\s*)XW\b/g, '$1KW');
}
/** Voltages written on one line: 480Y/277V, 208/120 VAC, VOLTS: 480, 13.8 kV. */
export function voltagesIn(s) {
s = s.replace(/(\d),(\d{3})\b/g, '$1$2'); // 13,800 V
const values = [];
let display = null;
const add = (v, shown) => {
if (!(v >= 100 && v <= 40000)) return;
if (!values.includes(v)) values.push(v);
if (!display) display = shown;
};
const pair = /\b(\d{3,5})\s*(Y)?\s*\/\s*(\d{3,5})\s*(?:V|VAC|VOLTS?)?\b/g;
let m;
const isLabelled = /VOLT|\bV\b|\bVAC\b|\dV\b/.test(s);
while ((m = pair.exec(s))) {
// "480/277" with no V anywhere on the line could be anything; require a voltage word or unit.
if (!isLabelled && !/\d\s*(?:V|VAC|VOLTS?)\b/.test(m[0])) continue;
add(+m[1], `${m[1]}${m[2] ? 'Y' : ''}/${m[3]} V`);
add(+m[3], null);
}
const single = /\b(\d{3,5})\s*(?:V|VAC|VDC|VOLTS?)\b/g;
while ((m = single.exec(s))) add(+m[1], `${m[1]} V`);
const labelled = /\bVOLT(?:S|AGE)?\b[^0-9]{0,12}(\d{3,5})\b/g;
while ((m = labelled.exec(s))) add(+m[1], `${m[1]} V`);
const kv = /\b(\d{1,2}(?:\.\d{1,2})?)\s*KV\b/g;
while ((m = kv.exec(s))) add(Math.round(parseFloat(m[1]) * 1000), `${m[1]} kV`);
return { values, display };
}
// ------------------------------------------------------------------ schedule
const HEADERS = {
tag: /^(tag|mark|equipment tag|equip(ment)? (id|no\.?|number|tag)|asset( tag| id)?|id|unit tag)$/i,
type: /^(type|equipment|equipment type|description|desc|item|system)$/i,
manufacturer: /^(manufacturer|mfr\.?|mfg\.?|make|brand|vendor|basis of design)$/i,
model: /^(model|model no\.?|model number|model #|cat(alog)? no\.?|catalog number|part (no\.?|number))$/i,
serial: /^(serial|serial no\.?|serial number|s\/n)$/i,
voltage: /^(voltage|volts|v|input voltage|volts\/phase|electrical)$/i,
phase: /^(phase|ph|phases)$/i,
kva: /^(kva|rating \(kva\)|kva rating|capacity \(kva\))$/i,
kw: /^(kw|ekw|rating \(kw\)|kw rating|capacity \(kw\))$/i,
amps: /^(amps|a|amperage|fla|rated current|current)$/i,
mca: /^(mca|min\.? circuit amp(s|acity)?|minimum circuit ampacity)$/i,
mocp: /^(mocp|max\.? (fuse|breaker|ocp)( size)?|maximum overcurrent protection)$/i,
refrigerant: /^(refrigerant|ref\.?)$/i,
location: /^(location|room|area|level|space|served from|zone)$/i,
};
/** Equipment schedule from a CSV export: the header row is the first that names a tag or model column plus one more. */
export function parseEquipmentSchedule(text) {
const rows = parseCsv(String(text || ''));
const known = r => Object.values(HEADERS).filter(re => r.some(c => re.test(c.trim()))).length;
const headerIdx = rows.findIndex(r => (r.some(c => HEADERS.tag.test(c.trim())) || r.some(c => HEADERS.model.test(c.trim()))) && known(r) >= 2);
if (headerIdx < 0) return { items: [], problems: ['No header row with a Tag or Model column was found.'] };
const header = rows[headerIdx].map(c => c.trim());
const cols = Object.fromEntries(Object.keys(HEADERS).map(k => [k, header.findIndex(h => HEADERS[k].test(h))]));
const items = [], problems = [];
rows.slice(headerIdx + 1).forEach((r, i) => {
const get = k => (cols[k] >= 0 ? String(r[cols[k]] ?? '').trim() : '');
const tag = get('tag') || (get('model') ? `Row ${headerIdx + i + 2}` : '');
if (!tag) return;
if (items.some(x => x.tag === tag)) problems.push(`Tag ${tag} appears more than once; the first row is used.`);
else items.push({
tag, type: get('type'), manufacturer: get('manufacturer'), model: get('model'), serial: get('serial'),
voltage: get('voltage'), phase: num(get('phase').match(/[13]/)?.[0]), kva: num(get('kva')) || null, kw: num(get('kw')) || null,
amps: num(get('amps')) || null, mca: num(get('mca')) || null, mocp: num(get('mocp')) || null, refrigerant: get('refrigerant'), location: get('location'),
});
});
if (!items.length) problems.push('The header was found but no equipment rows under it.');
return { items, problems };
}
/** Voltages a schedule cell asks for: "480Y/277", "480V 3PH", "13.8kV". */
export function scheduleVoltages(cell) {
const s = up(cell);
const r = voltagesIn(s);
if (r.values.length) return r.values;
return [...s.matchAll(/(?<![\d.])(\d{3,5})(?![\d.])/g)].map(m => +m[1]).filter(v => v >= 100 && v <= 40000);
}
// ------------------------------------------------------------------ matching
/** Model/serial codes compared the way OCR gets them wrong: O/0, I/L/1, S/5, B/8, Z/2, punctuation and case ignored. */
export function foldCode(s) {
return up(s).replace(/[^A-Z0-9]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1').replace(/S/g, '5').replace(/B/g, '8').replace(/Z/g, '2');
}
export function codeSimilarity(a, b) {
const x = foldCode(a), y = foldCode(b);
if (!x || !y) return 0;
if (x === y) return 1;
// Plate models often carry option suffixes the schedule leaves off (9395P-750 vs 9395P-750-N-480).
const [short, long] = x.length <= y.length ? [x, y] : [y, x];
if (short.length >= 5 && long.startsWith(short)) return 0.92;
return 1 - levenshtein(x, y) / Math.max(x.length, y.length);
}
const sameMaker = (a, b) => {
const x = up(a).replace(/[^A-Z0-9]/g, ''), y = up(b).replace(/[^A-Z0-9]/g, '');
return !!x && !!y && (x.includes(y) || y.includes(x));
};
/**
* Schedule rows this reading could be, best first: [{ item, score, registered }].
* Data halls repeat, so several tags often share one model (UPS-1A, UPS-1B…):
* among equal scores, tags not on the register yet come first.
*/
export function candidatesFor(reading, items, register = []) {
const f = reading.fields || {};
const taken = new Set(register.map(r => r.tag).filter(Boolean));
const out = [];
for (const item of items) {
let score;
if (item.serial && f.serial?.value && foldCode(item.serial) === foldCode(f.serial.value)) score = 1.5;
else {
const m = item.model && f.model?.value ? codeSimilarity(item.model, f.model.value) : 0;
const mk = item.manufacturer && f.manufacturer?.value ? (sameMaker(item.manufacturer, f.manufacturer.value) ? 1 : 0) : 0.5;
const ty = reading.type && item.type ? (typeOf(item.type) === reading.type ? 1 : 0) : 0.5;
score = 0.7 * m + 0.15 * mk + 0.15 * ty;
}
if (score >= 0.5) out.push({ item, score: Math.round(score * 100) / 100, registered: taken.has(item.tag) });
}
return out.sort((a, b) => (b.score - a.score) || (a.registered - b.registered));
}
/** Equipment type key for free text such as a schedule's description column. */
export function typeOf(text) {
const s = up(text);
return (EQUIPMENT_TYPES.find(([, , re]) => re.test(s)) || [null])[0];
}
/**
* Field-by-field check of a reading against one schedule row:
* [{ key, label, status: 'ok' | 'mismatch' | 'unread', expected, found }].
* Fields the schedule leaves blank are not checked.
*/
export function checkAgainst(reading, item) {
const f = reading.fields || {};
const out = [];
const push = (key, expected, found, ok) => out.push({
key, label: FIELDS.find(x => x[0] === key)?.[1] || key, expected: String(expected), found: found == null ? '' : String(found),
status: found == null || found === '' ? 'unread' : ok ? 'ok' : 'mismatch',
});
if (item.manufacturer) push('manufacturer', item.manufacturer, f.manufacturer?.value, sameMaker(item.manufacturer, f.manufacturer?.value));
if (item.model) push('model', item.model, f.model?.value, codeSimilarity(item.model, f.model?.value) >= 0.85);
if (item.serial) push('serial', item.serial, f.serial?.value, foldCode(item.serial) === foldCode(f.serial?.value));
if (item.voltage) {
const want = scheduleVoltages(item.voltage);
const have = f.voltage?.values || (f.voltage?.value ? scheduleVoltages(f.voltage.value) : []);
push('voltage', item.voltage, f.voltage?.value, want.length > 0 && want.every(v => have.includes(v)));
}
if (item.phase) push('phase', item.phase, f.phase?.value, +item.phase === +f.phase?.value);
for (const k of ['kva', 'kw', 'amps', 'mca', 'mocp']) {
if (item[k]) push(k, item[k], f[k]?.value, Math.abs(+f[k]?.value - item[k]) <= Math.max(0.5, item[k] * 0.01));
}
if (item.refrigerant) push('refrigerant', item.refrigerant, f.refrigerant?.value, up(item.refrigerant).replace(/[^A-Z0-9]/g, '') === up(f.refrigerant?.value).replace(/[^A-Z0-9]/g, ''));
return out;
}
/** Overall status of a register entry: matched and clean, matched with a problem, or not on the schedule. */
export function statusOf(checks, tag) {
if (!tag) return 'unmatched';
if (checks.some(c => c.status === 'mismatch')) return 'mismatch';
if (checks.some(c => c.status === 'unread')) return 'partial';
return 'verified';
}
export const STATUS_LABELS = { verified: 'Verified', partial: 'Matched, fields unread', mismatch: 'Does not match schedule', unmatched: 'Not on schedule', missing: 'Not scanned yet' };
/** Register entries that share a serial (the same plate scanned twice, or a copied serial). */
export function duplicateSerials(register) {
const seen = new Map();
for (const r of register) {
const s = foldCode(r.fields?.serial?.value);
if (!s) continue;
seen.set(s, [...(seen.get(s) || []), r.id]);
}
return [...seen.values()].filter(ids => ids.length > 1);
}
/** Progress against the schedule: scanned, verified, flagged, not scanned. */
export function registerSummary(register, items = []) {
const tags = new Set(register.map(r => r.tag).filter(Boolean));
return {
scanned: register.length,
scheduled: items.length,
scannedOfSchedule: items.filter(i => tags.has(i.tag)).length,
verified: register.filter(r => r.status === 'verified').length,
flagged: register.filter(r => r.status === 'mismatch').length,
unmatched: register.filter(r => r.status === 'unmatched').length,
duplicates: duplicateSerials(register).length,
missing: items.filter(i => !tags.has(i.tag)).map(i => i.tag),
};
}
/** Asset register CSV: one row per scanned plate, then every scheduled tag not scanned yet. */
export function registerCsv(register, items = []) {
const head = ['Tag', 'Status', 'Type', 'Location', 'Manufacturer', 'Model', 'Serial', 'Voltage', 'Phase', 'Hz', 'kVA', 'kW', 'Amps', 'MCA', 'MOCP', 'Refrigerant', 'Mfg date', 'Issues', 'Scanned'];
const byTag = new Map(items.map(i => [i.tag, i]));
const typeLabel = k => (EQUIPMENT_TYPES.find(t => t[0] === k) || [])[1] || '';
const dups = new Set(duplicateSerials(register).flat());
const rows = register.map(r => {
const f = r.fields || {};
const v = k => f[k]?.value ?? '';
const issues = (r.checks || []).filter(c => c.status === 'mismatch').map(c => `${c.label}: schedule ${c.expected}, plate ${c.found}`);
if (dups.has(r.id)) issues.push('Serial also on another scanned plate');
return [r.tag || '', STATUS_LABELS[r.status] || r.status, typeLabel(r.type), byTag.get(r.tag)?.location || '',
v('manufacturer'), v('model'), v('serial'), v('voltage'), v('phase'), v('hz'), v('kva'), v('kw'), v('amps'),
v('mca'), v('mocp'), v('refrigerant'), v('mfgDate'), issues.join('; '), r.scannedAt ? new Date(r.scannedAt).toISOString().slice(0, 16).replace('T', ' ') : ''];
});
const tags = new Set(register.map(r => r.tag));
for (const i of items) {
if (tags.has(i.tag)) continue;
rows.push([i.tag, STATUS_LABELS.missing, i.type, i.location, i.manufacturer, i.model, i.serial, i.voltage, i.phase ?? '', '',
i.kva ?? '', i.kw ?? '', i.amps ?? '', i.mca ?? '', i.mocp ?? '', i.refrigerant, '', '', '']);
}
return [head, ...rows].map(r => r.map(csvCell).join(',')).join('\n') + '\n';
}
// ------------------------------------------------------------------ sample data
// Fictional maker and models: the sample shows the workflow without implying
// any real product's ratings.
export const SAMPLE_SCHEDULE = `Tag,Description,Manufacturer,Model,Voltage,Phase,kVA,kW,MCA,MOCP,Refrigerant,Location
UPS-1A,Static UPS module,Northline Power Systems,NPX-750-480,480Y/277,3,750,675,,,,Electrical Room 1A
UPS-1B,Static UPS module,Northline Power Systems,NPX-750-480,480Y/277,3,750,675,,,,Electrical Room 1B
UPS-2A,Static UPS module,Northline Power Systems,NPX-750-480,480Y/277,3,750,675,,,,Electrical Room 2A
UPS-2B,Static UPS module,Northline Power Systems,NPX-750-480,480Y/277,3,750,675,,,,Electrical Room 2B
PDU-1-01,Power distribution unit,Northline Power Systems,NPD-300-415,480 / 415Y/240,3,300,,,,,Data Hall 1
PDU-1-02,Power distribution unit,Northline Power Systems,NPD-300-415,480 / 415Y/240,3,300,,,,,Data Hall 1
GEN-01,Diesel generator,Harbor Engine Works,HG-3000,13800,3,3750,3000,,,,Generator Yard
GEN-02,Diesel generator,Harbor Engine Works,HG-3000,13800,3,3750,3000,,,,Generator Yard
CRAH-1-01,Computer room air handler,Coldline Systems,CW-150-480,480,3,,,45,60,,Data Hall 1
CH-01,Air-cooled chiller,Coldline Systems,AC-1200,460,3,,,612,800,R-513A,Roof
`;
/** Text of the two sample plates: one that matches UPS-1A, one delivered at the wrong voltage. */
export const SAMPLE_PLATES = [
['NORTHLINE POWER SYSTEMS', 'UNINTERRUPTIBLE POWER SUPPLY', 'MODEL NO: NPX-750-480', 'SERIAL NO: NL26A01937',
'INPUT: 480Y/277 VAC 3 PH 60 HZ', 'RATING: 750 KVA / 675 KW', 'INPUT CURRENT: 1002 A', 'MFG DATE: 03/2026'],
['NORTHLINE POWER SYSTEMS', 'UNINTERRUPTIBLE POWER SUPPLY', 'MODEL NO: NPX-750-415', 'SERIAL NO: NL26A02210',
'INPUT: 415Y/240 VAC 3 PH 50 HZ', 'RATING: 750 KVA / 675 KW', 'INPUT CURRENT: 1160 A', 'MFG DATE: 04/2026'],
];
// ------------------------------------------------------------------ helpers
function up(s) { return String(s ?? '').toUpperCase(); }
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function cleanCode(s) { return s.replace(/[.\-/]+$/, ''); }
function titleCase(s) { return s.toLowerCase().replace(/\b[a-z]/g, c => c.toUpperCase()); }
function csvCell(v) { const s = String(v ?? ''); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }
export function levenshtein(a, b) {
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
let diag = prev[0];
prev[0] = i;
for (let j = 1; j <= b.length; j++) {
const tmp = prev[j];
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
diag = tmp;
}
}
return prev[b.length];
}