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

Update data-centre nameplates to v2: 31 fields, 55 equipment types, extraction benchmark

Browse files
README.md CHANGED
@@ -8,6 +8,7 @@ tags:
8
  - ocr
9
  - document-ai
10
  - information-extraction
 
11
  - nameplate
12
  - nameplate-ocr
13
  - equipment-nameplate
@@ -17,6 +18,7 @@ tags:
17
  - mission-critical
18
  - mep
19
  - electrical
 
20
  - equipment-schedule
21
  - schedule-verification
22
  - asset-register
@@ -34,6 +36,7 @@ tags:
34
  - crac
35
  - chiller
36
  - cooling
 
37
  - construction
38
  - aec
39
  - edge-ai
@@ -43,139 +46,187 @@ tags:
43
  - browser
44
  ---
45
 
46
- # Data Centre Nameplates — equipment-nameplate OCR checked against the schedule
47
 
48
  **Read an equipment nameplate and know if it is the unit the schedule asked for.** Point a phone at the
49
- nameplate of a UPS, PDU, switchgear, generator, transformer, busway, battery, CRAH or chiller; the model returns
50
- the plate's fields — manufacturer, model, serial, voltage, phase, Hz, kVA, kW, amps, MCA, MOCP, refrigerant,
51
- manufacture date — and **checks each one against the equipment schedule**, flagging a mismatch instead of a
52
- silent pass. It builds an asset register and exports it to CSV.
53
 
54
  Built for the part of a data-centre job where the gear *is* the job. The expensive mistakes are a unit
55
  delivered at the wrong voltage or rating, or one that quietly never makes it onto the asset register.
56
 
57
- > **Runs on the device.** OCR and matching run in the browser (or Node) with no server and no upload. A
58
- > nameplate photo of live infrastructure never leaves the phone that took it.
 
59
 
60
- **▶ Try it live:** [huggingface.co/spaces/constructelligence/data-centre-nameplates](https://huggingface.co/spaces/constructelligence/data-centre-nameplates) — a browser demo with a sample schedule and a sample plate.
61
 
62
  | | |
63
  |---|---|
64
  | **Task** | Image → structured nameplate fields → schedule verification |
65
- | **Fields** | 13 (manufacturer, model, serial, voltage, phase, Hz, kVA, kW, amps, MCA, MOCP, refrigerant, mfg date) |
66
- | **Equipment** | 13 types (UPS, generator, ATS/STS, switchgear, transformer, PDU/RPP, panelboard, busway, battery, CDU, chiller, CRAH/CRAC, pump) |
67
  | **OCR** | Tesseract.js 5 (English LSTM), pretrained — no fine-tuning |
68
- | **Extraction** | Deterministic labelled-field parser with an OCR error model |
 
 
69
  | **Runtime** | Browser · Node · edge — no GPU, no server, no data egress |
 
70
  | **Licence** | Apache-2.0 |
71
 
72
  ---
73
 
74
  ## Why this exists
75
 
76
- General OCR reads the text on a nameplate. A data-centre commissioning or QA team needs the text to answer a
77
- narrower question: **is this the right unit, and does it match what was specified?** That means three things a
78
- plain OCR call does not do:
79
 
80
  1. **Turn plate text into fields, not paragraphs.** "INPUT: 480Y/277 VAC 3 PH 60 HZ" becomes
81
  `voltage=480Y/277 V`, `phase=3`, `hz=60`; "RATING: 750 KVA / 675 KW" becomes `kva=750`, `kw=675`.
82
  2. **Survive the way OCR misreads plate lettering.** Serifed `V`→`Vv`, `K`→`X` (`XVA`), `HZ`→`Hw`, codes split
83
- after their punctuation (`NPX- 750- 480`). The parser repairs these before matching.
84
- 3. **Compare to the schedule the way codes actually differ.** Model and serial codes are compared with `O/0`,
85
- `I/L/1`, `S/5`, `B/8`, `Z/2` folded and punctuation ignored, so a misread is not a false mismatch, while a
86
- genuine voltage variant (`NPX-750-415` vs `NPX-750-480`) still fails.
 
 
 
87
 
88
  The result is a pass/mismatch verdict per plate, with the fields that disagree named.
89
 
90
  ## How it works
91
 
92
  ```
93
- photo ──► OCR (Tesseract.js) ──► field parser ──► schedule matcher ──► verdict + asset register CSV
94
- │ │ │
95
- lines + confidence labelled-value rules code folding, tolerance checks
96
  ```
97
 
98
- 1. **OCR** (`tesseract.js@5`, English) returns lines with a per-line confidence.
99
- 2. **Field parser** scans lines for labelled values (`MODEL`, `S/N`, `MVA`, `MCA`, `MOCP`, `FLA`, `VOLTS`,
100
- `REFRIGERANT`, `MFG DATE`), reading voltages as `480Y/277`, `13.8 kV`, `208/120`, and rejecting look-alikes
101
- (`480/277` with no voltage word is not a voltage; `R-513A` and a charge weight are not amps). Lines OCR
102
- scored near zero (brushed metal, background texture) are dropped before parsing.
103
- 3. **Equipment typing** recognises the family from plate wording (UPS, genset, ATS, switchgear, transformer,
104
- PDU/RPP, panelboard, busway, battery, CDU, chiller, CRAH/CRAC, pump).
105
- 4. **Schedule matcher** ranks candidate tags — a serial match wins outright; otherwise model similarity
106
- dominates, with manufacturer and equipment type as tie-breakers. Data halls repeat, so among equal matches
107
- the first **unscanned** tag is offered.
108
- 5. **Field checks** compare the reading to the chosen row and return `ok`, `mismatch` or `unread` per field, and
 
 
 
109
  an overall status: `verified`, `partial`, `mismatch`, `unmatched` or `missing` (scheduled but not scanned).
110
 
111
- ## Fields extracted
 
 
 
 
112
 
113
  | Field | Example | Notes |
114
  |---|---|---|
115
- | `manufacturer` | Northline Power Systems | ~44 data-centre makers recognised as whole words; otherwise the company-looking line |
116
- | `model` | NPX-750-480 | `MODEL`, `MOD.`, `M/N`, `CAT. NO.`, `P/N`, `PART NO.` |
117
- | `serial` | NL26A01937 | `SERIAL`, `SER.`, `S/N`, `5/N`, `SN` |
118
- | `voltage` | 480Y/277 V | every voltage on the plate (transformers and UPSes carry more than one) |
119
- | `phase` | 3 | `1`/`3`, `PH`, `PHASE`, `Ø` |
120
- | `hz` | 60 | 50 or 60, `HZ`/`FREQUENCY` |
121
- | `kva` | 750 | thousands separators handled (`3,750`); European decimals too |
122
- | `kw` | 675 | `KW` / `EKW` |
123
- | `amps` | 1002 | labelled (`FLA`, `RLA`, `CURRENT`, `AMPS`) first |
124
- | `mca` | 612 | minimum circuit ampacity |
125
- | `mocp` | 800 | `MOCP` / `MAX FUSE` / `MAX BREAKER` |
126
- | `refrigerant` | R-513A | R-410A, 134A, 1234ZE, 1234YF, 513A, 514A, 1233ZD, 454B, 407C, 32, 22 |
127
- | `mfgDate` | 03/2026 | `MFG`, `MANUFACTURED`, `MFD`, `BUILD`, `PRODUCTION` |
128
-
129
- ## Equipment types recognised
130
-
131
- `ups` · `generator` · `ats` (also STS) · `switchgear` · `transformer` · `pdu` (also RPP) · `panelboard` ·
132
- `busway` · `battery` · `cdu` · `chiller` · `crah` (also CRAC) · `pump`
133
-
134
- Manufacturers recognised include Schneider Electric, Square D, APC, Eaton, ABB, Siemens, Vertiv, Liebert,
135
- Caterpillar, Cummins, Kohler, Rolls-Royce, MTU, Generac, Trane, Carrier, Daikin, York, Johnson Controls,
136
- Stulz, Munters, nVent, Legrand, Mitsubishi Electric, Toshiba, General Electric, Hubbell, Socomec, Piller,
137
- Starline, Raritan, Server Technology, Emerson, Russelectric, ASCO, Hitachi, Riello, Rittal, Motivair, CoolIT,
138
- Hitec, EnerSys, Leclanché and Samsung SDI.
139
-
140
- ## Results — OCR and extraction, end to end
141
-
142
- Six drawn plates (UPS, diesel genset, chiller, dry-type transformer, PDU, CRAH), each pushed through the
143
- degradations a phone photo actually has, then run through the app's own OCR + parser and scored **field by
144
- field**. **56 checks per condition** (six plates × their fields), 13 conditions.
145
-
146
- | Condition | Field accuracy | ms/plate |
147
- |---|:---:|---:|
148
- | clean | **100%** (56/56) | 1236 |
149
- | small (640 px) | **100%** (56/56) | 897 |
150
- | blur | **100%** (56/56) | 540 |
151
- | noise | **100%** (56/56) | 748 |
152
- | low contrast | **100%** (56/56) | 557 |
153
- | glare | **100%** (56/56) | 532 |
154
- | tilt (7°) | **100%** (56/56) | 612 |
155
- | keystone | **100%** (56/56) | 474 |
156
- | dark anodised plate | **100%** (56/56) | 808 |
157
- | JPEG | **100%** (56/56) | 524 |
158
- | plate small in a cluttered scene | **100%** (56/56) | 895 |
159
- | sideways | **100%** (56/56) | 554 |
160
- | phone (blur + glare + noise + tilt + scene + JPEG) | **98.2%** (55/56) | 1607 |
161
- | **Overall** | **99.9% (727/728)** | **768** |
162
-
163
- The single miss is one `hz` value on the hardest "phone" composite. The benchmark measures what **OCR** loses,
164
- not parser coverage: the ground truth is the parser's own reading of the clean plate text, so every reduction
165
- is an OCR failure the parser could not repair. Reproduce it with `node tests/nameplates-bench.mjs`.
166
-
167
- **Read this honestly.** These are crisp, machine-drawn plates. A real nameplate photographed on a live unit is
168
- harder: cast shadows, embossed lettering, print over brushed metal, a plate half out of frame. Expect the
169
- degradation suite, not the clean row, and treat every low-confidence field as one a person must confirm.
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
  ## Fine-tuning — the higher-accuracy server model
173
 
174
- The engine above is the on-device path. Alongside it, a **Donut** model
175
  ([`naver-clova-ix/donut-base`](https://huggingface.co/naver-clova-ix/donut-base)) is fine-tuned to read a plate
176
  **straight into fields** (`<s_nameplate><s_type>ups</s_type><s_manufacturer>…`) for a server / endpoint where
177
- the browser engine falls short. Training data is synthetic plates with real-photo degradations, plus corrected
178
- real plates from the field. Once trained, the weights are published in a sibling repository and linked here.
 
179
 
180
  Nothing on this page claims the accuracy of that model yet — it is the roadmap, not a result.
181
 
@@ -191,16 +242,16 @@ Nothing on this page claims the accuracy of that model yet — it is the roadmap
191
 
192
  - **Safety, code compliance or energisation decisions.** It reads a plate; it does not verify that a unit is
193
  safe to energise, correctly protected, or code-compliant.
194
- - **An as-built or a legal record on its own.** OCR misreads happen; low-confidence fields are highlighted for a
195
- person to check, and any field that disagrees with the schedule must be confirmed by eye.
196
- - **Serial-number evidence in a dispute** without human verification. Two plates can share a misread serial;
197
- the register flags duplicate serials but cannot tell a repeated plate from a copied number.
198
 
199
  ## Limitations
200
 
201
  - **English nameplates**, Latin script. Non-Latin or handwritten plates are out of scope.
202
- - **Glare, blur and angle degrade OCR.** The parser repairs common misreads, but a plate that is small in the
203
- frame, badly glared or very low contrast will lose fields (those fields come back `unread`, not invented).
204
  - **Deterministic parser, not a language model.** It reports only what it can match to a rule; it will not
205
  infer a missing digit.
206
  - **The schedule must be structured** (CSV with a Tag or Model column). Free-form schedule PDFs are not parsed
@@ -214,7 +265,6 @@ The engine is plain ES modules with no build step. `nameplate-model.js` is the e
214
  ```js
215
  import { parseNameplate, parseEquipmentSchedule, candidatesFor, checkAgainst, statusOf } from './nameplate-model.js';
216
 
217
- // 1. Parse plate text (from Tesseract, or typed by hand)
218
  const reading = parseNameplate(`NORTHLINE POWER SYSTEMS
219
  UNINTERRUPTIBLE POWER SUPPLY
220
  MODEL NO: NPX-750-480
@@ -227,24 +277,24 @@ MFG DATE: 03/2026`);
227
  console.log(reading.type); // 'ups'
228
  console.log(reading.fields.voltage); // { value: '480Y/277 V', values: [480, 277], conf, line }
229
 
230
- // 2. Match against the equipment schedule and check each field
231
  const { items } = parseEquipmentSchedule(scheduleCsv);
232
  const [best] = candidatesFor(reading, items);
233
- const checks = checkAgainst(reading, best.item);
234
- console.log(statusOf(checks, best.item.tag)); // 'verified' | 'partial' | 'mismatch' | ...
235
  ```
236
 
237
- In a browser, feed Tesseract's `data.lines` (`[{ text, confidence, bbox }]`) straight into `parseNameplate`
238
- to keep OCR confidence and the plate line each value came from.
239
 
240
- ## Evaluation
241
 
242
- - **Extraction/verification logic:** 12 unit tests over the parser, voltage reader, code folding, schedule
243
- parsing, candidate ranking and mismatch detection — all passing (`node --test tests/nameplate-model.test.mjs`).
244
- - **OCR end-to-end:** a field-by-field benchmark draws plates for six equipment types and puts each through the
245
- degradations real phone photos have — small, blurred, noisy, low-contrast, glare, tilt, keystone, dark
246
- anodised plate, JPEG, plate-small-in-scene and sideways — then scores the app's own OCR + parsing pipeline
247
- field by field. Run it with `node tests/nameplates-bench.mjs`.
 
 
248
 
249
  ## Repository contents
250
 
@@ -254,9 +304,9 @@ to keep OCR confidence and the plate line each value came from.
254
  | `schedule-import.js` | the CSV schedule reader it depends on |
255
  | `progress-project.js` | a helper the schedule reader imports |
256
  | `sample_schedule.csv` | a 10-row data-centre equipment schedule |
257
- | `sample_plates.json` | two sample plates: one that verifies, one flagged on model and voltage |
258
- | `eval/` | the OCR benchmark harness output |
259
- | `config.json` | machine-readable fields, types and statuses |
260
 
261
  ## Provenance
262
 
@@ -275,8 +325,8 @@ acting on it.
275
  ## Licence and trademarks
276
 
277
  Code released under **Apache-2.0**. Brand and product names (Schneider Electric, Eaton, Vertiv, Caterpillar,
278
- Trane, …) are trademarks of their owners and are matched only to identify user-supplied plates; this project is
279
- independent and not affiliated with or endorsed by them.
280
 
281
  ## Citation
282
 
 
8
  - ocr
9
  - document-ai
10
  - information-extraction
11
+ - key-information-extraction
12
  - nameplate
13
  - nameplate-ocr
14
  - equipment-nameplate
 
18
  - mission-critical
19
  - mep
20
  - electrical
21
+ - mechanical
22
  - equipment-schedule
23
  - schedule-verification
24
  - asset-register
 
36
  - crac
37
  - chiller
38
  - cooling
39
+ - server-rack
40
  - construction
41
  - aec
42
  - edge-ai
 
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
 
57
  Built for the part of a data-centre job where the gear *is* the job. The expensive mistakes are a unit
58
  delivered at the wrong voltage or rating, or one that quietly never makes it onto the asset register.
59
 
60
+ > **Runs on the device.** OCR, extraction and matching run in the browser (or Node) with no server and no
61
+ > upload. A nameplate photo of live infrastructure never leaves the phone that took it — no GPU, no key, no
62
+ > data egress.
63
 
64
+ **▶ Try it live:** [huggingface.co/spaces/constructelligence/data-centre-nameplates](https://huggingface.co/spaces/constructelligence/data-centre-nameplates) — a browser demo with a sample schedule and sample plates.
65
 
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 |
73
+ | **Matching** | Code folding (`O/0`, `I/L/1`, `S/5`, `B/8`, `Z/2`) + voltage-compatibility signal |
74
+ | **Quality gate** | Advisory photo check (blur, glare, darkness, resolution) before OCR |
75
  | **Runtime** | Browser · Node · edge — no GPU, no server, no data egress |
76
+ | **Field accuracy** | **99.8%** clean / **80.0%** under character-error stress (extraction benchmark) |
77
  | **Licence** | Apache-2.0 |
78
 
79
  ---
80
 
81
  ## Why this exists
82
 
83
+ General OCR reads the text on a nameplate. A commissioning or QA team needs the text to answer a narrower
84
+ question: **is this the right unit, and does it match what was specified?** That means three things a plain
85
+ OCR call does not do:
86
 
87
  1. **Turn plate text into fields, not paragraphs.** "INPUT: 480Y/277 VAC 3 PH 60 HZ" becomes
88
  `voltage=480Y/277 V`, `phase=3`, `hz=60`; "RATING: 750 KVA / 675 KW" becomes `kva=750`, `kw=675`.
89
  2. **Survive the way OCR misreads plate lettering.** Serifed `V`→`Vv`, `K`→`X` (`XVA`), `HZ`→`Hw`, codes split
90
+ after their punctuation (`NPX- 750- 480`). The parser repairs these before matching, and rejects
91
+ out-of-range noise (`0 KVA`, a stray `0` amp) rather than recording it.
92
+ 3. **Compare to the schedule the way codes actually differ.** Model and serial codes are compared with
93
+ `O/0`, `I/L/1`, `S/5`, `B/8`, `Z/2` folded and punctuation ignored, so a misread is not a false mismatch,
94
+ while a genuine voltage variant (`NPX-750-415` vs `NPX-750-480`) still fails. A voltage a plate and a
95
+ schedule row share is a positive signal; the equipment **type** still wins ties, so a 415 V UPS plate is not
96
+ matched to a same-voltage PDU.
97
 
98
  The result is a pass/mismatch verdict per plate, with the fields that disagree named.
99
 
100
  ## How it works
101
 
102
  ```
103
+ photo ─► photo check ─► OCR (Tesseract.js) ─► field parser ─► equipment typing ─► schedule matcher ─► verdict + CSV
104
+ blur/glare lines + confidence labelled rules maker line excluded code folding, voltage signal
 
105
  ```
106
 
107
+ 1. **Photo check** — an advisory blur/glare/darkness/resolution read before OCR; the shot can be retaken
108
+ before a bad read.
109
+ 2. **OCR** (`tesseract.js@5`, English) returns lines with a per-line confidence; two page-segmentation passes
110
+ are merged field by field when the first is weak.
111
+ 3. **Field parser** scans lines for labelled values (`MODEL`, `S/N`, `MVA`, `MCA`, `MOCP`, `FLA`, `VOLTS`,
112
+ `REFRIGERANT`, `MFG DATE`, …), reading voltages as `480Y/277`, `13.8 kV`, `208/120`, and rejecting
113
+ look-alikes. Numeric fields are range-guarded. Lines OCR scored near zero are dropped first.
114
+ 4. **Equipment typing** recognises **55 families** and ranks the specific device above its family (flywheel
115
+ before UPS; paralleling gear before generator; dry cooler before chiller; rack before server). The **maker
116
+ line is excluded** from typing, so a vendor called “…Transformer Corp” cannot type a chiller plate.
117
+ 5. **Schedule matcher** ranks candidate tags — a serial match wins outright; otherwise model similarity
118
+ dominates, with manufacturer, equipment type and a compatible voltage as signals. Among equal matches the
119
+ first **unscanned** tag is offered.
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
+
127
+ **Electrical**
128
 
129
  | Field | Example | Notes |
130
  |---|---|---|
131
+ | `voltage` | 480Y/277 V | every voltage on the plate; `13.8 kV`, `208/120`, `480` |
132
+ | `phase` / `hz` | 3 / 60 | `PH`/`PHASE`/`Ø`; 50 or 60 Hz |
133
+ | `kva` / `kw` | 750 / 675 | `kVA`, `KW`, `EKW`; thousands separators |
134
+ | `amps` | 1002 | labelled `FLA`/`RLA`/`CURRENT`/`AMPS` first |
135
+ | `pf` | 0.8 | power factor, 0–1 |
136
+ | `mca` / `mocp` | 612 / 800 | min circuit ampacity; max overcurrent protection |
137
+ | `sccr` | 65 | short-circuit / interrupting rating (kAIC) |
138
+ | `enclosure` | NEMA 3R | NEMA type or IEC IP code |
139
+ | `standards` | UL 1989 | UL / IEC / ANSI / IEEE / NFPA / CSA / EN listings |
140
+ | `batteryType` | VRLA | VRLA, AGM, Gel, Flooded, Li-ion, LiFePO4, NiCd, Lead-acid |
141
+ | `batteryAh` / `cells` | 500 / 48 | amp-hours; cell count |
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
+
149
+ ## Equipment types recognised (55)
150
+
151
+ Power: flywheel · UPS · STS · ATS · rectifier/DC · inverter · BESS · nitrogen generator · paralleling gear ·
152
+ generator · fuel system · switchgear/switchboard · MCC · metering/CT · protective relay · transformer/DGA monitor ·
153
+ SPD · capacitor/PFC · neutral grounding resistor · VFD · busway · panelboard · PDU/RPP · battery · load bank
154
+
155
+ Cooling & mechanical: heat pump · pump · cooling tower · thermal storage · chiller · dry cooler/condenser · CDU ·
156
+ CRAH/CRAC · AHU · heat exchanger · expansion tank · air separator · boiler · humidifier · water treatment ·
157
+ air dryer · compressor · fan coil · rooftop unit · makeup air · economizer
158
+
159
+ Fire, safety & controls: fire suppression · fire alarm · leak/gas detection · BMS/EPMS/DCIM
160
+
161
+ IT & white space: **rack** · **server/storage** · network gear · KVM/console
162
+
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
+
172
+ ### Extraction benchmark — labelled plates, field by field
173
+
174
+ A generated corpus of plates with **by-construction** ground truth (not the parser's own reading), rendered
175
+ with wording variety. `clean` isolates the extractor + matcher; `ocr` adds the character errors OCR makes on
176
+ plate lettering. n = 1000. Reproduce with `node tests/nameplates-extraction-bench.mjs --n=1000`.
177
+
178
+ | Condition | Exact plate | Micro-F1 | ms/plate |
179
+ |---|:---:|:---:|---:|
180
+ | clean | **94.1%** | **99.7%** | 0.49 |
181
+ | ocr (character-error stress) | 1.1% | **79.9%** | 0.49 |
182
+
183
+ Per-field F1 is 100% for manufacturer, type, model, serial, kVA, phase, Hz, MCA, MOCP, refrigerant,
184
+ pf, cfm, uHeight, sccr and enclosure in the clean condition; the residual clean misses are a small number of
185
+ voltage and `mfgDate` cases. The `ocr` condition is deliberately harsh (per-character flips); low-confidence
186
+ fields are surfaced for a human rather than trusted. Per-field tables: `eval/extraction-bench.json`.
187
+
188
+ ### OCR benchmark — drawn plates through phone-photo degradations
189
+
190
+ Plates drawn for several equipment types, each pushed through real-photo degradations (small, blurred, noisy,
191
+ low-contrast, glare, tilted, keystoned, dark anodised, JPEG, plate-small-in-scene, sideways, and a "phone"
192
+ composite), then run through the app's own OCR + parser and scored field by field. An earlier run over six
193
+ plate types and 13 conditions scored **99.9% overall (727/728)**; the harness now draws more plate types and
194
+ is re-run on release. Run it with `node tests/nameplates-bench.mjs` (needs a browser for Tesseract.js).
195
+
196
+ **Read this honestly.** These are machine-drawn plates. A real plate on a live unit is harder: cast shadows,
197
+ embossed lettering, print over brushed metal, a plate half out of frame. Expect the degradation suite, not the
198
+ clean row, and treat every low-confidence field as one a person must confirm.
199
+
200
+ ## Comparison with related models
201
+
202
+ Nameplate extraction has no shared public benchmark — FUNSD/CORD measure invoices and forms, not equipment
203
+ plates. These are the closest published reference points, on **their own** datasets; they are context, not a
204
+ head-to-head. The only directly comparable scores are ones produced by the corpus in
205
+ `ml/nameplates/bench/`.
206
+
207
+ | Model / method | Task & dataset | Reported |
208
+ |---|---|---|
209
+ | **This engine** | **named-field extraction, own labelled plate corpus** | **99.7% micro-F1 (clean)** |
210
+ | LayoutLMv3 (base) | entity extraction, FUNSD | 92.08 F1 |
211
+ | LiLT | entity extraction, FUNSD | 88.41 F1 |
212
+ | Donut | field extraction, CORD | 84.1 F1 |
213
+ | CTPN + Transformer | power-equipment nameplate detection / recognition | 88.7% det F1 · 92.3% char acc |
214
+ | PP-OCRv4 (TL-DREN) | electricity nameplate detection / recognition | 0.524 det F1 · 0.82 rec acc |
215
+ | RNN nameplate OCR | power-equipment nameplate chars (zh + alnum) | 99.9% zh · 99.3% alnum |
216
+ | OCR-free VLM (e.g. Qwen2.5-VL-7B) | document KIE, zero-shot | varies; strong but large |
217
+
218
+ What is different here: the engine is **small, deterministic, on-device, and schedule-aware**. It does not
219
+ just transcribe a plate — it decides `verified` / `mismatch` against a schedule and names the fields that
220
+ disagree, which no general document model does out of the box.
221
 
222
  ## Fine-tuning — the higher-accuracy server model
223
 
224
+ Alongside the on-device path, a **Donut** model
225
  ([`naver-clova-ix/donut-base`](https://huggingface.co/naver-clova-ix/donut-base)) is fine-tuned to read a plate
226
  **straight into fields** (`<s_nameplate><s_type>ups</s_type><s_manufacturer>…`) for a server / endpoint where
227
+ the browser engine falls short. Training data is synthetic plates with real-photo degradations plus corrected
228
+ real plates; best checkpoints are selected by **field accuracy**, using the same OCR-aware comparison the app
229
+ uses. The corpus in `ml/nameplates/bench/` scores it on the same test set as the on-device engine.
230
 
231
  Nothing on this page claims the accuracy of that model yet — it is the roadmap, not a result.
232
 
 
242
 
243
  - **Safety, code compliance or energisation decisions.** It reads a plate; it does not verify that a unit is
244
  safe to energise, correctly protected, or code-compliant.
245
+ - **An as-built or a legal record on its own.** OCR misreads happen; low-confidence fields are highlighted and
246
+ any field that disagrees with the schedule must be confirmed by eye.
247
+ - **Serial-number evidence in a dispute** without human verification; the register flags duplicate serials but
248
+ cannot tell a repeated plate from a copied number.
249
 
250
  ## Limitations
251
 
252
  - **English nameplates**, Latin script. Non-Latin or handwritten plates are out of scope.
253
+ - **Glare, blur and angle degrade OCR.** The parser repairs common misreads, but a badly glared or very
254
+ low-contrast plate will lose fields (those come back `unread`, not invented).
255
  - **Deterministic parser, not a language model.** It reports only what it can match to a rule; it will not
256
  infer a missing digit.
257
  - **The schedule must be structured** (CSV with a Tag or Model column). Free-form schedule PDFs are not parsed
 
265
  ```js
266
  import { parseNameplate, parseEquipmentSchedule, candidatesFor, checkAgainst, statusOf } from './nameplate-model.js';
267
 
 
268
  const reading = parseNameplate(`NORTHLINE POWER SYSTEMS
269
  UNINTERRUPTIBLE POWER SUPPLY
270
  MODEL NO: NPX-750-480
 
277
  console.log(reading.type); // 'ups'
278
  console.log(reading.fields.voltage); // { value: '480Y/277 V', values: [480, 277], conf, line }
279
 
 
280
  const { items } = parseEquipmentSchedule(scheduleCsv);
281
  const [best] = candidatesFor(reading, items);
282
+ console.log(statusOf(checkAgainst(reading, best.item), best.item.tag)); // 'verified' | 'partial' | 'mismatch' | …
 
283
  ```
284
 
285
+ In a browser, feed Tesseract's `data.lines` (`[{ text, confidence, bbox }]`) straight into `parseNameplate` to
286
+ keep OCR confidence and the plate line each value came from.
287
 
288
+ ## Evaluation & reproducibility
289
 
290
+ - **Extraction:** `node tests/nameplates-extraction-bench.mjs --n=1000 --json=…` — labelled corpus, per-field
291
+ precision/recall/F1, whole-plate exact match, ms/plate. Corpus dump for other models:
292
+ `… --dump=…/extraction-corpus.jsonl`. Method and comparator: `ml/nameplates/bench/README.md`.
293
+ - **Unit tests:** the parser, voltage reader, code folding, schedule parsing, candidate ranking, mismatch
294
+ detection, range guards and the matcher — `node --test tests/nameplate-model.test.mjs` (all passing).
295
+ - **OCR end-to-end:** `node tests/nameplates-bench.mjs` — drawn plates through phone-photo degradations.
296
+ - **Fine-tuned reader:** `ml/nameplates/cloud/eval_donut.py` scores the Donut model field by field on the
297
+ held-out split.
298
 
299
  ## Repository contents
300
 
 
304
  | `schedule-import.js` | the CSV schedule reader it depends on |
305
  | `progress-project.js` | a helper the schedule reader imports |
306
  | `sample_schedule.csv` | a 10-row data-centre equipment schedule |
307
+ | `sample_plates.json` | sample plates: a match, a flagged mismatch, a switchboard, a server |
308
+ | `eval/` | OCR benchmark output, extraction benchmark output and corpus |
309
+ | `config.json` | machine-readable fields, groups, types and statuses |
310
 
311
  ## Provenance
312
 
 
325
  ## Licence and trademarks
326
 
327
  Code released under **Apache-2.0**. Brand and product names (Schneider Electric, Eaton, Vertiv, Caterpillar,
328
+ Trane, Dell, Cisco, …) are trademarks of their owners and are matched only to identify user-supplied plates;
329
+ this project is independent and not affiliated with or endorsed by them.
330
 
331
  ## Citation
332
 
bench/README.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Benchmark: nameplate field extraction
2
+
3
+ One labelled corpus, one metric, so this engine and any other model (Donut, LayoutLMv3, a VLM, a
4
+ cloud document-AI API) can be compared on the **same** data.
5
+
6
+ ```sh
7
+ # Run the deterministic engine and write the numbers + the corpus
8
+ node tests/nameplates-extraction-bench.mjs \
9
+ --n=1000 \
10
+ --json=ml/nameplates/hf/data-centre-nameplates/eval/extraction-bench.json \
11
+ --dump=ml/nameplates/hf/data-centre-nameplates/eval/extraction-corpus.jsonl \
12
+ --dumpN=200
13
+ ```
14
+
15
+ ## What it measures
16
+
17
+ Each item is generated from **constructed** field values (so the ground truth is not the parser's own
18
+ reading), rendered with the wording variety real plates use, and scored field by field:
19
+
20
+ - **clean** — measures the extractor + matcher alone (no OCR loss).
21
+ - **ocr** — the same plate text pushed through the character errors OCR actually makes on plate
22
+ lettering (`O/0`, `I/L/1`, `S/5`, `B/8`, `Z/2`, `HZ→Hw`, `KVA→XVA`, dropped/split characters).
23
+
24
+ Reported: per-field precision / recall / F1, whole-plate exact match (no missed and no spurious field),
25
+ and ms/plate. Code similarity uses the same `O/0`, `I/L/1`, `S/5`, `B/8`, `Z/2` folding the app uses.
26
+
27
+ ## Scoring another model on the same corpus
28
+
29
+ `--dump` writes JSONL, one item per line:
30
+
31
+ ```json
32
+ {"id":0,"type":"transformer","clean":"<plate text>","ocr":"<corrupted text>","truth":{"manufacturer":"…","model":"…","voltage":[4160,480,277],"kva":1500,…}}
33
+ ```
34
+
35
+ Two ways to use it:
36
+
37
+ 1. **Text in / fields out** (LayoutLM, an LLM/VLM over OCR text, a cloud KIE API): feed `ocr`, parse
38
+ the model's fields, and compare to `truth` with the same normalisers. This isolates extraction.
39
+ 2. **Image in / fields out** (Donut, TrOCR+KIE, a VLM): render `clean`/`ocr` text to an image with the
40
+ project's plate drawer (`window.__nameplates.samplePlate` in `nameplates-app.js`, or
41
+ `ml/nameplates/synth/generate.py`), run the model, compare to `truth`. This folds OCR into the score.
42
+
43
+ Use the project's own comparator so the rules are identical:
44
+
45
+ ```js
46
+ import { codeSimilarity } from '@constructelligence/nameplate-model'; // or '../nameplate-model.js'
47
+ const codeOk = (want, got) => codeSimilarity(want, got) >= 0.85; // model / serial
48
+ const numOk = (want, got) => got != null && Math.abs(+got - +want) < 1e-6;
49
+ const voltOk = (want, got) => want.values.every(v => got.values.includes(v));
50
+ ```
51
+
52
+ ## Reference points (published, different datasets — not directly comparable)
53
+
54
+ These are reported by their authors on their own benchmarks. They are context for the task, not a
55
+ head-to-head; the only numbers comparable to each other are ones produced by the corpus above.
56
+
57
+ | Model | Task / dataset | Reported |
58
+ |---|---|---|
59
+ | LayoutLMv3 (base) | entity extraction, FUNSD | 92.08 F1 |
60
+ | LiLT | entity extraction, FUNSD | 88.41 F1 |
61
+ | Donut | field extraction, CORD | 84.1 F1 |
62
+ | LayoutLMv3-large | information extraction, FUNSD | ~92 F1 |
63
+ | CTPN + Transformer | power-equipment nameplate detection / recognition | 88.7% det F1 · 92.3% char acc |
64
+ | PP-OCRv4 (TL-DREN) | electricity nameplate detection / recognition | 0.524 det F1 · 0.82 rec acc |
65
+ | RNN nameplate OCR | power-equipment nameplate chars (zh + alnum) | 99.9% zh · 99.3% alnum |
66
+
67
+ Nameplate extraction is a small field with no shared public benchmark — FUNSD/CORD measure invoices and
68
+ forms, not equipment plates. That is exactly why this corpus is published: to give the task a
69
+ reproducible, domain-specific test set.
config.json CHANGED
@@ -1,23 +1,38 @@
1
  {
2
  "name": "data-centre-nameplates",
3
- "version": "1.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 gear: UPS, PDU, switchgear, generators, ATS, transformers, busway, batteries, CRAH/CRAC, chillers, CDUs and pumps.",
8
  "engine": {
9
  "ocr": "tesseract.js 5 (LSTM English)",
10
- "extraction": "deterministic labelled-field parser + OCR error model",
11
- "matching": "code-similarity (O/0, I/L/1, S/5, B/8, Z/2 folding) + schedule field checks",
 
12
  "runtime": "browser / Node, no server, no upload"
13
  },
 
 
 
 
 
 
 
14
  "fields": [
15
- "manufacturer", "model", "serial", "voltage", "phase", "hz",
16
- "kva", "kw", "amps", "mca", "mocp", "refrigerant", "mfgDate"
 
 
 
17
  ],
18
  "equipment_types": [
19
- "ups", "generator", "ats", "switchgear", "transformer", "pdu",
20
- "panelboard", "busway", "battery", "cdu", "chiller", "crah", "pump"
 
 
 
 
21
  ],
22
  "statuses": ["verified", "partial", "mismatch", "unmatched", "missing"],
23
  "license": "apache-2.0",
 
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",
11
+ "matching": "code-similarity (O/0, I/L/1, S/5, B/8, Z/2 folding), voltage-compatibility signal + schedule field checks",
12
+ "quality": "advisory photo check (blur, glare, darkness, resolution) before OCR",
13
  "runtime": "browser / Node, no server, no upload"
14
  },
15
+ "field_groups": {
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": [
30
+ "flywheel", "ups", "sts", "ats", "rectifier", "inverter", "bess", "nitrogen", "paralleling", "generator",
31
+ "fueltank", "switchgear", "mcc", "metering", "relay", "tmonitor", "transformer", "spd", "capacitor", "ngr",
32
+ "vfd", "busway", "panelboard", "pdu", "battery", "loadbank", "heatpump", "pump", "coolingtower", "thermalstorage",
33
+ "chiller", "condenser", "cdu", "crah", "ahu", "heatexchanger", "expansiontank", "airseparator", "boiler", "humidifier",
34
+ "watertreatment", "dryer", "compressor", "fcu", "rtu", "mua", "economizer", "firesuppression", "firealarm", "leakdetection",
35
+ "bms", "kvm", "rack", "server", "network"
36
  ],
37
  "statuses": ["verified", "partial", "mismatch", "unmatched", "missing"],
38
  "license": "apache-2.0",
eval/extraction-bench.json ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "clean": {
3
+ "condition": "clean",
4
+ "n": 1000,
5
+ "exactPlate": 0.941,
6
+ "microF1": 0.997,
7
+ "msPerPlate": 0.48,
8
+ "fields": {
9
+ "manufacturer": {
10
+ "precision": 1,
11
+ "recall": 1,
12
+ "f1": 1,
13
+ "support": 1000
14
+ },
15
+ "type": {
16
+ "precision": 1,
17
+ "recall": 1,
18
+ "f1": 1,
19
+ "support": 1000
20
+ },
21
+ "voltage": {
22
+ "precision": 1,
23
+ "recall": 0.958,
24
+ "f1": 0.979,
25
+ "support": 1000
26
+ },
27
+ "kva": {
28
+ "precision": 1,
29
+ "recall": 1,
30
+ "f1": 1,
31
+ "support": 563
32
+ },
33
+ "phase": {
34
+ "precision": 1,
35
+ "recall": 1,
36
+ "f1": 1,
37
+ "support": 867
38
+ },
39
+ "hz": {
40
+ "precision": 1,
41
+ "recall": 1,
42
+ "f1": 1,
43
+ "support": 867
44
+ },
45
+ "model": {
46
+ "precision": 1,
47
+ "recall": 1,
48
+ "f1": 1,
49
+ "support": 1000
50
+ },
51
+ "serial": {
52
+ "precision": 1,
53
+ "recall": 1,
54
+ "f1": 1,
55
+ "support": 1000
56
+ },
57
+ "mfgDate": {
58
+ "precision": 1,
59
+ "recall": 1,
60
+ "f1": 1,
61
+ "support": 867
62
+ },
63
+ "batteryType": {
64
+ "precision": 1,
65
+ "recall": 1,
66
+ "f1": 1,
67
+ "support": 133
68
+ },
69
+ "cells": {
70
+ "precision": 1,
71
+ "recall": 1,
72
+ "f1": 1,
73
+ "support": 133
74
+ },
75
+ "batteryAh": {
76
+ "precision": 0.993,
77
+ "recall": 1,
78
+ "f1": 0.996,
79
+ "support": 133
80
+ },
81
+ "kw": {
82
+ "precision": 0.992,
83
+ "recall": 0.996,
84
+ "f1": 0.994,
85
+ "support": 235
86
+ },
87
+ "amps": {
88
+ "precision": 0.991,
89
+ "recall": 0.998,
90
+ "f1": 0.994,
91
+ "support": 428
92
+ },
93
+ "mca": {
94
+ "precision": 1,
95
+ "recall": 1,
96
+ "f1": 1,
97
+ "support": 204
98
+ },
99
+ "mocp": {
100
+ "precision": 1,
101
+ "recall": 1,
102
+ "f1": 1,
103
+ "support": 204
104
+ },
105
+ "refrigerant": {
106
+ "precision": 0.991,
107
+ "recall": 1,
108
+ "f1": 0.996,
109
+ "support": 111
110
+ },
111
+ "pf": {
112
+ "precision": 1,
113
+ "recall": 1,
114
+ "f1": 1,
115
+ "support": 108
116
+ },
117
+ "cfm": {
118
+ "precision": 1,
119
+ "recall": 1,
120
+ "f1": 1,
121
+ "support": 193
122
+ },
123
+ "uHeight": {
124
+ "precision": 1,
125
+ "recall": 1,
126
+ "f1": 1,
127
+ "support": 100
128
+ },
129
+ "powerW": {
130
+ "precision": 0.943,
131
+ "recall": 0.99,
132
+ "f1": 0.966,
133
+ "support": 100
134
+ },
135
+ "sccr": {
136
+ "precision": 1,
137
+ "recall": 1,
138
+ "f1": 1,
139
+ "support": 111
140
+ },
141
+ "enclosure": {
142
+ "precision": 1,
143
+ "recall": 1,
144
+ "f1": 1,
145
+ "support": 111
146
+ }
147
+ }
148
+ },
149
+ "ocr": {
150
+ "condition": "ocr",
151
+ "n": 1000,
152
+ "exactPlate": 0.011,
153
+ "microF1": 0.799,
154
+ "msPerPlate": 0.49,
155
+ "fields": {
156
+ "manufacturer": {
157
+ "precision": 1,
158
+ "recall": 0.72,
159
+ "f1": 0.837,
160
+ "support": 1000
161
+ },
162
+ "type": {
163
+ "precision": 1,
164
+ "recall": 0.518,
165
+ "f1": 0.682,
166
+ "support": 1000
167
+ },
168
+ "voltage": {
169
+ "precision": 1,
170
+ "recall": 0.675,
171
+ "f1": 0.806,
172
+ "support": 1000
173
+ },
174
+ "kva": {
175
+ "precision": 1,
176
+ "recall": 0.622,
177
+ "f1": 0.767,
178
+ "support": 555
179
+ },
180
+ "phase": {
181
+ "precision": 1,
182
+ "recall": 0.94,
183
+ "f1": 0.969,
184
+ "support": 878
185
+ },
186
+ "hz": {
187
+ "precision": 1,
188
+ "recall": 0.786,
189
+ "f1": 0.88,
190
+ "support": 878
191
+ },
192
+ "model": {
193
+ "precision": 1,
194
+ "recall": 0.6,
195
+ "f1": 0.75,
196
+ "support": 1000
197
+ },
198
+ "serial": {
199
+ "precision": 1,
200
+ "recall": 0.627,
201
+ "f1": 0.771,
202
+ "support": 1000
203
+ },
204
+ "mfgDate": {
205
+ "precision": 1,
206
+ "recall": 0.321,
207
+ "f1": 0.486,
208
+ "support": 878
209
+ },
210
+ "uHeight": {
211
+ "precision": 0.974,
212
+ "recall": 0.958,
213
+ "f1": 0.966,
214
+ "support": 118
215
+ },
216
+ "powerW": {
217
+ "precision": 0.849,
218
+ "recall": 0.619,
219
+ "f1": 0.716,
220
+ "support": 118
221
+ },
222
+ "cfm": {
223
+ "precision": 1,
224
+ "recall": 0.653,
225
+ "f1": 0.79,
226
+ "support": 225
227
+ },
228
+ "amps": {
229
+ "precision": 0.94,
230
+ "recall": 0.809,
231
+ "f1": 0.87,
232
+ "support": 446
233
+ },
234
+ "sccr": {
235
+ "precision": 1,
236
+ "recall": 0.847,
237
+ "f1": 0.917,
238
+ "support": 124
239
+ },
240
+ "enclosure": {
241
+ "precision": 1,
242
+ "recall": 0.815,
243
+ "f1": 0.898,
244
+ "support": 124
245
+ },
246
+ "kw": {
247
+ "precision": 0.994,
248
+ "recall": 0.719,
249
+ "f1": 0.835,
250
+ "support": 221
251
+ },
252
+ "pf": {
253
+ "precision": 1,
254
+ "recall": 0.946,
255
+ "f1": 0.972,
256
+ "support": 112
257
+ },
258
+ "mca": {
259
+ "precision": 1,
260
+ "recall": 0.834,
261
+ "f1": 0.91,
262
+ "support": 205
263
+ },
264
+ "mocp": {
265
+ "precision": 1,
266
+ "recall": 0.707,
267
+ "f1": 0.829,
268
+ "support": 205
269
+ },
270
+ "refrigerant": {
271
+ "precision": 0.973,
272
+ "recall": 0.735,
273
+ "f1": 0.837,
274
+ "support": 98
275
+ },
276
+ "batteryType": {
277
+ "precision": 1,
278
+ "recall": 0.656,
279
+ "f1": 0.792,
280
+ "support": 122
281
+ },
282
+ "cells": {
283
+ "precision": 1,
284
+ "recall": 0.459,
285
+ "f1": 0.629,
286
+ "support": 122
287
+ },
288
+ "batteryAh": {
289
+ "precision": 0.965,
290
+ "recall": 0.672,
291
+ "f1": 0.792,
292
+ "support": 122
293
+ }
294
+ }
295
+ }
296
+ }
eval/extraction-bench.log ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ CLEAN n=1000 exact-plate 94.1% micro-F1 99.7% 0.49 ms/plate
3
+ powerW P 94.3 R 99.0 F1 96.6 (100)
4
+ voltage P 100.0 R 95.8 F1 97.9 (1000)
5
+ kw P 99.2 R 99.6 F1 99.4 (235)
6
+ amps P 99.1 R 99.8 F1 99.4 (428)
7
+ batteryAh P 99.3 R 100.0 F1 99.6 (133)
8
+ refrigerant P 99.1 R 100.0 F1 99.6 (111)
9
+ manufacturer P 100.0 R 100.0 F1 100.0 (1000)
10
+ type P 100.0 R 100.0 F1 100.0 (1000)
11
+ kva P 100.0 R 100.0 F1 100.0 (563)
12
+ phase P 100.0 R 100.0 F1 100.0 (867)
13
+ hz P 100.0 R 100.0 F1 100.0 (867)
14
+ model P 100.0 R 100.0 F1 100.0 (1000)
15
+ serial P 100.0 R 100.0 F1 100.0 (1000)
16
+ mfgDate P 100.0 R 100.0 F1 100.0 (867)
17
+ batteryType P 100.0 R 100.0 F1 100.0 (133)
18
+ cells P 100.0 R 100.0 F1 100.0 (133)
19
+ mca P 100.0 R 100.0 F1 100.0 (204)
20
+ mocp P 100.0 R 100.0 F1 100.0 (204)
21
+ pf P 100.0 R 100.0 F1 100.0 (108)
22
+ cfm P 100.0 R 100.0 F1 100.0 (193)
23
+ uHeight P 100.0 R 100.0 F1 100.0 (100)
24
+ sccr P 100.0 R 100.0 F1 100.0 (111)
25
+ enclosure P 100.0 R 100.0 F1 100.0 (111)
26
+
27
+ OCR n=1000 exact-plate 1.1% micro-F1 79.9% 0.49 ms/plate
28
+ mfgDate P 100.0 R 32.1 F1 48.6 (878)
29
+ cells P 100.0 R 45.9 F1 62.9 (122)
30
+ type P 100.0 R 51.8 F1 68.2 (1000)
31
+ powerW P 84.9 R 61.9 F1 71.6 (118)
32
+ model P 100.0 R 60.0 F1 75.0 (1000)
33
+ kva P 100.0 R 62.2 F1 76.7 (555)
34
+ serial P 100.0 R 62.7 F1 77.1 (1000)
35
+ cfm P 100.0 R 65.3 F1 79.0 (225)
36
+ batteryType P 100.0 R 65.6 F1 79.2 (122)
37
+ batteryAh P 96.5 R 67.2 F1 79.2 (122)
38
+ voltage P 100.0 R 67.5 F1 80.6 (1000)
39
+ mocp P 100.0 R 70.7 F1 82.9 (205)
40
+ kw P 99.4 R 71.9 F1 83.5 (221)
41
+ manufacturer P 100.0 R 72.0 F1 83.7 (1000)
42
+ refrigerant P 97.3 R 73.5 F1 83.7 (98)
43
+ amps P 94.0 R 80.9 F1 87.0 (446)
44
+ hz P 100.0 R 78.6 F1 88.0 (878)
45
+ enclosure P 100.0 R 81.5 F1 89.8 (124)
46
+ mca P 100.0 R 83.4 F1 91.0 (205)
47
+ sccr P 100.0 R 84.7 F1 91.7 (124)
48
+ uHeight P 97.4 R 95.8 F1 96.6 (118)
49
+ phase P 100.0 R 94.0 F1 96.9 (878)
50
+ pf P 100.0 R 94.6 F1 97.2 (112)
eval/extraction-corpus.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
nameplate-model.js CHANGED
@@ -11,41 +11,130 @@
11
  import { parseCsv } from './schedule-import.js';
12
 
13
  export const FIELDS = [
14
- ['manufacturer', 'Manufacturer'], ['model', 'Model'], ['serial', 'Serial'], ['voltage', 'Voltage'],
15
- ['phase', 'Phase'], ['hz', 'Hz'], ['kva', 'kVA'], ['kw', 'kW'], ['amps', 'Amps'], ['mca', 'MCA'], ['mocp', 'MOCP'],
16
- ['refrigerant', 'Refrigerant'], ['mfgDate', 'Mfg date'],
 
 
 
 
 
 
 
 
 
 
 
 
17
  ];
18
 
 
 
 
 
 
 
 
 
19
  export const EQUIPMENT_TYPES = [
 
 
20
  ['ups', 'UPS', /\bUPS\b|UNINTERRUPTIBLE/],
21
- ['generator', 'Generator', /GENERATOR|GENSET|\bEKW\b|STANDBY POWER|PRIME POWER/],
22
- ['ats', 'Transfer switch', /TRANSFER SWITCH|\bATS\b|\bSTS\b/],
23
- ['switchgear', 'Switchgear / switchboard', /SWITCHGEAR|SWITCHBOARD/],
24
- ['transformer', 'Transformer', /TRANSFORMER|\bXFMR\b|PRIMARY.*SECONDARY/],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  ['pdu', 'PDU / RPP', /POWER DISTRIBUTION UNIT|\bPDU\b|\bRPP\b|REMOTE POWER PANEL/],
26
- ['panelboard', 'Panelboard', /PANELBOARD/],
27
- ['busway', 'Busway', /BUSWAY|BUS PLUG|BUS DUCT/],
28
- ['battery', 'Battery cabinet', /BATTERY|\bVRLA\b|LITHIUM/],
29
- ['cdu', 'Coolant distribution unit', /COOLANT DISTRIBUTION|\bCDU\b/],
30
- ['chiller', 'Chiller', /CHILLER/],
31
- ['crah', 'CRAH / CRAC', /\bCRAH\b|\bCRAC\b|COMPUTER ROOM AIR|AIR HANDL/],
32
  ['pump', 'Pump', /\bPUMP\b/],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  ];
34
 
35
- // Makers common on data centre electrical and cooling packages. Matched as
36
- // whole words; anything else falls back to a line that reads like a company.
 
37
  const MAKERS = [
38
  'Schneider Electric', 'Square D', 'APC', 'Eaton', 'ABB', 'Siemens', 'Vertiv', 'Liebert', 'Caterpillar', 'Cummins',
39
- 'Kohler', 'Rolls-Royce', 'MTU', 'Generac', 'Trane', 'Carrier', 'Daikin', 'York', 'Johnson Controls', 'Stulz',
40
- 'Munters', 'nVent', 'Legrand', 'Mitsubishi Electric', 'Toshiba', 'General Electric', 'Hubbell', 'Socomec', 'Piller',
41
- 'Starline', 'Raritan', 'Server Technology', 'Emerson', 'Russelectric', 'ASCO', 'Hitachi', 'Riello',
42
- 'Rittal', 'Motivair', 'CoolIT', 'Hitec', 'EnerSys', 'Leclanché', 'Samsung SDI',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  ];
44
- const COMPANY_LINE = /\b(INC|CORP|CORPORATION|LTD|LLC|GMBH|CO\.|COMPANY|SYSTEMS|ELECTRIC|POWER|TECHNOLOGIES|INDUSTRIES)\b/;
45
 
46
  const NUM = '(\\d{1,5}(?:[.,]\\d+)?)';
47
  // "1,002" is a thousands separator on a US plate; "2,5" is a European decimal.
48
  const num = s => (s == null ? null : parseFloat(/^\d{1,3},\d{3}$/.test(String(s)) ? String(s).replace(',', '') : String(s).replace(',', '.')));
 
 
 
49
 
50
  /**
51
  * Nameplate fields from OCR output: a string, or lines as [{ text, confidence }]
@@ -71,11 +160,11 @@ export function parseNameplate(input) {
71
  const CODE = '([A-Z0-9][A-Z0-9\\-\\/\\.]{2,})';
72
  scan('model', new RegExp(`(?:\\bMODEL\\b|\\bMOD\\.|\\bM\\/N\\b|\\bCAT(?:ALOG)?\\.?\\s*(?:NO\\b\\.?|#|NUMBER)|\\bP\\/N\\b|\\bPART\\s*(?:NO\\b\\.?|#|NUMBER))\\s*(?:NO\\b\\.?|NUMBER\\b|#)?\\s*[:#.]?\\s*${CODE}`), m => cleanCode(m[1]));
73
  scan('serial', new RegExp(`(?:\\bSERIAL\\b|\\bSER\\.|\\bS\\/N\\b|\\b5\\/N\\b|\\bSN\\b)\\s*(?:NO\\b\\.?|NUMBER\\b|#)?\\s*[:#.]?\\s*([A-Z0-9][A-Z0-9\\-]{3,})`), m => cleanCode(m[1]));
74
- scan('refrigerant', /\bR[-\s]?(410A|134A|1234ZE|1234YF|513A|514A|1233ZD|454B|407C|32|22)\b/, m => `R-${m[1]}`);
75
  scan('hz', /\b(50|60)\s*HZ\b|\bHZ\s*[:]?\s*(50|60)\b|\bFREQ(?:UENCY)?\.?\s*[:]?\s*(50|60)\b/, m => +(m[1] || m[2] || m[3]));
76
  scan('phase', /\b([13])\s*-?\s*(?:PH|PHASE|Ø|Φ)\b|\bPH(?:ASE)?\s*[:]?\s*([13])\b/, m => +(m[1] || m[2]));
77
- scan('kva', new RegExp(`${NUM}\\s*KVA\\b|\\bKVA\\s*[:]?\\s*${NUM}`), m => num(m[1] || m[2]));
78
- scan('kw', new RegExp(`${NUM}\\s*E?KW\\b|\\bE?KW\\s*[:]?\\s*${NUM}`), m => num(m[1] || m[2]));
79
  scan('mfgDate', /\b(?:MFG\.?|MANUFACTURED|MFD\.?|BUILD|PROD(?:UCTION)?\.?)\s*(?:DATE)?\s*[:]?\s*(\d{1,2}[/-]\d{2,4}|\d{4}[/-]\d{1,2}(?:[/-]\d{1,2})?|\d{1,2}[/-]\d{1,2}[/-]\d{2,4})/, m => m[1]);
80
 
81
  // Voltage: every voltage on the plate (a transformer or UPS carries more than one).
@@ -91,24 +180,55 @@ export function parseNameplate(input) {
91
  if (volts.length) fields.voltage = { value: voltLine.text, conf: voltLine.conf, line: voltLine.line, values: volts };
92
 
93
  // Amps: a labelled rated current first, then any bare "1002 A". Refrigerants (R-410A) are not amps.
94
- scan('amps', new RegExp(`(?:\\bFLA\\b|\\bRLA\\b|\\bCURRENT\\b|\\bAMPS?\\b|\\bAMPERES\\b)\\s*[:]?\\s*${NUM}`), m => num(m[1]));
95
- if (!fields.amps) scan('amps', new RegExp(`(?<![-\\d.R])${NUM}\\s*(?:A|AMPS?)\\b`), m => num(m[1]));
96
- scan('mca', new RegExp(`\\bMCA\\b\\s*[:]?\\s*${NUM}`), m => num(m[1]));
97
- scan('mocp', new RegExp(`(?:\\bMOCP\\b|\\bMAX\\.?\\s*(?:FUSE|BREAKER|OCP))\\s*(?:SIZE)?\\s*[:]?\\s*${NUM}`), m => num(m[1]));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
  // Manufacturer: a known maker anywhere, else a line that reads like a company name.
 
100
  for (const [i, l] of lines.entries()) {
101
  // "New York" on a supplier's address is not York the maker.
102
  const t = up(l.text).replace(/NEW YORK/g, '');
103
  const maker = MAKERS.find(mk => new RegExp(`\\b${escapeRe(up(mk))}\\b`).test(t));
104
- if (maker) { set('manufacturer', maker, l.conf, i); break; }
105
  }
106
  if (!fields.manufacturer) {
107
  const i = lines.findIndex(x => COMPANY_LINE.test(up(x.text)) && !/\d{3}/.test(x.text) && x.text.length <= 48);
108
- if (i >= 0) set('manufacturer', titleCase(lines[i].text.replace(/[^A-Za-z0-9&.,\- ]/g, '').trim()), lines[i].conf, i);
109
  }
110
 
111
- const all = up(lines.map(l => l.text).join('\n'));
 
 
112
  const type = (EQUIPMENT_TYPES.find(([, , re]) => re.test(all)) || [null])[0];
113
  const conf = lines.length ? Math.round(lines.reduce((s, l) => s + l.conf, 0) / lines.length) : 0;
114
  return { fields, type, conf, text: lines.map(l => l.text).join('\n'), lines: lines.map(l => ({ text: l.text, conf: l.conf, bbox: l.bbox })) };
@@ -174,9 +294,27 @@ const HEADERS = {
174
  kva: /^(kva|rating \(kva\)|kva rating|capacity \(kva\))$/i,
175
  kw: /^(kw|ekw|rating \(kw\)|kw rating|capacity \(kw\))$/i,
176
  amps: /^(amps|a|amperage|fla|rated current|current)$/i,
 
177
  mca: /^(mca|min\.? circuit amp(s|acity)?|minimum circuit ampacity)$/i,
178
  mocp: /^(mocp|max\.? (fuse|breaker|ocp)( size)?|maximum overcurrent protection)$/i,
 
 
 
 
179
  refrigerant: /^(refrigerant|ref\.?)$/i,
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  location: /^(location|room|area|level|space|served from|zone)$/i,
181
  };
182
 
@@ -197,7 +335,13 @@ export function parseEquipmentSchedule(text) {
197
  else items.push({
198
  tag, type: get('type'), manufacturer: get('manufacturer'), model: get('model'), serial: get('serial'),
199
  voltage: get('voltage'), phase: num(get('phase').match(/[13]/)?.[0]), kva: num(get('kva')) || null, kw: num(get('kw')) || null,
200
- amps: num(get('amps')) || null, mca: num(get('mca')) || null, mocp: num(get('mocp')) || null, refrigerant: get('refrigerant'), location: get('location'),
 
 
 
 
 
 
201
  });
202
  });
203
  if (!items.length) problems.push('The header was found but no equipment rows under it.');
@@ -251,12 +395,23 @@ export function candidatesFor(reading, items, register = []) {
251
  const mk = item.manufacturer && f.manufacturer?.value ? (sameMaker(item.manufacturer, f.manufacturer.value) ? 1 : 0) : 0.5;
252
  const ty = reading.type && item.type ? (typeOf(item.type) === reading.type ? 1 : 0) : 0.5;
253
  score = 0.7 * m + 0.15 * mk + 0.15 * ty;
 
 
 
254
  }
255
  if (score >= 0.5) out.push({ item, score: Math.round(score * 100) / 100, registered: taken.has(item.tag) });
256
  }
257
  return out.sort((a, b) => (b.score - a.score) || (a.registered - b.registered));
258
  }
259
 
 
 
 
 
 
 
 
 
260
  /** Equipment type key for free text such as a schedule's description column. */
261
  export function typeOf(text) {
262
  const s = up(text);
@@ -284,13 +439,31 @@ export function checkAgainst(reading, item) {
284
  push('voltage', item.voltage, f.voltage?.value, want.length > 0 && want.every(v => have.includes(v)));
285
  }
286
  if (item.phase) push('phase', item.phase, f.phase?.value, +item.phase === +f.phase?.value);
287
- for (const k of ['kva', 'kw', 'amps', 'mca', 'mocp']) {
 
288
  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));
289
  }
 
 
 
290
  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, ''));
291
  return out;
292
  }
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  /** Overall status of a register entry: matched and clean, matched with a problem, or not on the schedule. */
295
  export function statusOf(checks, tag) {
296
  if (!tag) return 'unmatched';
@@ -329,7 +502,10 @@ export function registerSummary(register, items = []) {
329
 
330
  /** Asset register CSV: one row per scanned plate, then every scheduled tag not scanned yet. */
331
  export function registerCsv(register, items = []) {
332
- const head = ['Tag', 'Status', 'Type', 'Location', 'Manufacturer', 'Model', 'Serial', 'Voltage', 'Phase', 'Hz', 'kVA', 'kW', 'Amps', 'MCA', 'MOCP', 'Refrigerant', 'Mfg date', 'Issues', 'Scanned'];
 
 
 
333
  const byTag = new Map(items.map(i => [i.tag, i]));
334
  const typeLabel = k => (EQUIPMENT_TYPES.find(t => t[0] === k) || [])[1] || '';
335
  const dups = new Set(duplicateSerials(register).flat());
@@ -339,14 +515,19 @@ export function registerCsv(register, items = []) {
339
  const issues = (r.checks || []).filter(c => c.status === 'mismatch').map(c => `${c.label}: schedule ${c.expected}, plate ${c.found}`);
340
  if (dups.has(r.id)) issues.push('Serial also on another scanned plate');
341
  return [r.tag || '', STATUS_LABELS[r.status] || r.status, typeLabel(r.type), byTag.get(r.tag)?.location || '',
342
- v('manufacturer'), v('model'), v('serial'), v('voltage'), v('phase'), v('hz'), v('kva'), v('kw'), v('amps'),
343
- v('mca'), v('mocp'), v('refrigerant'), v('mfgDate'), issues.join('; '), r.scannedAt ? new Date(r.scannedAt).toISOString().slice(0, 16).replace('T', ' ') : ''];
 
 
344
  });
345
  const tags = new Set(register.map(r => r.tag));
346
  for (const i of items) {
347
  if (tags.has(i.tag)) continue;
348
- rows.push([i.tag, STATUS_LABELS.missing, i.type, i.location, i.manufacturer, i.model, i.serial, i.voltage, i.phase ?? '', '',
349
- i.kva ?? '', i.kw ?? '', i.amps ?? '', i.mca ?? '', i.mocp ?? '', i.refrigerant, '', '', '']);
 
 
 
350
  }
351
  return [head, ...rows].map(r => r.map(csvCell).join(',')).join('\n') + '\n';
352
  }
@@ -368,12 +549,25 @@ CRAH-1-01,Computer room air handler,Coldline Systems,CW-150-480,480,3,,,45,60,,D
368
  CH-01,Air-cooled chiller,Coldline Systems,AC-1200,460,3,,,612,800,R-513A,Roof
369
  `;
370
 
371
- /** Text of the two sample plates: one that matches UPS-1A, one delivered at the wrong voltage. */
372
  export const SAMPLE_PLATES = [
373
  ['NORTHLINE POWER SYSTEMS', 'UNINTERRUPTIBLE POWER SUPPLY', 'MODEL NO: NPX-750-480', 'SERIAL NO: NL26A01937',
374
  'INPUT: 480Y/277 VAC 3 PH 60 HZ', 'RATING: 750 KVA / 675 KW', 'INPUT CURRENT: 1002 A', 'MFG DATE: 03/2026'],
375
  ['NORTHLINE POWER SYSTEMS', 'UNINTERRUPTIBLE POWER SUPPLY', 'MODEL NO: NPX-750-415', 'SERIAL NO: NL26A02210',
376
  'INPUT: 415Y/240 VAC 3 PH 50 HZ', 'RATING: 750 KVA / 675 KW', 'INPUT CURRENT: 1160 A', 'MFG DATE: 04/2026'],
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  ];
378
 
379
  // ------------------------------------------------------------------ helpers
@@ -382,6 +576,27 @@ function up(s) { return String(s ?? '').toUpperCase(); }
382
  function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
383
  function cleanCode(s) { return s.replace(/[.\-/]+$/, ''); }
384
  function titleCase(s) { return s.toLowerCase().replace(/\b[a-z]/g, c => c.toUpperCase()); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  function csvCell(v) { const s = String(v ?? ''); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }
386
 
387
  export function levenshtein(a, b) {
 
11
  import { parseCsv } from './schedule-import.js';
12
 
13
  export const FIELDS = [
14
+ // Identification.
15
+ ['manufacturer', 'Manufacturer', 'id'], ['model', 'Model', 'id'], ['serial', 'Serial', 'id'], ['mfgDate', 'Mfg date', 'id'],
16
+ // Electrical.
17
+ ['voltage', 'Voltage', 'electrical'], ['phase', 'Phase', 'electrical'], ['hz', 'Hz', 'electrical'],
18
+ ['kva', 'kVA', 'electrical'], ['kw', 'kW', 'electrical'], ['amps', 'Amps', 'electrical'], ['pf', 'Power factor', 'electrical'],
19
+ ['mca', 'MCA', 'electrical'], ['mocp', 'MOCP', 'electrical'], ['sccr', 'SCCR (kA)', 'electrical'],
20
+ ['enclosure', 'Enclosure', 'electrical'], ['standards', 'Listings', 'electrical'], ['batteryType', 'Battery type', 'electrical'],
21
+ ['batteryAh', 'Battery Ah', 'electrical'], ['cells', 'Battery cells', 'electrical'],
22
+ // Cooling.
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
  ];
30
 
31
+ // Field groups for the reading panel; keys match the third element of FIELDS.
32
+ export const FIELD_GROUPS = [['id', 'Identification'], ['electrical', 'Electrical'], ['cooling', 'Cooling'], ['rack', 'Server racks / IT'], ['mechanical', 'Mechanical']];
33
+
34
+ // Data centre equipment types. Order is the whole game: the first pattern that
35
+ // matches a plate wins, so device-specific wording comes before the families it
36
+ // belongs to (flywheel before UPS, paralleling gear before generator, heat pump
37
+ // and pumps before the cooling families, metering before transformer, controls
38
+ // before racks).
39
  export const EQUIPMENT_TYPES = [
40
+ // Power conversion and transfer.
41
+ ['flywheel', 'Flywheel UPS', /FLYWHEEL/],
42
  ['ups', 'UPS', /\bUPS\b|UNINTERRUPTIBLE/],
43
+ ['sts', 'Static transfer switch', /STATIC TRANSFER|STATIC BYPASS|\bSTS\b|RACK[-\s]?ATS/],
44
+ ['ats', 'Automatic transfer switch', /TRANSFER SWITCH|\bATS\b|\bATSE\b/],
45
+ ['rectifier', 'Rectifier / DC power', /RECTIFIER|BATTERY CHARGER|DC POWER SYSTEM/],
46
+ ['inverter', 'Inverter', /\bINVERTER\b|POWER CONVERSION SYSTEM/],
47
+ ['bess', 'Battery energy storage', /BATTERY ENERGY STORAGE|\bBESS\b|ENERGY STORAGE/],
48
+ // Prime power and distribution.
49
+ ['nitrogen', 'Nitrogen generator', /NITROGEN GENERATOR|\bN2\s?GENERATOR\b/],
50
+ ['paralleling', 'Paralleling gear', /PARALLELING|GENERATOR CONTROL|SYNCHRONIS|BUS TIE/],
51
+ ['generator', 'Generator', /GENERATOR|GENSET|\bEKW\b|STANDBY POWER|PRIME POWER|DIESEL ENGINE/],
52
+ ['fueltank', 'Fuel system', /FUEL TANK|DAY TANK|DIESEL TANK|FUEL OIL|FUEL POLISH|BULK FUEL/],
53
+ ['switchgear', 'Switchgear / switchboard', /SWITCHGEAR|SWITCHBOARD|DISTRIBUTION BOARD|\bMDP\b|MAIN DISTRIBUTION/],
54
+ ['mcc', 'Motor control centre', /MOTOR CONTROL|\bMCC\b/],
55
+ ['metering', 'Metering / CT cabinet', /METERING|POWER METER|KWH METER|ELECTRIC METER|CT CABINET/],
56
+ ['relay', 'Protective relay', /PROTECTIVE RELAY|PROTECTION RELAY|FEEDER RELAY|POWER SYSTEM RELAY|\bANSI\s?(?:50|51|27|59|81|87)\b/],
57
+ ['tmonitor', 'Transformer / DGA monitor', /TRANSFORMER MONITOR|DISSOLVED GAS|\bDGA\b|TRANSFORMER MONITORING/],
58
+ ['transformer', 'Transformer', /TRANSFORMER|\bXFMR\b|PRIMARY.*SECONDARY|DRY TYPE|ISOLATION TRANSFORMER|K[-\s]?RATED/],
59
+ ['spd', 'Surge protection', /SURGE PROTECT|\bSPD\b|\bTVSS\b|TRANSIENT VOLTAGE/],
60
+ ['capacitor', 'Power factor / filter', /CAPACITOR|POWER FACTOR|HARMONIC FILTER|\bPFC\b/],
61
+ ['ngr', 'Neutral grounding resistor', /NEUTRAL GROUNDING|GROUNDING RESISTOR|\bNGR\b/],
62
+ ['vfd', 'Variable frequency drive', /VARIABLE FREQUENCY|VARIABLE SPEED|\bVFD\b|\bVSD\b|\bAFD\b/],
63
+ ['busway', 'Busway', /BUSWAY|BUS PLUG|BUS DUCT|ISOLATED PHASE BUS|PHASE BUS/],
64
+ ['panelboard', 'Panelboard', /PANELBOARD|PANEL BOARD|LOAD CENTER/],
65
  ['pdu', 'PDU / RPP', /POWER DISTRIBUTION UNIT|\bPDU\b|\bRPP\b|REMOTE POWER PANEL/],
66
+ ['battery', 'Battery / battery cabinet', /\bBATTERY\b|VRLA|LITHIUM|LEAD ACID/],
67
+ ['loadbank', 'Load bank', /LOAD BANK|LOADBANK|RESISTIVE LOAD/],
68
+ // Pumps come before the cooling families: a "condenser water pump" or a
69
+ // "chilled water pump" is a pump, not the machine it serves; "heat pump" is
70
+ // matched first so the compressor word inside it does not win.
71
+ ['heatpump', 'Heat pump', /HEAT PUMP/],
72
  ['pump', 'Pump', /\bPUMP\b/],
73
+ // Cooling and mechanical.
74
+ ['coolingtower', 'Cooling tower', /COOLING TOWER|COOLING TWR|EVAPORATIVE CONDENSER/],
75
+ ['thermalstorage', 'Thermal storage', /THERMAL STORAGE|CHILLED WATER STORAGE|ICE STORAGE/],
76
+ ['chiller', 'Chiller', /\bCHILLER\b/],
77
+ ['condenser', 'Dry cooler / condenser', /DRY COOLER|FLUID COOLER|EVAPORATIVE COOLER|REMOTE CONDENSER|AIR[-\s]?COOLED CONDENSER|ADIABATIC|CONDENSER/],
78
+ ['cdu', 'CDU / liquid cooling', /COOLANT DISTRIBUTION|\bCDU\b|LIQUID COOLING|IMMERSION|DIRECT TO CHIP|COOLANT MANIFOLD|REAR DOOR/],
79
+ ['crah', 'CRAH / CRAC', /\bCRAH\b|\bCRAC\b|COMPUTER ROOM AIR/],
80
+ ['ahu', 'Air handling unit', /\bAHU\b|AIR HANDLING|AIR HANDLER/],
81
+ ['heatexchanger', 'Heat exchanger', /HEAT EXCHANGER|PLATE HEAT|PLATE AND FRAME/],
82
+ ['expansiontank', 'Expansion tank', /EXPANSION TANK|HYDROPNEUMATIC/],
83
+ ['airseparator', 'Air separator', /AIR SEPARATOR|AIR SEPARATION|\bAIR SCOOP\b/],
84
+ ['boiler', 'Boiler', /\bBOILER\b/],
85
+ ['humidifier', 'Humidifier / dehumidifier', /HUMIDIFIER|HUMIDIFICATION|DEHUMIDIFIER/],
86
+ ['watertreatment', 'Water treatment', /WATER TREATMENT|WATER SOFTENER|REVERSE OSMOSIS|\bRO SYSTEM\b|CHEMICAL DOSING|SIDE STREAM FILTER|SAND FILTER|CARBON FILTER|WATER FILTER/],
87
+ ['dryer', 'Air dryer', /AIR DRYER|DESICCANT|REFRIGERATED DRYER/],
88
+ ['compressor', 'Compressor', /\bCOMPRESSOR\b/],
89
+ ['fcu', 'Fan coil / fan wall', /\bFCU\b|FAN COIL|FAN WALL|EXHAUST FAN|SUPPLY FAN|RETURN FAN/],
90
+ ['rtu', 'Rooftop unit', /ROOFTOP UNIT|\bRTU\b|PACKAGED UNIT|PACKAGE UNIT/],
91
+ ['mua', 'Makeup air unit', /MAKEUP AIR|MAKE-UP AIR|\bMUA\b/],
92
+ ['economizer', 'Economizer', /ECONOMIZER/],
93
+ // Fire, safety and controls.
94
+ ['firesuppression', 'Fire suppression', /FIRE SUPPRESSION|CLEAN AGENT|\bFM[-\s]?200\b|NOVEC|INERT GAS|SPRINKLER|PRE[-\s]?ACTION|\bCO2\b|AEROSOL/],
95
+ ['firealarm', 'Fire alarm', /FIRE ALARM|\bVESDA\b|SMOKE DETECT|ASPIRATING|HEAT DETECT/],
96
+ ['leakdetection', 'Leak / gas detection', /LEAK DETECT|WATER DETECT|REFRIGERANT MONITOR|GAS DETECT/],
97
+ ['bms', 'BMS / controls', /\bBMS\b|\bEPMS\b|\bDCIM\b|BUILDING MANAGEMENT|\bBAS\b|CONTROL PANEL|CONTROL CABINET|\bPLC\b|CONTROLLER/],
98
+ // IT and white space. Rack before server so "server rack / enclosure" is a
99
+ // rack; KVM before server so "console server" is a console.
100
+ ['kvm', 'KVM / console', /\bKVM\b|CONSOLE SERVER/],
101
+ ['rack', 'Rack / enclosure', /EQUIPMENT RACK|\bRACK\b|ENCLOSURE|\bCABINET\b/],
102
+ ['server', 'Server / storage', /\bSERVER\b|BLADE|STORAGE ARRAY|\bSAN\b|\bNAS\b|HCI NODE/],
103
+ ['network', 'Network gear', /NETWORK SWITCH|ETHERNET SWITCH|\bROUTER\b|PATCH PANEL|FIREWALL|LOAD BALANCER/],
104
  ];
105
 
106
+ // Makers common on data centre electrical, cooling, IT and fire packages.
107
+ // Matched as whole words; anything else falls back to a line that reads like a
108
+ // company. Longer names precede the shorter names they contain.
109
  const MAKERS = [
110
  'Schneider Electric', 'Square D', 'APC', 'Eaton', 'ABB', 'Siemens', 'Vertiv', 'Liebert', 'Caterpillar', 'Cummins',
111
+ 'Kohler', 'Rolls-Royce Power Systems', 'Rolls-Royce', 'MTU', 'Generac', 'Trane', 'Carrier', 'Daikin', 'York',
112
+ 'Johnson Controls', 'Stulz', 'Munters', 'nVent', 'Legrand', 'Mitsubishi Electric', 'Toshiba', 'General Electric',
113
+ 'Hubbell', 'Socomec', 'Piller', 'Starline', 'Raritan', 'Server Technology', 'Emerson', 'Russelectric', 'ASCO',
114
+ 'Hitachi', 'Riello', 'Rittal', 'Motivair', 'CoolIT', 'Hitec', 'EnerSys', 'Leclanché', 'Samsung SDI',
115
+ // Cooling and mechanical.
116
+ 'Airedale', 'Uniflair', 'Climaveneta', 'Aermec', 'FläktGroup', 'FlaktGroup', 'Systemair', 'ebm-papst', 'Danfoss',
117
+ 'Grundfos', 'Wilo', 'Xylem', 'Bell & Gossett', 'Armstrong', 'Taco', 'Flowserve', 'SPX', 'Baltimore Aircoil',
118
+ 'Evapco', 'Marley', 'LU-VE', 'Alfa Laval', 'SWEP', 'Tranter', 'Boyd', 'Asetek',
119
+ // Power distribution, switchgear and batteries.
120
+ 'PDI', 'Power Distribution Inc', 'Anord Mardix', 'Mardix', 'Universal Electric', 'Hammond', 'Hoffman',
121
+ 'Exide', 'C&D Technologies', 'GS Yuasa', 'FIAMM', 'NorthStar', 'Saft', 'Hoppecke', 'LG Energy', 'CATL', 'Fluence',
122
+ 'Aggreko', 'Himoinsa', 'Perkins', 'Volvo Penta', 'Deutz', 'Wartsila',
123
+ // IT and network.
124
+ 'Dell', 'HPE', 'Hewlett Packard', 'Cisco', 'Juniper', 'Arista', 'Lenovo', 'Supermicro', 'IBM', 'Fujitsu', 'NetApp',
125
+ 'Pure Storage', 'Aten', 'Avocent',
126
+ // Fire, detection and controls.
127
+ 'Fike', 'Kidde', 'Tyco', 'Viking', 'Victaulic', 'Hochiki', 'Xtralis', 'VESDA', 'Notifier', 'Honeywell',
128
+ 'Delta Controls', 'Distech', 'Automated Logic', 'Tridium',
129
  ];
130
+ const COMPANY_LINE = /\b(INC|CORP|CORPORATION|LTD|LLC|GMBH|CO\.|COMPANY|SYSTEMS|ELECTRIC|POWER|TECHNOLOGIES|INDUSTRIES|ENGINEERING|INTERNATIONAL|GROUP|AG|SA|BV|NV|PTY|WORKS|THERMAL|CONTROLS|SOLUTIONS|MANUFACTURING)\b/;
131
 
132
  const NUM = '(\\d{1,5}(?:[.,]\\d+)?)';
133
  // "1,002" is a thousands separator on a US plate; "2,5" is a European decimal.
134
  const num = s => (s == null ? null : parseFloat(/^\d{1,3},\d{3}$/.test(String(s)) ? String(s).replace(',', '') : String(s).replace(',', '.')));
135
+ // Range guards: a rating of 0, or an absurd value, is an OCR artefact, not a field.
136
+ const positive = v => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null);
137
+ const inRange = (v, lo, hi) => (typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi ? v : null);
138
 
139
  /**
140
  * Nameplate fields from OCR output: a string, or lines as [{ text, confidence }]
 
160
  const CODE = '([A-Z0-9][A-Z0-9\\-\\/\\.]{2,})';
161
  scan('model', new RegExp(`(?:\\bMODEL\\b|\\bMOD\\.|\\bM\\/N\\b|\\bCAT(?:ALOG)?\\.?\\s*(?:NO\\b\\.?|#|NUMBER)|\\bP\\/N\\b|\\bPART\\s*(?:NO\\b\\.?|#|NUMBER))\\s*(?:NO\\b\\.?|NUMBER\\b|#)?\\s*[:#.]?\\s*${CODE}`), m => cleanCode(m[1]));
162
  scan('serial', new RegExp(`(?:\\bSERIAL\\b|\\bSER\\.|\\bS\\/N\\b|\\b5\\/N\\b|\\bSN\\b)\\s*(?:NO\\b\\.?|NUMBER\\b|#)?\\s*[:#.]?\\s*([A-Z0-9][A-Z0-9\\-]{3,})`), m => cleanCode(m[1]));
163
+ scan('refrigerant', /\bR[-\s]?(410A|134A|1234ZE|1234YF|1233ZD|513A|514A|515B|454B|468A|449A|407C|452A|455A|245FA|123|32|22|717|744|290|128|503|508B)\b/, m => `R-${m[1]}`);
164
  scan('hz', /\b(50|60)\s*HZ\b|\bHZ\s*[:]?\s*(50|60)\b|\bFREQ(?:UENCY)?\.?\s*[:]?\s*(50|60)\b/, m => +(m[1] || m[2] || m[3]));
165
  scan('phase', /\b([13])\s*-?\s*(?:PH|PHASE|Ø|Φ)\b|\bPH(?:ASE)?\s*[:]?\s*([13])\b/, m => +(m[1] || m[2]));
166
+ scan('kva', new RegExp(`${NUM}\\s*KVA\\b|\\bKVA\\s*[:]?\\s*${NUM}`), m => positive(num(m[1] || m[2])));
167
+ scan('kw', new RegExp(`${NUM}\\s*E?KW\\b|\\bE?KW\\s*[:]?\\s*${NUM}`), m => positive(num(m[1] || m[2])));
168
  scan('mfgDate', /\b(?:MFG\.?|MANUFACTURED|MFD\.?|BUILD|PROD(?:UCTION)?\.?)\s*(?:DATE)?\s*[:]?\s*(\d{1,2}[/-]\d{2,4}|\d{4}[/-]\d{1,2}(?:[/-]\d{1,2})?|\d{1,2}[/-]\d{1,2}[/-]\d{2,4})/, m => m[1]);
169
 
170
  // Voltage: every voltage on the plate (a transformer or UPS carries more than one).
 
180
  if (volts.length) fields.voltage = { value: voltLine.text, conf: voltLine.conf, line: voltLine.line, values: volts };
181
 
182
  // Amps: a labelled rated current first, then any bare "1002 A". Refrigerants (R-410A) are not amps.
183
+ scan('amps', new RegExp(`(?:\\bFLA\\b|\\bRLA\\b|\\bCURRENT\\b|\\bAMPS?\\b|\\bAMPERES\\b)\\s*[:]?\\s*${NUM}`), m => positive(num(m[1])));
184
+ if (!fields.amps) scan('amps', new RegExp(`(?<![-\\d.R])${NUM}\\s*(?:A|AMPS?)\\b`), m => positive(num(m[1])));
185
+ scan('mca', new RegExp(`\\bMCA\\b\\s*[:]?\\s*${NUM}`), m => positive(num(m[1])));
186
+ scan('mocp', new RegExp(`(?:\\bMOCP\\b|\\bMAX\\.?\\s*(?:FUSE|BREAKER|OCP))\\s*(?:SIZE)?\\s*[:]?\\s*${NUM}`), m => positive(num(m[1])));
187
+
188
+ // Data centre electrical: power factor and short-circuit/interrupting rating.
189
+ scan('pf', /(?:POWER\s*FACTOR|\bP\.?F\b)\s*[:=]?\s*(0?[.,]\d{1,3}|1(?:[.,]0+)?)\b|(0?[.,]\d{1,3})\s*P\.?F\b/, m => inRange(num(m[1] || m[2]), 0.01, 1));
190
+ scan('sccr', new RegExp(`${NUM}\\s*KAIC\\b|(?:\\bSCCR\\b|SHORT[\\s-]?CIRCUIT|WITHSTAND|INTERRUPTING RATING|\\bKAIC\\b)[^0-9]{0,12}${NUM}\\s*(?:KA|K)?\\b`), m => inRange(num(m[1] || m[2]), 0.1, 1000));
191
+
192
+ // Enclosure rating: NEMA type or IEC IP code.
193
+ scan('enclosure', /\bNEMA\s*(?:TYPE\s*)?(\d{1,2}[A-Z]?)\b|\bIP\s*(\d{2}[A-Z]{0,2})\b/, m => (m[1] ? `NEMA ${m[1]}` : `IP ${m[2]}`));
194
+
195
+ // Mechanical / cooling nameplates: refrigerant charge, capacity, airflow and flow.
196
+ scan('charge', new RegExp(`CHARGE\\s*(?:WEIGHT)?\\s*[:]?\\s*${NUM}\\s*(?:LBS?|KG|KGS?|OZ)?\\b`), m => positive(num(m[1])));
197
+ scan('tons', new RegExp(`${NUM}\\s*TONS?\\b|\\bTONS?\\s*[:]?\\s*${NUM}`), m => positive(num(m[1] || m[2])));
198
+ scan('btu', new RegExp(`${NUM}\\s*(?:BTU\\/?H?R?|BTUH)\\b|\\bBTU\\/?H?R?\\s*[:]?\\s*${NUM}`), m => positive(num(m[1] || m[2])));
199
+ scan('cfm', new RegExp(`${NUM}\\s*CFM\\b|\\bCFM\\s*[:]?\\s*${NUM}`), m => positive(num(m[1] || m[2])));
200
+ scan('gpm', new RegExp(`${NUM}\\s*(?:GPM|LPM|L\\/S|M3\\/H)\\b|\\bGPM\\s*[:]?\\s*${NUM}`), m => positive(num(m[1] || m[2])));
201
+ scan('batteryAh', new RegExp(`${NUM}\\s*(?:AH|AMP[\\s-]?HOURS?)\\b`), m => positive(num(m[1])));
202
+
203
+ // Battery chemistry, cell count, rotating speed, rigging weight and listing marks.
204
+ scan('batteryType', /\b(VRLA|AGM|GEL|FLOODED|LITHIUM[-\s]?ION|LI[-\s]?ION|LIFEPO4|NICD|NI[-\s]?CD|LEAD[-\s]?ACID|NICKEL[-\s]?CADMIUM|VALVE[-\s]?REGULATED)\b/, m => batteryTypeName(m[1]));
205
+ scan('cells', new RegExp(`(?:CELLS?|CELL\\s*COUNT)\\s*[:]?\\s*${NUM}\\b|${NUM}\\s*CELLS?\\b`), m => inRange(num(m[1] || m[2]), 1, 1000));
206
+ scan('rpm', new RegExp(`${NUM}\\s*RPM\\b|\\bRPM\\s*[:]?\\s*${NUM}`), m => inRange(num(m[1] || m[2]), 1, 200000));
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()) {
219
  // "New York" on a supplier's address is not York the maker.
220
  const t = up(l.text).replace(/NEW YORK/g, '');
221
  const maker = MAKERS.find(mk => new RegExp(`\\b${escapeRe(up(mk))}\\b`).test(t));
222
+ if (maker) { set('manufacturer', maker, l.conf, i); makerLine = i; break; }
223
  }
224
  if (!fields.manufacturer) {
225
  const i = lines.findIndex(x => COMPANY_LINE.test(up(x.text)) && !/\d{3}/.test(x.text) && x.text.length <= 48);
226
+ if (i >= 0) { set('manufacturer', titleCase(lines[i].text.replace(/[^A-Za-z0-9&.,\- ]/g, '').trim()), lines[i].conf, i); makerLine = i; }
227
  }
228
 
229
+ // Equipment type from plate text, excluding the maker line: a vendor called
230
+ // "…Transformer Corp" or "…Switchgear Ltd" must not type a chiller plate.
231
+ const all = up(lines.filter((_, i) => i !== makerLine).map(l => l.text).join('\n'));
232
  const type = (EQUIPMENT_TYPES.find(([, , re]) => re.test(all)) || [null])[0];
233
  const conf = lines.length ? Math.round(lines.reduce((s, l) => s + l.conf, 0) / lines.length) : 0;
234
  return { fields, type, conf, text: lines.map(l => l.text).join('\n'), lines: lines.map(l => ({ text: l.text, conf: l.conf, bbox: l.bbox })) };
 
294
  kva: /^(kva|rating \(kva\)|kva rating|capacity \(kva\))$/i,
295
  kw: /^(kw|ekw|rating \(kw\)|kw rating|capacity \(kw\))$/i,
296
  amps: /^(amps|a|amperage|fla|rated current|current)$/i,
297
+ pf: /^(pf|power factor)$/i,
298
  mca: /^(mca|min\.? circuit amp(s|acity)?|minimum circuit ampacity)$/i,
299
  mocp: /^(mocp|max\.? (fuse|breaker|ocp)( size)?|maximum overcurrent protection)$/i,
300
+ sccr: /^(sccr|short.?circuit|withstand|interrupting rating|kaic)$/i,
301
+ enclosure: /^(enclosure|nema|nema type|ip rating|enclosure type)$/i,
302
+ standards: /^(standards?|listings?|approvals?|compliance|ul|iec|ansi)$/i,
303
+ batteryType: /^(battery type|battery technology|battery chemistry|cell type)$/i,
304
  refrigerant: /^(refrigerant|ref\.?)$/i,
305
+ charge: /^(charge|refrigerant charge|charge (lbs|kg))$/i,
306
+ tons: /^(tons|tonnage|cooling \(tons\)|cooling tons|refrigeration tons|tr)$/i,
307
+ btu: /^(btu|btuh|btu\/h|cooling \(btu\/?h\)|cooling btu)$/i,
308
+ cfm: /^(cfm|airflow|air flow|air volume)$/i,
309
+ gpm: /^(gpm|flow|water flow|flow rate)$/i,
310
+ batteryAh: /^(ah|amp.?hours?|battery ah|battery capacity)$/i,
311
+ cells: /^(cells?|cell count|battery cells)$/i,
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
  };
320
 
 
335
  else items.push({
336
  tag, type: get('type'), manufacturer: get('manufacturer'), model: get('model'), serial: get('serial'),
337
  voltage: get('voltage'), phase: num(get('phase').match(/[13]/)?.[0]), kva: num(get('kva')) || null, kw: num(get('kw')) || null,
338
+ amps: num(get('amps')) || null, pf: num(get('pf')) || null, mca: num(get('mca')) || null, mocp: num(get('mocp')) || null,
339
+ sccr: num(get('sccr')) || null, enclosure: get('enclosure'), standards: get('standards'), batteryType: get('batteryType'),
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.');
 
395
  const mk = item.manufacturer && f.manufacturer?.value ? (sameMaker(item.manufacturer, f.manufacturer.value) ? 1 : 0) : 0.5;
396
  const ty = reading.type && item.type ? (typeOf(item.type) === reading.type ? 1 : 0) : 0.5;
397
  score = 0.7 * m + 0.15 * mk + 0.15 * ty;
398
+ // A compatible voltage is a positive signal; mismatched voltages are only a
399
+ // tie-breaker, so a UPS plate still prefers a UPS row over a same-voltage PDU.
400
+ if (voltageCompatible(item, reading)) score += 0.1;
401
  }
402
  if (score >= 0.5) out.push({ item, score: Math.round(score * 100) / 100, registered: taken.has(item.tag) });
403
  }
404
  return out.sort((a, b) => (b.score - a.score) || (a.registered - b.registered));
405
  }
406
 
407
+ /** True when the schedule row and the plate share at least one voltage. */
408
+ function voltageCompatible(item, reading) {
409
+ if (!item.voltage) return false;
410
+ const want = scheduleVoltages(item.voltage);
411
+ const have = reading.fields?.voltage?.values || [];
412
+ return want.length > 0 && have.length > 0 && want.some(v => have.includes(v));
413
+ }
414
+
415
  /** Equipment type key for free text such as a schedule's description column. */
416
  export function typeOf(text) {
417
  const s = up(text);
 
439
  push('voltage', item.voltage, f.voltage?.value, want.length > 0 && want.every(v => have.includes(v)));
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
  }
452
 
453
+ /** Two short strings equal ignoring case, punctuation and spacing ("IP55" vs "IP 55"). */
454
+ function looseEq(a, b) {
455
+ const norm = s => up(s).replace(/[^A-Z0-9]/g, '');
456
+ const x = norm(a), y = norm(b);
457
+ return !!x && !!y && (x === y || x.includes(y) || y.includes(x));
458
+ }
459
+
460
+ /** "NEMA 3R" vs "3R", "IP55" vs "IP 55": compare the rating code, ignoring the label. */
461
+ function enclosureMatch(a, b) {
462
+ const norm = s => up(s).replace(/\bNEMA\b|\bTYPE\b|\bIP\b/g, '').replace(/[^A-Z0-9]/g, '');
463
+ const x = norm(a), y = norm(b);
464
+ return !!x && !!y && (x === y || x.includes(y) || y.includes(x));
465
+ }
466
+
467
  /** Overall status of a register entry: matched and clean, matched with a problem, or not on the schedule. */
468
  export function statusOf(checks, tag) {
469
  if (!tag) return 'unmatched';
 
502
 
503
  /** Asset register CSV: one row per scanned plate, then every scheduled tag not scanned yet. */
504
  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());
 
515
  const issues = (r.checks || []).filter(c => c.status === 'mismatch').map(c => `${c.label}: schedule ${c.expected}, plate ${c.found}`);
516
  if (dups.has(r.id)) issues.push('Serial also on another scanned plate');
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));
524
  for (const i of items) {
525
  if (tags.has(i.tag)) continue;
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';
533
  }
 
549
  CH-01,Air-cooled chiller,Coldline Systems,AC-1200,460,3,,,612,800,R-513A,Roof
550
  `;
551
 
552
+ /** The two sample plates: one that matches UPS-1A, one delivered at the wrong voltage. */
553
  export const SAMPLE_PLATES = [
554
  ['NORTHLINE POWER SYSTEMS', 'UNINTERRUPTIBLE POWER SUPPLY', 'MODEL NO: NPX-750-480', 'SERIAL NO: NL26A01937',
555
  'INPUT: 480Y/277 VAC 3 PH 60 HZ', 'RATING: 750 KVA / 675 KW', 'INPUT CURRENT: 1002 A', 'MFG DATE: 03/2026'],
556
  ['NORTHLINE POWER SYSTEMS', 'UNINTERRUPTIBLE POWER SUPPLY', 'MODEL NO: NPX-750-415', 'SERIAL NO: NL26A02210',
557
  'INPUT: 415Y/240 VAC 3 PH 50 HZ', 'RATING: 750 KVA / 675 KW', 'INPUT CURRENT: 1160 A', 'MFG DATE: 04/2026'],
558
+ // A few more data centre plates so the sample shows the wider equipment set.
559
+ ['IRONSIDE SWITCHGEAR CORP', 'SWITCHBOARD', 'MODEL NO: IS-4000-MDP', 'SERIAL NO: IS26C01188',
560
+ '480Y/277 V 3 PH 60 HZ', '1000 KVA MAIN BUS', 'SCCR 65 KAIC', 'NEMA 3R MFG DATE: 05/2026'],
561
+ ['COLDLINE SYSTEMS', 'COMPUTER ROOM AIR HANDLER', 'MODEL NO: CW-150-480', 'SERIAL NO: CL6600172',
562
+ '480 V 3 PH 60 HZ', 'FLA 38.5 MCA 45 MOCP 60', 'AIRFLOW 12000 CFM', 'MFG DATE: 01/2026'],
563
+ ['HARBOR ENGINE WORKS INC', 'DIESEL GENERATOR SET', 'MODEL: HG-3000', 'S/N: HEW0098812',
564
+ '13.8 KV 3 PHASE 60 HZ', '3750 KVA 3000 EKW', '0.8 PF', 'BUILD DATE 2026-01'],
565
+ ['POLARIS THERMAL', 'COOLING TOWER', 'MODEL NO: PT-CT-900', 'SERIAL NO: PT26D00417',
566
+ '460 V 3 PH 60 HZ', 'MCA 86 MOCP 110', 'FLOW 2400 GPM', 'MFG DATE: 07/2026'],
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
 
576
  function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
577
  function cleanCode(s) { return s.replace(/[.\-/]+$/, ''); }
578
  function titleCase(s) { return s.toLowerCase().replace(/\b[a-z]/g, c => c.toUpperCase()); }
579
+ /** Canonical battery chemistry from the many ways plates spell it. */
580
+ function batteryTypeName(s) {
581
+ const t = up(s).replace(/\s+/g, ' ');
582
+ if (/LIFEPO4/.test(t)) return 'LiFePO4';
583
+ if (/LITHIUM|LI[-\s]?ION/.test(t)) return 'Lithium-ion';
584
+ if (/NICD|NICKEL/.test(t)) return 'NiCd';
585
+ if (/LEAD/.test(t)) return 'Lead-acid';
586
+ if (/VRLA|VALVE/.test(t)) return 'VRLA';
587
+ if (/AGM/.test(t)) return 'AGM';
588
+ if (/GEL/.test(t)) return 'Gel';
589
+ if (/FLOODED/.test(t)) return 'Flooded';
590
+ return titleCase(s);
591
+ }
592
+ /** Weight normalised to kilograms so a lb plate and a kg schedule compare. */
593
+ function weightKg(v, unit) {
594
+ if (v == null) return null;
595
+ const u = up(unit || 'KG');
596
+ if (/^LB/.test(u)) return Math.round(v * 0.45359237 * 10) / 10;
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) {
sample_plates.json CHANGED
@@ -1,8 +1,9 @@
1
  {
2
- "note": "Two fictional UPS plates. The first matches schedule tag UPS-1A; the second is the same kVA and model family but a 415 V / 50 Hz unit, so it should be flagged on model and voltage.",
3
  "plates": [
4
  {
5
  "id": "ups-match",
 
6
  "expect_tag": "UPS-1A",
7
  "lines": [
8
  "NORTHLINE POWER SYSTEMS",
@@ -17,6 +18,7 @@
17
  },
18
  {
19
  "id": "ups-wrong-voltage",
 
20
  "expect_tag": "UPS-1A",
21
  "expect_mismatch": ["model", "voltage"],
22
  "lines": [
@@ -29,6 +31,51 @@
29
  "INPUT CURRENT: 1160 A",
30
  "MFG DATE: 04/2026"
31
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  }
33
  ]
34
  }
 
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",
6
+ "type": "ups",
7
  "expect_tag": "UPS-1A",
8
  "lines": [
9
  "NORTHLINE POWER SYSTEMS",
 
18
  },
19
  {
20
  "id": "ups-wrong-voltage",
21
+ "type": "ups",
22
  "expect_tag": "UPS-1A",
23
  "expect_mismatch": ["model", "voltage"],
24
  "lines": [
 
31
  "INPUT CURRENT: 1160 A",
32
  "MFG DATE: 04/2026"
33
  ]
34
+ },
35
+ {
36
+ "id": "switchboard",
37
+ "type": "switchgear",
38
+ "expect_tag": null,
39
+ "lines": [
40
+ "IRONSIDE SWITCHGEAR CORP",
41
+ "SWITCHBOARD",
42
+ "MODEL NO: IS-4000-MDP",
43
+ "SERIAL NO: IS26C01188",
44
+ "480Y/277 V 3 PH 60 HZ",
45
+ "1000 KVA MAIN BUS",
46
+ "SCCR 65 KAIC NEMA 3R",
47
+ "MFG DATE: 05/2026"
48
+ ]
49
+ },
50
+ {
51
+ "id": "server-rack",
52
+ "type": "server",
53
+ "expect_tag": null,
54
+ "lines": [
55
+ "DELL EMC",
56
+ "POWEREDGE R650 SERVER",
57
+ "MODEL NO: R650-2U",
58
+ "SERIAL NO: DL26R00442",
59
+ "480 V 3 PH 60 HZ",
60
+ "800 W 12.5 A",
61
+ "AIRFLOW 120 CFM",
62
+ "MFG DATE: 06/2026"
63
+ ]
64
+ },
65
+ {
66
+ "id": "rack-pdu",
67
+ "type": "pdu",
68
+ "expect_tag": null,
69
+ "lines": [
70
+ "VERTIV",
71
+ "RACK PDU",
72
+ "MODEL NO: MPX-30-C13",
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
  }
tests/nameplate-model.test.mjs CHANGED
@@ -4,7 +4,8 @@ import { test } from 'node:test';
4
  import assert from 'node:assert/strict';
5
  import {
6
  parseNameplate, voltagesIn, parseEquipmentSchedule, scheduleVoltages, candidatesFor, checkAgainst, statusOf,
7
- codeSimilarity, foldCode, duplicateSerials, registerSummary, registerCsv, SAMPLE_SCHEDULE, SAMPLE_PLATES,
 
8
  } from '../nameplate-model.js';
9
 
10
  const plate = lines => parseNameplate(lines.join('\n'));
@@ -58,6 +59,11 @@ test('"New York" in an address is not the maker York; unknown makers come from a
58
  const r = plate(['ACME SWITCHGEAR CORP', 'NEW YORK NY', 'SWITCHBOARD', 'MODEL SB-4000']);
59
  assert.equal(val(r, 'manufacturer'), 'Acme Switchgear Corp');
60
  assert.equal(r.type, 'switchgear');
 
 
 
 
 
61
  });
62
 
63
  test('codes compare the way OCR misreads them', () => {
@@ -91,6 +97,24 @@ test('a serial on the schedule decides the match outright', () => {
91
  assert.equal(candidatesFor(plate(SAMPLE_PLATES[0]), items)[0].item.tag, 'UPS-1B');
92
  });
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  test('checks: the right unit verifies; the wrong-voltage unit is flagged on model and voltage', () => {
95
  const ups1a = schedule.find(i => i.tag === 'UPS-1A');
96
  const good = checkAgainst(plate(SAMPLE_PLATES[0]), ups1a);
@@ -121,7 +145,7 @@ test('register: summary, duplicate serials, and a CSV that lists what is still u
121
  assert.equal(s.missing.length, schedule.length - 1);
122
  const rows = registerCsv(reg, schedule).trim().split('\n');
123
  assert.equal(rows.length, 1 + 2 + schedule.length - 1);
124
- 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$/);
125
  assert.match(rows[3], /^UPS-1B,Not scanned yet,/);
126
  });
127
 
@@ -144,3 +168,145 @@ test('MCA and MOCP are checked when the schedule lists them; near-zero-confidenc
144
  const checks = checkAgainst(r, ch);
145
  assert.deepEqual(checks.filter(c => c.status === 'mismatch').map(c => c.key), ['mocp']);
146
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import assert from 'node:assert/strict';
5
  import {
6
  parseNameplate, voltagesIn, parseEquipmentSchedule, scheduleVoltages, candidatesFor, checkAgainst, statusOf,
7
+ codeSimilarity, foldCode, duplicateSerials, registerSummary, registerCsv, typeOf, EQUIPMENT_TYPES,
8
+ SAMPLE_SCHEDULE, SAMPLE_PLATES,
9
  } from '../nameplate-model.js';
10
 
11
  const plate = lines => parseNameplate(lines.join('\n'));
 
59
  const r = plate(['ACME SWITCHGEAR CORP', 'NEW YORK NY', 'SWITCHBOARD', 'MODEL SB-4000']);
60
  assert.equal(val(r, 'manufacturer'), 'Acme Switchgear Corp');
61
  assert.equal(r.type, 'switchgear');
62
+
63
+ // A transformer winding is "delta"; that is not the maker Delta.
64
+ const t = plate(['VOLTARC TRANSFORMER CORP', 'DRY TYPE TRANSFORMER', 'MODEL VT-2500-HV', 'PRIMARY 13800 V DELTA', 'SECONDARY 480Y/277 V']);
65
+ assert.equal(val(t, 'manufacturer'), 'Voltarc Transformer Corp');
66
+ assert.equal(t.type, 'transformer');
67
  });
68
 
69
  test('codes compare the way OCR misreads them', () => {
 
97
  assert.equal(candidatesFor(plate(SAMPLE_PLATES[0]), items)[0].item.tag, 'UPS-1B');
98
  });
99
 
100
+ test('voltage compatibility is a positive signal, but the equipment type still wins', () => {
101
+ // The 415 V UPS shares a voltage with the PDU rows (480/415Y/240), yet the
102
+ // matching UPS rows must still rank first.
103
+ const first = candidatesFor(plate(SAMPLE_PLATES[1]), schedule);
104
+ assert.ok(first[0].item.tag.startsWith('UPS'), `first was ${first[0].item.tag}`);
105
+ assert.ok(!first.slice(0, 4).some(c => c.item.tag.startsWith('PDU')), 'no PDU outranks the UPS rows');
106
+ });
107
+
108
+ test('out-of-range OCR noise is not accepted as a rating', () => {
109
+ const r = plate(['ACME', 'SWITCHBOARD', 'MODEL SB-1', '0 KVA', 'SCCR 0', '0 A', '0 OUTLETS']);
110
+ assert.equal(val(r, 'kva'), undefined, 'a zero rating is not a rating');
111
+ assert.equal(val(r, 'sccr'), undefined);
112
+ assert.equal(val(r, 'amps'), undefined);
113
+ assert.equal(val(r, 'outlets'), undefined);
114
+ const ok = plate(['ACME', 'SWITCHBOARD', 'MODEL SB-1', '480 V', '65 KAIC']);
115
+ assert.equal(val(ok, 'sccr'), 65, 'a plausible value is kept');
116
+ });
117
+
118
  test('checks: the right unit verifies; the wrong-voltage unit is flagged on model and voltage', () => {
119
  const ups1a = schedule.find(i => i.tag === 'UPS-1A');
120
  const good = checkAgainst(plate(SAMPLE_PLATES[0]), ups1a);
 
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
 
 
168
  const checks = checkAgainst(r, ch);
169
  assert.deepEqual(checks.filter(c => c.status === 'mismatch').map(c => c.key), ['mocp']);
170
  });
171
+
172
+ test('a wide data centre equipment set is recognised, device wording beating its family', () => {
173
+ assert.ok(EQUIPMENT_TYPES.length >= 40, 'the taxonomy covers more than the classic dozen');
174
+ const cases = [
175
+ ['FLYWHEEL UPS', 'flywheel'], ['UNINTERRUPTIBLE POWER SUPPLY', 'ups'], ['STATIC TRANSFER SWITCH', 'sts'],
176
+ ['AUTOMATIC TRANSFER SWITCH', 'ats'], ['BATTERY CHARGER', 'rectifier'], ['POWER CONVERSION SYSTEM INVERTER', 'inverter'],
177
+ ['BATTERY ENERGY STORAGE SYSTEM', 'bess'], ['PARALLELING SWITCHGEAR', 'paralleling'], ['DIESEL GENERATOR SET', 'generator'],
178
+ ['FUEL OIL DAY TANK', 'fueltank'], ['SWITCHBOARD', 'switchgear'], ['MOTOR CONTROL CENTER', 'mcc'],
179
+ ['POWER METER', 'metering'], ['DRY TYPE TRANSFORMER', 'transformer'], ['SURGE PROTECTIVE DEVICE', 'spd'],
180
+ ['POWER FACTOR CORRECTION CAPACITOR', 'capacitor'], ['VARIABLE FREQUENCY DRIVE', 'vfd'], ['ISOLATED PHASE BUS', 'busway'],
181
+ ['PANELBOARD', 'panelboard'], ['REMOTE POWER PANEL', 'pdu'], ['BATTERY CABINET', 'battery'], ['LOAD BANK', 'loadbank'],
182
+ ['COOLING TOWER', 'coolingtower'], ['AIR-COOLED CHILLER', 'chiller'], ['DRY COOLER', 'condenser'],
183
+ ['LIQUID COOLING CDU', 'cdu'], ['REAR DOOR HEAT EXCHANGER', 'cdu'], ['COMPUTER ROOM AIR HANDLER', 'crah'],
184
+ ['AIR HANDLING UNIT', 'ahu'], ['HEAT PUMP', 'heatpump'], ['PLATE HEAT EXCHANGER', 'heatexchanger'],
185
+ ['CHILLED WATER PUMP', 'pump'], ['CONDENSER WATER PUMP', 'pump'], ['STEAM HUMIDIFIER', 'humidifier'],
186
+ ['WATER TREATMENT PLANT', 'watertreatment'], ['DESICCANT DRYER', 'dryer'], ['AIR COMPRESSOR', 'compressor'],
187
+ ['FAN COIL UNIT', 'fcu'], ['ROOFTOP UNIT', 'rtu'], ['MAKEUP AIR UNIT', 'mua'], ['FM-200 FIRE SUPPRESSION', 'firesuppression'],
188
+ ['VESDA ASPIRATING SMOKE DETECT', 'firealarm'], ['WATER LEAK DETECT', 'leakdetection'], ['BMS CONTROLLER', 'bms'],
189
+ ['KVM SWITCH', 'kvm'], ['SERVER RACK ENCLOSURE', 'rack'], ['BLADE SERVER', 'server'], ['NETWORK SWITCH', 'network'],
190
+ ['PROTECTIVE RELAY PANEL', 'relay'], ['TRANSFORMER MONITOR DGA', 'tmonitor'], ['NEUTRAL GROUNDING RESISTOR', 'ngr'],
191
+ ['ISOLATION TRANSFORMER', 'transformer'], ['K-RATED TRANSFORMER', 'transformer'], ['WATER SOFTENER', 'watertreatment'],
192
+ ['REVERSE OSMOSIS SYSTEM', 'watertreatment'], ['EXPANSION TANK', 'expansiontank'], ['AIR SEPARATOR', 'airseparator'],
193
+ ['NITROGEN GENERATOR', 'nitrogen'], ['HOT WATER BOILER', 'boiler'], ['THERMAL STORAGE TANK', 'thermalstorage'],
194
+ ['EVAPORATIVE COOLER', 'condenser'], ['STATIC BYPASS', 'sts'], ['RACK ATS', 'sts'],
195
+ ];
196
+ for (const [text, want] of cases) assert.equal(typeOf(text), want, text);
197
+ });
198
+
199
+ test('cooling and electrical nameplates read the wider field set', () => {
200
+ const sg = plate(['IRONSIDE SWITCHGEAR CORP', 'SWITCHBOARD', 'MODEL IS-4000-MDP', 'SERIAL IS26C01188',
201
+ '480Y/277 V 3 PH 60 HZ', 'SCCR 65 KAIC', 'NEMA 3R', '0.8 PF']);
202
+ assert.equal(sg.type, 'switchgear');
203
+ assert.equal(val(sg, 'sccr'), 65);
204
+ assert.equal(val(sg, 'enclosure'), 'NEMA 3R');
205
+ assert.equal(val(sg, 'pf'), 0.8);
206
+
207
+ const ch = plate(['POLARIS THERMAL', 'WATER-COOLED CHILLER', 'MODEL PT-WC-900', '460 V 3 PH 60 HZ',
208
+ 'REFRIGERANT R-1234ZE CHARGE 620 LBS', 'CAPACITY 500 TONS', 'AIRFLOW 24000 CFM', 'FLOW 2400 GPM']);
209
+ assert.equal(ch.type, 'chiller');
210
+ assert.equal(val(ch, 'refrigerant'), 'R-1234ZE');
211
+ assert.equal(val(ch, 'charge'), 620);
212
+ assert.equal(val(ch, 'tons'), 500);
213
+ assert.equal(val(ch, 'cfm'), 24000);
214
+ assert.equal(val(ch, 'gpm'), 2400);
215
+
216
+ const bat = plate(['ENERSYS', 'BATTERY CABINET', 'MODEL MX-500', '480 V 3 PH', 'CAPACITY 500 AH', 'IP20']);
217
+ assert.equal(bat.type, 'battery');
218
+ assert.equal(val(bat, 'batteryAh'), 500);
219
+ assert.equal(val(bat, 'enclosure'), 'IP 20');
220
+ });
221
+
222
+ test('battery chemistry, listings, speed and rigging weight are read', () => {
223
+ const bat = plate(['EXIDE', 'BATTERY STRING', 'MODEL VR-48', 'VRLA 48 CELLS', 'LISTED UL 1989', 'WEIGHT 1250 LBS']);
224
+ assert.equal(val(bat, 'batteryType'), 'VRLA');
225
+ assert.equal(val(bat, 'cells'), 48);
226
+ assert.equal(val(bat, 'standards'), 'UL 1989');
227
+ assert.equal(val(bat, 'weight'), 567, '1250 lb normalised to kg');
228
+
229
+ const pump = plate(['GRUNDFOS', 'END SUCTION PUMP', 'MODEL CR-64', 'RATED SPEED 1750 RPM', 'MOTOR IE3', 'WEIGHT 320 KG']);
230
+ assert.equal(pump.type, 'pump');
231
+ assert.equal(val(pump, 'rpm'), 1750);
232
+ assert.equal(val(pump, 'weight'), 320);
233
+
234
+ const li = plate(['SAFT', 'BATTERY CABINET', 'LITHIUM-ION', 'MODEL LI-500', 'IEC 62619']);
235
+ assert.equal(val(li, 'batteryType'), 'Lithium-ion');
236
+ assert.equal(val(li, 'standards'), 'IEC 62619');
237
+ });
238
+
239
+ test('battery, listing, speed and weight columns are imported and checked', () => {
240
+ const { items, problems } = parseEquipmentSchedule(
241
+ 'Tag,Type,Model,Voltage,Battery type,Listings,Ah,Cells,RPM,Weight\n' +
242
+ 'BAT-1,Battery cabinet,MX-500,480,VRLA,UL 1989,500,48,,\n' +
243
+ 'PMP-1,Pump,CR-64,460,,,,,1750,320\n');
244
+ assert.deepEqual(problems, []);
245
+ const [bat, pump] = items;
246
+ assert.equal(bat.batteryType, 'VRLA');
247
+ assert.equal(bat.standards, 'UL 1989');
248
+ assert.equal(bat.cells, 48);
249
+ assert.equal(pump.rpm, 1750);
250
+ assert.equal(pump.weight, 320);
251
+
252
+ const good = checkAgainst(plate(['EXIDE', 'BATTERY CABINET', 'MODEL MX-500', '480 V', 'VRLA 500 AH 48 CELLS', 'UL 1989']), bat);
253
+ assert.deepEqual(good.filter(c => c.status !== 'ok'), []);
254
+ const bad = checkAgainst(plate(['EXIDE', 'BATTERY CABINET', 'MODEL MX-500', '480 V', 'AGM 400 AH 24 CELLS', 'UL 891']), bat);
255
+ assert.deepEqual(bad.filter(c => c.status === 'mismatch').map(c => c.key), ['batteryAh', 'cells', 'batteryType', 'standards']);
256
+ });
257
+
258
+ test('server-rack nameplates: rack U height, watts and airflow', () => {
259
+ const srv = plate(['DELL EMC', 'POWEREDGE R650 SERVER', 'MODEL NO: R650-2U', 'SERIAL NO: DL26R00442',
260
+ '480 V 3 PH 60 HZ', '800 W 12.5 A', 'AIRFLOW 120 CFM']);
261
+ assert.equal(srv.type, 'server');
262
+ assert.equal(val(srv, 'uHeight'), 2, 'from the model suffix R650-2U');
263
+ assert.equal(val(srv, 'powerW'), 800);
264
+ assert.equal(val(srv, 'amps'), 12.5);
265
+ assert.equal(val(srv, 'cfm'), 120);
266
+
267
+ // A kW line must not be read as watts.
268
+ const pdu = plate(['VERTIV', 'RACK PDU', 'MODEL MPX-30', '400 V 3 PH', '5.5 KW', '24 OUTLETS']);
269
+ assert.equal(val(pdu, 'powerW'), undefined);
270
+ assert.equal(val(pdu, 'kw'), 5.5);
271
+
272
+ const { items } = parseEquipmentSchedule('Tag,Type,Model,W,Rack U\nSRV-1,Server,R650,800,2\n');
273
+ assert.equal(items[0].powerW, 800);
274
+ assert.equal(items[0].uHeight, 2);
275
+ assert.deepEqual(checkAgainst(srv, items[0]).filter(c => c.status === 'mismatch').map(c => c.key), ['model']);
276
+
277
+ // A rack PDU: outlets, kW (not watts) and amps.
278
+ const rpdu = plate(['VERTIV', 'RACK PDU', 'MODEL NO: MPX-30-C13', 'SERIAL NO: VR26P00117',
279
+ '400 V 3 PH 60 HZ', '30 A 5.5 KW', '24 OUTLETS']);
280
+ assert.equal(rpdu.type, 'pdu');
281
+ assert.equal(val(rpdu, 'outlets'), 24);
282
+ assert.equal(val(rpdu, 'kw'), 5.5);
283
+ assert.equal(val(rpdu, 'powerW'), undefined, 'kW is not watts');
284
+ assert.equal(val(rpdu, 'amps'), 30);
285
+
286
+ const sw = plate(['CISCO', 'CATALYST NETWORK SWITCH', 'MODEL NO: C9300-48P', 'SERIAL NO: FOC26X0011',
287
+ '230 V 3 PH 50 HZ', '48-PORT POE 1100 W']);
288
+ assert.equal(sw.type, 'network');
289
+ assert.equal(val(sw, 'ports'), 48);
290
+ assert.equal(val(sw, 'powerW'), 1100);
291
+ });
292
+
293
+ test('schedule columns for the extra fields are read and checked', () => {
294
+ const { items, problems } = parseEquipmentSchedule('Tag,Type,Model,Voltage,PF,SCCR,Enclosure,Tons,CFM,GPM,Ah\n' +
295
+ 'SWB-1,Switchboard,IS-4000,480Y/277,0.8,65,NEMA 3R,,,,,\n' +
296
+ 'CH-1,Chiller,PT-900,460,,,,500,24000,2400,\n' +
297
+ 'BAT-1,Battery cabinet,MX-500,480,,,,,,,500\n');
298
+ assert.deepEqual(problems, []);
299
+ const [swb, ch, bat] = items;
300
+ assert.equal(swb.pf, 0.8);
301
+ assert.equal(swb.sccr, 65);
302
+ assert.equal(swb.enclosure, 'NEMA 3R');
303
+ assert.equal(ch.tons, 500);
304
+ assert.equal(ch.cfm, 24000);
305
+ assert.equal(ch.gpm, 2400);
306
+ assert.equal(bat.batteryAh, 500);
307
+
308
+ const good = checkAgainst(plate(['ACME SWITCHBOARD', 'SWITCHBOARD', 'MODEL IS-4000', '480Y/277 V', 'PF 0.8', 'SCCR 65 KA', 'NEMA 3R']), swb);
309
+ assert.deepEqual(good.filter(c => c.status !== 'ok'), []);
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
+ });
tests/nameplates-bench.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Nameplate OCR benchmark: drawn plates of six equipment types, each put
2
  // through the degradations real phone photos have (small, blurred, noisy,
3
  // low-contrast, glare, tilted, keystoned, dark anodised plate, JPEG, plate
4
  // small in a cluttered frame, sideways), OCR'd by the app's own pipeline and
@@ -28,6 +28,16 @@ export const BENCH_PLATES = [
28
  'INPUT 480 V 3 PH 60 HZ', 'OUTPUT 415Y/240 V', '300 KVA', 'MFG DATE: 02/2026'],
29
  ['COLDLINE SYSTEMS', 'COMPUTER ROOM AIR HANDLER', 'MODEL: CW-150-480', 'SERIAL: CL6600172',
30
  '480 V 3 PH 60 HZ', 'FLA 38.5', 'MCA 45 MOCP 60', 'MFG DATE: 01/2026'],
 
 
 
 
 
 
 
 
 
 
31
  ];
32
 
33
  // Each degradation runs in the page against a canvas holding the clean plate.
 
1
+ // Nameplate OCR benchmark: drawn plates of several equipment types, each put
2
  // through the degradations real phone photos have (small, blurred, noisy,
3
  // low-contrast, glare, tilted, keystoned, dark anodised plate, JPEG, plate
4
  // small in a cluttered frame, sideways), OCR'd by the app's own pipeline and
 
28
  'INPUT 480 V 3 PH 60 HZ', 'OUTPUT 415Y/240 V', '300 KVA', 'MFG DATE: 02/2026'],
29
  ['COLDLINE SYSTEMS', 'COMPUTER ROOM AIR HANDLER', 'MODEL: CW-150-480', 'SERIAL: CL6600172',
30
  '480 V 3 PH 60 HZ', 'FLA 38.5', 'MCA 45 MOCP 60', 'MFG DATE: 01/2026'],
31
+ ['IRONSIDE SWITCHGEAR CORP', 'SWITCHBOARD', 'MODEL NO: IS-4000-MDP', 'SERIAL NO: IS26C01188',
32
+ '480Y/277 V 3 PH 60 HZ', '1000 KVA MAIN BUS', 'SCCR 65 KAIC NEMA 3R', 'MFG DATE: 05/2026'],
33
+ ['POLARIS THERMAL', 'COOLING TOWER', 'MODEL NO: PT-CT-900', 'SERIAL NO: PT26D00417',
34
+ '460 V 3 PH 60 HZ', 'MCA 86 MOCP 110', 'FLOW 2400 GPM', 'MFG DATE: 07/2026'],
35
+ ['COLDLINE SYSTEMS', 'WATER-COOLED CHILLER', 'MODEL NO. WC-900-R5', 'SERIAL NO. CL5529901',
36
+ '460 V 3 PH 60 HZ', 'REFRIGERANT R-1234ZE CHARGE 620 LBS', 'CAPACITY 500 TONS', 'AIRFLOW 24000 CFM'],
37
+ ['EXIDE', 'BATTERY CABINET', 'MODEL NO. VR-48', 'SERIAL NO. EX26A00445',
38
+ '480 V 3 PH 60 HZ', 'VRLA 48 CELLS 500 AH', 'LISTED UL 1989', 'WEIGHT 1250 LBS'],
39
+ ['GRUNDFOS', 'END SUCTION PUMP', 'MODEL NO. CR-64-2', 'SERIAL NO. GF26C00912',
40
+ '460 V 3 PH 60 HZ', 'FLA 42 SPEED 1750 RPM', 'WEIGHT 320 KG', 'IEC 60034'],
41
  ];
42
 
43
  // Each degradation runs in the page against a canvas holding the clean plate.
tests/nameplates-extraction-bench.mjs ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Nameplate extraction benchmark (pure, runs in Node — no browser, no OCR).
2
+ //
3
+ // The OCR benchmark (tests/nameplates-bench.mjs) measures what OCR loses; this
4
+ // measures the *extraction and matching* layer on a labelled plate corpus. Each
5
+ // plate is generated from constructed field values, rendered with the wording
6
+ // variety real plates use, then optionally corrupted with the character errors
7
+ // OCR actually makes on plate lettering. The parser must recover the fields the
8
+ // generator put in — the truth is by construction, not the parser's own reading,
9
+ // so this is not circular.
10
+ //
11
+ // node tests/nameplates-extraction-bench.mjs [--n=400] [--json=out.json] [--verbose]
12
+ //
13
+ // Reported: per-field precision / recall / F1, whole-plate exact match, and
14
+ // ms/plate. Feed the same corpus to any model (Donut, LayoutLM, a VLM) via
15
+ // ml/nameplates/bench/README.md to compare on one test set.
16
+
17
+ import { writeFileSync } from 'node:fs';
18
+ import { parseNameplate, foldCode, codeSimilarity } from '../nameplate-model.js';
19
+
20
+ // ---------------------------------------------------------------- vocabulary
21
+
22
+ const MAKERS = ['Northline Power Systems', 'Harbor Engine Works', 'Coldline Systems', 'Voltarc Transformer Corp',
23
+ 'Ironside Switchgear Corp', 'Polaris Thermal', 'Meridian Power', 'Westgate Power', 'Bluewater Power'];
24
+ const MODEL_PREFIX = { ups: 'NPX', generator: 'HG', chiller: 'AC', transformer: 'VT', pdu: 'NPD', crah: 'CW', switchgear: 'IS', battery: 'VR', server: 'R' };
25
+ const VOLT_PAIRS = [['480Y/277', 480, 277], ['415Y/240', 415, 240], ['208Y/120', 208, 120], ['600Y/347', 600, 347], ['380Y/220', 380, 220]];
26
+ const VOLT_SINGLE = [208, 230, 380, 415, 460, 480, 575, 600, 2400, 4160, 13800];
27
+ const KVA = [100, 150, 225, 300, 500, 750, 1000, 1500, 2000, 2500];
28
+ const REFRIGERANTS = ['R-410A', 'R-134A', 'R-513A', 'R-1234ZE', 'R-1234YF', 'R-454B', 'R-407C'];
29
+ const BATTERY_TYPES = ['VRLA', 'AGM', 'GEL', 'LITHIUM-ION', 'LEAD ACID', 'NICD'];
30
+
31
+ // ---------------------------------------------------------------- rng
32
+
33
+ function mulberry(a) {
34
+ return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
35
+ }
36
+ const pick = (r, a) => a[Math.floor(r() * a.length)];
37
+ const int = (r, lo, hi) => lo + Math.floor(r() * (hi - lo + 1));
38
+
39
+ function code(r, prefix, withVolt) {
40
+ const n = int(r, 100, 9999);
41
+ const letters = String.fromCharCode(65 + int(r, 0, 25)) + String.fromCharCode(65 + int(r, 0, 25));
42
+ return withVolt ? `${prefix}-${n}-${pick(r, [480, 415, 208, 600])}` : `${prefix}-${n}${letters}`;
43
+ }
44
+ function serial(r) {
45
+ const y = 24 + int(r, 0, 2);
46
+ let s = '';
47
+ for (let i = 0; i < 7; i++) s += '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'[int(r, 0, 35)];
48
+ return `NL${y}A${s.slice(0, 5)}`; // no 0/O ambiguity in the suffix
49
+ }
50
+ function mfgDate(r) {
51
+ const m = int(r, 1, 12), y = int(r, 2024, 2026);
52
+ return `${String(m).padStart(2, '0')}/${y}`;
53
+ }
54
+
55
+ // ---------------------------------------------------------------- plate generator
56
+ // Renders a plate and its by-construction truth. Wording is varied per plate so
57
+ // the parser cannot be keyed to one layout.
58
+
59
+ function makePlate(r) {
60
+ const manufacturer = pick(r, MAKERS);
61
+ const type = pick(r, ['ups', 'generator', 'chiller', 'transformer', 'pdu', 'crah', 'switchgear', 'battery', 'server']);
62
+ const prefix = MODEL_PREFIX[type];
63
+ const truth = { manufacturer, type };
64
+ const lines = [];
65
+
66
+ const modelLabel = pick(r, ['MODEL NO: ', 'MODEL: ', 'CAT. NO. ', 'M/N ']);
67
+ const serialLabel = pick(r, ['SERIAL NO: ', 'S/N: ', 'SER. ']);
68
+
69
+ if (type === 'server') {
70
+ const u = pick(r, [1, 2, 4]);
71
+ truth.model = `R${int(r, 600, 900)}-${u}U`;
72
+ truth.serial = serial(r);
73
+ truth.uHeight = u;
74
+ truth.powerW = pick(r, [450, 600, 800, 1100, 1600, 2000]);
75
+ truth.cfm = pick(r, [80, 120, 160, 220]);
76
+ truth.voltage = [pick(r, [208, 230, 400, 480])];
77
+ truth.phase = 3; truth.hz = 60;
78
+ truth.amps = int(r, 5, 20);
79
+ const d = mfgDate(r);
80
+ lines.push(manufacturer, 'POWEREDGE SERVER', `${modelLabel}${truth.model}`, `${serialLabel}${truth.serial}`,
81
+ `${truth.voltage[0]} V 3 PH ${truth.hz} HZ`, `${truth.powerW} W ${truth.amps} A`, `AIRFLOW ${truth.cfm} CFM`, `MFG DATE: ${d}`);
82
+ truth.mfgDate = d;
83
+ return { lines, truth };
84
+ }
85
+
86
+ if (type === 'battery') {
87
+ const bt = pick(r, BATTERY_TYPES);
88
+ truth.model = code(r, 'VR', false);
89
+ truth.serial = serial(r);
90
+ truth.batteryType = bt === 'LITHIUM-ION' ? 'Lithium-ion' : bt === 'LEAD ACID' ? 'Lead-acid' : bt === 'NICD' ? 'NiCd' : bt;
91
+ truth.cells = pick(r, [24, 40, 48, 60, 80]);
92
+ truth.batteryAh = pick(r, [100, 200, 300, 500]);
93
+ truth.voltage = [pick(r, [48, 110, 240, 480])];
94
+ lines.push(manufacturer, 'BATTERY CABINET', `${modelLabel}${truth.model}`, `${serialLabel}${truth.serial}`,
95
+ `${truth.voltage[0]} V`, `${truth.batteryType === 'NiCd' ? 'NICD' : truth.batteryType === 'Lithium-ion' ? 'LITHIUM-ION' : truth.batteryType === 'Lead-acid' ? 'LEAD ACID' : truth.batteryType} ${truth.cells} CELLS ${truth.batteryAh} AH`);
96
+ truth.mfgDate = null;
97
+ return { lines, truth };
98
+ }
99
+
100
+ // Equipment with one or two voltages.
101
+ const pair = pick(r, VOLT_PAIRS);
102
+ const single = pick(r, VOLT_SINGLE);
103
+ const usePair = type !== 'transformer' && (type === 'ups' || type === 'pdu' || r() < 0.4);
104
+ if (type === 'transformer') {
105
+ truth.voltage = [single, pair[1], pair[2]];
106
+ truth.kva = pick(r, KVA);
107
+ truth.phase = 3; truth.hz = pick(r, [50, 60]);
108
+ truth.model = code(r, prefix, false);
109
+ truth.serial = serial(r);
110
+ const d = mfgDate(r);
111
+ lines.push(manufacturer, 'DRY TYPE TRANSFORMER', `${modelLabel}${truth.model}`, `${serialLabel}${truth.serial}`,
112
+ `PRIMARY ${single} V DELTA`, `SECONDARY ${pair[0]} V`, `${truth.kva} KVA 3 PH ${truth.hz} HZ`, `MFG DATE ${d}`);
113
+ truth.mfgDate = d;
114
+ return { lines, truth };
115
+ }
116
+
117
+ const volt = usePair ? pair : [single];
118
+ truth.voltage = usePair ? [pair[1], pair[2]] : [single];
119
+ truth.phase = 3; truth.hz = pick(r, [50, 60]);
120
+ truth.model = code(r, prefix, usePair);
121
+ truth.serial = serial(r);
122
+ const desc = { ups: 'UNINTERRUPTIBLE POWER SUPPLY', generator: 'DIESEL GENERATOR SET', chiller: 'AIR-COOLED CHILLER', pdu: 'POWER DISTRIBUTION UNIT', crah: 'COMPUTER ROOM AIR HANDLER', switchgear: 'SWITCHBOARD' }[type];
123
+ lines.push(manufacturer, desc, `${modelLabel}${truth.model}`, `${serialLabel}${truth.serial}`);
124
+ lines.push(usePair ? `INPUT: ${pair[0]} VAC ${truth.phase} PH ${truth.hz} HZ` : `INPUT ${single} V ${truth.phase} PH ${truth.hz} HZ`);
125
+ if (type === 'ups' || type === 'generator') {
126
+ truth.kva = pick(r, KVA); truth.kw = Math.round(truth.kva * 0.9);
127
+ truth.amps = Math.round(truth.kva * 1.34);
128
+ lines.push(type === 'generator' ? `${truth.kva} KVA ${truth.kw} EKW` : `RATING: ${truth.kva} KVA / ${truth.kw} KW`);
129
+ lines.push(type === 'generator' ? `RATED CURRENT ${truth.amps} A` : `INPUT CURRENT: ${truth.amps} A`);
130
+ if (type === 'generator') { truth.pf = 0.8; lines.push('0.8 PF'); }
131
+ } else if (type === 'chiller') {
132
+ truth.mca = int(r, 30, 900); truth.mocp = truth.mca + int(r, 10, 200); truth.refrigerant = pick(r, REFRIGERANTS);
133
+ lines.push(`REFRIGERANT ${truth.refrigerant}`, `MCA ${truth.mca} MOCP ${truth.mocp}`);
134
+ } else if (type === 'crah') {
135
+ truth.mca = int(r, 20, 200); truth.mocp = truth.mca + int(r, 5, 60); truth.cfm = pick(r, [4000, 8000, 12000, 20000, 24000]);
136
+ truth.amps = +(r() * 60 + 5).toFixed(1);
137
+ lines.push(`FLA ${truth.amps}`, `MCA ${truth.mca} MOCP ${truth.mocp}`, `AIRFLOW ${truth.cfm} CFM`);
138
+ } else if (type === 'switchgear') {
139
+ truth.sccr = pick(r, [42, 65, 85, 100]); truth.enclosure = pick(r, ['NEMA 1', 'NEMA 3R', 'NEMA 12', 'IP 20']);
140
+ lines.push(`${truth.kva = pick(r, KVA)} KVA MAIN BUS`, `SCCR ${truth.sccr} KAIC`, truth.enclosure);
141
+ } else if (type === 'pdu') {
142
+ truth.kva = pick(r, [100, 200, 300, 500]);
143
+ lines.push(`${truth.kva} KVA`);
144
+ }
145
+ const d = mfgDate(r);
146
+ lines.push(pick(r, [`MFG DATE: ${d}`, `MFG DATE ${d}`, `BUILD DATE ${d}`]));
147
+ truth.mfgDate = d;
148
+ return { lines, truth };
149
+ }
150
+
151
+ // ---------------------------------------------------------------- OCR corruption
152
+
153
+ const RULES = [['O', '0'], ['0', 'O'], ['I', '1'], ['L', '1'], ['S', '5'], ['B', '8'], ['Z', '2'], ['G', '6'], ['D', '0']];
154
+ function corruptLine(s, r) {
155
+ let out = '';
156
+ for (const ch of s) {
157
+ const idx = RULES.findIndex(([from]) => from === ch);
158
+ if (idx >= 0 && r() < 0.15) { out += RULES[idx][1]; continue; }
159
+ if (r() < 0.01) continue; // dropped character
160
+ if (r() < 0.02) { out += ch + ' '; continue; } // split after a character
161
+ out += ch;
162
+ }
163
+ return out
164
+ .replace(/\s+([0-9])/g, ' $1')
165
+ .replace(/HZ/g, r() < 0.3 ? 'Hw' : 'HZ')
166
+ .replace(/KVA/g, r() < 0.2 ? 'XVA' : 'KVA')
167
+ .replace(/(\d)V\b/g, r() < 0.2 ? '$1 Vv' : '$1V');
168
+ }
169
+
170
+ // ---------------------------------------------------------------- scoring
171
+
172
+ const normCode = v => foldCode(v);
173
+ const normStr = v => String(v).toUpperCase().replace(/[^A-Z0-9]/g, '');
174
+ const numEq = (a, b) => Math.abs(Number(a) - Number(b)) < 1e-6;
175
+ function fieldEq(key, want, got, fieldObj) {
176
+ if (got == null) return false;
177
+ if (key === 'model' || key === 'serial') return codeSimilarity(want, got) >= 0.85;
178
+ if (key === 'voltage') { const g = fieldObj?.values || []; return want.every(v => g.includes(v)); }
179
+ if (key === 'manufacturer' || key === 'batteryType' || key === 'enclosure' || key === 'refrigerant' || key === 'standards') return normStr(want) === normStr(got) || normStr(got).includes(normStr(want));
180
+ if (key === 'mfgDate') return normStr(want) === normStr(got);
181
+ return numEq(want, got);
182
+ }
183
+
184
+ function scorePlate(truth, reading) {
185
+ const out = { tp: 0, fn: 0, fp: 0, fields: {} };
186
+ for (const [k, want] of Object.entries(truth)) {
187
+ if (k === 'type') { const ok = reading.type === want; out.fields.type = ok ? 'ok' : 'miss'; out.tp += ok ? 1 : 0; out.fn += ok ? 0 : 1; continue; }
188
+ if (want == null) continue; // field not on this plate
189
+ const fieldObj = reading.fields[k];
190
+ const ok = fieldEq(k, want, fieldObj?.value, fieldObj);
191
+ out.fields[k] = ok ? 'ok' : 'miss';
192
+ out.tp += ok ? 1 : 0; out.fn += ok ? 0 : 1;
193
+ }
194
+ // A field the parser returns that the plate never had is a false positive.
195
+ for (const k of Object.keys(reading.fields)) {
196
+ if (!(k in truth) || truth[k] == null) { out.fields[k] = 'extra'; out.fp++; }
197
+ }
198
+ return out;
199
+ }
200
+
201
+ // ---------------------------------------------------------------- main
202
+
203
+ function run(condition, n, seed, verbose) {
204
+ const r = mulberry(seed);
205
+ const per = {}; // field -> {tp,fn,fp}
206
+ let plateOk = 0, ms = 0;
207
+ for (let i = 0; i < n; i++) {
208
+ const { lines, truth } = makePlate(r);
209
+ const text = (condition === 'ocr' ? lines.map(l => corruptLine(l, r)) : lines).join('\n');
210
+ const t0 = performance.now();
211
+ const reading = parseNameplate(text);
212
+ ms += performance.now() - t0;
213
+ const s = scorePlate(truth, reading);
214
+ if (s.fn === 0 && s.fp === 0) plateOk++;
215
+ for (const [k, res] of Object.entries(s.fields)) {
216
+ per[k] ??= { tp: 0, fn: 0, fp: 0 };
217
+ if (res === 'ok') per[k].tp++;
218
+ else if (res === 'extra') per[k].fp++;
219
+ else per[k].fn++;
220
+ }
221
+ if (verbose && s.fn) console.log(` #${i} ${truth.type}: missed ${Object.entries(s.fields).filter(([, v]) => v === 'miss').map(([k]) => k).join(', ')}`);
222
+ }
223
+ const fields = {};
224
+ let T = 0, P = 0, R = 0;
225
+ for (const [k, c] of Object.entries(per)) {
226
+ const prec = c.tp / Math.max(1, c.tp + c.fp), rec = c.tp / Math.max(1, c.tp + c.fn);
227
+ const f1 = (prec + rec) ? (2 * prec * rec) / (prec + rec) : 0;
228
+ fields[k] = { precision: round3(prec), recall: round3(rec), f1: round3(f1), support: c.tp + c.fn };
229
+ T += c.tp; R += c.tp + c.fn; P += c.tp + c.fp;
230
+ void f1;
231
+ }
232
+ const Pmicro = T / Math.max(1, P), Rmicro = T / Math.max(1, R);
233
+ return { condition, n, exactPlate: round3(plateOk / n), microF1: round3((Pmicro + Rmicro) ? (2 * Pmicro * Rmicro) / (Pmicro + Rmicro) : 0), msPerPlate: round2(ms / n), fields };
234
+ }
235
+
236
+ const round2 = v => Math.round(v * 100) / 100;
237
+ const round3 = v => Math.round(v * 1000) / 1000;
238
+
239
+ function corpus(n, seed) {
240
+ const r = mulberry(seed);
241
+ const items = [];
242
+ for (let i = 0; i < n; i++) {
243
+ const { lines, truth } = makePlate(r);
244
+ const clean = lines.join('\n');
245
+ const ocr = lines.map(l => corruptLine(l, r)).join('\n');
246
+ items.push({ id: i, type: truth.type, clean, ocr, truth });
247
+ }
248
+ return items;
249
+ }
250
+
251
+ function main() {
252
+ const args = Object.fromEntries(process.argv.slice(2).map(a => a.replace(/^--/, '').split('=')));
253
+ const n = +(args.n || 400);
254
+ const results = { clean: run('clean', n, 20, !!args.verbose), ocr: run('ocr', n, 20, !!args.verbose) };
255
+ for (const key of ['clean', 'ocr']) {
256
+ const res = results[key];
257
+ console.log(`\n${key.toUpperCase()} n=${res.n} exact-plate ${(100 * res.exactPlate).toFixed(1)}% micro-F1 ${(100 * res.microF1).toFixed(1)}% ${res.msPerPlate} ms/plate`);
258
+ const rows = Object.entries(res.fields).sort((a, b) => a[1].f1 - b[1].f1);
259
+ for (const [k, c] of rows) console.log(` ${k.padEnd(13)} P ${(100 * c.precision).toFixed(1).padStart(5)} R ${(100 * c.recall).toFixed(1).padStart(5)} F1 ${(100 * c.f1).toFixed(1).padStart(5)} (${c.support})`);
260
+ }
261
+ if (args.json) writeFileSync(args.json, JSON.stringify(results, null, 2));
262
+ if (args.dump) writeFileSync(args.dump, corpus(+(args.dumpN || 200), 20).map(x => JSON.stringify(x)).join('\n') + '\n');
263
+ }
264
+
265
+ if (import.meta.url === `file://${process.argv[1]}`) main();