Image-to-Text
English
custom
ocr
document-ai
information-extraction
key-information-extraction
nameplate
nameplate-ocr
equipment-nameplate
data-center
data-centre
datacenter
mission-critical
mep
electrical
mechanical
equipment-schedule
schedule-verification
asset-register
commissioning
quality-assurance
ups
pdu
switchgear
generator
ats
transformer
busway
battery
crah
crac
chiller
cooling
server-rack
construction
aec
edge-ai
on-device
privacy-preserving
tesseract
browser
Eval Results (legacy)
Update data-centre nameplates to v2: 31 fields, 55 equipment types, extraction benchmark
Browse files- README.md +165 -115
- bench/README.md +69 -0
- config.json +23 -8
- eval/extraction-bench.json +296 -0
- eval/extraction-bench.log +50 -0
- eval/extraction-corpus.jsonl +0 -0
- nameplate-model.js +253 -38
- sample_plates.json +48 -1
- tests/nameplate-model.test.mjs +168 -2
- tests/nameplates-bench.mjs +11 -1
- tests/nameplates-extraction-bench.mjs +265 -0
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
|
| 50 |
-
the plate's fields
|
| 51 |
-
|
| 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
|
| 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
|
| 61 |
|
| 62 |
| | |
|
| 63 |
|---|---|
|
| 64 |
| **Task** | Image → structured nameplate fields → schedule verification |
|
| 65 |
-
| **Fields** |
|
| 66 |
-
| **Equipment** |
|
| 67 |
| **OCR** | Tesseract.js 5 (English LSTM), pretrained — no fine-tuning |
|
| 68 |
-
| **Extraction** | Deterministic labelled-field parser
|
|
|
|
|
|
|
| 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
|
| 77 |
-
|
| 78 |
-
|
| 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 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
| 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) ─
|
| 94 |
-
|
| 95 |
-
lines + confidence labelled-value rules code folding, tolerance checks
|
| 96 |
```
|
| 97 |
|
| 98 |
-
1. **
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
4. **
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
5. **
|
|
|
|
|
|
|
|
|
|
| 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 |
-
| `
|
| 116 |
-
| `
|
| 117 |
-
| `
|
| 118 |
-
| `
|
| 119 |
-
| `
|
| 120 |
-
| `
|
| 121 |
-
| `
|
| 122 |
-
| `
|
| 123 |
-
| `
|
| 124 |
-
| `
|
| 125 |
-
| `
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
| 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 |
-
|
| 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
|
| 178 |
-
real plates
|
|
|
|
| 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
|
| 195 |
-
|
| 196 |
-
- **Serial-number evidence in a dispute** without human verification
|
| 197 |
-
|
| 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
|
| 203 |
-
|
| 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 |
-
|
| 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 |
-
|
| 239 |
|
| 240 |
-
## Evaluation
|
| 241 |
|
| 242 |
-
- **Extraction
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
| 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` |
|
| 258 |
-
| `eval/` |
|
| 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;
|
| 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": "
|
| 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
|
| 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", "
|
| 16 |
-
"kva", "kw", "amps", "mca", "mocp", "
|
|
|
|
|
|
|
|
|
|
| 17 |
],
|
| 18 |
"equipment_types": [
|
| 19 |
-
"ups", "
|
| 20 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 15 |
-
['
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
];
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
export const EQUIPMENT_TYPES = [
|
|
|
|
|
|
|
| 20 |
['ups', 'UPS', /\bUPS\b|UNINTERRUPTIBLE/],
|
| 21 |
-
['
|
| 22 |
-
['ats', '
|
| 23 |
-
['
|
| 24 |
-
['
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
['pdu', 'PDU / RPP', /POWER DISTRIBUTION UNIT|\bPDU\b|\bRPP\b|REMOTE POWER PANEL/],
|
| 26 |
-
['
|
| 27 |
-
['
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
['
|
| 32 |
['pump', 'Pump', /\bPUMP\b/],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
];
|
| 34 |
|
| 35 |
-
// Makers common on data centre electrical and
|
| 36 |
-
// whole words; anything else falls back to a line that reads like a
|
|
|
|
| 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',
|
| 40 |
-
'
|
| 41 |
-
'
|
| 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|
|
| 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 |
-
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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',
|
|
|
|
|
|
|
|
|
|
| 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('
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
| 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 |
-
/**
|
| 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": "
|
| 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,
|
|
|
|
| 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
|
| 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();
|