Image-to-Text
English
custom
ocr
document-ai
information-extraction
nameplate
nameplate-ocr
equipment-nameplate
data-center
data-centre
datacenter
mission-critical
mep
electrical
equipment-schedule
schedule-verification
asset-register
commissioning
quality-assurance
ups
pdu
switchgear
generator
ats
transformer
busway
battery
crah
crac
chiller
cooling
construction
aec
edge-ai
on-device
privacy-preserving
tesseract
browser
Download nameplate-model.js from constructelligence/data-centre-nameplates: direct link, hf CLI and curl.
- Browser
- Download file 22.6 kB
-
https://huggingface.co/constructelligence/data-centre-nameplates/resolve/main/nameplate-model.js
- Command line
-
hf download hf://constructelligence/data-centre-nameplates/nameplate-model.js
-
curl -L -o nameplate-model.js https://huggingface.co/constructelligence/data-centre-nameplates/resolve/main/nameplate-model.js
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]; | |
| } | |