Files
autocad-mcp-pro/engineering/understand/labels.py
T
Umut CANandClaude Opus 5.5 fb2953c324 fix(understand): a line number is not an equipment tag
parse_tag started a token at the first letter, so '100-P-001', a line
number that begins with its size, read as the pump tag P-001. The review of
the network engine found this. On a P&ID the phantom tag can capture a
valve beside the line as equipment, or collide with the real pump P-001.

A token that is the tail of a word beginning with a digit is no longer a
tag. The same text still yields T101 in 'LINE 100-P-001 TO T101', and
P-001 alone is still a tag. The new test failed before this change; the
label, network, describe, scale, cable and plant-pair suites all pass
(160 tests).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:48:56 +03:00

309 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, and
neither is the tail of a word that begins with a digit - a line number
such as ``100-P-001`` names a pipe, not the pump ``P-001``.
"""
source = plain(text)
for match in _TAG_TOKEN.finditer(source):
word_start = max(source.rfind(" ", 0, match.start()), source.rfind("\n", 0, match.start()))
if source[word_start + 1 : match.start()][:1].isdigit():
continue
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