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
Upload folder using huggingface_hub
Browse files- LICENSE +190 -0
- README.md +277 -0
- config.json +25 -0
- eval/bench.json +99 -0
- eval/bench.log +14 -0
- nameplate-model.js +399 -0
- sample_plates.json +34 -0
- sample_schedule.csv +11 -0
- schedule-import.js +195 -0
- tests/nameplate-model.test.mjs +146 -0
- tests/nameplates-bench.mjs +129 -0
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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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();
|