feat(understand): the snapshot, the vocabulary and the label parsers; MAX_DXF_BYTES 512 MB; the office extra
Track H's foundation, landed on main before its three groups branch. - engineering/understand/snapshot.py: one bulk read of a drawing into frozen WCS records (OCS translated through backends/ocs.py, text decoded, an INSERT boxed by its block geometry without its attribute texts). A DXF is read with ezdxf after the size check; the headless engine's document is read under its own lock; the live engine exports the active document with Document.Export(<tmp>/snap, "DXF", <set of all>) - never renamed, deleted afterwards - and a DWG is opened read-only, exported and closed. Measured on AutoCAD 2026: ".dxf" is refused with Invalid argument, the file lands as snap.DXF, model space, paper space and unused block definitions all reach it, and the selection set does not narrow a DXF export. - engineering/understand/labels.py: %%c, \U+2205, the U+2300 sign, o-stroke and MTEXT format runs all read as one diameter spelling; sizes are kept as drawn and never equated; Cyrillic look-alike capitals fold into Latin tags; panels, wiring callouts and electrical data (kW, phases, volts, N, VFD) in EN / TR / RU / NL / DE. A power that is not written stays None. - engineering/understand/vocab.py: layer classification with a confidence and the deciding keyword, supply / return words, room labels and block kinds in five languages. A CIP layer without a direction word is not guessed. - MAX_DXF_BYTES defaults to 512 MB (a real layout was 188 MB); the refusal names the size and the variable. New `office` extra (openpyxl), also in `full`; uv.lock regenerated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f0c50d772f
commit
9ab0ae1773
@@ -0,0 +1,302 @@
|
||||
"""Label parsers for foreign drawings: diameters, equipment tags, panels, electrical data.
|
||||
|
||||
Pure functions over one string each - no ezdxf, no I/O - so every rule here is
|
||||
tested on the spellings real drawings use, and the
|
||||
readers above it (the network, the takeoffs, the scale check) share one
|
||||
reading of a label instead of three.
|
||||
|
||||
What a label *says* is kept exactly as drawn. ``Ø51``, ``SMS51``, ``DN20``,
|
||||
``Ø25,4`` and ``Ø25`` are five different sizes: the parser normalises only the
|
||||
spelling of the diameter sign and the whitespace, never the number, and never
|
||||
equates two sizing systems (an SMS size and a DN size are different series; the
|
||||
drawing has to say they are the same pipe, not this module).
|
||||
|
||||
Two kinds of foreign-drawing noise are decoded before anything is parsed:
|
||||
|
||||
* **AutoCAD text codes.** ``%%c`` is AutoCAD's diameter sign, ``%%d`` the degree
|
||||
sign, ``%%p`` plus-minus; ``\\U+2205`` is a Unicode escape (AutoCAD's help
|
||||
lists U+2205 as its diameter symbol). MTEXT carries format runs
|
||||
(``{\\fArial|b0;Ø51}``, ``\\H2.5;``, ``\\P`` for a new paragraph) that are
|
||||
formatting, not content.
|
||||
* **Cyrillic look-alikes in tags.** A Russian-labelled P&ID typed on a Russian
|
||||
keyboard writes ``Т4100`` with a Cyrillic Т (U+0422); it must match the
|
||||
layout's Latin ``T4100``. Eleven capitals have a Latin twin of identical
|
||||
shape (Т М С Р А В Е К Н О Х) and are folded; a Cyrillic letter without one
|
||||
(П, Щ, Ш, ...) is left alone, so a Russian word never turns into a tag.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
__all__ = [
|
||||
"CYRILLIC_LOOKALIKES",
|
||||
"fold_lookalikes",
|
||||
"normalize_panel",
|
||||
"parse_diameters",
|
||||
"parse_electrical",
|
||||
"parse_tag",
|
||||
"plain",
|
||||
"wiring_target",
|
||||
]
|
||||
|
||||
#: Cyrillic capitals drawn identically to a Latin capital, and that capital.
|
||||
CYRILLIC_LOOKALIKES: dict[str, str] = {
|
||||
"\u0422": "T", # Т
|
||||
"\u041c": "M", # М
|
||||
"\u0421": "C", # С
|
||||
"\u0420": "P", # Р
|
||||
"\u0410": "A", # А
|
||||
"\u0412": "B", # В
|
||||
"\u0415": "E", # Е
|
||||
"\u041a": "K", # К
|
||||
"\u041d": "H", # Н
|
||||
"\u041e": "O", # О
|
||||
"\u0425": "X", # Х
|
||||
}
|
||||
_LOOKALIKE_TABLE = str.maketrans(CYRILLIC_LOOKALIKES)
|
||||
|
||||
#: ``%%`` codes that stand for a character (AutoCAD's control codes).
|
||||
_PERCENT_CHARS = {"c": "\u00d8", "d": "\u00b0", "p": "\u00b1"}
|
||||
#: MTEXT codes that carry an argument up to the next ``;`` and draw nothing.
|
||||
_ARG_CODES = frozenset("fFHWQTACcp")
|
||||
#: MTEXT on/off toggles (underline, overline, strike-through, column break).
|
||||
_TOGGLE_CODES = frozenset("LlOoKkN")
|
||||
|
||||
_UNICODE_ESCAPE = re.compile(r"\\[Uu]\+([0-9A-Fa-f]{4})")
|
||||
_PERCENT_CODE = re.compile(r"%%(\d{3}|[cdpCDP]|[uoUOkK])")
|
||||
|
||||
#: Every spelling of the diameter sign met in drawings: Ø (U+00D8), ø (U+00F8),
|
||||
#: ∅ (U+2205, AutoCAD's listed diameter symbol), ⌀ (U+2300,
|
||||
#: the Unicode DIAMETER SIGN).
|
||||
_DIAMETER_SIGNS = "\u00d8\u00f8\u2205\u2300"
|
||||
_NUMBER = r"\d+(?:[.,]\d+)?"
|
||||
_DIAMETER_RE = re.compile(
|
||||
rf"(?P<sign>[{_DIAMETER_SIGNS}])\s*(?P<sign_n>{_NUMBER})"
|
||||
rf"|(?<![A-Za-z])(?P<sms>SMS)\s*-?\s*(?P<sms_n>{_NUMBER})"
|
||||
rf"|(?<![A-Za-z])(?P<dn>DN)\s*-?\s*(?P<dn_n>\d+)"
|
||||
rf"|(?<![\d.,])(?P<a>{_NUMBER})\s*[xX\u00d7\u0445\u0425]\s*(?P<b>{_NUMBER})(?![.,]?\d)"
|
||||
r"(?!\s*(?:mm2|mm\u00b2|\u043c\u043c2|\u043c\u043c\u00b2))"
|
||||
rf"|(?<![\d.,])(?P<inch>\d+(?:[.,]\d+)?(?:\s*/\s*\d+)?)\s*(?:\"|''|\u201d|\u2033)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
#: A token that may be a tag: starts with a letter, keeps letters, digits, dots
|
||||
#: and hyphens (``CP-M82``, ``M47.1``).
|
||||
_TAG_TOKEN = re.compile(r"[A-Za-z\u0400-\u04ff][A-Za-z0-9\u0400-\u04ff.\-]*")
|
||||
#: An equipment tag: 1-4 letters, an optional second letter group after a
|
||||
#: hyphen (``CP-M82``), an optional hyphen, 1-5 digits, an optional letter
|
||||
#: suffix (``M41A``) and an optional ``.n`` (``M47.1``).
|
||||
_TAG_RE = re.compile(r"[A-Z]{1,4}(?:-[A-Z]{1,4})?-?\d{1,5}[A-Z]?(?:\.\d{1,3})?")
|
||||
#: Prefixes that look like a tag and are not one: nominal sizes (DN20, SMS51),
|
||||
#: a pressure rating (PN16) and an ingress-protection code (IP65).
|
||||
_NOT_TAG_PREFIXES = ("DN", "SMS", "PN", "IP")
|
||||
|
||||
_PANEL_RE = re.compile(
|
||||
r"(?<![A-Z0-9])([A-Z]{1,4})\s*[-_ ]?\s*((?:[A-Z]{1,3})?\d{1,4}[A-Z]?)(?![A-Z0-9])"
|
||||
)
|
||||
|
||||
_KW_RE = re.compile(
|
||||
r"(\d+(?:[.,]\d+)?)\s*(?:kw|\u043a\u0432\u0442)(?![a-z\u0430-\u044f])", re.IGNORECASE
|
||||
)
|
||||
_PHASE_RE = re.compile(
|
||||
r"(?<!\d)([13])\s*(?:\u0444\b\.?|ph\b\.?|phase|faz|~|/\s*n\b)", re.IGNORECASE
|
||||
)
|
||||
_VOLT_RE = re.compile(
|
||||
r"(?<![\d.,])(\d{2,4})\s*(?:v(?:ac|dc)?|\u0432)(?![a-z\u0430-\u044f])", re.IGNORECASE
|
||||
)
|
||||
_NEUTRAL_RE = re.compile(r"(?:\+|/)\s*n(?![a-z])", re.IGNORECASE)
|
||||
#: Words that say a load is driven by a frequency converter, EN/TR/RU/NL/DE.
|
||||
_VFD_WORDS = (
|
||||
"vfd",
|
||||
"vsd",
|
||||
"inverter",
|
||||
"frekans",
|
||||
"\u0447\u0430\u0441\u0442\u043e\u0442\u043d", # частотн(ый преобразователь)
|
||||
"\u0447\u0440\u043f", # ЧРП
|
||||
"frequentieregelaar",
|
||||
"frequenzumrichter",
|
||||
)
|
||||
#: Phrases that introduce the panel a load is wired to, EN/TR/RU/NL/DE.
|
||||
_WIRING_PHRASES = (
|
||||
"wiring to",
|
||||
"wired to",
|
||||
"cable to",
|
||||
"connected to",
|
||||
"\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a", # подключение к
|
||||
"\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043a", # подключить к
|
||||
"bedrading naar",
|
||||
"verdrahtung zu",
|
||||
"verdrahtung nach",
|
||||
)
|
||||
#: Turkish puts the panel first: ``CP1 panosuna`` ("to the CP1 panel").
|
||||
_WIRING_PHRASES_AFTER = ("panosuna", "panosundan")
|
||||
|
||||
|
||||
def plain(text: str | None) -> str:
|
||||
"""The text a reader sees: codes decoded, MTEXT formatting removed.
|
||||
|
||||
``\\U+XXXX`` becomes its character; ``%%c`` / ``%%d`` / ``%%p`` become Ø / ° /
|
||||
±, ``%%nnn`` the character with that code, ``%%%`` a percent sign, and the
|
||||
``%%u`` / ``%%o`` / ``%%k`` toggles disappear. In MTEXT, ``\\P`` is a line
|
||||
break, ``\\~`` a space, ``\\S a^b;`` / ``\\S a/b;`` the stacked ``a/b``,
|
||||
``\\\\`` ``\\{`` ``\\}`` the literal characters; every argument code
|
||||
(``\\f...;``, ``\\H...;``, ``\\C...;`` ...) and every toggle is dropped, and so
|
||||
are the grouping braces.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
source = _UNICODE_ESCAPE.sub(lambda m: chr(int(m.group(1), 16)), str(text))
|
||||
source = source.replace("%%%", "\x00")
|
||||
|
||||
def percent(match: re.Match) -> str:
|
||||
code = match.group(1)
|
||||
if code.isdigit():
|
||||
return chr(int(code))
|
||||
return _PERCENT_CHARS.get(code.lower(), "")
|
||||
|
||||
source = _PERCENT_CODE.sub(percent, source).replace("\x00", "%")
|
||||
out: list[str] = []
|
||||
i, n = 0, len(source)
|
||||
while i < n:
|
||||
ch = source[i]
|
||||
if ch == "\\" and i + 1 < n:
|
||||
code = source[i + 1]
|
||||
if code in "\\{}":
|
||||
out.append(code)
|
||||
i += 2
|
||||
elif code == "P":
|
||||
out.append("\n")
|
||||
i += 2
|
||||
elif code == "~":
|
||||
out.append(" ")
|
||||
i += 2
|
||||
elif code == "S":
|
||||
end = source.find(";", i + 2)
|
||||
end = n if end < 0 else end
|
||||
out.append(re.sub(r"[\^#]", "/", source[i + 2 : end]))
|
||||
i = end + 1
|
||||
elif code in _ARG_CODES:
|
||||
end = source.find(";", i + 2)
|
||||
i = n if end < 0 else end + 1
|
||||
elif code in _TOGGLE_CODES:
|
||||
i += 2
|
||||
else:
|
||||
out.append(code)
|
||||
i += 2
|
||||
elif ch in "{}":
|
||||
i += 1
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out).strip()
|
||||
|
||||
|
||||
def parse_diameters(text: str | None) -> tuple[str, ...]:
|
||||
"""Every size label in ``text``, in order, each once, spelled one way.
|
||||
|
||||
``Ø51`` / ``%%c51`` / ``\\U+2205 51`` / ``⌀51`` / ``ø51`` -> ``"Ø51"``;
|
||||
``Ø25,4`` keeps its comma; ``SMS 51`` -> ``"SMS51"``; ``DN 20`` ->
|
||||
``"DN20"``; ``20 x 27`` / ``20х27`` (Cyrillic х) -> ``"20x27"``; ``1,5"`` ->
|
||||
``'1,5"'``. An ``a x b`` followed by mm² is a cable cross-section, not a
|
||||
pipe, and is skipped.
|
||||
"""
|
||||
found: list[str] = []
|
||||
for match in _DIAMETER_RE.finditer(plain(text)):
|
||||
if match.group("sign"):
|
||||
token = "\u00d8" + match.group("sign_n")
|
||||
elif match.group("sms"):
|
||||
token = "SMS" + match.group("sms_n")
|
||||
elif match.group("dn"):
|
||||
token = "DN" + match.group("dn_n")
|
||||
elif match.group("a"):
|
||||
token = f"{match.group('a')}x{match.group('b')}"
|
||||
else:
|
||||
token = re.sub(r"\s+", "", match.group("inch")) + '"'
|
||||
if token not in found:
|
||||
found.append(token)
|
||||
return tuple(found)
|
||||
|
||||
|
||||
def fold_lookalikes(text: str) -> str:
|
||||
"""Uppercase, with the eleven Cyrillic look-alike capitals made Latin."""
|
||||
return str(text).upper().translate(_LOOKALIKE_TABLE)
|
||||
|
||||
|
||||
def parse_tag(text: str | None) -> str | None:
|
||||
"""The first equipment tag in ``text``, uppercase, look-alikes folded.
|
||||
|
||||
``"Т4100"`` (Cyrillic Т) -> ``"T4100"``; ``"pump M41A"`` -> ``"M41A"``;
|
||||
``"CP-M82"`` stays ``"CP-M82"``. A size label (``DN20``, ``SMS51``), a
|
||||
pressure rating (``PN16``) or an IP code (``IP65``) is not a tag; a token
|
||||
that keeps a Cyrillic letter without a Latin twin is not one either.
|
||||
"""
|
||||
for match in _TAG_TOKEN.finditer(plain(text)):
|
||||
token = fold_lookalikes(match.group(0)).rstrip(".-")
|
||||
if not _TAG_RE.fullmatch(token):
|
||||
continue
|
||||
if re.match(r"[A-Z]+", token).group(0) in _NOT_TAG_PREFIXES:
|
||||
continue
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def normalize_panel(text: str | None) -> str | None:
|
||||
"""A panel name spelled one way: ``CP 1`` / ``CP1`` / ``cp-1`` -> ``CP-1``.
|
||||
|
||||
The letters, one hyphen, then the rest: ``CP M82`` -> ``CP-M82``. Cyrillic
|
||||
look-alikes are folded first (``СР-1`` typed in Cyrillic is ``CP-1``).
|
||||
Returns None when the text holds no panel-shaped token.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
match = _PANEL_RE.search(fold_lookalikes(plain(text)))
|
||||
if match is None:
|
||||
return None
|
||||
return f"{match.group(1)}-{match.group(2)}"
|
||||
|
||||
|
||||
def parse_electrical(text: str | None) -> dict:
|
||||
"""Power, phases, voltage, neutral and drive type stated in ``text``.
|
||||
|
||||
``"P = 18,5 кВт, 3 ф. 400 В + N + PE, ЧРП"`` ->
|
||||
``{"kw": 18.5, "phases": 3, "voltage": 400, "neutral": True, "vfd": True}``.
|
||||
What the text does not state stays ``None`` / ``False``: a power that is not
|
||||
written is never estimated.
|
||||
"""
|
||||
body = plain(text)
|
||||
lowered = body.lower()
|
||||
kw = _KW_RE.search(body)
|
||||
phases = _PHASE_RE.search(body)
|
||||
volts = _VOLT_RE.search(body)
|
||||
return {
|
||||
"kw": float(kw.group(1).replace(",", ".")) if kw else None,
|
||||
"phases": int(phases.group(1)) if phases else None,
|
||||
"voltage": int(volts.group(1)) if volts else None,
|
||||
"neutral": bool(_NEUTRAL_RE.search(body)),
|
||||
"vfd": any(word in lowered for word in _VFD_WORDS),
|
||||
}
|
||||
|
||||
|
||||
def wiring_target(text: str | None) -> str | None:
|
||||
"""The panel a wiring callout points to, normalised.
|
||||
|
||||
``"Wiring to CP1"`` -> ``"CP-1"``; ``"подключение к шкафу CP-1"`` ->
|
||||
``"CP-1"``; ``"CP 2 panosuna"`` -> ``"CP-2"`` (Turkish names the panel
|
||||
first). None when the text has no wiring phrase or no panel beside it.
|
||||
"""
|
||||
body = plain(text)
|
||||
lowered = body.lower()
|
||||
for phrase in _WIRING_PHRASES:
|
||||
at = lowered.find(phrase)
|
||||
if at >= 0:
|
||||
panel = normalize_panel(body[at + len(phrase) :])
|
||||
if panel is not None:
|
||||
return panel
|
||||
for phrase in _WIRING_PHRASES_AFTER:
|
||||
at = lowered.find(phrase)
|
||||
if at > 0:
|
||||
matches = list(_PANEL_RE.finditer(fold_lookalikes(body[:at])))
|
||||
if matches:
|
||||
return f"{matches[-1].group(1)}-{matches[-1].group(2)}"
|
||||
return None
|
||||
Reference in New Issue
Block a user