Image-to-Text
English
custom
ocr
document-ai
information-extraction
key-information-extraction
nameplate
nameplate-ocr
equipment-nameplate
data-center
data-centre
datacenter
mission-critical
mep
electrical
mechanical
equipment-schedule
schedule-verification
asset-register
commissioning
quality-assurance
ups
pdu
switchgear
generator
ats
transformer
busway
battery
crah
crac
chiller
cooling
server-rack
construction
aec
edge-ai
on-device
privacy-preserving
tesseract
browser
Eval Results (legacy)
Data Centre Construction: rename + frontier-construction-AI positioning
Browse files- README.md +19 -10
- config.json +4 -4
- nameplate-model.js +66 -10
- sample_plates.json +32 -2
- tests/nameplate-model.test.mjs +55 -1
README.md
CHANGED
|
@@ -46,11 +46,15 @@ tags:
|
|
| 46 |
- browser
|
| 47 |
---
|
| 48 |
|
| 49 |
-
# Data Centre
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
**Read an equipment nameplate and know if it is the unit the schedule asked for.** Point a phone at the
|
| 52 |
nameplate of a UPS, PDU, switchgear, generator, transformer, busway, battery, CRAH, chiller — or a **server
|
| 53 |
-
rack** — and the engine returns the plate's fields (
|
| 54 |
Server racks / Mechanical) and **checks each one against the equipment schedule**, flagging a mismatch instead
|
| 55 |
of a silent pass. It builds an asset register and exports it to CSV.
|
| 56 |
|
|
@@ -66,7 +70,7 @@ delivered at the wrong voltage or rating, or one that quietly never makes it ont
|
|
| 66 |
| | |
|
| 67 |
|---|---|
|
| 68 |
| **Task** | Image → structured nameplate fields → schedule verification |
|
| 69 |
-
| **Fields** | **
|
| 70 |
| **Equipment** | **55 types**, device wording ranked above family (see below) |
|
| 71 |
| **OCR** | Tesseract.js 5 (English LSTM), pretrained — no fine-tuning |
|
| 72 |
| **Extraction** | Deterministic labelled-field parser + OCR error model + numeric range guards |
|
|
@@ -120,7 +124,7 @@ The result is a pass/mismatch verdict per plate, with the fields that disagree n
|
|
| 120 |
6. **Field checks** compare the reading to the chosen row (`ok` / `mismatch` / `unread` per field) and return
|
| 121 |
an overall status: `verified`, `partial`, `mismatch`, `unmatched` or `missing` (scheduled but not scanned).
|
| 122 |
|
| 123 |
-
## Fields extracted (
|
| 124 |
|
| 125 |
**Identification** — `manufacturer`, `model`, `serial`, `mfgDate`
|
| 126 |
|
|
@@ -142,7 +146,9 @@ The result is a pass/mismatch verdict per plate, with the fields that disagree n
|
|
| 142 |
|
| 143 |
**Cooling** — `refrigerant`, `charge`, `tons`, `btu`, `cfm`, `gpm`
|
| 144 |
|
| 145 |
-
**Server racks / IT** — `uHeight` (rack U
|
|
|
|
|
|
|
| 146 |
|
| 147 |
**Mechanical** — `rpm`, `weight` (normalised to kg)
|
| 148 |
|
|
@@ -163,9 +169,11 @@ IT & white space: **rack** · **server/storage** · network gear · KVM/console
|
|
| 163 |
## Server racks — a first-class subsection
|
| 164 |
|
| 165 |
Racks, rack PDUs, switches and in-row cooling are what the electrical and cooling design actually serves, so
|
| 166 |
-
they are treated as data-centre equipment, not an afterthought: `Rack U`, `
|
| 167 |
-
`
|
| 168 |
-
|
|
|
|
|
|
|
| 169 |
|
| 170 |
## Results
|
| 171 |
|
|
@@ -311,7 +319,8 @@ keep OCR confidence and the plate line each value came from.
|
|
| 311 |
## Provenance
|
| 312 |
|
| 313 |
An open extraction and verification engine from Constructelligence, the same code path that powers the
|
| 314 |
-
**
|
|
|
|
| 315 |
It is the document-AI sibling of the vision models
|
| 316 |
[`construction-site-safety-hazards`](https://huggingface.co/constructelligence/construction-site-safety-hazards)
|
| 317 |
and [`electrical-circuit-connectivity`](https://huggingface.co/constructelligence/electrical-circuit-connectivity).
|
|
@@ -332,7 +341,7 @@ this project is independent and not affiliated with or endorsed by them.
|
|
| 332 |
|
| 333 |
```bibtex
|
| 334 |
@misc{constructelligence_nameplates,
|
| 335 |
-
title = {Data Centre
|
| 336 |
author = {Constructelligence},
|
| 337 |
year = {2026},
|
| 338 |
howpublished = {\url{https://huggingface.co/constructelligence/data-centre-nameplates}}
|
|
|
|
| 46 |
- browser
|
| 47 |
---
|
| 48 |
|
| 49 |
+
# Data Centre Construction — equipment-nameplate OCR checked against the equipment schedule
|
| 50 |
+
|
| 51 |
+
> **Constructelligence is developing frontier construction-AI models.** This kit is the *lite*, open tier —
|
| 52 |
+
> released for research and evaluation; the company's flagship production models are a separate, larger
|
| 53 |
+
> tier (see [constructelligence.co](https://constructelligence.co)).
|
| 54 |
|
| 55 |
**Read an equipment nameplate and know if it is the unit the schedule asked for.** Point a phone at the
|
| 56 |
nameplate of a UPS, PDU, switchgear, generator, transformer, busway, battery, CRAH, chiller — or a **server
|
| 57 |
+
rack** — and the engine returns the plate's fields (35 of them, grouped Identification / Electrical / Cooling /
|
| 58 |
Server racks / Mechanical) and **checks each one against the equipment schedule**, flagging a mismatch instead
|
| 59 |
of a silent pass. It builds an asset register and exports it to CSV.
|
| 60 |
|
|
|
|
| 70 |
| | |
|
| 71 |
|---|---|
|
| 72 |
| **Task** | Image → structured nameplate fields → schedule verification |
|
| 73 |
+
| **Fields** | **35**, in five groups (Identification, Electrical, Cooling, Server racks/IT, Mechanical) |
|
| 74 |
| **Equipment** | **55 types**, device wording ranked above family (see below) |
|
| 75 |
| **OCR** | Tesseract.js 5 (English LSTM), pretrained — no fine-tuning |
|
| 76 |
| **Extraction** | Deterministic labelled-field parser + OCR error model + numeric range guards |
|
|
|
|
| 124 |
6. **Field checks** compare the reading to the chosen row (`ok` / `mismatch` / `unread` per field) and return
|
| 125 |
an overall status: `verified`, `partial`, `mismatch`, `unmatched` or `missing` (scheduled but not scanned).
|
| 126 |
|
| 127 |
+
## Fields extracted (35)
|
| 128 |
|
| 129 |
**Identification** — `manufacturer`, `model`, `serial`, `mfgDate`
|
| 130 |
|
|
|
|
| 146 |
|
| 147 |
**Cooling** — `refrigerant`, `charge`, `tons`, `btu`, `cfm`, `gpm`
|
| 148 |
|
| 149 |
+
**Server racks / IT** — `uHeight` (rack U, incl. "42RU"), `formFactor` (blade / tower / a labelled "2U"),
|
| 150 |
+
`powerW`, `rackLoad` (static/dynamic load, kg), `outletTypes` (canonical outlet mix, e.g. `C13x12 C19x4`),
|
| 151 |
+
`outlets`, `portSpeed` (fastest link, GbE), `ports`
|
| 152 |
|
| 153 |
**Mechanical** — `rpm`, `weight` (normalised to kg)
|
| 154 |
|
|
|
|
| 169 |
## Server racks — a first-class subsection
|
| 170 |
|
| 171 |
Racks, rack PDUs, switches and in-row cooling are what the electrical and cooling design actually serves, so
|
| 172 |
+
they are treated as data-centre equipment, not an afterthought: `Rack U`, `Form factor`, `Power (W)`,
|
| 173 |
+
`Rack load (kg)`, `Outlet mix`, `PDU outlets`, `Port speed (GbE)` and `Ports` are extracted and checked like any
|
| 174 |
+
other field, and the equipment set covers rack/enclosure, server, network, KVM and rack PDU. Sample plates
|
| 175 |
+
include a PowerEdge-class server, a rack PDU with a C13/C19 outlet mix, a 25G switch and a blade chassis with a
|
| 176 |
+
static floor load.
|
| 177 |
|
| 178 |
## Results
|
| 179 |
|
|
|
|
| 319 |
## Provenance
|
| 320 |
|
| 321 |
An open extraction and verification engine from Constructelligence, the same code path that powers the
|
| 322 |
+
**Data Centre Construction** mode of BuildVision. No customer data is used; sample plates use fictional
|
| 323 |
+
makers and models.
|
| 324 |
It is the document-AI sibling of the vision models
|
| 325 |
[`construction-site-safety-hazards`](https://huggingface.co/constructelligence/construction-site-safety-hazards)
|
| 326 |
and [`electrical-circuit-connectivity`](https://huggingface.co/constructelligence/electrical-circuit-connectivity).
|
|
|
|
| 341 |
|
| 342 |
```bibtex
|
| 343 |
@misc{constructelligence_nameplates,
|
| 344 |
+
title = {Data Centre Construction: equipment-nameplate OCR checked against the equipment schedule},
|
| 345 |
author = {Constructelligence},
|
| 346 |
year = {2026},
|
| 347 |
howpublished = {\url{https://huggingface.co/constructelligence/data-centre-nameplates}}
|
config.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
{
|
| 2 |
"name": "data-centre-nameplates",
|
| 3 |
-
"version": "2.
|
| 4 |
"task": "image-to-text",
|
| 5 |
"modality": "image",
|
| 6 |
"language": ["en"],
|
| 7 |
-
"summary": "Reads an equipment nameplate (OCR) and checks it against an equipment schedule for data-centre MEP and IT gear across 55 equipment types, including a server-rack subsection (rack U
|
| 8 |
"engine": {
|
| 9 |
"ocr": "tesseract.js 5 (LSTM English)",
|
| 10 |
"extraction": "deterministic labelled-field parser + OCR error model + numeric range guards",
|
|
@@ -16,14 +16,14 @@
|
|
| 16 |
"id": ["manufacturer", "model", "serial", "mfgDate"],
|
| 17 |
"electrical": ["voltage", "phase", "hz", "kva", "kw", "amps", "pf", "mca", "mocp", "sccr", "enclosure", "standards", "batteryType", "batteryAh", "cells"],
|
| 18 |
"cooling": ["refrigerant", "charge", "tons", "btu", "cfm", "gpm"],
|
| 19 |
-
"rack": ["uHeight", "powerW", "outlets", "ports"],
|
| 20 |
"mechanical": ["rpm", "weight"]
|
| 21 |
},
|
| 22 |
"fields": [
|
| 23 |
"manufacturer", "model", "serial", "mfgDate",
|
| 24 |
"voltage", "phase", "hz", "kva", "kw", "amps", "pf", "mca", "mocp", "sccr", "enclosure", "standards", "batteryType", "batteryAh", "cells",
|
| 25 |
"refrigerant", "charge", "tons", "btu", "cfm", "gpm",
|
| 26 |
-
"uHeight", "powerW", "outlets", "ports",
|
| 27 |
"rpm", "weight"
|
| 28 |
],
|
| 29 |
"equipment_types": [
|
|
|
|
| 1 |
{
|
| 2 |
"name": "data-centre-nameplates",
|
| 3 |
+
"version": "2.1",
|
| 4 |
"task": "image-to-text",
|
| 5 |
"modality": "image",
|
| 6 |
"language": ["en"],
|
| 7 |
+
"summary": "Reads an equipment nameplate (OCR) and checks it against an equipment schedule for data-centre MEP and IT gear across 55 equipment types, including a server-rack subsection (rack U and form factor, power and static/dynamic load, PDU outlet mix, network ports and link speed).",
|
| 8 |
"engine": {
|
| 9 |
"ocr": "tesseract.js 5 (LSTM English)",
|
| 10 |
"extraction": "deterministic labelled-field parser + OCR error model + numeric range guards",
|
|
|
|
| 16 |
"id": ["manufacturer", "model", "serial", "mfgDate"],
|
| 17 |
"electrical": ["voltage", "phase", "hz", "kva", "kw", "amps", "pf", "mca", "mocp", "sccr", "enclosure", "standards", "batteryType", "batteryAh", "cells"],
|
| 18 |
"cooling": ["refrigerant", "charge", "tons", "btu", "cfm", "gpm"],
|
| 19 |
+
"rack": ["uHeight", "formFactor", "powerW", "rackLoad", "outletTypes", "outlets", "portSpeed", "ports"],
|
| 20 |
"mechanical": ["rpm", "weight"]
|
| 21 |
},
|
| 22 |
"fields": [
|
| 23 |
"manufacturer", "model", "serial", "mfgDate",
|
| 24 |
"voltage", "phase", "hz", "kva", "kw", "amps", "pf", "mca", "mocp", "sccr", "enclosure", "standards", "batteryType", "batteryAh", "cells",
|
| 25 |
"refrigerant", "charge", "tons", "btu", "cfm", "gpm",
|
| 26 |
+
"uHeight", "formFactor", "powerW", "rackLoad", "outletTypes", "outlets", "portSpeed", "ports",
|
| 27 |
"rpm", "weight"
|
| 28 |
],
|
| 29 |
"equipment_types": [
|
nameplate-model.js
CHANGED
|
@@ -23,7 +23,10 @@ export const FIELDS = [
|
|
| 23 |
['refrigerant', 'Refrigerant', 'cooling'], ['charge', 'Refrigerant charge', 'cooling'], ['tons', 'Cooling (tons)', 'cooling'],
|
| 24 |
['btu', 'Cooling (BTU/h)', 'cooling'], ['cfm', 'Airflow (CFM)', 'cooling'], ['gpm', 'Flow (GPM)', 'cooling'],
|
| 25 |
// Server racks and IT.
|
| 26 |
-
['uHeight', 'Rack U', 'rack'], ['
|
|
|
|
|
|
|
|
|
|
| 27 |
// Mechanical.
|
| 28 |
['rpm', 'Speed (RPM)', 'mechanical'], ['weight', 'Weight (kg)', 'mechanical'],
|
| 29 |
];
|
|
@@ -207,12 +210,31 @@ export function parseNameplate(input) {
|
|
| 207 |
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])));
|
| 208 |
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());
|
| 209 |
|
| 210 |
-
// Server racks
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
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])));
|
|
|
|
| 213 |
scan('outlets', new RegExp(`${NUM}\\s*(?:OUTLETS?|RECEPTACLES?)\\b|\\bOUTLETS?\\s*[:]?\\s*${NUM}`), m => inRange(num(m[1] || m[2]), 1, 10000));
|
| 214 |
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));
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
// Manufacturer: a known maker anywhere, else a line that reads like a company name.
|
| 217 |
let makerLine = -1;
|
| 218 |
for (const [i, l] of lines.entries()) {
|
|
@@ -312,8 +334,12 @@ const HEADERS = {
|
|
| 312 |
rpm: /^(rpm|speed|rated speed|r\/min)$/i,
|
| 313 |
weight: /^(weight|net weight|mass|wt|weight \(kg\))$/i,
|
| 314 |
uHeight: /^(u|u height|rack u|ru|height \(u\)|u size)$/i,
|
|
|
|
| 315 |
powerW: /^(power|power \(w\)|watts?|w|rated power|input power)$/i,
|
|
|
|
|
|
|
| 316 |
outlets: /^(outlets?|receptacles?|pdu outlets?|outlet count)$/i,
|
|
|
|
| 317 |
ports: /^(ports?|port count|network ports?)$/i,
|
| 318 |
location: /^(location|room|area|level|space|served from|zone)$/i,
|
| 319 |
};
|
|
@@ -340,8 +366,10 @@ export function parseEquipmentSchedule(text) {
|
|
| 340 |
refrigerant: get('refrigerant'), charge: num(get('charge')) || null, tons: num(get('tons')) || null, btu: num(get('btu')) || null,
|
| 341 |
cfm: num(get('cfm')) || null, gpm: num(get('gpm')) || null, batteryAh: num(get('batteryAh')) || null,
|
| 342 |
cells: num(get('cells')) || null, rpm: num(get('rpm')) || null, weight: num(get('weight')) || null,
|
| 343 |
-
uHeight: num(get('uHeight')) || null, powerW: num(get('powerW')) || null,
|
| 344 |
-
|
|
|
|
|
|
|
| 345 |
});
|
| 346 |
});
|
| 347 |
if (!items.length) problems.push('The header was found but no equipment rows under it.');
|
|
@@ -440,12 +468,14 @@ export function checkAgainst(reading, item) {
|
|
| 440 |
}
|
| 441 |
if (item.phase) push('phase', item.phase, f.phase?.value, +item.phase === +f.phase?.value);
|
| 442 |
if (item.pf) push('pf', item.pf, f.pf?.value, Math.abs(+f.pf?.value - item.pf) <= 0.02);
|
| 443 |
-
for (const k of ['kva', 'kw', 'amps', 'mca', 'mocp', 'sccr', 'charge', 'tons', 'btu', 'cfm', 'gpm', 'batteryAh', 'cells', 'rpm', 'weight', 'uHeight', 'powerW', 'outlets', 'ports']) {
|
| 444 |
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));
|
| 445 |
}
|
| 446 |
if (item.enclosure) push('enclosure', item.enclosure, f.enclosure?.value, enclosureMatch(item.enclosure, f.enclosure?.value));
|
| 447 |
if (item.batteryType) push('batteryType', item.batteryType, f.batteryType?.value, looseEq(item.batteryType, f.batteryType?.value));
|
| 448 |
if (item.standards) push('standards', item.standards, f.standards?.value, looseEq(item.standards, f.standards?.value));
|
|
|
|
|
|
|
| 449 |
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, ''));
|
| 450 |
return out;
|
| 451 |
}
|
|
@@ -505,7 +535,8 @@ export function registerCsv(register, items = []) {
|
|
| 505 |
const head = ['Tag', 'Status', 'Type', 'Location', 'Manufacturer', 'Model', 'Serial', 'Voltage', 'Phase', 'Hz', 'kVA', 'kW',
|
| 506 |
'Amps', 'PF', 'MCA', 'MOCP', 'SCCR', 'Enclosure', 'Listings', 'Battery type', 'Battery Ah', 'Battery cells',
|
| 507 |
'Refrigerant', 'Charge', 'Cooling (tons)', 'Cooling (BTU/h)', 'Airflow (CFM)', 'Flow (GPM)', 'Speed (RPM)',
|
| 508 |
-
'Weight (kg)', 'Rack U', 'Power (W)', '
|
|
|
|
| 509 |
const byTag = new Map(items.map(i => [i.tag, i]));
|
| 510 |
const typeLabel = k => (EQUIPMENT_TYPES.find(t => t[0] === k) || [])[1] || '';
|
| 511 |
const dups = new Set(duplicateSerials(register).flat());
|
|
@@ -517,7 +548,7 @@ export function registerCsv(register, items = []) {
|
|
| 517 |
return [r.tag || '', STATUS_LABELS[r.status] || r.status, typeLabel(r.type), byTag.get(r.tag)?.location || '',
|
| 518 |
v('manufacturer'), v('model'), v('serial'), v('voltage'), v('phase'), v('hz'), v('kva'), v('kw'), v('amps'), v('pf'),
|
| 519 |
v('mca'), v('mocp'), v('sccr'), v('enclosure'), v('standards'), v('batteryType'), v('batteryAh'), v('cells'),
|
| 520 |
-
v('refrigerant'), v('charge'), v('tons'), v('btu'), v('cfm'), v('gpm'), v('rpm'), v('weight'), v('uHeight'), v('powerW'), v('outlets'), v('ports'), v('mfgDate'),
|
| 521 |
issues.join('; '), r.scannedAt ? new Date(r.scannedAt).toISOString().slice(0, 16).replace('T', ' ') : ''];
|
| 522 |
});
|
| 523 |
const tags = new Set(register.map(r => r.tag));
|
|
@@ -526,7 +557,8 @@ export function registerCsv(register, items = []) {
|
|
| 526 |
rows.push([i.tag, STATUS_LABELS.missing, i.type, i.location, i.manufacturer, i.model, i.serial, i.voltage, i.phase ?? '',
|
| 527 |
'', i.kva ?? '', i.kw ?? '', i.amps ?? '', i.pf ?? '', i.mca ?? '', i.mocp ?? '', i.sccr ?? '', i.enclosure ?? '',
|
| 528 |
i.standards ?? '', i.batteryType ?? '', i.batteryAh ?? '', i.cells ?? '', i.refrigerant, i.charge ?? '', i.tons ?? '',
|
| 529 |
-
i.btu ?? '', i.cfm ?? '', i.gpm ?? '', i.rpm ?? '', i.weight ?? '', i.uHeight ?? '', i.
|
|
|
|
| 530 |
i.ports ?? '', '', '', '']);
|
| 531 |
}
|
| 532 |
return [head, ...rows].map(r => r.map(csvCell).join(',')).join('\n') + '\n';
|
|
@@ -567,7 +599,11 @@ export const SAMPLE_PLATES = [
|
|
| 567 |
['DELL EMC', 'POWEREDGE R650 SERVER', 'MODEL NO: R650-2U', 'SERIAL NO: DL26R00442',
|
| 568 |
'480 V 3 PH 60 HZ', '800 W 12.5 A', 'AIRFLOW 120 CFM', 'MFG DATE: 06/2026'],
|
| 569 |
['VERTIV', 'RACK PDU', 'MODEL NO: MPX-30-C13', 'SERIAL NO: VR26P00117',
|
| 570 |
-
'400 V 3 PH 60 HZ', '30 A 5.5 KW', '24 OUTLETS', 'MFG DATE: 08/2026'],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
];
|
| 572 |
|
| 573 |
// ------------------------------------------------------------------ helpers
|
|
@@ -597,6 +633,26 @@ function weightKg(v, unit) {
|
|
| 597 |
if (/^TON/.test(u)) return Math.round(v * 907.18474 * 10) / 10;
|
| 598 |
return v;
|
| 599 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
function csvCell(v) { const s = String(v ?? ''); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }
|
| 601 |
|
| 602 |
export function levenshtein(a, b) {
|
|
|
|
| 23 |
['refrigerant', 'Refrigerant', 'cooling'], ['charge', 'Refrigerant charge', 'cooling'], ['tons', 'Cooling (tons)', 'cooling'],
|
| 24 |
['btu', 'Cooling (BTU/h)', 'cooling'], ['cfm', 'Airflow (CFM)', 'cooling'], ['gpm', 'Flow (GPM)', 'cooling'],
|
| 25 |
// Server racks and IT.
|
| 26 |
+
['uHeight', 'Rack U', 'rack'], ['formFactor', 'Form factor', 'rack'],
|
| 27 |
+
['powerW', 'Power (W)', 'rack'], ['rackLoad', 'Rack load (kg)', 'rack'],
|
| 28 |
+
['outletTypes', 'Outlet mix', 'rack'], ['outlets', 'PDU outlets', 'rack'],
|
| 29 |
+
['portSpeed', 'Port speed (GbE)', 'rack'], ['ports', 'Ports', 'rack'],
|
| 30 |
// Mechanical.
|
| 31 |
['rpm', 'Speed (RPM)', 'mechanical'], ['weight', 'Weight (kg)', 'mechanical'],
|
| 32 |
];
|
|
|
|
| 210 |
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])));
|
| 211 |
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());
|
| 212 |
|
| 213 |
+
// Server racks and IT: enclosure U height, the equipment's own form factor,
|
| 214 |
+
// power draw and static/dynamic load, the PDU outlet mix, and the network port
|
| 215 |
+
// count and speed. Rack U also catches the "42RU" spelling; the form factor is
|
| 216 |
+
// blade/tower or an explicitly labelled "FORM FACTOR 2U", so a server's model
|
| 217 |
+
// suffix ("R650-2U") is its Rack U, not a second field.
|
| 218 |
+
scan('uHeight', /\b(\d{1,2})\s*R?U\b/, m => { const v = +m[1]; return v >= 1 && v <= 60 ? v : null; });
|
| 219 |
+
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`);
|
| 220 |
+
if (!fields.formFactor) scan('formFactor', /\b(BLADE|TOWER)\b/, m => m[1].toLowerCase());
|
| 221 |
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])));
|
| 222 |
+
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));
|
| 223 |
scan('outlets', new RegExp(`${NUM}\\s*(?:OUTLETS?|RECEPTACLES?)\\b|\\bOUTLETS?\\s*[:]?\\s*${NUM}`), m => inRange(num(m[1] || m[2]), 1, 10000));
|
| 224 |
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));
|
| 225 |
|
| 226 |
+
// The outlet mix and the port speed need the whole line: a count can sit on
|
| 227 |
+
// either side of an outlet family ("24x C13" or "C13 x24"), and a switch's
|
| 228 |
+
// speed is the fastest link, not the first number seen.
|
| 229 |
+
for (const [i, l] of lines.entries()) {
|
| 230 |
+
const mix = outletMix(up(l.text));
|
| 231 |
+
if (mix) { set('outletTypes', mix, l.conf, i); break; }
|
| 232 |
+
}
|
| 233 |
+
for (const [i, l] of lines.entries()) {
|
| 234 |
+
const spd = portSpeed(up(l.text));
|
| 235 |
+
if (spd) { set('portSpeed', spd, l.conf, i); break; }
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
// Manufacturer: a known maker anywhere, else a line that reads like a company name.
|
| 239 |
let makerLine = -1;
|
| 240 |
for (const [i, l] of lines.entries()) {
|
|
|
|
| 334 |
rpm: /^(rpm|speed|rated speed|r\/min)$/i,
|
| 335 |
weight: /^(weight|net weight|mass|wt|weight \(kg\))$/i,
|
| 336 |
uHeight: /^(u|u height|rack u|ru|height \(u\)|u size)$/i,
|
| 337 |
+
formFactor: /^(form factor|ff|form|chassis|mounting|mount type)$/i,
|
| 338 |
powerW: /^(power|power \(w\)|watts?|w|rated power|input power)$/i,
|
| 339 |
+
rackLoad: /^(rack load|load|load (rating|capacity)|static load|dynamic load|floor load|seismic load|max load)$/i,
|
| 340 |
+
outletTypes: /^(outlet (types?|mix|configuration)|receptacle types?|outlet arrangement)$/i,
|
| 341 |
outlets: /^(outlets?|receptacles?|pdu outlets?|outlet count)$/i,
|
| 342 |
+
portSpeed: /^(port speed|uplink speed|link speed|network speed|speed \(gbe\))$/i,
|
| 343 |
ports: /^(ports?|port count|network ports?)$/i,
|
| 344 |
location: /^(location|room|area|level|space|served from|zone)$/i,
|
| 345 |
};
|
|
|
|
| 366 |
refrigerant: get('refrigerant'), charge: num(get('charge')) || null, tons: num(get('tons')) || null, btu: num(get('btu')) || null,
|
| 367 |
cfm: num(get('cfm')) || null, gpm: num(get('gpm')) || null, batteryAh: num(get('batteryAh')) || null,
|
| 368 |
cells: num(get('cells')) || null, rpm: num(get('rpm')) || null, weight: num(get('weight')) || null,
|
| 369 |
+
uHeight: num(get('uHeight')) || null, formFactor: get('formFactor'), powerW: num(get('powerW')) || null,
|
| 370 |
+
rackLoad: num(get('rackLoad')) || null, outletTypes: get('outletTypes'),
|
| 371 |
+
outlets: num(get('outlets')) || null, portSpeed: num(get('portSpeed')) || null,
|
| 372 |
+
ports: num(get('ports')) || null, location: get('location'),
|
| 373 |
});
|
| 374 |
});
|
| 375 |
if (!items.length) problems.push('The header was found but no equipment rows under it.');
|
|
|
|
| 468 |
}
|
| 469 |
if (item.phase) push('phase', item.phase, f.phase?.value, +item.phase === +f.phase?.value);
|
| 470 |
if (item.pf) push('pf', item.pf, f.pf?.value, Math.abs(+f.pf?.value - item.pf) <= 0.02);
|
| 471 |
+
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']) {
|
| 472 |
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));
|
| 473 |
}
|
| 474 |
if (item.enclosure) push('enclosure', item.enclosure, f.enclosure?.value, enclosureMatch(item.enclosure, f.enclosure?.value));
|
| 475 |
if (item.batteryType) push('batteryType', item.batteryType, f.batteryType?.value, looseEq(item.batteryType, f.batteryType?.value));
|
| 476 |
if (item.standards) push('standards', item.standards, f.standards?.value, looseEq(item.standards, f.standards?.value));
|
| 477 |
+
if (item.formFactor) push('formFactor', item.formFactor, f.formFactor?.value, looseEq(item.formFactor, f.formFactor?.value));
|
| 478 |
+
if (item.outletTypes) push('outletTypes', item.outletTypes, f.outletTypes?.value, outletMix(up(item.outletTypes)) === outletMix(up(f.outletTypes?.value)));
|
| 479 |
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, ''));
|
| 480 |
return out;
|
| 481 |
}
|
|
|
|
| 535 |
const head = ['Tag', 'Status', 'Type', 'Location', 'Manufacturer', 'Model', 'Serial', 'Voltage', 'Phase', 'Hz', 'kVA', 'kW',
|
| 536 |
'Amps', 'PF', 'MCA', 'MOCP', 'SCCR', 'Enclosure', 'Listings', 'Battery type', 'Battery Ah', 'Battery cells',
|
| 537 |
'Refrigerant', 'Charge', 'Cooling (tons)', 'Cooling (BTU/h)', 'Airflow (CFM)', 'Flow (GPM)', 'Speed (RPM)',
|
| 538 |
+
'Weight (kg)', 'Rack U', 'Form factor', 'Power (W)', 'Rack load (kg)', 'Outlet mix', 'Outlets', 'Port speed (GbE)',
|
| 539 |
+
'Ports', 'Mfg date', 'Issues', 'Scanned'];
|
| 540 |
const byTag = new Map(items.map(i => [i.tag, i]));
|
| 541 |
const typeLabel = k => (EQUIPMENT_TYPES.find(t => t[0] === k) || [])[1] || '';
|
| 542 |
const dups = new Set(duplicateSerials(register).flat());
|
|
|
|
| 548 |
return [r.tag || '', STATUS_LABELS[r.status] || r.status, typeLabel(r.type), byTag.get(r.tag)?.location || '',
|
| 549 |
v('manufacturer'), v('model'), v('serial'), v('voltage'), v('phase'), v('hz'), v('kva'), v('kw'), v('amps'), v('pf'),
|
| 550 |
v('mca'), v('mocp'), v('sccr'), v('enclosure'), v('standards'), v('batteryType'), v('batteryAh'), v('cells'),
|
| 551 |
+
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'),
|
| 552 |
issues.join('; '), r.scannedAt ? new Date(r.scannedAt).toISOString().slice(0, 16).replace('T', ' ') : ''];
|
| 553 |
});
|
| 554 |
const tags = new Set(register.map(r => r.tag));
|
|
|
|
| 557 |
rows.push([i.tag, STATUS_LABELS.missing, i.type, i.location, i.manufacturer, i.model, i.serial, i.voltage, i.phase ?? '',
|
| 558 |
'', i.kva ?? '', i.kw ?? '', i.amps ?? '', i.pf ?? '', i.mca ?? '', i.mocp ?? '', i.sccr ?? '', i.enclosure ?? '',
|
| 559 |
i.standards ?? '', i.batteryType ?? '', i.batteryAh ?? '', i.cells ?? '', i.refrigerant, i.charge ?? '', i.tons ?? '',
|
| 560 |
+
i.btu ?? '', i.cfm ?? '', i.gpm ?? '', i.rpm ?? '', i.weight ?? '', i.uHeight ?? '', i.formFactor ?? '', i.powerW ?? '',
|
| 561 |
+
i.rackLoad ?? '', i.outletTypes ?? '', i.outlets ?? '', i.portSpeed ?? '',
|
| 562 |
i.ports ?? '', '', '', '']);
|
| 563 |
}
|
| 564 |
return [head, ...rows].map(r => r.map(csvCell).join(',')).join('\n') + '\n';
|
|
|
|
| 599 |
['DELL EMC', 'POWEREDGE R650 SERVER', 'MODEL NO: R650-2U', 'SERIAL NO: DL26R00442',
|
| 600 |
'480 V 3 PH 60 HZ', '800 W 12.5 A', 'AIRFLOW 120 CFM', 'MFG DATE: 06/2026'],
|
| 601 |
['VERTIV', 'RACK PDU', 'MODEL NO: MPX-30-C13', 'SERIAL NO: VR26P00117',
|
| 602 |
+
'400 V 3 PH 60 HZ', '30 A 5.5 KW', '24 OUTLETS (12x C13, 4x C19)', 'MFG DATE: 08/2026'],
|
| 603 |
+
['CISCO', 'CATALYST NETWORK SWITCH', 'MODEL NO: C9300-48P', 'SERIAL NO: FOC26X0011',
|
| 604 |
+
'230 V 3 PH 50 HZ', '48-PORT POE 1100 W', 'UPLINK 25G', 'MFG DATE: 09/2026'],
|
| 605 |
+
['NEXUS COMPUTE', 'BLADE SERVER CHASSIS', 'MODEL NO: BX-7000', 'SERIAL NO: NC26B00123',
|
| 606 |
+
'FORM FACTOR 8U', '480 V 3 PH 60 HZ', 'STATIC LOAD 1360 KG', 'MFG DATE: 05/2026'],
|
| 607 |
];
|
| 608 |
|
| 609 |
// ------------------------------------------------------------------ helpers
|
|
|
|
| 633 |
if (/^TON/.test(u)) return Math.round(v * 907.18474 * 10) / 10;
|
| 634 |
return v;
|
| 635 |
}
|
| 636 |
+
/** Canonical PDU outlet mix from the ways plates write it: "24x C13, 6x C19" -> "C13x24 C19x6". */
|
| 637 |
+
function outletMix(t) {
|
| 638 |
+
const counts = new Map();
|
| 639 |
+
let m;
|
| 640 |
+
const pairA = /\b(\d{1,3})\s*[X\u00d7]\s*(C\d{1,2})\b/g; // 24x C13
|
| 641 |
+
while ((m = pairA.exec(t))) { const n = +m[1]; if (n >= 1 && n <= 1000) counts.set(m[2], (counts.get(m[2]) || 0) + n); }
|
| 642 |
+
const pairB = /\b(C\d{1,2})\s*[X\u00d7]\s*(\d{1,3})\b/g; // C13 x24
|
| 643 |
+
while ((m = pairB.exec(t))) { const n = +m[2]; if (n >= 1 && n <= 1000) counts.set(m[1], (counts.get(m[1]) || 0) + n); }
|
| 644 |
+
if (!counts.size && !/OUTLET|RECEPTACLE/.test(t)) return null; // a "C13" in a model code is not an outlet mix
|
| 645 |
+
const bare = /\b(C\d{1,2})\b/g;
|
| 646 |
+
while ((m = bare.exec(t))) if (!counts.has(m[1])) counts.set(m[1], 0);
|
| 647 |
+
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([f, n]) => (n > 0 ? `${f}x${n}` : f)).join(' ');
|
| 648 |
+
}
|
| 649 |
+
/** Fastest link speed on a line, in GbE: "48-port 10G", "25/100GbE" -> 100. */
|
| 650 |
+
function portSpeed(t) {
|
| 651 |
+
let max = 0, m;
|
| 652 |
+
const re = /\b(1|10|25|40|50|100|200|400)\s*G(?:BE|BPS|BASE-T|IGABIT|IGE)?\b/g;
|
| 653 |
+
while ((m = re.exec(t))) max = Math.max(max, +m[1]);
|
| 654 |
+
return max || null;
|
| 655 |
+
}
|
| 656 |
function csvCell(v) { const s = String(v ?? ''); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }
|
| 657 |
|
| 658 |
export function levenshtein(a, b) {
|
sample_plates.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"note": "Fictional sample plates. UPS-1A matches schedule tag UPS-1A; the 415 V UPS is the same family but a different voltage variant, so it is flagged on model and voltage. The switchboard and
|
| 3 |
"plates": [
|
| 4 |
{
|
| 5 |
"id": "ups-match",
|
|
@@ -73,9 +73,39 @@
|
|
| 73 |
"SERIAL NO: VR26P00117",
|
| 74 |
"400 V 3 PH 60 HZ",
|
| 75 |
"30 A 5.5 KW",
|
| 76 |
-
"24 OUTLETS",
|
| 77 |
"MFG DATE: 08/2026"
|
| 78 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
}
|
| 80 |
]
|
| 81 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"note": "Fictional sample plates. UPS-1A matches schedule tag UPS-1A; the 415 V UPS is the same family but a different voltage variant, so it is flagged on model and voltage. The switchboard and the IT gear are not on the sample schedule, showing wider equipment recognition and the server-rack fields (Rack U, Form factor, Power W, Rack load, Outlet mix, Port speed, Ports).",
|
| 3 |
"plates": [
|
| 4 |
{
|
| 5 |
"id": "ups-match",
|
|
|
|
| 73 |
"SERIAL NO: VR26P00117",
|
| 74 |
"400 V 3 PH 60 HZ",
|
| 75 |
"30 A 5.5 KW",
|
| 76 |
+
"24 OUTLETS (12x C13, 4x C19)",
|
| 77 |
"MFG DATE: 08/2026"
|
| 78 |
]
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"id": "network-switch",
|
| 82 |
+
"type": "network",
|
| 83 |
+
"expect_tag": null,
|
| 84 |
+
"lines": [
|
| 85 |
+
"CISCO",
|
| 86 |
+
"CATALYST NETWORK SWITCH",
|
| 87 |
+
"MODEL NO: C9300-48P",
|
| 88 |
+
"SERIAL NO: FOC26X0011",
|
| 89 |
+
"230 V 3 PH 50 HZ",
|
| 90 |
+
"48-PORT POE 1100 W",
|
| 91 |
+
"UPLINK 25G",
|
| 92 |
+
"MFG DATE: 09/2026"
|
| 93 |
+
]
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"id": "blade-chassis",
|
| 97 |
+
"type": "server",
|
| 98 |
+
"expect_tag": null,
|
| 99 |
+
"lines": [
|
| 100 |
+
"NEXUS COMPUTE",
|
| 101 |
+
"BLADE SERVER CHASSIS",
|
| 102 |
+
"MODEL NO: BX-7000",
|
| 103 |
+
"SERIAL NO: NC26B00123",
|
| 104 |
+
"FORM FACTOR 8U",
|
| 105 |
+
"480 V 3 PH 60 HZ",
|
| 106 |
+
"STATIC LOAD 1360 KG",
|
| 107 |
+
"MFG DATE: 05/2026"
|
| 108 |
+
]
|
| 109 |
}
|
| 110 |
]
|
| 111 |
}
|
tests/nameplate-model.test.mjs
CHANGED
|
@@ -145,7 +145,7 @@ test('register: summary, duplicate serials, and a CSV that lists what is still u
|
|
| 145 |
assert.equal(s.missing.length, schedule.length - 1);
|
| 146 |
const rows = registerCsv(reg, schedule).trim().split('\n');
|
| 147 |
assert.equal(rows.length, 1 + 2 + schedule.length - 1);
|
| 148 |
-
assert.match(rows[1], /^UPS-1A,Verified,UPS,Electrical Room 1A,Northline Power Systems,NPX-750-480,NL26A01937,480Y\/277 V,3,60,750,675,1002,,,,,,,,,,,,,,,,,,,,,,03\/2026,Serial also on another scanned plate,2026-10-02 14:30$/);
|
| 149 |
assert.match(rows[3], /^UPS-1B,Not scanned yet,/);
|
| 150 |
});
|
| 151 |
|
|
@@ -310,3 +310,57 @@ test('schedule columns for the extra fields are read and checked', () => {
|
|
| 310 |
const bad = checkAgainst(plate(['ACME SWITCHBOARD', 'SWITCHBOARD', 'MODEL IS-4000', '480Y/277 V', 'PF 0.9', 'SCCR 42 KA', 'NEMA 1']), swb);
|
| 311 |
assert.deepEqual(bad.filter(c => c.status === 'mismatch').map(c => c.key), ['pf', 'sccr', 'enclosure']);
|
| 312 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
assert.equal(s.missing.length, schedule.length - 1);
|
| 146 |
const rows = registerCsv(reg, schedule).trim().split('\n');
|
| 147 |
assert.equal(rows.length, 1 + 2 + schedule.length - 1);
|
| 148 |
+
assert.match(rows[1], /^UPS-1A,Verified,UPS,Electrical Room 1A,Northline Power Systems,NPX-750-480,NL26A01937,480Y\/277 V,3,60,750,675,1002,,,,,,,,,,,,,,,,,,,,,,,,,,03\/2026,Serial also on another scanned plate,2026-10-02 14:30$/);
|
| 149 |
assert.match(rows[3], /^UPS-1B,Not scanned yet,/);
|
| 150 |
});
|
| 151 |
|
|
|
|
| 310 |
const bad = checkAgainst(plate(['ACME SWITCHBOARD', 'SWITCHBOARD', 'MODEL IS-4000', '480Y/277 V', 'PF 0.9', 'SCCR 42 KA', 'NEMA 1']), swb);
|
| 311 |
assert.deepEqual(bad.filter(c => c.status === 'mismatch').map(c => c.key), ['pf', 'sccr', 'enclosure']);
|
| 312 |
});
|
| 313 |
+
|
| 314 |
+
test('server-rack plates read form factor, static load, outlet mix and port speed', () => {
|
| 315 |
+
// A blade chassis: its own form factor, and a static floor load.
|
| 316 |
+
const blade = plate(['NEXUS COMPUTE', 'BLADE SERVER CHASSIS', 'MODEL NO: BX-7000', 'SERIAL NO: NC26B00123',
|
| 317 |
+
'FORM FACTOR 8U', '480 V 3 PH 60 HZ', 'STATIC LOAD 1360 KG']);
|
| 318 |
+
assert.equal(blade.type, 'server');
|
| 319 |
+
assert.equal(val(blade, 'formFactor'), '8U');
|
| 320 |
+
assert.equal(val(blade, 'rackLoad'), 1360);
|
| 321 |
+
|
| 322 |
+
// A rack PDU: the outlet mix is canonicalised whichever way it is written.
|
| 323 |
+
const pdu = plate(['VERTIV', 'RACK PDU', 'MODEL NO: MPX-30-C13', 'SERIAL NO: VR26P00117',
|
| 324 |
+
'400 V 3 PH 60 HZ', '30 A 5.5 KW', '24 OUTLETS (12x C13, 4x C19)']);
|
| 325 |
+
assert.equal(pdu.type, 'pdu');
|
| 326 |
+
assert.equal(val(pdu, 'outletTypes'), 'C13x12 C19x4');
|
| 327 |
+
assert.equal(val(pdu, 'outlets'), 24);
|
| 328 |
+
// "C13" in a model code alone is not an outlet mix.
|
| 329 |
+
assert.equal(val(plate(['VERTIV', 'RACK PDU', 'MODEL NO: MPX-30-C13', '400 V 3 PH']), 'outletTypes'), undefined);
|
| 330 |
+
|
| 331 |
+
// A switch: the fastest link, not the first number.
|
| 332 |
+
const sw = plate(['CISCO', 'CATALYST NETWORK SWITCH', 'MODEL NO: C9300-48P', 'SERIAL NO: FOC26X0011',
|
| 333 |
+
'230 V 3 PH 50 HZ', '48-PORT POE 1100 W', 'UPLINK 10/25G']);
|
| 334 |
+
assert.equal(sw.type, 'network');
|
| 335 |
+
assert.equal(val(sw, 'ports'), 48);
|
| 336 |
+
assert.equal(val(sw, 'portSpeed'), 25);
|
| 337 |
+
assert.equal(val(sw, 'powerW'), 1100);
|
| 338 |
+
|
| 339 |
+
// "42RU" is a rack height, and a tower server is a form factor.
|
| 340 |
+
assert.equal(val(plate(['RITTAL', 'SERVER RACK ENCLOSURE', 'MODEL TS-42RU', '42RU 800x1200']), 'uHeight'), 42);
|
| 341 |
+
assert.equal(val(plate(['DELL', 'TOWER SERVER', 'MODEL T-360', 'Tower chassis']), 'formFactor'), 'tower');
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
test('new rack columns import from the schedule and are checked field by field', () => {
|
| 345 |
+
const { items, problems } = parseEquipmentSchedule(
|
| 346 |
+
'Tag,Type,Model,Form factor,Rack load,Outlet mix,Port speed,Ports\n' +
|
| 347 |
+
'SRV-1,Server,R650-2U,2U,1360,C13x24,25,48\n' +
|
| 348 |
+
'SW-1,Network switch,C9300,,,,25,48\n');
|
| 349 |
+
assert.deepEqual(problems, []);
|
| 350 |
+
const [srv, sw] = items;
|
| 351 |
+
assert.equal(srv.formFactor, '2U');
|
| 352 |
+
assert.equal(srv.rackLoad, 1360);
|
| 353 |
+
assert.equal(srv.outletTypes, 'C13x24');
|
| 354 |
+
assert.equal(srv.portSpeed, 25);
|
| 355 |
+
assert.equal(sw.portSpeed, 25);
|
| 356 |
+
assert.equal(sw.ports, 48);
|
| 357 |
+
|
| 358 |
+
const good = checkAgainst(plate(['ACME SERVER', 'POWEREDGE SERVER', 'MODEL R650-2U', '480 V 3 PH',
|
| 359 |
+
'FORM FACTOR 2U', 'STATIC LOAD 1360 KG', '25 OUTLETS (24x C13)', '48-PORT 25G']), srv);
|
| 360 |
+
assert.deepEqual(good.filter(c => c.status !== 'ok'), []);
|
| 361 |
+
|
| 362 |
+
const bad = checkAgainst(plate(['ACME SERVER', 'POWEREDGE SERVER', 'MODEL R650-2U', '480 V 3 PH',
|
| 363 |
+
'FORM FACTOR 4U', 'STATIC LOAD 900 KG', '25 OUTLETS (12x C13)', '48-PORT 10G']), srv);
|
| 364 |
+
assert.deepEqual(bad.filter(c => c.status === 'mismatch').map(c => c.key).sort(),
|
| 365 |
+
['formFactor', 'outletTypes', 'portSpeed', 'rackLoad'].sort());
|
| 366 |
+
});
|