constructelligence commited on
Commit
5e2d1eb
·
verified ·
1 Parent(s): 3fb6efd

Upload folder using huggingface_hub

Browse files
LICENSE ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ Copyright 2026 Constructelligence
179
+
180
+ Licensed under the Apache License, Version 2.0 (the "License");
181
+ you may not use this file except in compliance with the License.
182
+ You may obtain a copy of the License at
183
+
184
+ http://www.apache.org/licenses/LICENSE-2.0
185
+
186
+ Unless required by applicable law or agreed to in writing, software
187
+ distributed under the License is distributed on an "AS IS" BASIS,
188
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
189
+ See the License for the specific language governing permissions and
190
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ library_name: custom
6
+ pipeline_tag: image-to-text
7
+ tags:
8
+ - ocr
9
+ - document-ai
10
+ - information-extraction
11
+ - nameplate
12
+ - nameplate-ocr
13
+ - equipment-nameplate
14
+ - data-center
15
+ - data-centre
16
+ - datacenter
17
+ - mission-critical
18
+ - mep
19
+ - electrical
20
+ - equipment-schedule
21
+ - schedule-verification
22
+ - asset-register
23
+ - commissioning
24
+ - quality-assurance
25
+ - ups
26
+ - pdu
27
+ - switchgear
28
+ - generator
29
+ - ats
30
+ - transformer
31
+ - busway
32
+ - battery
33
+ - crah
34
+ - crac
35
+ - chiller
36
+ - cooling
37
+ - construction
38
+ - aec
39
+ - edge-ai
40
+ - on-device
41
+ - privacy-preserving
42
+ - tesseract
43
+ - browser
44
+ ---
45
+
46
+ # Data Centre Nameplates — equipment-nameplate OCR checked against the schedule
47
+
48
+ **Read an equipment nameplate and know if it is the unit the schedule asked for.** Point a phone at the
49
+ nameplate of a UPS, PDU, switchgear, generator, transformer, busway, battery, CRAH or chiller; the model returns
50
+ the plate's fields — manufacturer, model, serial, voltage, phase, Hz, kVA, kW, amps, MCA, MOCP, refrigerant,
51
+ manufacture date — and **checks each one against the equipment schedule**, flagging a mismatch instead of a
52
+ silent pass. It builds an asset register and exports it to CSV.
53
+
54
+ Built for the part of a data-centre job where the gear *is* the job. The expensive mistakes are a unit
55
+ delivered at the wrong voltage or rating, or one that quietly never makes it onto the asset register.
56
+
57
+ > **Runs on the device.** OCR and matching run in the browser (or Node) with no server and no upload. A
58
+ > nameplate photo of live infrastructure never leaves the phone that took it.
59
+
60
+ | | |
61
+ |---|---|
62
+ | **Task** | Image → structured nameplate fields → schedule verification |
63
+ | **Fields** | 13 (manufacturer, model, serial, voltage, phase, Hz, kVA, kW, amps, MCA, MOCP, refrigerant, mfg date) |
64
+ | **Equipment** | 13 types (UPS, generator, ATS/STS, switchgear, transformer, PDU/RPP, panelboard, busway, battery, CDU, chiller, CRAH/CRAC, pump) |
65
+ | **OCR** | Tesseract.js 5 (English LSTM), pretrained — no fine-tuning |
66
+ | **Extraction** | Deterministic labelled-field parser with an OCR error model |
67
+ | **Runtime** | Browser · Node · edge — no GPU, no server, no data egress |
68
+ | **Licence** | Apache-2.0 |
69
+
70
+ ---
71
+
72
+ ## Why this exists
73
+
74
+ General OCR reads the text on a nameplate. A data-centre commissioning or QA team needs the text to answer a
75
+ narrower question: **is this the right unit, and does it match what was specified?** That means three things a
76
+ plain OCR call does not do:
77
+
78
+ 1. **Turn plate text into fields, not paragraphs.** "INPUT: 480Y/277 VAC 3 PH 60 HZ" becomes
79
+ `voltage=480Y/277 V`, `phase=3`, `hz=60`; "RATING: 750 KVA / 675 KW" becomes `kva=750`, `kw=675`.
80
+ 2. **Survive the way OCR misreads plate lettering.** Serifed `V`→`Vv`, `K`→`X` (`XVA`), `HZ`→`Hw`, codes split
81
+ after their punctuation (`NPX- 750- 480`). The parser repairs these before matching.
82
+ 3. **Compare to the schedule the way codes actually differ.** Model and serial codes are compared with `O/0`,
83
+ `I/L/1`, `S/5`, `B/8`, `Z/2` folded and punctuation ignored, so a misread is not a false mismatch, while a
84
+ genuine voltage variant (`NPX-750-415` vs `NPX-750-480`) still fails.
85
+
86
+ The result is a pass/mismatch verdict per plate, with the fields that disagree named.
87
+
88
+ ## How it works
89
+
90
+ ```
91
+ photo ──► OCR (Tesseract.js) ──► field parser ──► schedule matcher ──► verdict + asset register CSV
92
+ │ │ │
93
+ lines + confidence labelled-value rules code folding, tolerance checks
94
+ ```
95
+
96
+ 1. **OCR** (`tesseract.js@5`, English) returns lines with a per-line confidence.
97
+ 2. **Field parser** scans lines for labelled values (`MODEL`, `S/N`, `MVA`, `MCA`, `MOCP`, `FLA`, `VOLTS`,
98
+ `REFRIGERANT`, `MFG DATE`), reading voltages as `480Y/277`, `13.8 kV`, `208/120`, and rejecting look-alikes
99
+ (`480/277` with no voltage word is not a voltage; `R-513A` and a charge weight are not amps). Lines OCR
100
+ scored near zero (brushed metal, background texture) are dropped before parsing.
101
+ 3. **Equipment typing** recognises the family from plate wording (UPS, genset, ATS, switchgear, transformer,
102
+ PDU/RPP, panelboard, busway, battery, CDU, chiller, CRAH/CRAC, pump).
103
+ 4. **Schedule matcher** ranks candidate tags — a serial match wins outright; otherwise model similarity
104
+ dominates, with manufacturer and equipment type as tie-breakers. Data halls repeat, so among equal matches
105
+ the first **unscanned** tag is offered.
106
+ 5. **Field checks** compare the reading to the chosen row and return `ok`, `mismatch` or `unread` per field, and
107
+ an overall status: `verified`, `partial`, `mismatch`, `unmatched` or `missing` (scheduled but not scanned).
108
+
109
+ ## Fields extracted
110
+
111
+ | Field | Example | Notes |
112
+ |---|---|---|
113
+ | `manufacturer` | Northline Power Systems | ~44 data-centre makers recognised as whole words; otherwise the company-looking line |
114
+ | `model` | NPX-750-480 | `MODEL`, `MOD.`, `M/N`, `CAT. NO.`, `P/N`, `PART NO.` |
115
+ | `serial` | NL26A01937 | `SERIAL`, `SER.`, `S/N`, `5/N`, `SN` |
116
+ | `voltage` | 480Y/277 V | every voltage on the plate (transformers and UPSes carry more than one) |
117
+ | `phase` | 3 | `1`/`3`, `PH`, `PHASE`, `Ø` |
118
+ | `hz` | 60 | 50 or 60, `HZ`/`FREQUENCY` |
119
+ | `kva` | 750 | thousands separators handled (`3,750`); European decimals too |
120
+ | `kw` | 675 | `KW` / `EKW` |
121
+ | `amps` | 1002 | labelled (`FLA`, `RLA`, `CURRENT`, `AMPS`) first |
122
+ | `mca` | 612 | minimum circuit ampacity |
123
+ | `mocp` | 800 | `MOCP` / `MAX FUSE` / `MAX BREAKER` |
124
+ | `refrigerant` | R-513A | R-410A, 134A, 1234ZE, 1234YF, 513A, 514A, 1233ZD, 454B, 407C, 32, 22 |
125
+ | `mfgDate` | 03/2026 | `MFG`, `MANUFACTURED`, `MFD`, `BUILD`, `PRODUCTION` |
126
+
127
+ ## Equipment types recognised
128
+
129
+ `ups` · `generator` · `ats` (also STS) · `switchgear` · `transformer` · `pdu` (also RPP) · `panelboard` ·
130
+ `busway` · `battery` · `cdu` · `chiller` · `crah` (also CRAC) · `pump`
131
+
132
+ Manufacturers recognised include Schneider Electric, Square D, APC, Eaton, ABB, Siemens, Vertiv, Liebert,
133
+ Caterpillar, Cummins, Kohler, Rolls-Royce, MTU, Generac, Trane, Carrier, Daikin, York, Johnson Controls,
134
+ Stulz, Munters, nVent, Legrand, Mitsubishi Electric, Toshiba, General Electric, Hubbell, Socomec, Piller,
135
+ Starline, Raritan, Server Technology, Emerson, Russelectric, ASCO, Hitachi, Riello, Rittal, Motivair, CoolIT,
136
+ Hitec, EnerSys, Leclanché and Samsung SDI.
137
+
138
+ ## Results — OCR and extraction, end to end
139
+
140
+ Six drawn plates (UPS, diesel genset, chiller, dry-type transformer, PDU, CRAH), each pushed through the
141
+ degradations a phone photo actually has, then run through the app's own OCR + parser and scored **field by
142
+ field**. **56 checks per condition** (six plates × their fields), 13 conditions.
143
+
144
+ | Condition | Field accuracy | ms/plate |
145
+ |---|:---:|---:|
146
+ | clean | **100%** (56/56) | 1236 |
147
+ | small (640 px) | **100%** (56/56) | 897 |
148
+ | blur | **100%** (56/56) | 540 |
149
+ | noise | **100%** (56/56) | 748 |
150
+ | low contrast | **100%** (56/56) | 557 |
151
+ | glare | **100%** (56/56) | 532 |
152
+ | tilt (7°) | **100%** (56/56) | 612 |
153
+ | keystone | **100%** (56/56) | 474 |
154
+ | dark anodised plate | **100%** (56/56) | 808 |
155
+ | JPEG | **100%** (56/56) | 524 |
156
+ | plate small in a cluttered scene | **100%** (56/56) | 895 |
157
+ | sideways | **100%** (56/56) | 554 |
158
+ | phone (blur + glare + noise + tilt + scene + JPEG) | **98.2%** (55/56) | 1607 |
159
+ | **Overall** | **99.9% (727/728)** | **768** |
160
+
161
+ The single miss is one `hz` value on the hardest "phone" composite. The benchmark measures what **OCR** loses,
162
+ not parser coverage: the ground truth is the parser's own reading of the clean plate text, so every reduction
163
+ is an OCR failure the parser could not repair. Reproduce it with `node tests/nameplates-bench.mjs`.
164
+
165
+ **Read this honestly.** These are crisp, machine-drawn plates. A real nameplate photographed on a live unit is
166
+ harder: cast shadows, embossed lettering, print over brushed metal, a plate half out of frame. Expect the
167
+ degradation suite, not the clean row, and treat every low-confidence field as one a person must confirm.
168
+
169
+
170
+ ## Intended use
171
+
172
+ - **Commissioning and QA walks:** photograph each unit as it is installed; confirm the delivered unit matches
173
+ the equipment schedule; capture an asset register as you go.
174
+ - **Receiving and delivery checks:** flag the unit that arrived at the wrong voltage or rating before it is set.
175
+ - **Register completion:** track scheduled tags that have no plate scanned against them yet.
176
+ - **Takeoff and submittal review:** pull model/rating fields off a plate photo into a spreadsheet.
177
+
178
+ ### Out of scope — do not use it for
179
+
180
+ - **Safety, code compliance or energisation decisions.** It reads a plate; it does not verify that a unit is
181
+ safe to energise, correctly protected, or code-compliant.
182
+ - **An as-built or a legal record on its own.** OCR misreads happen; low-confidence fields are highlighted for a
183
+ person to check, and any field that disagrees with the schedule must be confirmed by eye.
184
+ - **Serial-number evidence in a dispute** without human verification. Two plates can share a misread serial;
185
+ the register flags duplicate serials but cannot tell a repeated plate from a copied number.
186
+
187
+ ## Limitations
188
+
189
+ - **English nameplates**, Latin script. Non-Latin or handwritten plates are out of scope.
190
+ - **Glare, blur and angle degrade OCR.** The parser repairs common misreads, but a plate that is small in the
191
+ frame, badly glared or very low contrast will lose fields (those fields come back `unread`, not invented).
192
+ - **Deterministic parser, not a language model.** It reports only what it can match to a rule; it will not
193
+ infer a missing digit.
194
+ - **The schedule must be structured** (CSV with a Tag or Model column). Free-form schedule PDFs are not parsed
195
+ here.
196
+
197
+ ## Quickstart
198
+
199
+ The engine is plain ES modules with no build step. `nameplate-model.js` is the extractor and verifier;
200
+ `schedule-import.js` is the CSV reader it depends on.
201
+
202
+ ```js
203
+ import { parseNameplate, parseEquipmentSchedule, candidatesFor, checkAgainst, statusOf } from './nameplate-model.js';
204
+
205
+ // 1. Parse plate text (from Tesseract, or typed by hand)
206
+ const reading = parseNameplate(`NORTHLINE POWER SYSTEMS
207
+ UNINTERRUPTIBLE POWER SUPPLY
208
+ MODEL NO: NPX-750-480
209
+ SERIAL NO: NL26A01937
210
+ INPUT: 480Y/277 VAC 3 PH 60 HZ
211
+ RATING: 750 KVA / 675 KW
212
+ INPUT CURRENT: 1002 A
213
+ MFG DATE: 03/2026`);
214
+
215
+ console.log(reading.type); // 'ups'
216
+ console.log(reading.fields.voltage); // { value: '480Y/277 V', values: [480, 277], conf, line }
217
+
218
+ // 2. Match against the equipment schedule and check each field
219
+ const { items } = parseEquipmentSchedule(scheduleCsv);
220
+ const [best] = candidatesFor(reading, items);
221
+ const checks = checkAgainst(reading, best.item);
222
+ console.log(statusOf(checks, best.item.tag)); // 'verified' | 'partial' | 'mismatch' | ...
223
+ ```
224
+
225
+ In a browser, feed Tesseract's `data.lines` (`[{ text, confidence, bbox }]`) straight into `parseNameplate`
226
+ to keep OCR confidence and the plate line each value came from.
227
+
228
+ ## Evaluation
229
+
230
+ - **Extraction/verification logic:** 12 unit tests over the parser, voltage reader, code folding, schedule
231
+ parsing, candidate ranking and mismatch detection — all passing (`node --test tests/nameplate-model.test.mjs`).
232
+ - **OCR end-to-end:** a field-by-field benchmark draws plates for six equipment types and puts each through the
233
+ degradations real phone photos have — small, blurred, noisy, low-contrast, glare, tilt, keystone, dark
234
+ anodised plate, JPEG, plate-small-in-scene and sideways — then scores the app's own OCR + parsing pipeline
235
+ field by field. Run it with `node tests/nameplates-bench.mjs`.
236
+
237
+ ## Repository contents
238
+
239
+ | File | What it is |
240
+ |---|---|
241
+ | `nameplate-model.js` | the extractor, matcher and register builder (ES module) |
242
+ | `schedule-import.js` | the CSV schedule reader it depends on |
243
+ | `sample_schedule.csv` | a 10-row data-centre equipment schedule |
244
+ | `sample_plates.json` | two sample plates: one that verifies, one flagged on model and voltage |
245
+ | `eval/` | the OCR benchmark harness output |
246
+ | `config.json` | machine-readable fields, types and statuses |
247
+
248
+ ## Provenance
249
+
250
+ An open extraction and verification engine from Constructelligence, the same code path that powers the
251
+ **Nameplates** mode of BuildVision. No customer data is used; sample plates use fictional makers and models.
252
+ It is the document-AI sibling of the vision models
253
+ [`construction-site-safety-hazards`](https://huggingface.co/constructelligence/construction-site-safety-hazards)
254
+ and [`electrical-circuit-connectivity`](https://huggingface.co/constructelligence/electrical-circuit-connectivity).
255
+
256
+ ## Safety
257
+
258
+ This is a **prompt to look**, not a finding. It is not safety-rated, not a compliance decision, and not a
259
+ substitute for inspection by a competent person. Verify every flagged field against the physical plate before
260
+ acting on it.
261
+
262
+ ## Licence and trademarks
263
+
264
+ Code released under **Apache-2.0**. Brand and product names (Schneider Electric, Eaton, Vertiv, Caterpillar,
265
+ Trane, …) are trademarks of their owners and are matched only to identify user-supplied plates; this project is
266
+ independent and not affiliated with or endorsed by them.
267
+
268
+ ## Citation
269
+
270
+ ```bibtex
271
+ @misc{constructelligence_nameplates,
272
+ title = {Data Centre Nameplates: equipment-nameplate OCR checked against the equipment schedule},
273
+ author = {Constructelligence},
274
+ year = {2026},
275
+ howpublished = {\url{https://huggingface.co/constructelligence/data-centre-nameplates}}
276
+ }
277
+ ```
config.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "data-centre-nameplates",
3
+ "version": "1.0",
4
+ "task": "image-to-text",
5
+ "modality": "image",
6
+ "language": ["en"],
7
+ "summary": "Reads an equipment nameplate (OCR) and checks it against an equipment schedule for data-centre MEP gear: UPS, PDU, switchgear, generators, ATS, transformers, busway, batteries, CRAH/CRAC, chillers, CDUs and pumps.",
8
+ "engine": {
9
+ "ocr": "tesseract.js 5 (LSTM English)",
10
+ "extraction": "deterministic labelled-field parser + OCR error model",
11
+ "matching": "code-similarity (O/0, I/L/1, S/5, B/8, Z/2 folding) + schedule field checks",
12
+ "runtime": "browser / Node, no server, no upload"
13
+ },
14
+ "fields": [
15
+ "manufacturer", "model", "serial", "voltage", "phase", "hz",
16
+ "kva", "kw", "amps", "mca", "mocp", "refrigerant", "mfgDate"
17
+ ],
18
+ "equipment_types": [
19
+ "ups", "generator", "ats", "switchgear", "transformer", "pdu",
20
+ "panelboard", "busway", "battery", "cdu", "chiller", "crah", "pump"
21
+ ],
22
+ "statuses": ["verified", "partial", "mismatch", "unmatched", "missing"],
23
+ "license": "apache-2.0",
24
+ "repo": "constructelligence/data-centre-nameplates"
25
+ }
eval/bench.json ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "overall": 0.9986263736263736,
3
+ "msPerPlate": 768.0833333332976,
4
+ "results": {
5
+ "clean": {
6
+ "acc": 1,
7
+ "ok": 56,
8
+ "total": 56,
9
+ "msPerPlate": 1236,
10
+ "misses": {}
11
+ },
12
+ "small": {
13
+ "acc": 1,
14
+ "ok": 56,
15
+ "total": 56,
16
+ "msPerPlate": 897,
17
+ "misses": {}
18
+ },
19
+ "blur": {
20
+ "acc": 1,
21
+ "ok": 56,
22
+ "total": 56,
23
+ "msPerPlate": 540,
24
+ "misses": {}
25
+ },
26
+ "noise": {
27
+ "acc": 1,
28
+ "ok": 56,
29
+ "total": 56,
30
+ "msPerPlate": 748,
31
+ "misses": {}
32
+ },
33
+ "lowContrast": {
34
+ "acc": 1,
35
+ "ok": 56,
36
+ "total": 56,
37
+ "msPerPlate": 557,
38
+ "misses": {}
39
+ },
40
+ "glare": {
41
+ "acc": 1,
42
+ "ok": 56,
43
+ "total": 56,
44
+ "msPerPlate": 532,
45
+ "misses": {}
46
+ },
47
+ "tilt": {
48
+ "acc": 1,
49
+ "ok": 56,
50
+ "total": 56,
51
+ "msPerPlate": 612,
52
+ "misses": {}
53
+ },
54
+ "keystone": {
55
+ "acc": 1,
56
+ "ok": 56,
57
+ "total": 56,
58
+ "msPerPlate": 474,
59
+ "misses": {}
60
+ },
61
+ "darkPlate": {
62
+ "acc": 1,
63
+ "ok": 56,
64
+ "total": 56,
65
+ "msPerPlate": 808,
66
+ "misses": {}
67
+ },
68
+ "jpeg": {
69
+ "acc": 1,
70
+ "ok": 56,
71
+ "total": 56,
72
+ "msPerPlate": 524,
73
+ "misses": {}
74
+ },
75
+ "inScene": {
76
+ "acc": 1,
77
+ "ok": 56,
78
+ "total": 56,
79
+ "msPerPlate": 895,
80
+ "misses": {}
81
+ },
82
+ "sideways": {
83
+ "acc": 1,
84
+ "ok": 56,
85
+ "total": 56,
86
+ "msPerPlate": 554,
87
+ "misses": {}
88
+ },
89
+ "phone": {
90
+ "acc": 0.9821428571428571,
91
+ "ok": 55,
92
+ "total": 56,
93
+ "msPerPlate": 1607,
94
+ "misses": {
95
+ "hz": 1
96
+ }
97
+ }
98
+ }
99
+ }
eval/bench.log ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ clean 100.0% 1236 ms/plate missed: —
2
+ small 100.0% 897 ms/plate missed: —
3
+ blur 100.0% 540 ms/plate missed: —
4
+ noise 100.0% 748 ms/plate missed: —
5
+ lowContrast 100.0% 557 ms/plate missed: —
6
+ glare 100.0% 532 ms/plate missed: —
7
+ tilt 100.0% 612 ms/plate missed: —
8
+ keystone 100.0% 474 ms/plate missed: —
9
+ darkPlate 100.0% 808 ms/plate missed: —
10
+ jpeg 100.0% 524 ms/plate missed: —
11
+ inScene 100.0% 895 ms/plate missed: —
12
+ sideways 100.0% 554 ms/plate missed: —
13
+ phone 98.2% 1607 ms/plate missed: hz×1
14
+ OVERALL 99.9% 768 ms/plate
nameplate-model.js ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Nameplates: equipment nameplate OCR checked against the equipment schedule.
2
+ // On a data centre job the gear is the job (UPS, PDUs, switchgear, gensets,
3
+ // CRAHs, chillers), and the costly mistakes are a unit delivered at the wrong
4
+ // voltage or rating, or one that never gets onto the asset register. This
5
+ // module turns OCR text into nameplate fields, matches a reading to the
6
+ // schedule, checks each field, and builds the register CSV.
7
+ //
8
+ // OCR is Tesseract.js (pretrained, loaded from the CDN like the other models),
9
+ // so nothing here is trained: it is parsing and matching.
10
+ // Pure functions (tests/nameplate-model.test.mjs).
11
+ import { parseCsv } from './schedule-import.js';
12
+
13
+ export const FIELDS = [
14
+ ['manufacturer', 'Manufacturer'], ['model', 'Model'], ['serial', 'Serial'], ['voltage', 'Voltage'],
15
+ ['phase', 'Phase'], ['hz', 'Hz'], ['kva', 'kVA'], ['kw', 'kW'], ['amps', 'Amps'], ['mca', 'MCA'], ['mocp', 'MOCP'],
16
+ ['refrigerant', 'Refrigerant'], ['mfgDate', 'Mfg date'],
17
+ ];
18
+
19
+ export const EQUIPMENT_TYPES = [
20
+ ['ups', 'UPS', /\bUPS\b|UNINTERRUPTIBLE/],
21
+ ['generator', 'Generator', /GENERATOR|GENSET|\bEKW\b|STANDBY POWER|PRIME POWER/],
22
+ ['ats', 'Transfer switch', /TRANSFER SWITCH|\bATS\b|\bSTS\b/],
23
+ ['switchgear', 'Switchgear / switchboard', /SWITCHGEAR|SWITCHBOARD/],
24
+ ['transformer', 'Transformer', /TRANSFORMER|\bXFMR\b|PRIMARY.*SECONDARY/],
25
+ ['pdu', 'PDU / RPP', /POWER DISTRIBUTION UNIT|\bPDU\b|\bRPP\b|REMOTE POWER PANEL/],
26
+ ['panelboard', 'Panelboard', /PANELBOARD/],
27
+ ['busway', 'Busway', /BUSWAY|BUS PLUG|BUS DUCT/],
28
+ ['battery', 'Battery cabinet', /BATTERY|\bVRLA\b|LITHIUM/],
29
+ ['cdu', 'Coolant distribution unit', /COOLANT DISTRIBUTION|\bCDU\b/],
30
+ ['chiller', 'Chiller', /CHILLER/],
31
+ ['crah', 'CRAH / CRAC', /\bCRAH\b|\bCRAC\b|COMPUTER ROOM AIR|AIR HANDL/],
32
+ ['pump', 'Pump', /\bPUMP\b/],
33
+ ];
34
+
35
+ // Makers common on data centre electrical and cooling packages. Matched as
36
+ // whole words; anything else falls back to a line that reads like a company.
37
+ const MAKERS = [
38
+ 'Schneider Electric', 'Square D', 'APC', 'Eaton', 'ABB', 'Siemens', 'Vertiv', 'Liebert', 'Caterpillar', 'Cummins',
39
+ 'Kohler', 'Rolls-Royce', 'MTU', 'Generac', 'Trane', 'Carrier', 'Daikin', 'York', 'Johnson Controls', 'Stulz',
40
+ 'Munters', 'nVent', 'Legrand', 'Mitsubishi Electric', 'Toshiba', 'General Electric', 'Hubbell', 'Socomec', 'Piller',
41
+ 'Starline', 'Raritan', 'Server Technology', 'Emerson', 'Russelectric', 'ASCO', 'Hitachi', 'Riello',
42
+ 'Rittal', 'Motivair', 'CoolIT', 'Hitec', 'EnerSys', 'Leclanché', 'Samsung SDI',
43
+ ];
44
+ const COMPANY_LINE = /\b(INC|CORP|CORPORATION|LTD|LLC|GMBH|CO\.|COMPANY|SYSTEMS|ELECTRIC|POWER|TECHNOLOGIES|INDUSTRIES)\b/;
45
+
46
+ const NUM = '(\\d{1,5}(?:[.,]\\d+)?)';
47
+ // "1,002" is a thousands separator on a US plate; "2,5" is a European decimal.
48
+ const num = s => (s == null ? null : parseFloat(/^\d{1,3},\d{3}$/.test(String(s)) ? String(s).replace(',', '') : String(s).replace(',', '.')));
49
+
50
+ /**
51
+ * Nameplate fields from OCR output: a string, or lines as [{ text, confidence }]
52
+ * (Tesseract's data.lines). Each field is { value, conf } (conf 0–100, the OCR
53
+ * confidence of the line it came from; 100 for plain text).
54
+ */
55
+ export function parseNameplate(input) {
56
+ const lines = (typeof input === 'string' ? input.split(/\r?\n/).map(text => ({ text, confidence: 100 })) : input)
57
+ .map(l => ({ text: ocrFix(String(l.text || '').replace(/\s+/g, ' ').trim()), conf: Math.round(l.confidence ?? 100), bbox: l.bbox || null }))
58
+ // Lines Tesseract itself scores near zero are texture read as text (brushed metal, a busy background).
59
+ .filter(l => l.text && l.conf >= 20);
60
+ const fields = {};
61
+ // line: index of the OCR line the value came from, so the page can outline it on the photo.
62
+ const set = (key, value, conf, line) => { if (fields[key] == null && value != null && value !== '') fields[key] = { value, conf, line }; };
63
+ // Each rule runs over every line in order, so the first line that has the field wins.
64
+ const scan = (key, re, map = m => m[1]) => {
65
+ for (const [i, l] of lines.entries()) {
66
+ const m = up(l.text).match(re);
67
+ if (m) { const v = map(m); if (v != null && v !== '') { set(key, v, l.conf, i); return; } }
68
+ }
69
+ };
70
+
71
+ const CODE = '([A-Z0-9][A-Z0-9\\-\\/\\.]{2,})';
72
+ scan('model', new RegExp(`(?:\\bMODEL\\b|\\bMOD\\.|\\bM\\/N\\b|\\bCAT(?:ALOG)?\\.?\\s*(?:NO\\b\\.?|#|NUMBER)|\\bP\\/N\\b|\\bPART\\s*(?:NO\\b\\.?|#|NUMBER))\\s*(?:NO\\b\\.?|NUMBER\\b|#)?\\s*[:#.]?\\s*${CODE}`), m => cleanCode(m[1]));
73
+ scan('serial', new RegExp(`(?:\\bSERIAL\\b|\\bSER\\.|\\bS\\/N\\b|\\b5\\/N\\b|\\bSN\\b)\\s*(?:NO\\b\\.?|NUMBER\\b|#)?\\s*[:#.]?\\s*([A-Z0-9][A-Z0-9\\-]{3,})`), m => cleanCode(m[1]));
74
+ scan('refrigerant', /\bR[-\s]?(410A|134A|1234ZE|1234YF|513A|514A|1233ZD|454B|407C|32|22)\b/, m => `R-${m[1]}`);
75
+ scan('hz', /\b(50|60)\s*HZ\b|\bHZ\s*[:]?\s*(50|60)\b|\bFREQ(?:UENCY)?\.?\s*[:]?\s*(50|60)\b/, m => +(m[1] || m[2] || m[3]));
76
+ scan('phase', /\b([13])\s*-?\s*(?:PH|PHASE|Ø|Φ)\b|\bPH(?:ASE)?\s*[:]?\s*([13])\b/, m => +(m[1] || m[2]));
77
+ scan('kva', new RegExp(`${NUM}\\s*KVA\\b|\\bKVA\\s*[:]?\\s*${NUM}`), m => num(m[1] || m[2]));
78
+ scan('kw', new RegExp(`${NUM}\\s*E?KW\\b|\\bE?KW\\s*[:]?\\s*${NUM}`), m => num(m[1] || m[2]));
79
+ scan('mfgDate', /\b(?:MFG\.?|MANUFACTURED|MFD\.?|BUILD|PROD(?:UCTION)?\.?)\s*(?:DATE)?\s*[:]?\s*(\d{1,2}[/-]\d{2,4}|\d{4}[/-]\d{1,2}(?:[/-]\d{1,2})?|\d{1,2}[/-]\d{1,2}[/-]\d{2,4})/, m => m[1]);
80
+
81
+ // Voltage: every voltage on the plate (a transformer or UPS carries more than one).
82
+ const volts = [];
83
+ let voltLine = null;
84
+ for (const [i, l] of lines.entries()) {
85
+ const found = voltagesIn(up(l.text));
86
+ if (found.values.length) {
87
+ for (const v of found.values) if (!volts.includes(v)) volts.push(v);
88
+ if (!voltLine) voltLine = { text: found.display, conf: l.conf, line: i };
89
+ }
90
+ }
91
+ if (volts.length) fields.voltage = { value: voltLine.text, conf: voltLine.conf, line: voltLine.line, values: volts };
92
+
93
+ // Amps: a labelled rated current first, then any bare "1002 A". Refrigerants (R-410A) are not amps.
94
+ scan('amps', new RegExp(`(?:\\bFLA\\b|\\bRLA\\b|\\bCURRENT\\b|\\bAMPS?\\b|\\bAMPERES\\b)\\s*[:]?\\s*${NUM}`), m => num(m[1]));
95
+ if (!fields.amps) scan('amps', new RegExp(`(?<![-\\d.R])${NUM}\\s*(?:A|AMPS?)\\b`), m => num(m[1]));
96
+ scan('mca', new RegExp(`\\bMCA\\b\\s*[:]?\\s*${NUM}`), m => num(m[1]));
97
+ scan('mocp', new RegExp(`(?:\\bMOCP\\b|\\bMAX\\.?\\s*(?:FUSE|BREAKER|OCP))\\s*(?:SIZE)?\\s*[:]?\\s*${NUM}`), m => num(m[1]));
98
+
99
+ // Manufacturer: a known maker anywhere, else a line that reads like a company name.
100
+ for (const [i, l] of lines.entries()) {
101
+ // "New York" on a supplier's address is not York the maker.
102
+ const t = up(l.text).replace(/NEW YORK/g, '');
103
+ const maker = MAKERS.find(mk => new RegExp(`\\b${escapeRe(up(mk))}\\b`).test(t));
104
+ if (maker) { set('manufacturer', maker, l.conf, i); break; }
105
+ }
106
+ if (!fields.manufacturer) {
107
+ const i = lines.findIndex(x => COMPANY_LINE.test(up(x.text)) && !/\d{3}/.test(x.text) && x.text.length <= 48);
108
+ if (i >= 0) set('manufacturer', titleCase(lines[i].text.replace(/[^A-Za-z0-9&.,\- ]/g, '').trim()), lines[i].conf, i);
109
+ }
110
+
111
+ const all = up(lines.map(l => l.text).join('\n'));
112
+ const type = (EQUIPMENT_TYPES.find(([, , re]) => re.test(all)) || [null])[0];
113
+ const conf = lines.length ? Math.round(lines.reduce((s, l) => s + l.conf, 0) / lines.length) : 0;
114
+ return { fields, type, conf, text: lines.map(l => l.text).join('\n'), lines: lines.map(l => ({ text: l.text, conf: l.conf, bbox: l.bbox })) };
115
+ }
116
+
117
+ /**
118
+ * Undo the misreads Tesseract makes on plate lettering (seen in tests/nameplates-bench.mjs):
119
+ * a serifed "V" read as "Vv", "Y" as "¥", codes and decimals split after their punctuation
120
+ * ("NPX- 750- 480", "38. 5"), "HZ" as "Hw"/"H2", "KVA" as "XVA".
121
+ */
122
+ export function ocrFix(t) {
123
+ return t
124
+ .replace(/[¥]/g, 'Y')
125
+ .replace(/[‘’`]/g, "'")
126
+ .replace(/(\d)\s*V[vV]\b/g, '$1 V')
127
+ .replace(/([A-Za-z0-9])-\s+(?=[A-Za-z0-9])/g, '$1-')
128
+ .replace(/(\d)\.\s+(?=\d)/g, '$1.')
129
+ .replace(/\b(50|60)\s*H[wW2]\b/g, '$1 HZ')
130
+ // K read as X in KVA / KW / EKW.
131
+ .replace(/(\d\s*)XVA\b/g, '$1KVA')
132
+ .replace(/(\d\s*)E[KX][WN]\b/g, '$1EKW')
133
+ .replace(/(\d\s*)XW\b/g, '$1KW');
134
+ }
135
+
136
+ /** Voltages written on one line: 480Y/277V, 208/120 VAC, VOLTS: 480, 13.8 kV. */
137
+ export function voltagesIn(s) {
138
+ s = s.replace(/(\d),(\d{3})\b/g, '$1$2'); // 13,800 V
139
+ const values = [];
140
+ let display = null;
141
+ const add = (v, shown) => {
142
+ if (!(v >= 100 && v <= 40000)) return;
143
+ if (!values.includes(v)) values.push(v);
144
+ if (!display) display = shown;
145
+ };
146
+ const pair = /\b(\d{3,5})\s*(Y)?\s*\/\s*(\d{3,5})\s*(?:V|VAC|VOLTS?)?\b/g;
147
+ let m;
148
+ const isLabelled = /VOLT|\bV\b|\bVAC\b|\dV\b/.test(s);
149
+ while ((m = pair.exec(s))) {
150
+ // "480/277" with no V anywhere on the line could be anything; require a voltage word or unit.
151
+ if (!isLabelled && !/\d\s*(?:V|VAC|VOLTS?)\b/.test(m[0])) continue;
152
+ add(+m[1], `${m[1]}${m[2] ? 'Y' : ''}/${m[3]} V`);
153
+ add(+m[3], null);
154
+ }
155
+ const single = /\b(\d{3,5})\s*(?:V|VAC|VDC|VOLTS?)\b/g;
156
+ while ((m = single.exec(s))) add(+m[1], `${m[1]} V`);
157
+ const labelled = /\bVOLT(?:S|AGE)?\b[^0-9]{0,12}(\d{3,5})\b/g;
158
+ while ((m = labelled.exec(s))) add(+m[1], `${m[1]} V`);
159
+ const kv = /\b(\d{1,2}(?:\.\d{1,2})?)\s*KV\b/g;
160
+ while ((m = kv.exec(s))) add(Math.round(parseFloat(m[1]) * 1000), `${m[1]} kV`);
161
+ return { values, display };
162
+ }
163
+
164
+ // ------------------------------------------------------------------ schedule
165
+
166
+ const HEADERS = {
167
+ tag: /^(tag|mark|equipment tag|equip(ment)? (id|no\.?|number|tag)|asset( tag| id)?|id|unit tag)$/i,
168
+ type: /^(type|equipment|equipment type|description|desc|item|system)$/i,
169
+ manufacturer: /^(manufacturer|mfr\.?|mfg\.?|make|brand|vendor|basis of design)$/i,
170
+ model: /^(model|model no\.?|model number|model #|cat(alog)? no\.?|catalog number|part (no\.?|number))$/i,
171
+ serial: /^(serial|serial no\.?|serial number|s\/n)$/i,
172
+ voltage: /^(voltage|volts|v|input voltage|volts\/phase|electrical)$/i,
173
+ phase: /^(phase|ph|phases)$/i,
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
+
183
+ /** Equipment schedule from a CSV export: the header row is the first that names a tag or model column plus one more. */
184
+ export function parseEquipmentSchedule(text) {
185
+ const rows = parseCsv(String(text || ''));
186
+ const known = r => Object.values(HEADERS).filter(re => r.some(c => re.test(c.trim()))).length;
187
+ const headerIdx = rows.findIndex(r => (r.some(c => HEADERS.tag.test(c.trim())) || r.some(c => HEADERS.model.test(c.trim()))) && known(r) >= 2);
188
+ if (headerIdx < 0) return { items: [], problems: ['No header row with a Tag or Model column was found.'] };
189
+ const header = rows[headerIdx].map(c => c.trim());
190
+ const cols = Object.fromEntries(Object.keys(HEADERS).map(k => [k, header.findIndex(h => HEADERS[k].test(h))]));
191
+ const items = [], problems = [];
192
+ rows.slice(headerIdx + 1).forEach((r, i) => {
193
+ const get = k => (cols[k] >= 0 ? String(r[cols[k]] ?? '').trim() : '');
194
+ const tag = get('tag') || (get('model') ? `Row ${headerIdx + i + 2}` : '');
195
+ if (!tag) return;
196
+ if (items.some(x => x.tag === tag)) problems.push(`Tag ${tag} appears more than once; the first row is used.`);
197
+ else items.push({
198
+ tag, type: get('type'), manufacturer: get('manufacturer'), model: get('model'), serial: get('serial'),
199
+ voltage: get('voltage'), phase: num(get('phase').match(/[13]/)?.[0]), kva: num(get('kva')) || null, kw: num(get('kw')) || null,
200
+ amps: num(get('amps')) || null, mca: num(get('mca')) || null, mocp: num(get('mocp')) || null, refrigerant: get('refrigerant'), location: get('location'),
201
+ });
202
+ });
203
+ if (!items.length) problems.push('The header was found but no equipment rows under it.');
204
+ return { items, problems };
205
+ }
206
+
207
+ /** Voltages a schedule cell asks for: "480Y/277", "480V 3PH", "13.8kV". */
208
+ export function scheduleVoltages(cell) {
209
+ const s = up(cell);
210
+ const r = voltagesIn(s);
211
+ if (r.values.length) return r.values;
212
+ return [...s.matchAll(/(?<![\d.])(\d{3,5})(?![\d.])/g)].map(m => +m[1]).filter(v => v >= 100 && v <= 40000);
213
+ }
214
+
215
+ // ------------------------------------------------------------------ matching
216
+
217
+ /** Model/serial codes compared the way OCR gets them wrong: O/0, I/L/1, S/5, B/8, Z/2, punctuation and case ignored. */
218
+ export function foldCode(s) {
219
+ return up(s).replace(/[^A-Z0-9]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1').replace(/S/g, '5').replace(/B/g, '8').replace(/Z/g, '2');
220
+ }
221
+
222
+ export function codeSimilarity(a, b) {
223
+ const x = foldCode(a), y = foldCode(b);
224
+ if (!x || !y) return 0;
225
+ if (x === y) return 1;
226
+ // Plate models often carry option suffixes the schedule leaves off (9395P-750 vs 9395P-750-N-480).
227
+ const [short, long] = x.length <= y.length ? [x, y] : [y, x];
228
+ if (short.length >= 5 && long.startsWith(short)) return 0.92;
229
+ return 1 - levenshtein(x, y) / Math.max(x.length, y.length);
230
+ }
231
+
232
+ const sameMaker = (a, b) => {
233
+ const x = up(a).replace(/[^A-Z0-9]/g, ''), y = up(b).replace(/[^A-Z0-9]/g, '');
234
+ return !!x && !!y && (x.includes(y) || y.includes(x));
235
+ };
236
+
237
+ /**
238
+ * Schedule rows this reading could be, best first: [{ item, score, registered }].
239
+ * Data halls repeat, so several tags often share one model (UPS-1A, UPS-1B…):
240
+ * among equal scores, tags not on the register yet come first.
241
+ */
242
+ export function candidatesFor(reading, items, register = []) {
243
+ const f = reading.fields || {};
244
+ const taken = new Set(register.map(r => r.tag).filter(Boolean));
245
+ const out = [];
246
+ for (const item of items) {
247
+ let score;
248
+ if (item.serial && f.serial?.value && foldCode(item.serial) === foldCode(f.serial.value)) score = 1.5;
249
+ else {
250
+ const m = item.model && f.model?.value ? codeSimilarity(item.model, f.model.value) : 0;
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);
263
+ return (EQUIPMENT_TYPES.find(([, , re]) => re.test(s)) || [null])[0];
264
+ }
265
+
266
+ /**
267
+ * Field-by-field check of a reading against one schedule row:
268
+ * [{ key, label, status: 'ok' | 'mismatch' | 'unread', expected, found }].
269
+ * Fields the schedule leaves blank are not checked.
270
+ */
271
+ export function checkAgainst(reading, item) {
272
+ const f = reading.fields || {};
273
+ const out = [];
274
+ const push = (key, expected, found, ok) => out.push({
275
+ key, label: FIELDS.find(x => x[0] === key)?.[1] || key, expected: String(expected), found: found == null ? '' : String(found),
276
+ status: found == null || found === '' ? 'unread' : ok ? 'ok' : 'mismatch',
277
+ });
278
+ if (item.manufacturer) push('manufacturer', item.manufacturer, f.manufacturer?.value, sameMaker(item.manufacturer, f.manufacturer?.value));
279
+ if (item.model) push('model', item.model, f.model?.value, codeSimilarity(item.model, f.model?.value) >= 0.85);
280
+ if (item.serial) push('serial', item.serial, f.serial?.value, foldCode(item.serial) === foldCode(f.serial?.value));
281
+ if (item.voltage) {
282
+ const want = scheduleVoltages(item.voltage);
283
+ const have = f.voltage?.values || (f.voltage?.value ? scheduleVoltages(f.voltage.value) : []);
284
+ push('voltage', item.voltage, f.voltage?.value, want.length > 0 && want.every(v => have.includes(v)));
285
+ }
286
+ if (item.phase) push('phase', item.phase, f.phase?.value, +item.phase === +f.phase?.value);
287
+ for (const k of ['kva', 'kw', 'amps', 'mca', 'mocp']) {
288
+ if (item[k]) push(k, item[k], f[k]?.value, Math.abs(+f[k]?.value - item[k]) <= Math.max(0.5, item[k] * 0.01));
289
+ }
290
+ if (item.refrigerant) push('refrigerant', item.refrigerant, f.refrigerant?.value, up(item.refrigerant).replace(/[^A-Z0-9]/g, '') === up(f.refrigerant?.value).replace(/[^A-Z0-9]/g, ''));
291
+ return out;
292
+ }
293
+
294
+ /** Overall status of a register entry: matched and clean, matched with a problem, or not on the schedule. */
295
+ export function statusOf(checks, tag) {
296
+ if (!tag) return 'unmatched';
297
+ if (checks.some(c => c.status === 'mismatch')) return 'mismatch';
298
+ if (checks.some(c => c.status === 'unread')) return 'partial';
299
+ return 'verified';
300
+ }
301
+
302
+ export const STATUS_LABELS = { verified: 'Verified', partial: 'Matched, fields unread', mismatch: 'Does not match schedule', unmatched: 'Not on schedule', missing: 'Not scanned yet' };
303
+
304
+ /** Register entries that share a serial (the same plate scanned twice, or a copied serial). */
305
+ export function duplicateSerials(register) {
306
+ const seen = new Map();
307
+ for (const r of register) {
308
+ const s = foldCode(r.fields?.serial?.value);
309
+ if (!s) continue;
310
+ seen.set(s, [...(seen.get(s) || []), r.id]);
311
+ }
312
+ return [...seen.values()].filter(ids => ids.length > 1);
313
+ }
314
+
315
+ /** Progress against the schedule: scanned, verified, flagged, not scanned. */
316
+ export function registerSummary(register, items = []) {
317
+ const tags = new Set(register.map(r => r.tag).filter(Boolean));
318
+ return {
319
+ scanned: register.length,
320
+ scheduled: items.length,
321
+ scannedOfSchedule: items.filter(i => tags.has(i.tag)).length,
322
+ verified: register.filter(r => r.status === 'verified').length,
323
+ flagged: register.filter(r => r.status === 'mismatch').length,
324
+ unmatched: register.filter(r => r.status === 'unmatched').length,
325
+ duplicates: duplicateSerials(register).length,
326
+ missing: items.filter(i => !tags.has(i.tag)).map(i => i.tag),
327
+ };
328
+ }
329
+
330
+ /** Asset register CSV: one row per scanned plate, then every scheduled tag not scanned yet. */
331
+ export function registerCsv(register, items = []) {
332
+ const head = ['Tag', 'Status', 'Type', 'Location', 'Manufacturer', 'Model', 'Serial', 'Voltage', 'Phase', 'Hz', 'kVA', 'kW', 'Amps', 'MCA', 'MOCP', 'Refrigerant', 'Mfg date', 'Issues', 'Scanned'];
333
+ const byTag = new Map(items.map(i => [i.tag, i]));
334
+ const typeLabel = k => (EQUIPMENT_TYPES.find(t => t[0] === k) || [])[1] || '';
335
+ const dups = new Set(duplicateSerials(register).flat());
336
+ const rows = register.map(r => {
337
+ const f = r.fields || {};
338
+ const v = k => f[k]?.value ?? '';
339
+ const issues = (r.checks || []).filter(c => c.status === 'mismatch').map(c => `${c.label}: schedule ${c.expected}, plate ${c.found}`);
340
+ if (dups.has(r.id)) issues.push('Serial also on another scanned plate');
341
+ return [r.tag || '', STATUS_LABELS[r.status] || r.status, typeLabel(r.type), byTag.get(r.tag)?.location || '',
342
+ v('manufacturer'), v('model'), v('serial'), v('voltage'), v('phase'), v('hz'), v('kva'), v('kw'), v('amps'),
343
+ v('mca'), v('mocp'), v('refrigerant'), v('mfgDate'), issues.join('; '), r.scannedAt ? new Date(r.scannedAt).toISOString().slice(0, 16).replace('T', ' ') : ''];
344
+ });
345
+ const tags = new Set(register.map(r => r.tag));
346
+ for (const i of items) {
347
+ if (tags.has(i.tag)) continue;
348
+ rows.push([i.tag, STATUS_LABELS.missing, i.type, i.location, i.manufacturer, i.model, i.serial, i.voltage, i.phase ?? '', '',
349
+ i.kva ?? '', i.kw ?? '', i.amps ?? '', i.mca ?? '', i.mocp ?? '', i.refrigerant, '', '', '']);
350
+ }
351
+ return [head, ...rows].map(r => r.map(csvCell).join(',')).join('\n') + '\n';
352
+ }
353
+
354
+ // ------------------------------------------------------------------ sample data
355
+
356
+ // Fictional maker and models: the sample shows the workflow without implying
357
+ // any real product's ratings.
358
+ export const SAMPLE_SCHEDULE = `Tag,Description,Manufacturer,Model,Voltage,Phase,kVA,kW,MCA,MOCP,Refrigerant,Location
359
+ UPS-1A,Static UPS module,Northline Power Systems,NPX-750-480,480Y/277,3,750,675,,,,Electrical Room 1A
360
+ UPS-1B,Static UPS module,Northline Power Systems,NPX-750-480,480Y/277,3,750,675,,,,Electrical Room 1B
361
+ UPS-2A,Static UPS module,Northline Power Systems,NPX-750-480,480Y/277,3,750,675,,,,Electrical Room 2A
362
+ UPS-2B,Static UPS module,Northline Power Systems,NPX-750-480,480Y/277,3,750,675,,,,Electrical Room 2B
363
+ PDU-1-01,Power distribution unit,Northline Power Systems,NPD-300-415,480 / 415Y/240,3,300,,,,,Data Hall 1
364
+ PDU-1-02,Power distribution unit,Northline Power Systems,NPD-300-415,480 / 415Y/240,3,300,,,,,Data Hall 1
365
+ GEN-01,Diesel generator,Harbor Engine Works,HG-3000,13800,3,3750,3000,,,,Generator Yard
366
+ GEN-02,Diesel generator,Harbor Engine Works,HG-3000,13800,3,3750,3000,,,,Generator Yard
367
+ CRAH-1-01,Computer room air handler,Coldline Systems,CW-150-480,480,3,,,45,60,,Data Hall 1
368
+ CH-01,Air-cooled chiller,Coldline Systems,AC-1200,460,3,,,612,800,R-513A,Roof
369
+ `;
370
+
371
+ /** Text of the two sample plates: one that matches UPS-1A, one delivered at the wrong voltage. */
372
+ export const SAMPLE_PLATES = [
373
+ ['NORTHLINE POWER SYSTEMS', 'UNINTERRUPTIBLE POWER SUPPLY', 'MODEL NO: NPX-750-480', 'SERIAL NO: NL26A01937',
374
+ 'INPUT: 480Y/277 VAC 3 PH 60 HZ', 'RATING: 750 KVA / 675 KW', 'INPUT CURRENT: 1002 A', 'MFG DATE: 03/2026'],
375
+ ['NORTHLINE POWER SYSTEMS', 'UNINTERRUPTIBLE POWER SUPPLY', 'MODEL NO: NPX-750-415', 'SERIAL NO: NL26A02210',
376
+ 'INPUT: 415Y/240 VAC 3 PH 50 HZ', 'RATING: 750 KVA / 675 KW', 'INPUT CURRENT: 1160 A', 'MFG DATE: 04/2026'],
377
+ ];
378
+
379
+ // ------------------------------------------------------------------ helpers
380
+
381
+ 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) {
388
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
389
+ for (let i = 1; i <= a.length; i++) {
390
+ let diag = prev[0];
391
+ prev[0] = i;
392
+ for (let j = 1; j <= b.length; j++) {
393
+ const tmp = prev[j];
394
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
395
+ diag = tmp;
396
+ }
397
+ }
398
+ return prev[b.length];
399
+ }
sample_plates.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "note": "Two fictional UPS plates. The first matches schedule tag UPS-1A; the second is the same kVA and model family but a 415 V / 50 Hz unit, so it should be flagged on model and voltage.",
3
+ "plates": [
4
+ {
5
+ "id": "ups-match",
6
+ "expect_tag": "UPS-1A",
7
+ "lines": [
8
+ "NORTHLINE POWER SYSTEMS",
9
+ "UNINTERRUPTIBLE POWER SUPPLY",
10
+ "MODEL NO: NPX-750-480",
11
+ "SERIAL NO: NL26A01937",
12
+ "INPUT: 480Y/277 VAC 3 PH 60 HZ",
13
+ "RATING: 750 KVA / 675 KW",
14
+ "INPUT CURRENT: 1002 A",
15
+ "MFG DATE: 03/2026"
16
+ ]
17
+ },
18
+ {
19
+ "id": "ups-wrong-voltage",
20
+ "expect_tag": "UPS-1A",
21
+ "expect_mismatch": ["model", "voltage"],
22
+ "lines": [
23
+ "NORTHLINE POWER SYSTEMS",
24
+ "UNINTERRUPTIBLE POWER SUPPLY",
25
+ "MODEL NO: NPX-750-415",
26
+ "SERIAL NO: NL26A02210",
27
+ "INPUT: 415Y/240 VAC 3 PH 50 HZ",
28
+ "RATING: 750 KVA / 675 KW",
29
+ "INPUT CURRENT: 1160 A",
30
+ "MFG DATE: 04/2026"
31
+ ]
32
+ }
33
+ ]
34
+ }
sample_schedule.csv ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Tag,Description,Manufacturer,Model,Voltage,Phase,kVA,kW,MCA,MOCP,Refrigerant,Location
2
+ UPS-1A,Static UPS module,Northline Power Systems,NPX-750-480,480Y/277,3,750,675,,,,Electrical Room 1A
3
+ UPS-1B,Static UPS module,Northline Power Systems,NPX-750-480,480Y/277,3,750,675,,,,Electrical Room 1B
4
+ UPS-2A,Static UPS module,Northline Power Systems,NPX-750-480,480Y/277,3,750,675,,,,Electrical Room 2A
5
+ UPS-2B,Static UPS module,Northline Power Systems,NPX-750-480,480Y/277,3,750,675,,,,Electrical Room 2B
6
+ PDU-1-01,Power distribution unit,Northline Power Systems,NPD-300-415,480 / 415Y/240,3,300,,,,,Data Hall 1
7
+ PDU-1-02,Power distribution unit,Northline Power Systems,NPD-300-415,480 / 415Y/240,3,300,,,,,Data Hall 1
8
+ GEN-01,Diesel generator,Harbor Engine Works,HG-3000,13800,3,3750,3000,,,,Generator Yard
9
+ GEN-02,Diesel generator,Harbor Engine Works,HG-3000,13800,3,3750,3000,,,,Generator Yard
10
+ CRAH-1-01,Computer room air handler,Coldline Systems,CW-150-480,480,3,,,45,60,,Data Hall 1
11
+ CH-01,Air-cooled chiller,Coldline Systems,AC-1200,460,3,,,612,800,R-513A,Roof
schedule-import.js ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Schedule import for Progress mode: planned dates from the project schedule
2
+ // instead of typed in area by area — the link Buildots makes between what the
3
+ // camera saw and the programme (P6 / MS Project). Reads a CSV export (P6,
4
+ // MS Project, Smartsheet and Excel all export one) or an MS Project XML file,
5
+ // then matches each activity to the app's areas and trades by name:
6
+ //
7
+ // "L3 Unit 3B – hang drywall" → area "L3 Unit 3B", trade Walls
8
+ // "Level 4 ceiling grid" → every area on floor L4, trade Ceiling
9
+ // "L3 Unit 3B fit-out" → area "L3 Unit 3B", the area's own window
10
+ //
11
+ // Nothing is applied until the user has seen what matched and what did not.
12
+ // Pure functions (tests/schedule-import.test.mjs).
13
+ import { floorOf } from './progress-project.js';
14
+
15
+ const MONTHS = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };
16
+
17
+ /**
18
+ * Schedule dates as local noon (the app stores capture dates the same way).
19
+ * ISO 2026-09-01 (optionally with a time), US 9/1/2026 or 09/01/26, P6 01-Sep-26 / 01-Sep-2026, "Sep 1, 2026".
20
+ * A trailing "A" (P6 actual) or "*" (constraint) is ignored.
21
+ */
22
+ export function parseScheduleDate(raw) {
23
+ const s = String(raw ?? '').trim().replace(/\s*[A*]$/, '');
24
+ if (!s) return null;
25
+ let m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
26
+ if (m) return new Date(+m[1], +m[2] - 1, +m[3], 12).getTime();
27
+ m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
28
+ if (m) return new Date(+m[3] < 100 ? 2000 + +m[3] : +m[3], +m[1] - 1, +m[2], 12).getTime();
29
+ m = s.match(/^(\d{1,2})[- ]([A-Za-z]{3})[a-z]*[- ,]+(\d{2,4})/);
30
+ if (m && MONTHS[m[2].toLowerCase()] != null) return new Date(+m[3] < 100 ? 2000 + +m[3] : +m[3], MONTHS[m[2].toLowerCase()], +m[1], 12).getTime();
31
+ m = s.match(/^([A-Za-z]{3})[a-z]*\.? (\d{1,2}),? (\d{4})/);
32
+ if (m && MONTHS[m[1].toLowerCase()] != null) return new Date(+m[3], MONTHS[m[1].toLowerCase()], +m[2], 12).getTime();
33
+ return null;
34
+ }
35
+
36
+ /** RFC-4180-ish CSV: quoted fields, doubled quotes, commas/newlines inside quotes; also tab-separated. */
37
+ export function parseCsv(text) {
38
+ const delim = (text.split('\n')[0].match(/\t/g) || []).length > (text.split('\n')[0].match(/,/g) || []).length ? '\t' : ',';
39
+ const rows = [];
40
+ let row = [], field = '', q = false;
41
+ for (let i = 0; i < text.length; i++) {
42
+ const ch = text[i];
43
+ if (q) {
44
+ if (ch === '"' && text[i + 1] === '"') { field += '"'; i++; }
45
+ else if (ch === '"') q = false;
46
+ else field += ch;
47
+ } else if (ch === '"') q = true;
48
+ else if (ch === delim) { row.push(field); field = ''; }
49
+ else if (ch === '\n' || ch === '\r') {
50
+ if (ch === '\r' && text[i + 1] === '\n') i++;
51
+ row.push(field); field = '';
52
+ if (row.some(c => c.trim())) rows.push(row);
53
+ row = [];
54
+ } else field += ch;
55
+ }
56
+ row.push(field);
57
+ if (row.some(c => c.trim())) rows.push(row);
58
+ return rows;
59
+ }
60
+
61
+ const HEADERS = {
62
+ name: /^(activity|task|activity name|task name|name|description|activity description)$/i,
63
+ start: /^(start|start date|planned start|early start|baseline start|bl start)$/i,
64
+ finish: /^(finish|finish date|end|end date|planned finish|early finish|baseline finish|bl finish)$/i,
65
+ area: /^(area|location|zone|room|space|wbs name)$/i,
66
+ trade: /^(trade|discipline|activity code|responsibility)$/i,
67
+ };
68
+
69
+ /** Activities from a CSV export: finds the header row (first row naming a start and a finish column). */
70
+ export function parseScheduleCsv(text) {
71
+ const rows = parseCsv(text);
72
+ const headerIdx = rows.findIndex(r => r.some(c => HEADERS.start.test(c.trim())) && r.some(c => HEADERS.finish.test(c.trim())));
73
+ if (headerIdx < 0) return { activities: [], problems: ['No header row with a start and a finish column was found.'] };
74
+ const header = rows[headerIdx].map(c => c.trim());
75
+ const col = key => header.findIndex(h => HEADERS[key].test(h));
76
+ const cols = { name: col('name'), start: col('start'), finish: col('finish'), area: col('area'), trade: col('trade') };
77
+ if (cols.name < 0) cols.name = header.findIndex((h, i) => i !== cols.start && i !== cols.finish && h);
78
+ const activities = [], problems = [];
79
+ rows.slice(headerIdx + 1).forEach((r, i) => {
80
+ const name = (r[cols.name] || '').trim();
81
+ const start = parseScheduleDate(r[cols.start]), finish = parseScheduleDate(r[cols.finish]);
82
+ if (!name) return;
83
+ if (start == null || finish == null) { problems.push(`Row ${headerIdx + i + 2} (${name}): unreadable dates`); return; }
84
+ activities.push({ name, start: Math.min(start, finish), finish: Math.max(start, finish),
85
+ area: cols.area >= 0 ? (r[cols.area] || '').trim() || null : null, trade: cols.trade >= 0 ? (r[cols.trade] || '').trim() || null : null });
86
+ });
87
+ return { activities, problems };
88
+ }
89
+
90
+ const xmlText = s => s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
91
+
92
+ /** Activities from an MS Project XML export (File → Save As → XML). Summary tasks are skipped. */
93
+ export function parseMsProjectXml(text) {
94
+ const activities = [], problems = [];
95
+ for (const block of text.match(/<Task>[\s\S]*?<\/Task>/g) || []) {
96
+ const get = tag => { const m = block.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`)); return m ? xmlText(m[1]).trim() : null; };
97
+ if (get('Summary') === '1' || get('IsNull') === '1') continue;
98
+ const name = get('Name');
99
+ if (!name) continue;
100
+ const start = parseScheduleDate(get('Start')), finish = parseScheduleDate(get('Finish'));
101
+ if (start == null || finish == null) { problems.push(`${name}: unreadable dates`); continue; }
102
+ activities.push({ name, start, finish, area: null, trade: null });
103
+ }
104
+ return { activities, problems };
105
+ }
106
+
107
+ export function parseSchedule(text, fileName = '') {
108
+ return /\.xml$/i.test(fileName) || /^\s*<\?xml|<Project[\s>]/.test(text) ? parseMsProjectXml(text) : parseScheduleCsv(text);
109
+ }
110
+
111
+ // Trade keywords → Progress mode's item keys. Order matters: "ceiling grid" is Ceiling, not Walls.
112
+ const TRADES = [
113
+ ['ceiling', /\b(ceiling|ceilings|act|acoustic|grid|soffit|bulkhead)\b/i],
114
+ ['exits', /\bexit\b/i],
115
+ ['supply', /\b(diffuser|diffusers|supply air)\b/i],
116
+ ['returns', /\b(return|returns|grille|grilles)\b/i],
117
+ ['fixtures', /\b(light|lights|lighting|fixture|fixtures|luminaire|luminaires)\b/i],
118
+ ['wall', /\b(drywall|gypsum|gyp|plasterboard|sheetrock|board|partition|partitions|stud|studs|framing|frame|tape|taping|mud|finish walls|wall|walls|paint|painting)\b/i],
119
+ ];
120
+ export function tradeOf(text) {
121
+ for (const [key, re] of TRADES) if (re.test(text || '')) return key;
122
+ return null;
123
+ }
124
+
125
+ const norm = s => String(s || '').toLowerCase().replace(/[·•|_]+/g, ' ').replace(/[-–—]+/g, ' ').replace(/\s+/g, ' ').trim();
126
+
127
+ /** A floor named in an activity ("Level 4 …", "L4 …", "4th floor …") in the same keys floorOf gives areas. */
128
+ function floorInText(text) {
129
+ const s = String(text || '');
130
+ let m = s.match(/\b(?:l|lvl|level|fl|floor)\s*-?\s*0*(\d+)\b/i) || s.match(/\b0*(\d+)(?:st|nd|rd|th)?\s*(?:f|fl|floor)\b/i);
131
+ if (m) return `L${Number(m[1])}`;
132
+ m = s.match(/\b(?:b|basement)\s*-?\s*0*(\d+)\b/i);
133
+ return m ? `B${Number(m[1])}` : null;
134
+ }
135
+
136
+ /**
137
+ * Match activities to areas and trades. An activity names an area when the
138
+ * area's name appears in it (longest name wins, so "L3 Unit 3B" beats "L3");
139
+ * otherwise, if it names a floor, it applies to every area on that floor that
140
+ * has no activity of its own for that trade. Returns per-area windows and
141
+ * per-trade windows (earliest start → latest finish across matching activities)
142
+ * plus what could not be placed.
143
+ */
144
+ export function matchSchedule(activities, areaNames) {
145
+ const areas = [...areaNames].sort((a, b) => norm(b).length - norm(a).length);
146
+ const plan = {}, unmatched = [], used = [];
147
+ const widen = (w, a) => ({ start: Math.min(w?.start ?? Infinity, a.start), finish: Math.max(w?.finish ?? -Infinity, a.finish) });
148
+ const direct = new Set(); // "area|trade" set by an activity naming the area itself
149
+ const floorActs = [];
150
+ for (const act of activities) {
151
+ const hay = norm(`${act.area || ''} ${act.name}`);
152
+ const area = areas.find(n => norm(n) && (norm(act.area) === norm(n) || hay.includes(norm(n))));
153
+ const trade = tradeOf(act.trade) || tradeOf(act.name);
154
+ if (area) {
155
+ const p = (plan[area] ||= { window: null, trades: {}, activities: [] });
156
+ if (trade) { p.trades[trade] = widen(p.trades[trade], act); direct.add(`${area}|${trade}`); }
157
+ else p.window = widen(p.window, act);
158
+ p.activities.push(act.name);
159
+ used.push(act);
160
+ } else if (floorInText(act.area || act.name)) floorActs.push({ act, floor: floorInText(act.area || act.name), trade });
161
+ else unmatched.push(act.name);
162
+ }
163
+ for (const { act, floor, trade } of floorActs) {
164
+ const onFloor = areaNames.filter(n => floorOf(n) === floor);
165
+ if (!onFloor.length) { unmatched.push(act.name); continue; }
166
+ for (const area of onFloor) {
167
+ const p = (plan[area] ||= { window: null, trades: {}, activities: [] });
168
+ if (trade) { if (direct.has(`${area}|${trade}`)) continue; p.trades[trade] = widen(p.trades[trade], act); }
169
+ else if (!p.window) p.window = { start: act.start, finish: act.finish };
170
+ p.activities.push(`${act.name} (floor ${floor})`);
171
+ }
172
+ used.push(act);
173
+ }
174
+ // An area whose schedule only names trades gets a window spanning them —
175
+ // marked as derived, so applying it never replaces dates the area already has.
176
+ for (const p of Object.values(plan)) {
177
+ if (!p.window && Object.keys(p.trades).length) {
178
+ p.window = Object.values(p.trades).reduce((w, t) => ({ start: Math.min(w.start, t.start), finish: Math.max(w.finish, t.finish) }), { start: Infinity, finish: -Infinity });
179
+ p.derivedWindow = true;
180
+ }
181
+ }
182
+ return { plan, unmatched, matched: used.length };
183
+ }
184
+
185
+ /** Write a match into a Progress store (mutates and returns it). Imported dates replace typed ones for matched areas only. */
186
+ export function applySchedule(store, plan) {
187
+ for (const [area, p] of Object.entries(plan)) {
188
+ const rec = (store.areas[area] ||= { captures: [] });
189
+ const hasOwn = Number.isFinite(rec.schedule?.start) && Number.isFinite(rec.schedule?.finish);
190
+ const setWindow = p.window && !(p.derivedWindow && hasOwn);
191
+ rec.schedule = { ...(rec.schedule || {}), ...(setWindow ? { start: p.window.start, finish: p.window.finish } : {}),
192
+ trades: { ...(rec.schedule?.trades || {}), ...p.trades } };
193
+ }
194
+ return store;
195
+ }
tests/nameplate-model.test.mjs ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Nameplate parsing, schedule matching and the register CSV (pure functions).
2
+ // Run: npm test
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import {
6
+ parseNameplate, voltagesIn, parseEquipmentSchedule, scheduleVoltages, candidatesFor, checkAgainst, statusOf,
7
+ codeSimilarity, foldCode, duplicateSerials, registerSummary, registerCsv, SAMPLE_SCHEDULE, SAMPLE_PLATES,
8
+ } from '../nameplate-model.js';
9
+
10
+ const plate = lines => parseNameplate(lines.join('\n'));
11
+ const val = (r, k) => r.fields[k]?.value;
12
+ const schedule = parseEquipmentSchedule(SAMPLE_SCHEDULE).items;
13
+
14
+ test('a UPS plate reads into every field', () => {
15
+ const r = plate(SAMPLE_PLATES[0]);
16
+ assert.equal(r.type, 'ups');
17
+ assert.equal(val(r, 'manufacturer'), 'Northline Power Systems');
18
+ assert.equal(val(r, 'model'), 'NPX-750-480');
19
+ assert.equal(val(r, 'serial'), 'NL26A01937');
20
+ assert.equal(val(r, 'voltage'), '480Y/277 V');
21
+ assert.deepEqual(r.fields.voltage.values, [480, 277]);
22
+ assert.equal(val(r, 'phase'), 3);
23
+ assert.equal(val(r, 'hz'), 60);
24
+ assert.equal(val(r, 'kva'), 750);
25
+ assert.equal(val(r, 'kw'), 675);
26
+ assert.equal(val(r, 'amps'), 1002);
27
+ assert.equal(val(r, 'mfgDate'), '03/2026');
28
+ });
29
+
30
+ test('real-world plate wording: S/N, CAT. NO., kV, thousands separators, known makers', () => {
31
+ const gen = plate(['CATERPILLAR', 'GENERATOR SET', 'CAT. NO. 3516C-HD', 'S/N: ZBA00412', '13.8 KV 3 PHASE 60 HZ', '3,750 KVA 3000 EKW', 'FLA 1,569']);
32
+ assert.equal(gen.type, 'generator');
33
+ assert.equal(val(gen, 'manufacturer'), 'Caterpillar');
34
+ assert.equal(val(gen, 'model'), '3516C-HD');
35
+ assert.equal(val(gen, 'serial'), 'ZBA00412');
36
+ assert.deepEqual(gen.fields.voltage.values, [13800]);
37
+ assert.equal(val(gen, 'kva'), 3750);
38
+ assert.equal(val(gen, 'kw'), 3000);
39
+ assert.equal(val(gen, 'amps'), 1569);
40
+
41
+ const ch = plate(['AIR-COOLED CHILLER', 'MODEL: AC-1200', 'REFRIGERANT R-513A CHARGE 410 LBS', '460V 3PH 60HZ', 'MCA 612 MOCP 800']);
42
+ assert.equal(ch.type, 'chiller');
43
+ assert.equal(val(ch, 'refrigerant'), 'R-513A');
44
+ assert.equal(val(ch, 'mca'), 612);
45
+ assert.equal(val(ch, 'mocp'), 800);
46
+ assert.equal(val(ch, 'amps'), undefined, 'R-513A and a charge weight are not amps');
47
+ });
48
+
49
+ test('voltages: transformer pairs, labelled values, and numbers that are not voltages', () => {
50
+ assert.deepEqual(voltagesIn('PRI 13,800 V DELTA SEC 480Y/277 V').values, [480, 277, 13800]);
51
+ assert.deepEqual(voltagesIn('VOLTS: 208').values, [208]);
52
+ assert.deepEqual(voltagesIn('DWG 480/277 REV 2').values, [], 'a bare pair with no voltage word or unit');
53
+ assert.deepEqual(scheduleVoltages('480 / 415Y/240'), [480, 415, 240]);
54
+ assert.deepEqual(scheduleVoltages('13.8kV'), [13800]);
55
+ });
56
+
57
+ test('"New York" in an address is not the maker York; unknown makers come from a company line', () => {
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', () => {
64
+ assert.equal(foldCode('NPX-75O-48O'), foldCode('NPX750480'));
65
+ assert.equal(codeSimilarity('NL26A0I937', 'NL26A01937'), 1);
66
+ assert.ok(codeSimilarity('9395P-750', '9395P-750-N-480') >= 0.9, 'option suffix on the plate');
67
+ assert.ok(codeSimilarity('NPX-750-415', 'NPX-750-480') < 0.85, 'a different voltage variant is not the same model');
68
+ });
69
+
70
+ test('schedule CSV: header row found below title rows, duplicate tags reported', () => {
71
+ const { items, problems } = parseEquipmentSchedule('Data Hall 1 equipment schedule\n\nMark,Equipment,Mfr,Model No.,Volts,kVA\nUPS-1A,UPS,Eaton,9395XR,480,750\nUPS-1A,UPS,Eaton,9395XR,480,750\n');
72
+ assert.equal(items.length, 1);
73
+ assert.equal(items[0].manufacturer, 'Eaton');
74
+ assert.equal(items[0].kva, 750);
75
+ assert.match(problems[0], /UPS-1A appears more than once/);
76
+ assert.match(parseEquipmentSchedule('a,b\n1,2').problems[0], /No header row/);
77
+ });
78
+
79
+ test('identical units: the first unscanned tag is offered first', () => {
80
+ const r = plate(SAMPLE_PLATES[0]);
81
+ const first = candidatesFor(r, schedule);
82
+ assert.deepEqual(first.slice(0, 4).map(c => c.item.tag), ['UPS-1A', 'UPS-1B', 'UPS-2A', 'UPS-2B']);
83
+ const next = candidatesFor(r, schedule, [{ tag: 'UPS-1A' }]);
84
+ assert.equal(next[0].item.tag, 'UPS-1B');
85
+ assert.equal(next.find(c => c.item.tag === 'UPS-1A').registered, true);
86
+ assert.ok(!first.some(c => c.item.tag.startsWith('GEN')), 'a generator is not a candidate for a UPS plate');
87
+ });
88
+
89
+ test('a serial on the schedule decides the match outright', () => {
90
+ const items = parseEquipmentSchedule('Tag,Model,Serial\nUPS-1A,NPX-750-480,NL26A00001\nUPS-1B,NPX-750-480,NL26A01937\n').items;
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);
97
+ assert.ok(good.every(c => c.status === 'ok'), JSON.stringify(good));
98
+ assert.equal(statusOf(good, 'UPS-1A'), 'verified');
99
+
100
+ const bad = checkAgainst(plate(SAMPLE_PLATES[1]), ups1a);
101
+ assert.deepEqual(bad.filter(c => c.status === 'mismatch').map(c => c.key), ['model', 'voltage']);
102
+ assert.equal(statusOf(bad, 'UPS-1A'), 'mismatch');
103
+
104
+ const unread = checkAgainst(parseNameplate('MODEL NPX-750-480'), ups1a);
105
+ assert.equal(statusOf(unread, 'UPS-1A'), 'partial');
106
+ assert.equal(statusOf([], ''), 'unmatched');
107
+ });
108
+
109
+ test('register: summary, duplicate serials, and a CSV that lists what is still unscanned', () => {
110
+ const a = plate(SAMPLE_PLATES[0]);
111
+ const ups1a = schedule.find(i => i.tag === 'UPS-1A');
112
+ const reg = [
113
+ { id: 'a', tag: 'UPS-1A', type: 'ups', fields: a.fields, checks: checkAgainst(a, ups1a), status: 'verified', scannedAt: Date.UTC(2026, 9, 2, 14, 30) },
114
+ { id: 'b', tag: '', type: 'ups', fields: a.fields, checks: [], status: 'unmatched', scannedAt: Date.UTC(2026, 9, 2, 14, 35) },
115
+ ];
116
+ assert.deepEqual(duplicateSerials(reg), [['a', 'b']]);
117
+ const s = registerSummary(reg, schedule);
118
+ assert.equal(s.scannedOfSchedule, 1);
119
+ assert.equal(s.verified, 1);
120
+ assert.equal(s.unmatched, 1);
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
+
128
+ test('OCR misreads seen on real lettering are undone before parsing', () => {
129
+ const r = parseNameplate(['MODEL NO: NPX- 750- 480', '480¥/277 VAC 3 PH 60 Hw', '460 Vv', '750 XVA / 675 XW', 'FLA 38. 5'].join('\n'));
130
+ assert.equal(r.fields.model.value, 'NPX-750-480');
131
+ assert.deepEqual(r.fields.voltage.values, [480, 277, 460]);
132
+ assert.equal(r.fields.hz.value, 60);
133
+ assert.equal(r.fields.kva.value, 750);
134
+ assert.equal(r.fields.kw.value, 675);
135
+ assert.equal(r.fields.amps.value, 38.5);
136
+ });
137
+
138
+ test('MCA and MOCP are checked when the schedule lists them; near-zero-confidence lines are ignored', () => {
139
+ const ch = schedule.find(i => i.tag === 'CH-01');
140
+ assert.equal(ch.mca, 612);
141
+ const r = parseNameplate([{ text: 'MODEL AC-1200', confidence: 90 }, { text: '460 V 3 PH', confidence: 88 }, { text: 'MCA 612 MOCP 700', confidence: 85 },
142
+ { text: 'REFRIGERANT R-513A', confidence: 90 }, { text: 'MODEL ZZ-9 480 V', confidence: 8 }]);
143
+ assert.equal(r.fields.model.value, 'AC-1200', 'the 8% line is texture, not a second model');
144
+ const checks = checkAgainst(r, ch);
145
+ assert.deepEqual(checks.filter(c => c.status === 'mismatch').map(c => c.key), ['mocp']);
146
+ });
tests/nameplates-bench.mjs ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Nameplate OCR benchmark: drawn plates of six equipment types, each put
2
+ // through the degradations real phone photos have (small, blurred, noisy,
3
+ // low-contrast, glare, tilted, keystoned, dark anodised plate, JPEG, plate
4
+ // small in a cluttered frame, sideways), OCR'd by the app's own pipeline and
5
+ // scored field by field against the plate text. Not part of `npm test` (it
6
+ // takes minutes); run when changing OCR or parsing:
7
+ //
8
+ // PW_CHANNEL=chrome node tests/nameplates-bench.mjs [--only=glare,blur] [--json=out.json]
9
+ //
10
+ // Plates use fictional makers. The truth for each plate is parseNameplate()
11
+ // of its clean text, so this measures what OCR loses, not parser coverage
12
+ // (tests/nameplate-model.test.mjs covers that).
13
+ import { createServer } from 'vite';
14
+ import { chromium } from 'playwright';
15
+ import { writeFileSync } from 'node:fs';
16
+ import { parseNameplate, foldCode } from '../nameplate-model.js';
17
+
18
+ export const BENCH_PLATES = [
19
+ ['NORTHLINE POWER SYSTEMS', 'UNINTERRUPTIBLE POWER SUPPLY', 'MODEL NO: NPX-750-480', 'SERIAL NO: NL26A01937',
20
+ 'INPUT: 480Y/277 VAC 3 PH 60 HZ', 'RATING: 750 KVA / 675 KW', 'INPUT CURRENT: 1002 A', 'MFG DATE: 03/2026'],
21
+ ['HARBOR ENGINE WORKS INC', 'DIESEL GENERATOR SET', 'MODEL: HG-3000-MV', 'S/N: HEW0098812',
22
+ '13.8 KV 3 PHASE 60 HZ', '3750 KVA 3000 EKW 0.8 PF', 'RATED CURRENT 157 A', 'BUILD DATE 2026-01'],
23
+ ['COLDLINE SYSTEMS', 'AIR-COOLED CHILLER', 'MODEL NO. AC-1200-R5', 'SERIAL NO. CL5517320',
24
+ '460 V 3 PH 60 HZ', 'REFRIGERANT R-513A', 'MCA 612 MOCP 800', 'MFG DATE 11/2025'],
25
+ ['VOLTARC TRANSFORMER CORP', 'DRY TYPE TRANSFORMER', 'CAT. NO. VT-2500-HV', 'SERIAL VT2511047',
26
+ 'PRIMARY 13800 V DELTA', 'SECONDARY 480Y/277 V', '2500 KVA 3 PH 60 HZ', 'MFG DATE 08/2025'],
27
+ ['NORTHLINE POWER SYSTEMS', 'POWER DISTRIBUTION UNIT', 'MODEL NO: NPD-300-415', 'SERIAL NO: NL26B00411',
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.
34
+ export const DEGRADATIONS = {
35
+ clean: '',
36
+ small: 'c = resize(c, 640);',
37
+ blur: "c = filt(c, 'blur(2.2px)');",
38
+ noise: 'c = noise(c, 28);',
39
+ lowContrast: 'c = contrast(c, 0.28);',
40
+ glare: 'c = glare(c, 0.8);',
41
+ tilt: 'c = rotate(c, 7);',
42
+ keystone: 'c = keystone(c, 0.3);',
43
+ darkPlate: "c = filt(c, 'invert(1)');",
44
+ jpeg: 'c = await jpeg(resize(c, 900), 0.3);',
45
+ inScene: 'c = scene(c);',
46
+ sideways: 'c = rotate(c, 90);',
47
+ phone: "c = await jpeg(noise(glare(filt(rotate(scene(c), 4), 'blur(1.4px)'), 0.55), 14), 0.6);",
48
+ };
49
+
50
+ const HELPERS = `
51
+ const mk = (w, h) => { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; };
52
+ const resize = (s, w) => { const c = mk(w, Math.round(s.height * w / s.width)); c.getContext('2d').drawImage(s, 0, 0, c.width, c.height); return c; };
53
+ const filt = (s, f) => { const c = mk(s.width, s.height); const g = c.getContext('2d'); g.filter = f; g.drawImage(s, 0, 0); return c; };
54
+ let seed = 7; const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
55
+ const gauss = () => Math.sqrt(-2 * Math.log(rnd() + 1e-9)) * Math.cos(2 * Math.PI * rnd());
56
+ const pixels = (s, fn) => { const c = mk(s.width, s.height); const g = c.getContext('2d'); g.drawImage(s, 0, 0);
57
+ const im = g.getImageData(0, 0, c.width, c.height); fn(im.data, c.width, c.height); g.putImageData(im, 0, 0); return c; };
58
+ const noise = (s, sd) => pixels(s, d => { for (let i = 0; i < d.length; i += 4) { const n = gauss() * sd; d[i] += n; d[i + 1] += n; d[i + 2] += n; } });
59
+ const contrast = (s, k) => pixels(s, d => { for (let i = 0; i < d.length; i += 4) for (let j = 0; j < 3; j++) d[i + j] = 150 + (d[i + j] - 150) * k; });
60
+ const glare = (s, a) => { const c = filt(s, 'none'); const g = c.getContext('2d');
61
+ const r = g.createRadialGradient(c.width * 0.62, c.height * 0.45, 0, c.width * 0.62, c.height * 0.45, c.width * 0.33);
62
+ r.addColorStop(0, 'rgba(255,255,250,' + a + ')'); r.addColorStop(1, 'rgba(255,255,250,0)'); g.fillStyle = r; g.fillRect(0, 0, c.width, c.height); return c; };
63
+ const rotate = (s, deg) => { const t = deg * Math.PI / 180, w = Math.abs(s.width * Math.cos(t)) + Math.abs(s.height * Math.sin(t)), h = Math.abs(s.width * Math.sin(t)) + Math.abs(s.height * Math.cos(t));
64
+ const c = mk(Math.round(w), Math.round(h)); const g = c.getContext('2d'); g.fillStyle = '#4b5057'; g.fillRect(0, 0, c.width, c.height);
65
+ g.translate(w / 2, h / 2); g.rotate(t); g.drawImage(s, -s.width / 2, -s.height / 2); return c; };
66
+ const keystone = (s, k) => { const c = mk(s.width, s.height); const g = c.getContext('2d'); g.fillStyle = '#4b5057'; g.fillRect(0, 0, c.width, c.height);
67
+ for (let y = 0; y < s.height; y++) { const z = 1 - k * (1 - y / s.height); const w = s.width * z; g.drawImage(s, 0, y, s.width, 1, (s.width - w) / 2, y, w, 1); } return c; };
68
+ const jpeg = async (s, q) => { const img = new Image(); img.src = s.toDataURL('image/jpeg', q); await img.decode(); const c = mk(s.width, s.height); c.getContext('2d').drawImage(img, 0, 0); return c; };
69
+ const scene = s => { const c = mk(2400, 1800); const g = c.getContext('2d');
70
+ g.fillStyle = '#7d8a83'; g.fillRect(0, 0, c.width, c.height); // cabinet paint
71
+ g.fillStyle = '#6c7871'; for (let x = 0; x < c.width; x += 300) g.fillRect(x, 0, 8, c.height); // panel seams
72
+ g.fillStyle = '#f2c200'; g.fillRect(1650, 1280, 560, 300); g.fillStyle = '#111'; g.font = 'bold 54px Arial';
73
+ g.fillText('DANGER', 1800, 1370); g.font = 'bold 34px Arial'; g.fillText('HIGH VOLTAGE 480 V', 1700, 1450); g.fillText('ARC FLASH HAZARD', 1720, 1510);
74
+ g.drawImage(s, 360, 260, 1260, Math.round(1260 * s.height / s.width)); return c; };
75
+ `;
76
+
77
+ const score = (truth, got) => {
78
+ const keys = Object.keys(truth.fields);
79
+ const ok = keys.filter(k => {
80
+ const a = truth.fields[k].value, b = got.fields?.[k]?.value;
81
+ if (b == null) return false;
82
+ return ['model', 'serial'].includes(k) ? foldCode(a) === foldCode(b) : String(a).toUpperCase() === String(b).toUpperCase();
83
+ });
84
+ return { ok: ok.length, total: keys.length, missed: keys.filter(k => !ok.includes(k)) };
85
+ };
86
+
87
+ async function main() {
88
+ const args = Object.fromEntries(process.argv.slice(2).map(a => a.replace(/^--/, '').split('=')));
89
+ const only = args.only ? args.only.split(',') : Object.keys(DEGRADATIONS);
90
+ const server = await createServer({ server: { port: 0, host: '127.0.0.1' }, logLevel: 'error' });
91
+ await server.listen();
92
+ const browser = await chromium.launch(process.env.PW_CHANNEL ? { channel: process.env.PW_CHANNEL } : {});
93
+ const page = await (await browser.newContext({ viewport: { width: 1280, height: 900 } })).newPage();
94
+ page.on('pageerror', e => console.error('pageerror', e.message));
95
+ await page.goto(server.resolvedUrls.local[0] + 'nameplates.html');
96
+ await page.evaluate(() => window.__nameplates.getWorker());
97
+
98
+ const results = {};
99
+ let sumOk = 0, sumTotal = 0, sumMs = 0, runs = 0;
100
+ for (const name of only) {
101
+ let ok = 0, total = 0, ms = 0;
102
+ const misses = {};
103
+ for (const [i, lines] of BENCH_PLATES.entries()) {
104
+ const truth = parseNameplate(lines.join('\n'));
105
+ const out = await page.evaluate(`(async () => {
106
+ ${HELPERS}
107
+ let c = window.__nameplates.samplePlate(${JSON.stringify(lines)});
108
+ ${DEGRADATIONS[name]}
109
+ c = window.__nameplates.fitForOcr(c, c.width, c.height);
110
+ const t0 = performance.now();
111
+ const r = await window.__nameplates.ocrImage(c);
112
+ return { fields: Object.fromEntries(Object.entries(r.fields).map(([k, f]) => [k, { value: f.value }])), ms: performance.now() - t0 };
113
+ })()`);
114
+ const s = score(truth, out);
115
+ ok += s.ok; total += s.total; ms += out.ms;
116
+ for (const k of s.missed) misses[k] = (misses[k] || 0) + 1;
117
+ if (args.verbose && s.missed.length) console.log(` ${name} #${i}: missed ${s.missed.map(k => `${k}=${JSON.stringify(out.fields[k]?.value ?? null)} (want ${JSON.stringify(truth.fields[k].value)})`).join(', ')}`);
118
+ }
119
+ results[name] = { acc: ok / total, ok, total, msPerPlate: Math.round(ms / BENCH_PLATES.length), misses };
120
+ sumOk += ok; sumTotal += total; sumMs += ms; runs += BENCH_PLATES.length;
121
+ console.log(`${name.padEnd(12)} ${(100 * ok / total).toFixed(1).padStart(5)}% ${String(Math.round(ms / BENCH_PLATES.length)).padStart(5)} ms/plate missed: ${Object.entries(misses).map(([k, n]) => `${k}×${n}`).join(' ') || '—'}`);
122
+ }
123
+ console.log(`${'OVERALL'.padEnd(12)} ${(100 * sumOk / sumTotal).toFixed(1).padStart(5)}% ${String(Math.round(sumMs / runs)).padStart(5)} ms/plate`);
124
+ if (args.json) writeFileSync(args.json, JSON.stringify({ overall: sumOk / sumTotal, msPerPlate: sumMs / runs, results }, null, 2));
125
+ await browser.close();
126
+ await server.close();
127
+ }
128
+
129
+ if (import.meta.url === `file://${process.argv[1]}`) main();