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:
Umut CAN
2026-09-24 11:38:59 +03:00
co-authored by Claude Opus 5.5
parent f0c50d772f
commit 9ab0ae1773
13 changed files with 2105 additions and 7 deletions
+302
View File
@@ -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