File size: 20,506 Bytes
5e2d1eb
 
 
 
 
 
2843cff
 
5e2d1eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2843cff
 
 
 
 
5e2d1eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2843cff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e2d1eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc4e333
5e2d1eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2843cff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc4e333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
// Nameplate parsing, schedule matching and the register CSV (pure functions).
// Run: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  parseNameplate, voltagesIn, parseEquipmentSchedule, scheduleVoltages, candidatesFor, checkAgainst, statusOf,
  codeSimilarity, foldCode, duplicateSerials, registerSummary, registerCsv, typeOf, EQUIPMENT_TYPES,
  SAMPLE_SCHEDULE, SAMPLE_PLATES,
} from '../nameplate-model.js';

const plate = lines => parseNameplate(lines.join('\n'));
const val = (r, k) => r.fields[k]?.value;
const schedule = parseEquipmentSchedule(SAMPLE_SCHEDULE).items;

test('a UPS plate reads into every field', () => {
  const r = plate(SAMPLE_PLATES[0]);
  assert.equal(r.type, 'ups');
  assert.equal(val(r, 'manufacturer'), 'Northline Power Systems');
  assert.equal(val(r, 'model'), 'NPX-750-480');
  assert.equal(val(r, 'serial'), 'NL26A01937');
  assert.equal(val(r, 'voltage'), '480Y/277 V');
  assert.deepEqual(r.fields.voltage.values, [480, 277]);
  assert.equal(val(r, 'phase'), 3);
  assert.equal(val(r, 'hz'), 60);
  assert.equal(val(r, 'kva'), 750);
  assert.equal(val(r, 'kw'), 675);
  assert.equal(val(r, 'amps'), 1002);
  assert.equal(val(r, 'mfgDate'), '03/2026');
});

test('real-world plate wording: S/N, CAT. NO., kV, thousands separators, known makers', () => {
  const gen = plate(['CATERPILLAR', 'GENERATOR SET', 'CAT. NO. 3516C-HD', 'S/N: ZBA00412', '13.8 KV 3 PHASE 60 HZ', '3,750 KVA 3000 EKW', 'FLA 1,569']);
  assert.equal(gen.type, 'generator');
  assert.equal(val(gen, 'manufacturer'), 'Caterpillar');
  assert.equal(val(gen, 'model'), '3516C-HD');
  assert.equal(val(gen, 'serial'), 'ZBA00412');
  assert.deepEqual(gen.fields.voltage.values, [13800]);
  assert.equal(val(gen, 'kva'), 3750);
  assert.equal(val(gen, 'kw'), 3000);
  assert.equal(val(gen, 'amps'), 1569);

  const ch = plate(['AIR-COOLED CHILLER', 'MODEL: AC-1200', 'REFRIGERANT R-513A CHARGE 410 LBS', '460V 3PH 60HZ', 'MCA 612 MOCP 800']);
  assert.equal(ch.type, 'chiller');
  assert.equal(val(ch, 'refrigerant'), 'R-513A');
  assert.equal(val(ch, 'mca'), 612);
  assert.equal(val(ch, 'mocp'), 800);
  assert.equal(val(ch, 'amps'), undefined, 'R-513A and a charge weight are not amps');
});

test('voltages: transformer pairs, labelled values, and numbers that are not voltages', () => {
  assert.deepEqual(voltagesIn('PRI 13,800 V DELTA SEC 480Y/277 V').values, [480, 277, 13800]);
  assert.deepEqual(voltagesIn('VOLTS: 208').values, [208]);
  assert.deepEqual(voltagesIn('DWG 480/277 REV 2').values, [], 'a bare pair with no voltage word or unit');
  assert.deepEqual(scheduleVoltages('480 / 415Y/240'), [480, 415, 240]);
  assert.deepEqual(scheduleVoltages('13.8kV'), [13800]);
});

test('"New York" in an address is not the maker York; unknown makers come from a company line', () => {
  const r = plate(['ACME SWITCHGEAR CORP', 'NEW YORK NY', 'SWITCHBOARD', 'MODEL SB-4000']);
  assert.equal(val(r, 'manufacturer'), 'Acme Switchgear Corp');
  assert.equal(r.type, 'switchgear');

  // A transformer winding is "delta"; that is not the maker Delta.
  const t = plate(['VOLTARC TRANSFORMER CORP', 'DRY TYPE TRANSFORMER', 'MODEL VT-2500-HV', 'PRIMARY 13800 V DELTA', 'SECONDARY 480Y/277 V']);
  assert.equal(val(t, 'manufacturer'), 'Voltarc Transformer Corp');
  assert.equal(t.type, 'transformer');
});

test('codes compare the way OCR misreads them', () => {
  assert.equal(foldCode('NPX-75O-48O'), foldCode('NPX750480'));
  assert.equal(codeSimilarity('NL26A0I937', 'NL26A01937'), 1);
  assert.ok(codeSimilarity('9395P-750', '9395P-750-N-480') >= 0.9, 'option suffix on the plate');
  assert.ok(codeSimilarity('NPX-750-415', 'NPX-750-480') < 0.85, 'a different voltage variant is not the same model');
});

test('schedule CSV: header row found below title rows, duplicate tags reported', () => {
  const { items, problems } = parseEquipmentSchedule('Data Hall 1 equipment schedule\n\nMark,Equipment,Mfr,Model No.,Volts,kVA\nUPS-1A,UPS,Eaton,9395XR,480,750\nUPS-1A,UPS,Eaton,9395XR,480,750\n');
  assert.equal(items.length, 1);
  assert.equal(items[0].manufacturer, 'Eaton');
  assert.equal(items[0].kva, 750);
  assert.match(problems[0], /UPS-1A appears more than once/);
  assert.match(parseEquipmentSchedule('a,b\n1,2').problems[0], /No header row/);
});

test('identical units: the first unscanned tag is offered first', () => {
  const r = plate(SAMPLE_PLATES[0]);
  const first = candidatesFor(r, schedule);
  assert.deepEqual(first.slice(0, 4).map(c => c.item.tag), ['UPS-1A', 'UPS-1B', 'UPS-2A', 'UPS-2B']);
  const next = candidatesFor(r, schedule, [{ tag: 'UPS-1A' }]);
  assert.equal(next[0].item.tag, 'UPS-1B');
  assert.equal(next.find(c => c.item.tag === 'UPS-1A').registered, true);
  assert.ok(!first.some(c => c.item.tag.startsWith('GEN')), 'a generator is not a candidate for a UPS plate');
});

test('a serial on the schedule decides the match outright', () => {
  const items = parseEquipmentSchedule('Tag,Model,Serial\nUPS-1A,NPX-750-480,NL26A00001\nUPS-1B,NPX-750-480,NL26A01937\n').items;
  assert.equal(candidatesFor(plate(SAMPLE_PLATES[0]), items)[0].item.tag, 'UPS-1B');
});

test('voltage compatibility is a positive signal, but the equipment type still wins', () => {
  // The 415 V UPS shares a voltage with the PDU rows (480/415Y/240), yet the
  // matching UPS rows must still rank first.
  const first = candidatesFor(plate(SAMPLE_PLATES[1]), schedule);
  assert.ok(first[0].item.tag.startsWith('UPS'), `first was ${first[0].item.tag}`);
  assert.ok(!first.slice(0, 4).some(c => c.item.tag.startsWith('PDU')), 'no PDU outranks the UPS rows');
});

test('out-of-range OCR noise is not accepted as a rating', () => {
  const r = plate(['ACME', 'SWITCHBOARD', 'MODEL SB-1', '0 KVA', 'SCCR 0', '0 A', '0 OUTLETS']);
  assert.equal(val(r, 'kva'), undefined, 'a zero rating is not a rating');
  assert.equal(val(r, 'sccr'), undefined);
  assert.equal(val(r, 'amps'), undefined);
  assert.equal(val(r, 'outlets'), undefined);
  const ok = plate(['ACME', 'SWITCHBOARD', 'MODEL SB-1', '480 V', '65 KAIC']);
  assert.equal(val(ok, 'sccr'), 65, 'a plausible value is kept');
});

test('checks: the right unit verifies; the wrong-voltage unit is flagged on model and voltage', () => {
  const ups1a = schedule.find(i => i.tag === 'UPS-1A');
  const good = checkAgainst(plate(SAMPLE_PLATES[0]), ups1a);
  assert.ok(good.every(c => c.status === 'ok'), JSON.stringify(good));
  assert.equal(statusOf(good, 'UPS-1A'), 'verified');

  const bad = checkAgainst(plate(SAMPLE_PLATES[1]), ups1a);
  assert.deepEqual(bad.filter(c => c.status === 'mismatch').map(c => c.key), ['model', 'voltage']);
  assert.equal(statusOf(bad, 'UPS-1A'), 'mismatch');

  const unread = checkAgainst(parseNameplate('MODEL NPX-750-480'), ups1a);
  assert.equal(statusOf(unread, 'UPS-1A'), 'partial');
  assert.equal(statusOf([], ''), 'unmatched');
});

test('register: summary, duplicate serials, and a CSV that lists what is still unscanned', () => {
  const a = plate(SAMPLE_PLATES[0]);
  const ups1a = schedule.find(i => i.tag === 'UPS-1A');
  const reg = [
    { id: 'a', tag: 'UPS-1A', type: 'ups', fields: a.fields, checks: checkAgainst(a, ups1a), status: 'verified', scannedAt: Date.UTC(2026, 9, 2, 14, 30) },
    { id: 'b', tag: '', type: 'ups', fields: a.fields, checks: [], status: 'unmatched', scannedAt: Date.UTC(2026, 9, 2, 14, 35) },
  ];
  assert.deepEqual(duplicateSerials(reg), [['a', 'b']]);
  const s = registerSummary(reg, schedule);
  assert.equal(s.scannedOfSchedule, 1);
  assert.equal(s.verified, 1);
  assert.equal(s.unmatched, 1);
  assert.equal(s.missing.length, schedule.length - 1);
  const rows = registerCsv(reg, schedule).trim().split('\n');
  assert.equal(rows.length, 1 + 2 + schedule.length - 1);
  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$/);
  assert.match(rows[3], /^UPS-1B,Not scanned yet,/);
});

test('OCR misreads seen on real lettering are undone before parsing', () => {
  const r = parseNameplate(['MODEL NO: NPX- 750- 480', '480¥/277 VAC 3 PH 60 Hw', '460 Vv', '750 XVA / 675 XW', 'FLA 38. 5'].join('\n'));
  assert.equal(r.fields.model.value, 'NPX-750-480');
  assert.deepEqual(r.fields.voltage.values, [480, 277, 460]);
  assert.equal(r.fields.hz.value, 60);
  assert.equal(r.fields.kva.value, 750);
  assert.equal(r.fields.kw.value, 675);
  assert.equal(r.fields.amps.value, 38.5);
});

test('MCA and MOCP are checked when the schedule lists them; near-zero-confidence lines are ignored', () => {
  const ch = schedule.find(i => i.tag === 'CH-01');
  assert.equal(ch.mca, 612);
  const r = parseNameplate([{ text: 'MODEL AC-1200', confidence: 90 }, { text: '460 V 3 PH', confidence: 88 }, { text: 'MCA 612 MOCP 700', confidence: 85 },
    { text: 'REFRIGERANT R-513A', confidence: 90 }, { text: 'MODEL ZZ-9 480 V', confidence: 8 }]);
  assert.equal(r.fields.model.value, 'AC-1200', 'the 8% line is texture, not a second model');
  const checks = checkAgainst(r, ch);
  assert.deepEqual(checks.filter(c => c.status === 'mismatch').map(c => c.key), ['mocp']);
});

test('a wide data centre equipment set is recognised, device wording beating its family', () => {
  assert.ok(EQUIPMENT_TYPES.length >= 40, 'the taxonomy covers more than the classic dozen');
  const cases = [
    ['FLYWHEEL UPS', 'flywheel'], ['UNINTERRUPTIBLE POWER SUPPLY', 'ups'], ['STATIC TRANSFER SWITCH', 'sts'],
    ['AUTOMATIC TRANSFER SWITCH', 'ats'], ['BATTERY CHARGER', 'rectifier'], ['POWER CONVERSION SYSTEM INVERTER', 'inverter'],
    ['BATTERY ENERGY STORAGE SYSTEM', 'bess'], ['PARALLELING SWITCHGEAR', 'paralleling'], ['DIESEL GENERATOR SET', 'generator'],
    ['FUEL OIL DAY TANK', 'fueltank'], ['SWITCHBOARD', 'switchgear'], ['MOTOR CONTROL CENTER', 'mcc'],
    ['POWER METER', 'metering'], ['DRY TYPE TRANSFORMER', 'transformer'], ['SURGE PROTECTIVE DEVICE', 'spd'],
    ['POWER FACTOR CORRECTION CAPACITOR', 'capacitor'], ['VARIABLE FREQUENCY DRIVE', 'vfd'], ['ISOLATED PHASE BUS', 'busway'],
    ['PANELBOARD', 'panelboard'], ['REMOTE POWER PANEL', 'pdu'], ['BATTERY CABINET', 'battery'], ['LOAD BANK', 'loadbank'],
    ['COOLING TOWER', 'coolingtower'], ['AIR-COOLED CHILLER', 'chiller'], ['DRY COOLER', 'condenser'],
    ['LIQUID COOLING CDU', 'cdu'], ['REAR DOOR HEAT EXCHANGER', 'cdu'], ['COMPUTER ROOM AIR HANDLER', 'crah'],
    ['AIR HANDLING UNIT', 'ahu'], ['HEAT PUMP', 'heatpump'], ['PLATE HEAT EXCHANGER', 'heatexchanger'],
    ['CHILLED WATER PUMP', 'pump'], ['CONDENSER WATER PUMP', 'pump'], ['STEAM HUMIDIFIER', 'humidifier'],
    ['WATER TREATMENT PLANT', 'watertreatment'], ['DESICCANT DRYER', 'dryer'], ['AIR COMPRESSOR', 'compressor'],
    ['FAN COIL UNIT', 'fcu'], ['ROOFTOP UNIT', 'rtu'], ['MAKEUP AIR UNIT', 'mua'], ['FM-200 FIRE SUPPRESSION', 'firesuppression'],
    ['VESDA ASPIRATING SMOKE DETECT', 'firealarm'], ['WATER LEAK DETECT', 'leakdetection'], ['BMS CONTROLLER', 'bms'],
    ['KVM SWITCH', 'kvm'], ['SERVER RACK ENCLOSURE', 'rack'], ['BLADE SERVER', 'server'], ['NETWORK SWITCH', 'network'],
    ['PROTECTIVE RELAY PANEL', 'relay'], ['TRANSFORMER MONITOR DGA', 'tmonitor'], ['NEUTRAL GROUNDING RESISTOR', 'ngr'],
    ['ISOLATION TRANSFORMER', 'transformer'], ['K-RATED TRANSFORMER', 'transformer'], ['WATER SOFTENER', 'watertreatment'],
    ['REVERSE OSMOSIS SYSTEM', 'watertreatment'], ['EXPANSION TANK', 'expansiontank'], ['AIR SEPARATOR', 'airseparator'],
    ['NITROGEN GENERATOR', 'nitrogen'], ['HOT WATER BOILER', 'boiler'], ['THERMAL STORAGE TANK', 'thermalstorage'],
    ['EVAPORATIVE COOLER', 'condenser'], ['STATIC BYPASS', 'sts'], ['RACK ATS', 'sts'],
  ];
  for (const [text, want] of cases) assert.equal(typeOf(text), want, text);
});

test('cooling and electrical nameplates read the wider field set', () => {
  const sg = plate(['IRONSIDE SWITCHGEAR CORP', 'SWITCHBOARD', 'MODEL IS-4000-MDP', 'SERIAL IS26C01188',
    '480Y/277 V 3 PH 60 HZ', 'SCCR 65 KAIC', 'NEMA 3R', '0.8 PF']);
  assert.equal(sg.type, 'switchgear');
  assert.equal(val(sg, 'sccr'), 65);
  assert.equal(val(sg, 'enclosure'), 'NEMA 3R');
  assert.equal(val(sg, 'pf'), 0.8);

  const ch = plate(['POLARIS THERMAL', 'WATER-COOLED CHILLER', 'MODEL PT-WC-900', '460 V 3 PH 60 HZ',
    'REFRIGERANT R-1234ZE CHARGE 620 LBS', 'CAPACITY 500 TONS', 'AIRFLOW 24000 CFM', 'FLOW 2400 GPM']);
  assert.equal(ch.type, 'chiller');
  assert.equal(val(ch, 'refrigerant'), 'R-1234ZE');
  assert.equal(val(ch, 'charge'), 620);
  assert.equal(val(ch, 'tons'), 500);
  assert.equal(val(ch, 'cfm'), 24000);
  assert.equal(val(ch, 'gpm'), 2400);

  const bat = plate(['ENERSYS', 'BATTERY CABINET', 'MODEL MX-500', '480 V 3 PH', 'CAPACITY 500 AH', 'IP20']);
  assert.equal(bat.type, 'battery');
  assert.equal(val(bat, 'batteryAh'), 500);
  assert.equal(val(bat, 'enclosure'), 'IP 20');
});

test('battery chemistry, listings, speed and rigging weight are read', () => {
  const bat = plate(['EXIDE', 'BATTERY STRING', 'MODEL VR-48', 'VRLA 48 CELLS', 'LISTED UL 1989', 'WEIGHT 1250 LBS']);
  assert.equal(val(bat, 'batteryType'), 'VRLA');
  assert.equal(val(bat, 'cells'), 48);
  assert.equal(val(bat, 'standards'), 'UL 1989');
  assert.equal(val(bat, 'weight'), 567, '1250 lb normalised to kg');

  const pump = plate(['GRUNDFOS', 'END SUCTION PUMP', 'MODEL CR-64', 'RATED SPEED 1750 RPM', 'MOTOR IE3', 'WEIGHT 320 KG']);
  assert.equal(pump.type, 'pump');
  assert.equal(val(pump, 'rpm'), 1750);
  assert.equal(val(pump, 'weight'), 320);

  const li = plate(['SAFT', 'BATTERY CABINET', 'LITHIUM-ION', 'MODEL LI-500', 'IEC 62619']);
  assert.equal(val(li, 'batteryType'), 'Lithium-ion');
  assert.equal(val(li, 'standards'), 'IEC 62619');
});

test('battery, listing, speed and weight columns are imported and checked', () => {
  const { items, problems } = parseEquipmentSchedule(
    'Tag,Type,Model,Voltage,Battery type,Listings,Ah,Cells,RPM,Weight\n' +
    'BAT-1,Battery cabinet,MX-500,480,VRLA,UL 1989,500,48,,\n' +
    'PMP-1,Pump,CR-64,460,,,,,1750,320\n');
  assert.deepEqual(problems, []);
  const [bat, pump] = items;
  assert.equal(bat.batteryType, 'VRLA');
  assert.equal(bat.standards, 'UL 1989');
  assert.equal(bat.cells, 48);
  assert.equal(pump.rpm, 1750);
  assert.equal(pump.weight, 320);

  const good = checkAgainst(plate(['EXIDE', 'BATTERY CABINET', 'MODEL MX-500', '480 V', 'VRLA 500 AH 48 CELLS', 'UL 1989']), bat);
  assert.deepEqual(good.filter(c => c.status !== 'ok'), []);
  const bad = checkAgainst(plate(['EXIDE', 'BATTERY CABINET', 'MODEL MX-500', '480 V', 'AGM 400 AH 24 CELLS', 'UL 891']), bat);
  assert.deepEqual(bad.filter(c => c.status === 'mismatch').map(c => c.key), ['batteryAh', 'cells', 'batteryType', 'standards']);
});

test('server-rack nameplates: rack U height, watts and airflow', () => {
  const srv = plate(['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']);
  assert.equal(srv.type, 'server');
  assert.equal(val(srv, 'uHeight'), 2, 'from the model suffix R650-2U');
  assert.equal(val(srv, 'powerW'), 800);
  assert.equal(val(srv, 'amps'), 12.5);
  assert.equal(val(srv, 'cfm'), 120);

  // A kW line must not be read as watts.
  const pdu = plate(['VERTIV', 'RACK PDU', 'MODEL MPX-30', '400 V 3 PH', '5.5 KW', '24 OUTLETS']);
  assert.equal(val(pdu, 'powerW'), undefined);
  assert.equal(val(pdu, 'kw'), 5.5);

  const { items } = parseEquipmentSchedule('Tag,Type,Model,W,Rack U\nSRV-1,Server,R650,800,2\n');
  assert.equal(items[0].powerW, 800);
  assert.equal(items[0].uHeight, 2);
  assert.deepEqual(checkAgainst(srv, items[0]).filter(c => c.status === 'mismatch').map(c => c.key), ['model']);

  // A rack PDU: outlets, kW (not watts) and amps.
  const rpdu = plate(['VERTIV', 'RACK PDU', 'MODEL NO: MPX-30-C13', 'SERIAL NO: VR26P00117',
    '400 V 3 PH 60 HZ', '30 A 5.5 KW', '24 OUTLETS']);
  assert.equal(rpdu.type, 'pdu');
  assert.equal(val(rpdu, 'outlets'), 24);
  assert.equal(val(rpdu, 'kw'), 5.5);
  assert.equal(val(rpdu, 'powerW'), undefined, 'kW is not watts');
  assert.equal(val(rpdu, 'amps'), 30);

  const sw = plate(['CISCO', 'CATALYST NETWORK SWITCH', 'MODEL NO: C9300-48P', 'SERIAL NO: FOC26X0011',
    '230 V 3 PH 50 HZ', '48-PORT  POE 1100 W']);
  assert.equal(sw.type, 'network');
  assert.equal(val(sw, 'ports'), 48);
  assert.equal(val(sw, 'powerW'), 1100);
});

test('schedule columns for the extra fields are read and checked', () => {
  const { items, problems } = parseEquipmentSchedule('Tag,Type,Model,Voltage,PF,SCCR,Enclosure,Tons,CFM,GPM,Ah\n' +
    'SWB-1,Switchboard,IS-4000,480Y/277,0.8,65,NEMA 3R,,,,,\n' +
    'CH-1,Chiller,PT-900,460,,,,500,24000,2400,\n' +
    'BAT-1,Battery cabinet,MX-500,480,,,,,,,500\n');
  assert.deepEqual(problems, []);
  const [swb, ch, bat] = items;
  assert.equal(swb.pf, 0.8);
  assert.equal(swb.sccr, 65);
  assert.equal(swb.enclosure, 'NEMA 3R');
  assert.equal(ch.tons, 500);
  assert.equal(ch.cfm, 24000);
  assert.equal(ch.gpm, 2400);
  assert.equal(bat.batteryAh, 500);

  const good = checkAgainst(plate(['ACME SWITCHBOARD', 'SWITCHBOARD', 'MODEL IS-4000', '480Y/277 V', 'PF 0.8', 'SCCR 65 KA', 'NEMA 3R']), swb);
  assert.deepEqual(good.filter(c => c.status !== 'ok'), []);
  const bad = checkAgainst(plate(['ACME SWITCHBOARD', 'SWITCHBOARD', 'MODEL IS-4000', '480Y/277 V', 'PF 0.9', 'SCCR 42 KA', 'NEMA 1']), swb);
  assert.deepEqual(bad.filter(c => c.status === 'mismatch').map(c => c.key), ['pf', 'sccr', 'enclosure']);
});

test('server-rack plates read form factor, static load, outlet mix and port speed', () => {
  // A blade chassis: its own form factor, and a static floor load.
  const blade = plate(['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']);
  assert.equal(blade.type, 'server');
  assert.equal(val(blade, 'formFactor'), '8U');
  assert.equal(val(blade, 'rackLoad'), 1360);

  // A rack PDU: the outlet mix is canonicalised whichever way it is written.
  const pdu = plate(['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)']);
  assert.equal(pdu.type, 'pdu');
  assert.equal(val(pdu, 'outletTypes'), 'C13x12 C19x4');
  assert.equal(val(pdu, 'outlets'), 24);
  // "C13" in a model code alone is not an outlet mix.
  assert.equal(val(plate(['VERTIV', 'RACK PDU', 'MODEL NO: MPX-30-C13', '400 V 3 PH']), 'outletTypes'), undefined);

  // A switch: the fastest link, not the first number.
  const sw = plate(['CISCO', 'CATALYST NETWORK SWITCH', 'MODEL NO: C9300-48P', 'SERIAL NO: FOC26X0011',
    '230 V 3 PH 50 HZ', '48-PORT POE 1100 W', 'UPLINK 10/25G']);
  assert.equal(sw.type, 'network');
  assert.equal(val(sw, 'ports'), 48);
  assert.equal(val(sw, 'portSpeed'), 25);
  assert.equal(val(sw, 'powerW'), 1100);

  // "42RU" is a rack height, and a tower server is a form factor.
  assert.equal(val(plate(['RITTAL', 'SERVER RACK ENCLOSURE', 'MODEL TS-42RU', '42RU 800x1200']), 'uHeight'), 42);
  assert.equal(val(plate(['DELL', 'TOWER SERVER', 'MODEL T-360', 'Tower chassis']), 'formFactor'), 'tower');
});

test('new rack columns import from the schedule and are checked field by field', () => {
  const { items, problems } = parseEquipmentSchedule(
    'Tag,Type,Model,Form factor,Rack load,Outlet mix,Port speed,Ports\n' +
    'SRV-1,Server,R650-2U,2U,1360,C13x24,25,48\n' +
    'SW-1,Network switch,C9300,,,,25,48\n');
  assert.deepEqual(problems, []);
  const [srv, sw] = items;
  assert.equal(srv.formFactor, '2U');
  assert.equal(srv.rackLoad, 1360);
  assert.equal(srv.outletTypes, 'C13x24');
  assert.equal(srv.portSpeed, 25);
  assert.equal(sw.portSpeed, 25);
  assert.equal(sw.ports, 48);

  const good = checkAgainst(plate(['ACME SERVER', 'POWEREDGE SERVER', 'MODEL R650-2U', '480 V 3 PH',
    'FORM FACTOR 2U', 'STATIC LOAD 1360 KG', '25 OUTLETS (24x C13)', '48-PORT 25G']), srv);
  assert.deepEqual(good.filter(c => c.status !== 'ok'), []);

  const bad = checkAgainst(plate(['ACME SERVER', 'POWEREDGE SERVER', 'MODEL R650-2U', '480 V 3 PH',
    'FORM FACTOR 4U', 'STATIC LOAD 900 KG', '25 OUTLETS (12x C13)', '48-PORT 10G']), srv);
  assert.deepEqual(bad.filter(c => c.status === 'mismatch').map(c => c.key).sort(),
    ['formFactor', 'outletTypes', 'portSpeed', 'rackLoad'].sort());
});