constructelligence commited on
Commit
bc4e333
·
verified ·
1 Parent(s): 2843cff

Data Centre Construction: rename + frontier-construction-AI positioning

Browse files
README.md CHANGED
@@ -46,11 +46,15 @@ tags:
46
  - browser
47
  ---
48
 
49
- # Data Centre Nameplates — equipment-nameplate OCR checked against the equipment schedule
 
 
 
 
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 (31 of them, grouped Identification / Electrical / Cooling /
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** | **31**, in five groups (Identification, Electrical, Cooling, Server racks/IT, Mechanical) |
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 (31)
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), `powerW`, `outlets`, `ports`
 
 
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`, `Power (W)`, `PDU outlets` and
167
- `Ports` are extracted and checked like any other field, and the equipment set covers rack/enclosure, server,
168
- network, KVM and rack PDU. Sample plates include a PowerEdge-class server and a rack PDU.
 
 
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
- **Nameplates** mode of BuildVision. No customer data is used; sample plates use fictional makers and models.
 
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 Nameplates: equipment-nameplate OCR checked against the equipment schedule},
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.0",
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 height, power, PDU outlets, network ports).",
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'], ['powerW', 'Power (W)', 'rack'], ['outlets', 'PDU outlets', 'rack'], ['ports', 'Ports', '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: rack-unit height, IT power draw, PDU outlets and network ports.
211
- scan('uHeight', /\b(\d{1,2})\s*U\b/, m => { const v = +m[1]; return v >= 1 && v <= 60 ? v : null; });
 
 
 
 
 
 
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
- outlets: num(get('outlets')) || null, ports: num(get('ports')) || null, location: get('location'),
 
 
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)', 'Outlets', 'Ports', 'Mfg date', 'Issues', 'Scanned'];
 
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.powerW ?? '', i.outlets ?? '',
 
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 server are not on the sample schedule, showing wider equipment recognition and the server-rack fields (Rack U, Power W).",
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
+ });