Download nameplate-model.js from constructelligence/data-centre-nameplates: direct link, hf CLI and curl.
- Browser
- Download file 43.7 kB
-
https://huggingface.co/spaces/constructelligence/data-centre-nameplates/resolve/main/nameplate-model.js
- Command line
-
hf download hf://spaces/constructelligence/data-centre-nameplates/nameplate-model.js
-
curl -L -o nameplate-model.js https://huggingface.co/spaces/constructelligence/data-centre-nameplates/resolve/main/nameplate-model.js
43.7 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 = [ | |
| // Identification. | |
| ['manufacturer', 'Manufacturer', 'id'], ['model', 'Model', 'id'], ['serial', 'Serial', 'id'], ['mfgDate', 'Mfg date', 'id'], | |
| // Electrical. | |
| ['voltage', 'Voltage', 'electrical'], ['phase', 'Phase', 'electrical'], ['hz', 'Hz', 'electrical'], | |
| ['kva', 'kVA', 'electrical'], ['kw', 'kW', 'electrical'], ['amps', 'Amps', 'electrical'], ['pf', 'Power factor', 'electrical'], | |
| ['mca', 'MCA', 'electrical'], ['mocp', 'MOCP', 'electrical'], ['sccr', 'SCCR (kA)', 'electrical'], | |
| ['enclosure', 'Enclosure', 'electrical'], ['standards', 'Listings', 'electrical'], ['batteryType', 'Battery type', 'electrical'], | |
| ['batteryAh', 'Battery Ah', 'electrical'], ['cells', 'Battery cells', 'electrical'], | |
| // Cooling. | |
| ['refrigerant', 'Refrigerant', 'cooling'], ['charge', 'Refrigerant charge', 'cooling'], ['tons', 'Cooling (tons)', 'cooling'], | |
| ['btu', 'Cooling (BTU/h)', 'cooling'], ['cfm', 'Airflow (CFM)', 'cooling'], ['gpm', 'Flow (GPM)', 'cooling'], | |
| // Server racks and IT. | |
| ['uHeight', 'Rack U', 'rack'], ['formFactor', 'Form factor', 'rack'], | |
| ['powerW', 'Power (W)', 'rack'], ['rackLoad', 'Rack load (kg)', 'rack'], | |
| ['outletTypes', 'Outlet mix', 'rack'], ['outlets', 'PDU outlets', 'rack'], | |
| ['portSpeed', 'Port speed (GbE)', 'rack'], ['ports', 'Ports', 'rack'], | |
| // Mechanical. | |
| ['rpm', 'Speed (RPM)', 'mechanical'], ['weight', 'Weight (kg)', 'mechanical'], | |
| ]; | |
| // Field groups for the reading panel; keys match the third element of FIELDS. | |
| export const FIELD_GROUPS = [['id', 'Identification'], ['electrical', 'Electrical'], ['cooling', 'Cooling'], ['rack', 'Server racks / IT'], ['mechanical', 'Mechanical']]; | |
| // Data centre equipment types. Order is the whole game: the first pattern that | |
| // matches a plate wins, so device-specific wording comes before the families it | |
| // belongs to (flywheel before UPS, paralleling gear before generator, heat pump | |
| // and pumps before the cooling families, metering before transformer, controls | |
| // before racks). | |
| export const EQUIPMENT_TYPES = [ | |
| // Power conversion and transfer. | |
| ['flywheel', 'Flywheel UPS', /FLYWHEEL/], | |
| ['ups', 'UPS', /\bUPS\b|UNINTERRUPTIBLE/], | |
| ['sts', 'Static transfer switch', /STATIC TRANSFER|STATIC BYPASS|\bSTS\b|RACK[-\s]?ATS/], | |
| ['ats', 'Automatic transfer switch', /TRANSFER SWITCH|\bATS\b|\bATSE\b/], | |
| ['rectifier', 'Rectifier / DC power', /RECTIFIER|BATTERY CHARGER|DC POWER SYSTEM/], | |
| ['inverter', 'Inverter', /\bINVERTER\b|POWER CONVERSION SYSTEM/], | |
| ['bess', 'Battery energy storage', /BATTERY ENERGY STORAGE|\bBESS\b|ENERGY STORAGE/], | |
| // Prime power and distribution. | |
| ['nitrogen', 'Nitrogen generator', /NITROGEN GENERATOR|\bN2\s?GENERATOR\b/], | |
| ['paralleling', 'Paralleling gear', /PARALLELING|GENERATOR CONTROL|SYNCHRONIS|BUS TIE/], | |
| ['generator', 'Generator', /GENERATOR|GENSET|\bEKW\b|STANDBY POWER|PRIME POWER|DIESEL ENGINE/], | |
| ['fueltank', 'Fuel system', /FUEL TANK|DAY TANK|DIESEL TANK|FUEL OIL|FUEL POLISH|BULK FUEL/], | |
| ['switchgear', 'Switchgear / switchboard', /SWITCHGEAR|SWITCHBOARD|DISTRIBUTION BOARD|\bMDP\b|MAIN DISTRIBUTION/], | |
| ['mcc', 'Motor control centre', /MOTOR CONTROL|\bMCC\b/], | |
| ['metering', 'Metering / CT cabinet', /METERING|POWER METER|KWH METER|ELECTRIC METER|CT CABINET/], | |
| ['relay', 'Protective relay', /PROTECTIVE RELAY|PROTECTION RELAY|FEEDER RELAY|POWER SYSTEM RELAY|\bANSI\s?(?:50|51|27|59|81|87)\b/], | |
| ['tmonitor', 'Transformer / DGA monitor', /TRANSFORMER MONITOR|DISSOLVED GAS|\bDGA\b|TRANSFORMER MONITORING/], | |
| ['transformer', 'Transformer', /TRANSFORMER|\bXFMR\b|PRIMARY.*SECONDARY|DRY TYPE|ISOLATION TRANSFORMER|K[-\s]?RATED/], | |
| ['spd', 'Surge protection', /SURGE PROTECT|\bSPD\b|\bTVSS\b|TRANSIENT VOLTAGE/], | |
| ['capacitor', 'Power factor / filter', /CAPACITOR|POWER FACTOR|HARMONIC FILTER|\bPFC\b/], | |
| ['ngr', 'Neutral grounding resistor', /NEUTRAL GROUNDING|GROUNDING RESISTOR|\bNGR\b/], | |
| ['vfd', 'Variable frequency drive', /VARIABLE FREQUENCY|VARIABLE SPEED|\bVFD\b|\bVSD\b|\bAFD\b/], | |
| ['busway', 'Busway', /BUSWAY|BUS PLUG|BUS DUCT|ISOLATED PHASE BUS|PHASE BUS/], | |
| ['panelboard', 'Panelboard', /PANELBOARD|PANEL BOARD|LOAD CENTER/], | |
| ['pdu', 'PDU / RPP', /POWER DISTRIBUTION UNIT|\bPDU\b|\bRPP\b|REMOTE POWER PANEL/], | |
| ['battery', 'Battery / battery cabinet', /\bBATTERY\b|VRLA|LITHIUM|LEAD ACID/], | |
| ['loadbank', 'Load bank', /LOAD BANK|LOADBANK|RESISTIVE LOAD/], | |
| // Pumps come before the cooling families: a "condenser water pump" or a | |
| // "chilled water pump" is a pump, not the machine it serves; "heat pump" is | |
| // matched first so the compressor word inside it does not win. | |
| ['heatpump', 'Heat pump', /HEAT PUMP/], | |
| ['pump', 'Pump', /\bPUMP\b/], | |
| // Cooling and mechanical. | |
| ['coolingtower', 'Cooling tower', /COOLING TOWER|COOLING TWR|EVAPORATIVE CONDENSER/], | |
| ['thermalstorage', 'Thermal storage', /THERMAL STORAGE|CHILLED WATER STORAGE|ICE STORAGE/], | |
| ['chiller', 'Chiller', /\bCHILLER\b/], | |
| ['condenser', 'Dry cooler / condenser', /DRY COOLER|FLUID COOLER|EVAPORATIVE COOLER|REMOTE CONDENSER|AIR[-\s]?COOLED CONDENSER|ADIABATIC|CONDENSER/], | |
| ['cdu', 'CDU / liquid cooling', /COOLANT DISTRIBUTION|\bCDU\b|LIQUID COOLING|IMMERSION|DIRECT TO CHIP|COOLANT MANIFOLD|REAR DOOR/], | |
| ['crah', 'CRAH / CRAC', /\bCRAH\b|\bCRAC\b|COMPUTER ROOM AIR/], | |
| ['ahu', 'Air handling unit', /\bAHU\b|AIR HANDLING|AIR HANDLER/], | |
| ['heatexchanger', 'Heat exchanger', /HEAT EXCHANGER|PLATE HEAT|PLATE AND FRAME/], | |
| ['expansiontank', 'Expansion tank', /EXPANSION TANK|HYDROPNEUMATIC/], | |
| ['airseparator', 'Air separator', /AIR SEPARATOR|AIR SEPARATION|\bAIR SCOOP\b/], | |
| ['boiler', 'Boiler', /\bBOILER\b/], | |
| ['humidifier', 'Humidifier / dehumidifier', /HUMIDIFIER|HUMIDIFICATION|DEHUMIDIFIER/], | |
| ['watertreatment', 'Water treatment', /WATER TREATMENT|WATER SOFTENER|REVERSE OSMOSIS|\bRO SYSTEM\b|CHEMICAL DOSING|SIDE STREAM FILTER|SAND FILTER|CARBON FILTER|WATER FILTER/], | |
| ['dryer', 'Air dryer', /AIR DRYER|DESICCANT|REFRIGERATED DRYER/], | |
| ['compressor', 'Compressor', /\bCOMPRESSOR\b/], | |
| ['fcu', 'Fan coil / fan wall', /\bFCU\b|FAN COIL|FAN WALL|EXHAUST FAN|SUPPLY FAN|RETURN FAN/], | |
| ['rtu', 'Rooftop unit', /ROOFTOP UNIT|\bRTU\b|PACKAGED UNIT|PACKAGE UNIT/], | |
| ['mua', 'Makeup air unit', /MAKEUP AIR|MAKE-UP AIR|\bMUA\b/], | |
| ['economizer', 'Economizer', /ECONOMIZER/], | |
| // Fire, safety and controls. | |
| ['firesuppression', 'Fire suppression', /FIRE SUPPRESSION|CLEAN AGENT|\bFM[-\s]?200\b|NOVEC|INERT GAS|SPRINKLER|PRE[-\s]?ACTION|\bCO2\b|AEROSOL/], | |
| ['firealarm', 'Fire alarm', /FIRE ALARM|\bVESDA\b|SMOKE DETECT|ASPIRATING|HEAT DETECT/], | |
| ['leakdetection', 'Leak / gas detection', /LEAK DETECT|WATER DETECT|REFRIGERANT MONITOR|GAS DETECT/], | |
| ['bms', 'BMS / controls', /\bBMS\b|\bEPMS\b|\bDCIM\b|BUILDING MANAGEMENT|\bBAS\b|CONTROL PANEL|CONTROL CABINET|\bPLC\b|CONTROLLER/], | |
| // IT and white space. Rack before server so "server rack / enclosure" is a | |
| // rack; KVM before server so "console server" is a console. | |
| ['kvm', 'KVM / console', /\bKVM\b|CONSOLE SERVER/], | |
| ['rack', 'Rack / enclosure', /EQUIPMENT RACK|\bRACK\b|ENCLOSURE|\bCABINET\b/], | |
| ['server', 'Server / storage', /\bSERVER\b|BLADE|STORAGE ARRAY|\bSAN\b|\bNAS\b|HCI NODE/], | |
| ['network', 'Network gear', /NETWORK SWITCH|ETHERNET SWITCH|\bROUTER\b|PATCH PANEL|FIREWALL|LOAD BALANCER/], | |
| ]; | |
| // Makers common on data centre electrical, cooling, IT and fire packages. | |
| // Matched as whole words; anything else falls back to a line that reads like a | |
| // company. Longer names precede the shorter names they contain. | |
| const MAKERS = [ | |
| 'Schneider Electric', 'Square D', 'APC', 'Eaton', 'ABB', 'Siemens', 'Vertiv', 'Liebert', 'Caterpillar', 'Cummins', | |
| 'Kohler', 'Rolls-Royce Power Systems', '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', | |
| // Cooling and mechanical. | |
| 'Airedale', 'Uniflair', 'Climaveneta', 'Aermec', 'FläktGroup', 'FlaktGroup', 'Systemair', 'ebm-papst', 'Danfoss', | |
| 'Grundfos', 'Wilo', 'Xylem', 'Bell & Gossett', 'Armstrong', 'Taco', 'Flowserve', 'SPX', 'Baltimore Aircoil', | |
| 'Evapco', 'Marley', 'LU-VE', 'Alfa Laval', 'SWEP', 'Tranter', 'Boyd', 'Asetek', | |
| // Power distribution, switchgear and batteries. | |
| 'PDI', 'Power Distribution Inc', 'Anord Mardix', 'Mardix', 'Universal Electric', 'Hammond', 'Hoffman', | |
| 'Exide', 'C&D Technologies', 'GS Yuasa', 'FIAMM', 'NorthStar', 'Saft', 'Hoppecke', 'LG Energy', 'CATL', 'Fluence', | |
| 'Aggreko', 'Himoinsa', 'Perkins', 'Volvo Penta', 'Deutz', 'Wartsila', | |
| // IT and network. | |
| 'Dell', 'HPE', 'Hewlett Packard', 'Cisco', 'Juniper', 'Arista', 'Lenovo', 'Supermicro', 'IBM', 'Fujitsu', 'NetApp', | |
| 'Pure Storage', 'Aten', 'Avocent', | |
| // Fire, detection and controls. | |
| 'Fike', 'Kidde', 'Tyco', 'Viking', 'Victaulic', 'Hochiki', 'Xtralis', 'VESDA', 'Notifier', 'Honeywell', | |
| 'Delta Controls', 'Distech', 'Automated Logic', 'Tridium', | |
| ]; | |
| const COMPANY_LINE = /\b(INC|CORP|CORPORATION|LTD|LLC|GMBH|CO\.|COMPANY|SYSTEMS|ELECTRIC|POWER|TECHNOLOGIES|INDUSTRIES|ENGINEERING|INTERNATIONAL|GROUP|AG|SA|BV|NV|PTY|WORKS|THERMAL|CONTROLS|SOLUTIONS|MANUFACTURING)\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(',', '.'))); | |
| // Range guards: a rating of 0, or an absurd value, is an OCR artefact, not a field. | |
| const positive = v => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null); | |
| const inRange = (v, lo, hi) => (typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi ? v : null); | |
| /** | |
| * 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|1233ZD|513A|514A|515B|454B|468A|449A|407C|452A|455A|245FA|123|32|22|717|744|290|128|503|508B)\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 => positive(num(m[1] || m[2]))); | |
| scan('kw', new RegExp(`${NUM}\\s*E?KW\\b|\\bE?KW\\s*[:]?\\s*${NUM}`), m => positive(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 => positive(num(m[1]))); | |
| if (!fields.amps) scan('amps', new RegExp(`(?<![-\\d.R])${NUM}\\s*(?:A|AMPS?)\\b`), m => positive(num(m[1]))); | |
| scan('mca', new RegExp(`\\bMCA\\b\\s*[:]?\\s*${NUM}`), m => positive(num(m[1]))); | |
| scan('mocp', new RegExp(`(?:\\bMOCP\\b|\\bMAX\\.?\\s*(?:FUSE|BREAKER|OCP))\\s*(?:SIZE)?\\s*[:]?\\s*${NUM}`), m => positive(num(m[1]))); | |
| // Data centre electrical: power factor and short-circuit/interrupting rating. | |
| scan('pf', /(?:POWER\s*FACTOR|\bP\.?F\b)\s*[:=]?\s*(0?[.,]\d{1,3}|1(?:[.,]0+)?)\b|(0?[.,]\d{1,3})\s*P\.?F\b/, m => inRange(num(m[1] || m[2]), 0.01, 1)); | |
| scan('sccr', new RegExp(`${NUM}\\s*KAIC\\b|(?:\\bSCCR\\b|SHORT[\\s-]?CIRCUIT|WITHSTAND|INTERRUPTING RATING|\\bKAIC\\b)[^0-9]{0,12}${NUM}\\s*(?:KA|K)?\\b`), m => inRange(num(m[1] || m[2]), 0.1, 1000)); | |
| // Enclosure rating: NEMA type or IEC IP code. | |
| scan('enclosure', /\bNEMA\s*(?:TYPE\s*)?(\d{1,2}[A-Z]?)\b|\bIP\s*(\d{2}[A-Z]{0,2})\b/, m => (m[1] ? `NEMA ${m[1]}` : `IP ${m[2]}`)); | |
| // Mechanical / cooling nameplates: refrigerant charge, capacity, airflow and flow. | |
| scan('charge', new RegExp(`CHARGE\\s*(?:WEIGHT)?\\s*[:]?\\s*${NUM}\\s*(?:LBS?|KG|KGS?|OZ)?\\b`), m => positive(num(m[1]))); | |
| scan('tons', new RegExp(`${NUM}\\s*TONS?\\b|\\bTONS?\\s*[:]?\\s*${NUM}`), m => positive(num(m[1] || m[2]))); | |
| scan('btu', new RegExp(`${NUM}\\s*(?:BTU\\/?H?R?|BTUH)\\b|\\bBTU\\/?H?R?\\s*[:]?\\s*${NUM}`), m => positive(num(m[1] || m[2]))); | |
| scan('cfm', new RegExp(`${NUM}\\s*CFM\\b|\\bCFM\\s*[:]?\\s*${NUM}`), m => positive(num(m[1] || m[2]))); | |
| scan('gpm', new RegExp(`${NUM}\\s*(?:GPM|LPM|L\\/S|M3\\/H)\\b|\\bGPM\\s*[:]?\\s*${NUM}`), m => positive(num(m[1] || m[2]))); | |
| scan('batteryAh', new RegExp(`${NUM}\\s*(?:AH|AMP[\\s-]?HOURS?)\\b`), m => positive(num(m[1]))); | |
| // Battery chemistry, cell count, rotating speed, rigging weight and listing marks. | |
| scan('batteryType', /\b(VRLA|AGM|GEL|FLOODED|LITHIUM[-\s]?ION|LI[-\s]?ION|LIFEPO4|NICD|NI[-\s]?CD|LEAD[-\s]?ACID|NICKEL[-\s]?CADMIUM|VALVE[-\s]?REGULATED)\b/, m => batteryTypeName(m[1])); | |
| scan('cells', new RegExp(`(?:CELLS?|CELL\\s*COUNT)\\s*[:]?\\s*${NUM}\\b|${NUM}\\s*CELLS?\\b`), m => inRange(num(m[1] || m[2]), 1, 1000)); | |
| scan('rpm', new RegExp(`${NUM}\\s*RPM\\b|\\bRPM\\s*[:]?\\s*${NUM}`), m => inRange(num(m[1] || m[2]), 1, 200000)); | |
| scan('weight', new RegExp(`(?:WEIGHT|NET\\s?WEIGHT|NET\\s?WT|MASS)\\s*[:]?\\s*${NUM}\\s*(LBS?|LB|KGS?|KG|TONS?)?\\b`), m => positive(weightKg(num(m[1]), m[2]))); | |
| scan('standards', /\b(UL\s?\d{3,5}[A-Z]?|IEC\s?\d{4,5}|ANSI\s?[A-Z]{1,3}[-\s]?\d{1,4}|IEEE\s?\d{1,4}|NFPA\s?\d{1,3}|CSA\s?[A-Z]?\d{1,4}|EN\s?\d{4,5})\b/, m => m[1].replace(/\s+/g, ' ').trim()); | |
| // Server racks and IT: enclosure U height, the equipment's own form factor, | |
| // power draw and static/dynamic load, the PDU outlet mix, and the network port | |
| // count and speed. Rack U also catches the "42RU" spelling; the form factor is | |
| // blade/tower or an explicitly labelled "FORM FACTOR 2U", so a server's model | |
| // suffix ("R650-2U") is its Rack U, not a second field. | |
| scan('uHeight', /\b(\d{1,2})\s*R?U\b/, m => { const v = +m[1]; return v >= 1 && v <= 60 ? v : null; }); | |
| scan('formFactor', /\b(?:FORM\s*FACTOR|CHASSIS|FORM)\s*[:]?\s*(\d{1,2})\s*U\b|\b(\d{1,2})\s*U\s*(?:SERVER|NODE|STORAGE|CHASSIS)\b/, m => `${m[1] || m[2]}U`); | |
| if (!fields.formFactor) scan('formFactor', /\b(BLADE|TOWER)\b/, m => m[1].toLowerCase()); | |
| scan('powerW', new RegExp(`(?:POWER|INPUT POWER|RATED POWER)\\s*[:]?\\s*${NUM}\\s*W\\b|(?<![KE])${NUM}\\s*W\\b`), m => positive(num(m[1] || m[2]))); | |
| scan('rackLoad', new RegExp(`(?:(?:STATIC|DYNAMIC|SEISMIC)\\s*(?:LOAD|RATING|CAPACITY)|(?:RACK|FLOOR|CABINET|EQUIPMENT)\\s*LOAD|LOAD\\s*(?:RATING|CAPACITY))[^0-9]{0,16}${NUM}\\s*(LBS?|LB|KGS?|KG|TONS?)?\\b`), m => inRange(weightKg(num(m[1]), m[2]), 1, 200000)); | |
| scan('outlets', new RegExp(`${NUM}\\s*(?:OUTLETS?|RECEPTACLES?)\\b|\\bOUTLETS?\\s*[:]?\\s*${NUM}`), m => inRange(num(m[1] || m[2]), 1, 10000)); | |
| scan('ports', new RegExp(`\\b(\\d{1,3})\\s*-?\\s*PORTS?\\b|\\bPORTS?\\s*[:]?\\s*${NUM}`), m => inRange(num(m[1] || m[2]), 1, 10000)); | |
| // The outlet mix and the port speed need the whole line: a count can sit on | |
| // either side of an outlet family ("24x C13" or "C13 x24"), and a switch's | |
| // speed is the fastest link, not the first number seen. | |
| for (const [i, l] of lines.entries()) { | |
| const mix = outletMix(up(l.text)); | |
| if (mix) { set('outletTypes', mix, l.conf, i); break; } | |
| } | |
| for (const [i, l] of lines.entries()) { | |
| const spd = portSpeed(up(l.text)); | |
| if (spd) { set('portSpeed', spd, l.conf, i); break; } | |
| } | |
| // Manufacturer: a known maker anywhere, else a line that reads like a company name. | |
| let makerLine = -1; | |
| 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); makerLine = 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); makerLine = i; } | |
| } | |
| // Equipment type from plate text, excluding the maker line: a vendor called | |
| // "…Transformer Corp" or "…Switchgear Ltd" must not type a chiller plate. | |
| const all = up(lines.filter((_, i) => i !== makerLine).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, | |
| pf: /^(pf|power factor)$/i, | |
| mca: /^(mca|min\.? circuit amp(s|acity)?|minimum circuit ampacity)$/i, | |
| mocp: /^(mocp|max\.? (fuse|breaker|ocp)( size)?|maximum overcurrent protection)$/i, | |
| sccr: /^(sccr|short.?circuit|withstand|interrupting rating|kaic)$/i, | |
| enclosure: /^(enclosure|nema|nema type|ip rating|enclosure type)$/i, | |
| standards: /^(standards?|listings?|approvals?|compliance|ul|iec|ansi)$/i, | |
| batteryType: /^(battery type|battery technology|battery chemistry|cell type)$/i, | |
| refrigerant: /^(refrigerant|ref\.?)$/i, | |
| charge: /^(charge|refrigerant charge|charge (lbs|kg))$/i, | |
| tons: /^(tons|tonnage|cooling \(tons\)|cooling tons|refrigeration tons|tr)$/i, | |
| btu: /^(btu|btuh|btu\/h|cooling \(btu\/?h\)|cooling btu)$/i, | |
| cfm: /^(cfm|airflow|air flow|air volume)$/i, | |
| gpm: /^(gpm|flow|water flow|flow rate)$/i, | |
| batteryAh: /^(ah|amp.?hours?|battery ah|battery capacity)$/i, | |
| cells: /^(cells?|cell count|battery cells)$/i, | |
| rpm: /^(rpm|speed|rated speed|r\/min)$/i, | |
| weight: /^(weight|net weight|mass|wt|weight \(kg\))$/i, | |
| uHeight: /^(u|u height|rack u|ru|height \(u\)|u size)$/i, | |
| formFactor: /^(form factor|ff|form|chassis|mounting|mount type)$/i, | |
| powerW: /^(power|power \(w\)|watts?|w|rated power|input power)$/i, | |
| rackLoad: /^(rack load|load|load (rating|capacity)|static load|dynamic load|floor load|seismic load|max load)$/i, | |
| outletTypes: /^(outlet (types?|mix|configuration)|receptacle types?|outlet arrangement)$/i, | |
| outlets: /^(outlets?|receptacles?|pdu outlets?|outlet count)$/i, | |
| portSpeed: /^(port speed|uplink speed|link speed|network speed|speed \(gbe\))$/i, | |
| ports: /^(ports?|port count|network ports?)$/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, pf: num(get('pf')) || null, mca: num(get('mca')) || null, mocp: num(get('mocp')) || null, | |
| sccr: num(get('sccr')) || null, enclosure: get('enclosure'), standards: get('standards'), batteryType: get('batteryType'), | |
| refrigerant: get('refrigerant'), charge: num(get('charge')) || null, tons: num(get('tons')) || null, btu: num(get('btu')) || null, | |
| cfm: num(get('cfm')) || null, gpm: num(get('gpm')) || null, batteryAh: num(get('batteryAh')) || null, | |
| cells: num(get('cells')) || null, rpm: num(get('rpm')) || null, weight: num(get('weight')) || null, | |
| uHeight: num(get('uHeight')) || null, formFactor: get('formFactor'), powerW: num(get('powerW')) || null, | |
| rackLoad: num(get('rackLoad')) || null, outletTypes: get('outletTypes'), | |
| outlets: num(get('outlets')) || null, portSpeed: num(get('portSpeed')) || null, | |
| ports: num(get('ports')) || null, 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; | |
| // A compatible voltage is a positive signal; mismatched voltages are only a | |
| // tie-breaker, so a UPS plate still prefers a UPS row over a same-voltage PDU. | |
| if (voltageCompatible(item, reading)) score += 0.1; | |
| } | |
| 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)); | |
| } | |
| /** True when the schedule row and the plate share at least one voltage. */ | |
| function voltageCompatible(item, reading) { | |
| if (!item.voltage) return false; | |
| const want = scheduleVoltages(item.voltage); | |
| const have = reading.fields?.voltage?.values || []; | |
| return want.length > 0 && have.length > 0 && want.some(v => have.includes(v)); | |
| } | |
| /** 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); | |
| if (item.pf) push('pf', item.pf, f.pf?.value, Math.abs(+f.pf?.value - item.pf) <= 0.02); | |
| for (const k of ['kva', 'kw', 'amps', 'mca', 'mocp', 'sccr', 'charge', 'tons', 'btu', 'cfm', 'gpm', 'batteryAh', 'cells', 'rpm', 'weight', 'uHeight', 'powerW', 'rackLoad', 'outlets', 'portSpeed', 'ports']) { | |
| 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.enclosure) push('enclosure', item.enclosure, f.enclosure?.value, enclosureMatch(item.enclosure, f.enclosure?.value)); | |
| if (item.batteryType) push('batteryType', item.batteryType, f.batteryType?.value, looseEq(item.batteryType, f.batteryType?.value)); | |
| if (item.standards) push('standards', item.standards, f.standards?.value, looseEq(item.standards, f.standards?.value)); | |
| if (item.formFactor) push('formFactor', item.formFactor, f.formFactor?.value, looseEq(item.formFactor, f.formFactor?.value)); | |
| if (item.outletTypes) push('outletTypes', item.outletTypes, f.outletTypes?.value, outletMix(up(item.outletTypes)) === outletMix(up(f.outletTypes?.value))); | |
| 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; | |
| } | |
| /** Two short strings equal ignoring case, punctuation and spacing ("IP55" vs "IP 55"). */ | |
| function looseEq(a, b) { | |
| const norm = s => up(s).replace(/[^A-Z0-9]/g, ''); | |
| const x = norm(a), y = norm(b); | |
| return !!x && !!y && (x === y || x.includes(y) || y.includes(x)); | |
| } | |
| /** "NEMA 3R" vs "3R", "IP55" vs "IP 55": compare the rating code, ignoring the label. */ | |
| function enclosureMatch(a, b) { | |
| const norm = s => up(s).replace(/\bNEMA\b|\bTYPE\b|\bIP\b/g, '').replace(/[^A-Z0-9]/g, ''); | |
| const x = norm(a), y = norm(b); | |
| return !!x && !!y && (x === y || x.includes(y) || y.includes(x)); | |
| } | |
| /** 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', 'PF', 'MCA', 'MOCP', 'SCCR', 'Enclosure', 'Listings', 'Battery type', 'Battery Ah', 'Battery cells', | |
| 'Refrigerant', 'Charge', 'Cooling (tons)', 'Cooling (BTU/h)', 'Airflow (CFM)', 'Flow (GPM)', 'Speed (RPM)', | |
| 'Weight (kg)', 'Rack U', 'Form factor', 'Power (W)', 'Rack load (kg)', 'Outlet mix', 'Outlets', 'Port speed (GbE)', | |
| 'Ports', '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('pf'), | |
| v('mca'), v('mocp'), v('sccr'), v('enclosure'), v('standards'), v('batteryType'), v('batteryAh'), v('cells'), | |
| v('refrigerant'), v('charge'), v('tons'), v('btu'), v('cfm'), v('gpm'), v('rpm'), v('weight'), v('uHeight'), v('formFactor'), v('powerW'), v('rackLoad'), v('outletTypes'), v('outlets'), v('portSpeed'), v('ports'), 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.pf ?? '', i.mca ?? '', i.mocp ?? '', i.sccr ?? '', i.enclosure ?? '', | |
| i.standards ?? '', i.batteryType ?? '', i.batteryAh ?? '', i.cells ?? '', i.refrigerant, i.charge ?? '', i.tons ?? '', | |
| i.btu ?? '', i.cfm ?? '', i.gpm ?? '', i.rpm ?? '', i.weight ?? '', i.uHeight ?? '', i.formFactor ?? '', i.powerW ?? '', | |
| i.rackLoad ?? '', i.outletTypes ?? '', i.outlets ?? '', i.portSpeed ?? '', | |
| i.ports ?? '', '', '', '']); | |
| } | |
| 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 | |
| `; | |
| /** 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'], | |
| // A few more data centre plates so the sample shows the wider equipment set. | |
| ['IRONSIDE SWITCHGEAR CORP', 'SWITCHBOARD', 'MODEL NO: IS-4000-MDP', 'SERIAL NO: IS26C01188', | |
| '480Y/277 V 3 PH 60 HZ', '1000 KVA MAIN BUS', 'SCCR 65 KAIC', 'NEMA 3R MFG DATE: 05/2026'], | |
| ['COLDLINE SYSTEMS', 'COMPUTER ROOM AIR HANDLER', 'MODEL NO: CW-150-480', 'SERIAL NO: CL6600172', | |
| '480 V 3 PH 60 HZ', 'FLA 38.5 MCA 45 MOCP 60', 'AIRFLOW 12000 CFM', 'MFG DATE: 01/2026'], | |
| ['HARBOR ENGINE WORKS INC', 'DIESEL GENERATOR SET', 'MODEL: HG-3000', 'S/N: HEW0098812', | |
| '13.8 KV 3 PHASE 60 HZ', '3750 KVA 3000 EKW', '0.8 PF', 'BUILD DATE 2026-01'], | |
| ['POLARIS THERMAL', 'COOLING TOWER', 'MODEL NO: PT-CT-900', 'SERIAL NO: PT26D00417', | |
| '460 V 3 PH 60 HZ', 'MCA 86 MOCP 110', 'FLOW 2400 GPM', 'MFG DATE: 07/2026'], | |
| ['DELL EMC', 'POWEREDGE R650 SERVER', 'MODEL NO: R650-2U', 'SERIAL NO: DL26R00442', | |
| '480 V 3 PH 60 HZ', '800 W 12.5 A', 'AIRFLOW 120 CFM', 'MFG DATE: 06/2026'], | |
| ['VERTIV', 'RACK PDU', 'MODEL NO: MPX-30-C13', 'SERIAL NO: VR26P00117', | |
| '400 V 3 PH 60 HZ', '30 A 5.5 KW', '24 OUTLETS (12x C13, 4x C19)', 'MFG DATE: 08/2026'], | |
| ['CISCO', 'CATALYST NETWORK SWITCH', 'MODEL NO: C9300-48P', 'SERIAL NO: FOC26X0011', | |
| '230 V 3 PH 50 HZ', '48-PORT POE 1100 W', 'UPLINK 25G', 'MFG DATE: 09/2026'], | |
| ['NEXUS COMPUTE', 'BLADE SERVER CHASSIS', 'MODEL NO: BX-7000', 'SERIAL NO: NC26B00123', | |
| 'FORM FACTOR 8U', '480 V 3 PH 60 HZ', 'STATIC LOAD 1360 KG', 'MFG DATE: 05/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()); } | |
| /** Canonical battery chemistry from the many ways plates spell it. */ | |
| function batteryTypeName(s) { | |
| const t = up(s).replace(/\s+/g, ' '); | |
| if (/LIFEPO4/.test(t)) return 'LiFePO4'; | |
| if (/LITHIUM|LI[-\s]?ION/.test(t)) return 'Lithium-ion'; | |
| if (/NICD|NICKEL/.test(t)) return 'NiCd'; | |
| if (/LEAD/.test(t)) return 'Lead-acid'; | |
| if (/VRLA|VALVE/.test(t)) return 'VRLA'; | |
| if (/AGM/.test(t)) return 'AGM'; | |
| if (/GEL/.test(t)) return 'Gel'; | |
| if (/FLOODED/.test(t)) return 'Flooded'; | |
| return titleCase(s); | |
| } | |
| /** Weight normalised to kilograms so a lb plate and a kg schedule compare. */ | |
| function weightKg(v, unit) { | |
| if (v == null) return null; | |
| const u = up(unit || 'KG'); | |
| if (/^LB/.test(u)) return Math.round(v * 0.45359237 * 10) / 10; | |
| if (/^TON/.test(u)) return Math.round(v * 907.18474 * 10) / 10; | |
| return v; | |
| } | |
| /** Canonical PDU outlet mix from the ways plates write it: "24x C13, 6x C19" -> "C13x24 C19x6". */ | |
| function outletMix(t) { | |
| const counts = new Map(); | |
| let m; | |
| const pairA = /\b(\d{1,3})\s*[X\u00d7]\s*(C\d{1,2})\b/g; // 24x C13 | |
| while ((m = pairA.exec(t))) { const n = +m[1]; if (n >= 1 && n <= 1000) counts.set(m[2], (counts.get(m[2]) || 0) + n); } | |
| const pairB = /\b(C\d{1,2})\s*[X\u00d7]\s*(\d{1,3})\b/g; // C13 x24 | |
| while ((m = pairB.exec(t))) { const n = +m[2]; if (n >= 1 && n <= 1000) counts.set(m[1], (counts.get(m[1]) || 0) + n); } | |
| if (!counts.size && !/OUTLET|RECEPTACLE/.test(t)) return null; // a "C13" in a model code is not an outlet mix | |
| const bare = /\b(C\d{1,2})\b/g; | |
| while ((m = bare.exec(t))) if (!counts.has(m[1])) counts.set(m[1], 0); | |
| return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([f, n]) => (n > 0 ? `${f}x${n}` : f)).join(' '); | |
| } | |
| /** Fastest link speed on a line, in GbE: "48-port 10G", "25/100GbE" -> 100. */ | |
| function portSpeed(t) { | |
| let max = 0, m; | |
| const re = /\b(1|10|25|40|50|100|200|400)\s*G(?:BE|BPS|BASE-T|IGABIT|IGE)?\b/g; | |
| while ((m = re.exec(t))) max = Math.max(max, +m[1]); | |
| return max || null; | |
| } | |
| 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]; | |
| } | |