Files
autocad-mcp-pro/engineering/understand/vocab.py
T
Umut CANandClaude Opus 5.5 307ccc799f fix(understand): a network layer holds lines — valve, pump and annotation layers are no pipes
The field test ran the takeoffs on a real P&ID pair, kept outside the
repository. It showed that the default network layers took every layer the
vocabulary files under piping or electrical. That included the valve layers
(KLEPPEN, Valve_*), the pump layers (a "product pumps" layer reads as
product), and annotation layers such as pipe sizes, materials and valve
numbers. Symbol outlines and leader lines became pipe runs.

vocab.is_network_layer is now the one rule. A layer is a network layer when
it is piping or electrical at 0.9, its name names no equipment
(equipment_kind: valve, pump, tank, motor, panel, reducer), and it names no
annotation (sizes, materials, numbers, hatching, or an annotation word).
build_network's default layers, drawing_topology_check and the topo_*
focuses all use it, so they agree on which lines are the network.

Sixteen parametrized cases pin it, across symbol, annotation, pipe and cable
layers.

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

490 lines
17 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.
"""Multilingual keyword tables for reading foreign drawings: EN / TR / RU / NL / DE.
A foreign drawing says what a layer carries in its name and in the texts on it,
in whatever language the office that drew it speaks: a P&ID may name
its piping layers in English and Dutch (``P_cipsupplyline``,
``KLEPPEN``) and label its lines in Russian. These tables are what lets the
reader tell a CIP supply line from a product line without a human.
They are **vocabulary, not a standard**: words a drafter writes, each with the
language it belongs to. A classification made from them carries a confidence
(0.9 for a direct keyword, 0.6 for weaker evidence) and the keyword that
decided it, so a reader can always see why.
Matching is done on a *folded* spelling - casefolded, diacritics stripped,
Turkish dotless ı made i - so ``DÖNÜŞ``, ``dönüş`` and ``donus`` are one word.
Cyrillic keeps its letters (``подача`` stays ``подача``). A keyword of five
letters or more matches anywhere in a layer name written without separators
(``cipsupplyline`` holds ``supply``); a shorter one must start a word, so
``air`` finds ``air_line`` and not ``stairs``.
"""
from __future__ import annotations
import re
import unicodedata
__all__ = [
"DISCIPLINES",
"EQUIPMENT_KINDS",
"LANGUAGES",
"SERVICES",
"classify_layer",
"equipment_kind",
"fold",
"is_network_layer",
"room_label",
"supply_return",
]
LANGUAGES = ("en", "tr", "ru", "nl", "de")
SERVICES: tuple[str, ...] = (
"product",
"cip_supply",
"cip_return",
"glycol_supply",
"glycol_return",
"steam",
"condensate",
"cold_water",
"hot_water",
"ice_water",
"compressed_air",
"drain",
"electrical",
"unknown",
)
DISCIPLINES: tuple[str, ...] = (
"piping",
"electrical",
"architecture",
"equipment",
"annotation",
"structure",
"unknown",
)
EQUIPMENT_KINDS: tuple[str, ...] = (
"tank",
"pump",
"valve",
"reducer",
"motor",
"agitator",
"panel",
)
#: Confidence of a classification decided by one direct keyword.
DIRECT = 0.9
#: Confidence of a classification from weaker evidence: a CIP or glycol layer
#: whose name says no direction, a generic water layer.
WEAK = 0.6
#: A keyword this long matches anywhere in a separator-free name.
_SUBSTRING_MIN = 5
# (keyword, language) rows. Order matters inside a table: the first row that
# matches decides, so a longer or more specific word comes before a word it
# contains.
_SUPPLY_WORDS = (
("supply", "en"),
("besleme", "tr"),
("gidis", "tr"),
("подач", "ru"), # подача: supply
("подающ", "ru"), # подающий: supplying
("aanvoer", "nl"),
("vorlauf", "de"),
)
_RETURN_WORDS = (
("return", "en"),
("donus", "tr"),
("возврат", "ru"), # return
("обрат", "ru"), # обратка / обратный: return
("retour", "nl"),
("rucklauf", "de"),
)
_CIP_WORDS = (
("cip", "en"),
("мойк", "ru"), # мойка: washing, CIP
)
_GLYCOL_WORDS = (
("glycol", "en"),
("glikol", "tr"),
("гликол", "ru"), # гликоль: glycol
("glykol", "de"),
)
#: Services a layer name can state directly (CIP and glycol are decided with
#: their direction words above).
_SERVICE_WORDS = (
("product", "product", "en"),
("product", "urun", "tr"),
("product", "продукт", "ru"), # product
("product", "produkt", "de"),
("steam", "steam", "en"),
("steam", "buhar", "tr"),
("steam", "пар", "ru"), # steam
("steam", "stoom", "nl"),
("steam", "dampf", "de"),
("condensate", "condensate", "en"),
("condensate", "kondensat", "de"),
("condensate", "kondens", "tr"),
("condensate", "конденсат", "ru"), # condensate
("condensate", "condensaat", "nl"),
("ice_water", "ice water", "en"),
("ice_water", "buzlu su", "tr"),
("ice_water", "ледяная вода", "ru"),
("ice_water", "ijswater", "nl"),
("ice_water", "eiswasser", "de"),
("hot_water", "hot water", "en"),
("hot_water", "sicak su", "tr"),
("hot_water", "горячая вода", "ru"),
("hot_water", "warm water", "nl"),
("hot_water", "warmwasser", "de"),
("cold_water", "cold water", "en"),
("cold_water", "soguk su", "tr"),
("cold_water", "холодная вода", "ru"),
("cold_water", "koud water", "nl"),
("cold_water", "kaltwasser", "de"),
("compressed_air", "compressed air", "en"),
("compressed_air", "basincli hava", "tr"),
("compressed_air", "сжатый воздух", "ru"),
("compressed_air", "perslucht", "nl"),
("compressed_air", "druckluft", "de"),
("compressed_air", "air", "en"),
("compressed_air", "hava", "tr"),
("compressed_air", "воздух", "ru"), # air
("drain", "drain", "en"),
("drain", "sewer", "en"),
("drain", "drenaj", "tr"),
("drain", "atik su", "tr"),
("drain", "дренаж", "ru"), # drainage
("drain", "канализац", "ru"), # канализация: sewer
("drain", "riool", "nl"),
("drain", "abwasser", "de"),
)
_ELECTRICAL_WORDS = (
("electrical", "en"),
("electric", "en"),
("power", "en"),
("cable", "en"),
("elektrik", "tr"),
("kablo", "tr"),
("электр", "ru"), # электрика: electrical
("кабел", "ru"), # кабель: cable
("elektra", "nl"),
("kabel", "nl"),
("elektro", "de"),
)
#: Water words that name no temperature: a piping layer of unknown service.
_WATER_WORDS = (
("water", "en"),
("вода", "ru"), # water
("wasser", "de"),
)
_DISCIPLINE_WORDS = (
("piping", "piping", "en"),
("piping", "pipe", "en"),
("piping", "valve", "en"),
("piping", "boru", "tr"),
("piping", "tesisat", "tr"),
("piping", "vana", "tr"),
("piping", "труб", "ru"), # трубопровод: pipeline
("piping", "клапан", "ru"), # valve
("piping", "арматур", "ru"), # арматура: valves and fittings
("piping", "leiding", "nl"),
("piping", "klep", "nl"),
("piping", "afsluiter", "nl"),
("piping", "rohr", "de"),
("piping", "ventil", "de"),
("piping", "armatur", "de"),
("architecture", "wall", "en"),
("architecture", "door", "en"),
("architecture", "window", "en"),
("architecture", "duvar", "tr"),
("architecture", "kapi", "tr"),
("architecture", "pencere", "tr"),
("architecture", "стен", "ru"), # стена: wall
("architecture", "двер", "ru"), # дверь: door
("architecture", "окн", "ru"), # окно: window
("architecture", "muur", "nl"),
("architecture", "muren", "nl"),
("architecture", "deur", "nl"),
("architecture", "raam", "nl"),
("architecture", "wand", "de"),
("architecture", "fenster", "de"),
("equipment", "equipment", "en"),
("equipment", "tank", "en"),
("equipment", "pump", "en"),
("equipment", "ekipman", "tr"),
("equipment", "pompa", "tr"),
("equipment", "оборудован", "ru"), # оборудование: equipment
("equipment", "насос", "ru"), # pump
("equipment", "apparatuur", "nl"),
("equipment", "pomp", "nl"),
("equipment", "ausrustung", "de"),
("equipment", "pumpe", "de"),
("annotation", "text", "en"),
("annotation", "dim", "en"),
("annotation", "anno", "en"),
("annotation", "note", "en"),
("annotation", "title", "en"),
("annotation", "yazi", "tr"),
("annotation", "olcu", "tr"),
("annotation", "antet", "tr"),
("annotation", "текст", "ru"), # text
("annotation", "размер", "ru"), # dimension
("annotation", "штамп", "ru"), # title block
("annotation", "tekst", "nl"),
("annotation", "maat", "nl"),
("annotation", "bemassung", "de"),
("structure", "column", "en"),
("structure", "beam", "en"),
("structure", "steel", "en"),
("structure", "grid", "en"),
("structure", "kolon", "tr"),
("structure", "kiris", "tr"),
("structure", "celik", "tr"),
("structure", "aks", "tr"),
("structure", "колонн", "ru"), # колонна: column
("structure", "балк", "ru"), # балка: beam
("structure", "kolom", "nl"),
("structure", "balk", "nl"),
("structure", "staal", "nl"),
("structure", "stutze", "de"),
("structure", "trager", "de"),
("structure", "stahl", "de"),
)
_ROOM_WORDS = (
("room", "en"),
("oda", "tr"),
("mahal", "tr"),
("помещ", "ru"), # помещение: room
("комнат", "ru"), # комната: room
("ruimte", "nl"),
("kamer", "nl"),
("raum", "de"),
("zimmer", "de"),
)
_EQUIPMENT_KIND_WORDS = (
("reducer", "reducer", "en"),
("reducer", "reduksiyon", "tr"),
("reducer", "переход", "ru"), # reducer
("reducer", "verloop", "nl"),
("reducer", "reduzier", "de"),
("valve", "valve", "en"),
("valve", "vlv", "en"),
("valve", "vana", "tr"),
("valve", "клапан", "ru"), # valve
("valve", "кран", "ru"), # valve (cock)
("valve", "klep", "nl"),
("valve", "afsluiter", "nl"),
("valve", "ventil", "de"),
("valve", "armatur", "de"),
("agitator", "agitator", "en"),
("agitator", "mixer", "en"),
("agitator", "karistirici", "tr"),
("agitator", "мешалк", "ru"), # мешалка: agitator
("agitator", "roerwerk", "nl"),
("agitator", "ruhrwerk", "de"),
("pump", "pump", "en"),
("pump", "pompa", "tr"),
("pump", "насос", "ru"), # pump
("pump", "pomp", "nl"),
("pump", "pumpe", "de"),
("tank", "tank", "en"),
("tank", "vessel", "en"),
("tank", "емкост", "ru"), # емкость: vessel
("tank", "бак", "ru"), # tank
("tank", "vat", "nl"),
("tank", "behalter", "de"),
("motor", "motor", "en"),
("motor", "мотор", "ru"), # motor
("motor", "электродвиг", "ru"), # электродвигатель: electric motor
("panel", "panel", "en"),
("panel", "cabinet", "en"),
("panel", "pano", "tr"),
("panel", "шкаф", "ru"), # cabinet
("panel", "щит", "ru"), # switchboard
("panel", "schakelkast", "nl"),
("panel", "kast", "nl"),
("panel", "schrank", "de"),
)
def fold(text: str | None) -> str:
"""Casefolded, diacritics stripped, Turkish ı made i; Cyrillic letters kept."""
if not text:
return ""
decomposed = unicodedata.normalize("NFKD", str(text).casefold().replace("ı", "i"))
return "".join(ch for ch in decomposed if not unicodedata.combining(ch))
def _words(folded: str) -> list[str]:
return [w for w in re.split(r"[^\w]+|_|\d+", folded) if w]
def _compact(folded: str) -> str:
return "".join(w for w in _words(folded))
def _matches(keyword: str, folded: str) -> bool:
key = fold(keyword)
if " " in key or len(key) >= _SUBSTRING_MIN:
return key.replace(" ", "") in _compact(folded)
return any(word.startswith(key) for word in _words(folded))
def _first(rows, folded: str):
for row in rows:
if _matches(row[-2], folded):
return row
return None
def _direction(folded: str) -> tuple[str, str, str] | None:
"""("supply" | "return", keyword, language), or None when neither or both."""
supply = _first(_SUPPLY_WORDS, folded)
ret = _first(_RETURN_WORDS, folded)
if (supply is None) == (ret is None):
return None
if supply is not None:
return ("supply", *supply)
return ("return", *ret)
def _result(discipline, service, confidence, keyword, language) -> dict:
return {
"discipline": discipline,
"service": service,
"confidence": confidence,
"keyword": keyword,
"language": language,
}
def classify_layer(name: str) -> dict:
"""What a layer most likely carries, decided by the words in its name.
``{"discipline", "service", "confidence", "keyword", "language"}``. Order:
electrical words; CIP and glycol with a direction word (``P_cipsupplyline``
-> ``cip_supply``; without one, the service stays ``unknown`` at 0.6 - the
layer alone is not trusted to say which way a CIP line runs); the stated
services; a water word with no temperature (piping, ``unknown``, 0.6); the
discipline words. Nothing matched: ``unknown`` / ``unknown`` at 0.0.
"""
folded = fold(name)
row = _first(_ELECTRICAL_WORDS, folded)
if row is not None:
return _result("electrical", "electrical", DIRECT, *row)
for rows, stem in ((_CIP_WORDS, "cip"), (_GLYCOL_WORDS, "glycol")):
row = _first(rows, folded)
if row is None:
continue
direction = _direction(folded)
if direction is None:
return _result("piping", "unknown", WEAK, *row)
return _result("piping", f"{stem}_{direction[0]}", DIRECT, row[0], row[1])
row = _first(_SERVICE_WORDS, folded)
if row is not None:
return _result("piping", row[0], DIRECT, row[1], row[2])
row = _first(_WATER_WORDS, folded)
if row is not None:
return _result("piping", "unknown", WEAK, *row)
row = _first(_DISCIPLINE_WORDS, folded)
if row is not None:
return _result(row[0], "unknown", DIRECT, row[1], row[2])
return _result("unknown", "unknown", 0.0, None, None)
def supply_return(text: str | None) -> str | None:
"""``"supply"`` or ``"return"`` when the text says one of them, else None.
``ПОДАЧА`` / ``BESLEME`` / ``SUPPLY`` / ``AANVOER`` / ``VORLAUF`` are supply;
``ВОЗВРАТ`` / ``DÖNÜŞ`` / ``RETURN`` / ``RETOUR`` / ``RÜCKLAUF`` return. A
text naming both (``CIP SUPPLY / RETURN``) says neither and is None.
"""
direction = _direction(fold(text))
return None if direction is None else direction[0]
def room_label(text: str | None) -> dict | None:
"""A room label read: ``{"number", "name", "language"}``, or None.
A text is a room label when one of its words is a room word (*room*,
*oda*, *mahal*, *помещение*, *комната*, *ruimte*, *kamer*, *Raum*,
*Zimmer*). The number is the first word holding a digit; the name is what
is left, in the text's own spelling, or None. ``"ROOM 1 PROCESS HALL"`` ->
``{"number": "1", "name": "PROCESS HALL", "language": "en"}``.
"""
if not text:
return None
words = str(text).split()
for index, word in enumerate(words):
folded = fold(word)
for keyword, language in _ROOM_WORDS:
if folded.startswith(fold(keyword)):
rest = words[:index] + words[index + 1 :]
number = next((w for w in rest if any(ch.isdigit() for ch in w)), None)
name = " ".join(w for w in rest if w != number).strip() or None
return {"number": number, "name": name, "language": language}
return None
#: Words that make a piping or electrical layer hold annotation, not lines: pipe
#: sizes, materials, valve or line numbers, hatching. Matched like every other
#: keyword here (a word of five letters or more anywhere in the name).
_NOT_A_LINE_WORDS = (
("annotation", "diameter", "en"),
("annotation", "sizes", "en"),
("annotation", "size", "en"),
("annotation", "material", "en"),
("annotation", "number", "en"),
("annotation", "nummer", "nl"),
("annotation", "arcering", "nl"),
("annotation", "hatch", "en"),
)
def is_network_layer(name: str | None) -> bool:
"""A layer whose lines are pipes or cables.
`classify_layer` files it under piping or electrical at 0.9, and its name
says neither that it holds symbols - a valve, pump, tank, motor, panel or
reducer (`equipment_kind`), whose outlines are no pipe - nor that it holds
annotation: sizes, materials, numbers, hatching, or an annotation word.
Field P&IDs keep valves and pumps on their own layers (``KLEPPEN``,
``PRODUCT PUMPS``) that a service keyword alone would read as piping.
"""
meaning = classify_layer(name)
if meaning["discipline"] not in ("piping", "electrical") or meaning["confidence"] < DIRECT:
return False
if equipment_kind(name) is not None:
return False
folded = fold(name)
if _first(_NOT_A_LINE_WORDS, folded) is not None:
return False
annotation = [row for row in _DISCIPLINE_WORDS if row[0] == "annotation"]
return _first(annotation, folded) is None
def equipment_kind(block_name: str | None) -> dict | None:
"""The kind of equipment a block name says it is: ``{"kind", "keyword", "language"}``.
``KLEP_VLINDER`` -> valve (nl); ``VERLOOPSTUK`` -> reducer (nl); an
anonymous ``*U12`` or a name with no keyword -> None.
"""
folded = fold(block_name)
row = _first(_EQUIPMENT_KIND_WORDS, folded)
if row is None:
return None
return {"kind": row[0], "keyword": row[1], "language": row[2]}