diff --git a/.env.example b/.env.example index 8e52a43..58d4d93 100644 --- a/.env.example +++ b/.env.example @@ -85,8 +85,8 @@ ENABLE_3D=false # These four are read by config.py but were missing from this file, which made # it look like the complete list. Two of them are the remote-HTTP controls. -# Reject DXF input larger than this many bytes. 0 disables the check. -MAX_DXF_BYTES=52428800 +# Reject DXF input larger than this many bytes (512 MB). 0 disables the check. +MAX_DXF_BYTES=536870912 # Upper bound on list/selection response sizes. MAX_LIST_LIMIT=5000 diff --git a/README.md b/README.md index 455f68e..5f00738 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ Nothing loads a `.env` file — export these, or set them in your MCP client's ` | `ALLOWED_PATHS` | *(empty)* | Comma-separated absolute paths the server may access | | `MAX_UNDO_STACK` | `5` | Maximum retained undo snapshots | | `EZDXF_UNDO_DEPTH` | `0` | Headless undo history depth; `0` disables it | -| `MAX_DXF_BYTES` | `52428800` | Reject larger DXF input; `0` disables | +| `MAX_DXF_BYTES` | `536870912` | Reject larger DXF input (512 MB); `0` disables | | `MAX_LIST_LIMIT` | `5000` | Bound list/selection response sizes | | `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout (s); `0` disables | | `EZDXF_CALL_TIMEOUT` | `120` | Per-call headless timeout (s); `0` disables | diff --git a/config.py b/config.py index be7a235..0343389 100644 --- a/config.py +++ b/config.py @@ -9,6 +9,9 @@ from pathlib import Path #: CAD this project is developed and tested against. DEFAULT_CAD_PROGID = "AutoCAD.Application" +#: Default ``MAX_DXF_BYTES``: 512 MB. +DEFAULT_MAX_DXF_BYTES = 512 * 1024 * 1024 + class Settings: """Server configuration loaded from environment variables.""" @@ -62,9 +65,12 @@ class Settings: os.environ.get("DANGEROUS_COMMANDS_ENABLED", "false").lower().strip() == "true" ) - # Reject opening DXF files larger than this many bytes (default 50 MB). - # Set to 0 to disable the check. - self.max_dxf_bytes: int = int(os.environ.get("MAX_DXF_BYTES", str(50 * 1024 * 1024))) + # Reject opening DXF files larger than this many bytes (default 512 MB). + # Set to 0 to disable the check. It was 50 MB until v1.6's track H: a + # real plant layout read on 2026-09-24 was a 188 MB DXF, and the + # understanding tools exist for exactly such drawings. The refusal + # names the file's size and this variable. + self.max_dxf_bytes: int = int(os.environ.get("MAX_DXF_BYTES", str(DEFAULT_MAX_DXF_BYTES))) # Hard cap on list/select limit params to keep tool responses bounded. self.max_list_limit: int = int(os.environ.get("MAX_LIST_LIMIT", "5000")) diff --git a/engineering/understand/__init__.py b/engineering/understand/__init__.py new file mode 100644 index 0000000..88de9fa --- /dev/null +++ b/engineering/understand/__init__.py @@ -0,0 +1,57 @@ +"""Understanding drawings somebody else made (v1.6 track H). + +One bulk read per drawing (:mod:`.snapshot`), multilingual vocabulary +(:mod:`.vocab`) and label parsers (:mod:`.labels`) are the foundation; the +readers built on them - the one-call report, the scale check, the line +networks and takeoffs, the diff and the topology checks - add their modules +here. Everything except the snapshot's live export is pure: records in, +results out, the drawing never modified. +""" + +from engineering.understand.labels import ( + normalize_panel, + parse_diameters, + parse_electrical, + parse_tag, + plain, + wiring_target, +) +from engineering.understand.snapshot import ( + EntityRecord, + Pt, + Snapshot, + length_of, + read_snapshot, + records_from_doc, + take_snapshot, +) +from engineering.understand.vocab import ( + DISCIPLINES, + SERVICES, + classify_layer, + equipment_kind, + room_label, + supply_return, +) + +__all__ = [ + "DISCIPLINES", + "SERVICES", + "EntityRecord", + "Pt", + "Snapshot", + "classify_layer", + "equipment_kind", + "length_of", + "normalize_panel", + "parse_diameters", + "parse_electrical", + "parse_tag", + "plain", + "read_snapshot", + "records_from_doc", + "room_label", + "supply_return", + "take_snapshot", + "wiring_target", +] diff --git a/engineering/understand/labels.py b/engineering/understand/labels.py new file mode 100644 index 0000000..d9a76b8 --- /dev/null +++ b/engineering/understand/labels.py @@ -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[{_DIAMETER_SIGNS}])\s*(?P{_NUMBER})" + rf"|(?SMS)\s*-?\s*(?P{_NUMBER})" + rf"|(?DN)\s*-?\s*(?P\d+)" + rf"|(?{_NUMBER})\s*[xX\u00d7\u0445\u0425]\s*(?P{_NUMBER})(?![.,]?\d)" + r"(?!\s*(?:mm2|mm\u00b2|\u043c\u043c2|\u043c\u043c\u00b2))" + rf"|(?\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"(? 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 diff --git a/engineering/understand/snapshot.py b/engineering/understand/snapshot.py new file mode 100644 index 0000000..2cf93ab --- /dev/null +++ b/engineering/understand/snapshot.py @@ -0,0 +1,420 @@ +"""One bulk read of a drawing into frozen records - the input of every track H reader. + +Reading a 35,000-entity P&ID one COM call at a time takes minutes; reading its +DXF takes about a second. So every analysis in ``engineering/understand/`` runs +on a :class:`Snapshot`: the drawing read once, into small frozen +:class:`EntityRecord` s, and never touched again. + +Where the snapshot comes from: + +* **A DXF file** - :func:`read_snapshot`, ``ezdxf.readfile`` after the size + check (``MAX_DXF_BYTES``, 512 MB by default; the refusal names the size and + the variable). +* **The headless engine's current document** - :func:`records_from_doc` on the + in-memory ``ezdxf`` document, inside the backend's own lock. +* **The live engine's current document** - ``Document.Export(/snap, "DXF", + )``, then :func:`read_snapshot` on the file, which is + deleted afterwards. Measured on AutoCAD 2026 (``25.1s (LMS Tech)``) on + 2026-09-24, in a new scratch document closed afterwards: + + - the extension argument must be ``"DXF"``; ``".dxf"`` fails with *Invalid + argument*; the file is written as ``snap.DXF`` (upper-case extension); + - the document keeps its name (``Export`` is not ``SaveAs``), and an unsaved + document exports; + - **model space, every paper-space layout and every block definition reach + the snapshot** - a LINE in Layout1 was read back in Layout1, and an unused + block definition was in the BLOCKS section; + - **the selection set does not narrow a DXF export**: an empty selection set + wrote the same model space, paper space and blocks as the set of all. The + set of all is passed anyway, because the ActiveX signature requires one. + - xrefs were not measured, so nothing is claimed for the live engine; + headless, ezdxf reads the host file only. Either way an xref's own + entities are not read in 1.6 - the host drawing is - and + ``drawing_understand``'s docstring says so. + + A DWG path on the live engine is opened read-only, exported the same way and + closed without saving. + +Records hold what the readers need and nothing else: WCS points (OCS is +translated here with ``backends/ocs.py``, so a mirrored ARC or LWPOLYLINE is +where it looks), plain text (``labels.plain``: AutoCAD codes and MTEXT format +runs decoded), block names and attributes, a WCS box. Block definitions are not +exploded: an INSERT is one record with its block name, attributes and box. +""" + +from __future__ import annotations + +import asyncio +import math +import os +import shutil +import tempfile +import uuid +from dataclasses import dataclass +from pathlib import Path + +import ezdxf +from ezdxf import bbox as ezbbox + +import config +from backends.capability import UnsupportedCapabilityError +from backends.ocs import to_wcs_2d, wcs_arc_angles, wcs_bulge +from engineering.measure import polygon_area_perimeter +from engineering.understand.labels import plain + +__all__ = [ + "EntityRecord", + "Pt", + "Snapshot", + "length_of", + "read_snapshot", + "records_from_doc", + "take_snapshot", +] + +Pt = tuple[float, float] + +#: ezdxf's placeholder for an extents header nobody computed. +_UNSET_EXTENT = 1e19 +#: The live export's base name inside its temporary folder. +_SNAP_BASE = "snap" + + +@dataclass(frozen=True) +class EntityRecord: + """One entity of a layout, in WCS, as the readers need it.""" + + handle: str + type: str + layer: str + space: str + points: tuple[Pt, ...] = () + bulges: tuple[float, ...] = () + closed: bool = False + radius: float | None = None + angles: tuple[float, float] | None = None + text: str | None = None + height: float | None = None + block: str | None = None + attribs: tuple[tuple[str, str], ...] = () + bbox: tuple[float, float, float, float] | None = None + rotation: float = 0.0 + scale: tuple[float, float] = (1.0, 1.0) + + +@dataclass(frozen=True) +class Snapshot: + """A whole drawing, read once.""" + + source: str + insunits: int + extmin: Pt | None + extmax: Pt | None + layers: dict[str, dict] + layouts: tuple[str, ...] + records: tuple[EntityRecord, ...] + + +def _xy(point) -> Pt: + return (float(point[0]), float(point[1])) + + +def _box(entity) -> tuple[float, float, float, float] | None: + """The entity's WCS box. An INSERT's box is its block geometry only: the + ATTRIB texts are left out, because a TAG lettered beside a tank would push + the tank's box past its body (the rule `engineering/pid/graph.py` keeps).""" + try: + if entity.dxftype() == "INSERT": + box = ezbbox.extents(entity.virtual_entities(), fast=True) + else: + box = ezbbox.extents([entity], fast=True) + except Exception: # a proxy or a malformed entity has no box; the record stays + return None + if not box.has_data: + return None + return ( + float(box.extmin.x), + float(box.extmin.y), + float(box.extmax.x), + float(box.extmax.y), + ) + + +def _header_point(doc, name: str) -> Pt | None: + try: + value = doc.header.get(name) + except Exception: + return None + if value is None or abs(float(value[0])) >= _UNSET_EXTENT: + return None + return _xy(value) + + +def _extrusion(entity): + """The entity's extrusion, or +Z for a type that has none (MULTILEADER, ...).""" + if entity.dxf.is_supported("extrusion"): + return entity.dxf.get("extrusion", (0.0, 0.0, 1.0)) + return (0.0, 0.0, 1.0) + + +def _polyline(entity) -> tuple[tuple[Pt, ...], tuple[float, ...], bool]: + extrusion = _extrusion(entity) + if entity.dxftype() == "LWPOLYLINE": + elevation = float(entity.dxf.get("elevation", 0.0)) + rows = [(float(x), float(y), float(b)) for x, y, b in entity.get_points("xyb")] + points = tuple(tuple(to_wcs_2d(extrusion, x, y, elevation)) for x, y, _ in rows) + bulges = tuple(wcs_bulge(extrusion, b) for _, _, b in rows) + return points, bulges, bool(entity.closed) + vertices = list(entity.vertices) + if entity.is_2d_polyline: + points = tuple( + tuple(to_wcs_2d(extrusion, v.dxf.location.x, v.dxf.location.y, v.dxf.location.z)) + for v in vertices + ) + bulges = tuple(wcs_bulge(extrusion, v.dxf.get("bulge", 0.0)) for v in vertices) + else: # a 3D polyline is WCS already; its z is dropped + points = tuple(_xy(v.dxf.location) for v in vertices) + bulges = tuple(0.0 for _ in vertices) + return points, bulges, bool(entity.is_closed) + + +def _record(entity, space: str) -> EntityRecord | None: + kind = entity.dxftype() + dxf = entity.dxf + base = { + "handle": str(dxf.handle), + "type": kind, + "layer": str(dxf.get("layer", "0")), + "space": space, + "bbox": _box(entity), + } + extrusion = _extrusion(entity) + if kind == "LINE": + return EntityRecord(**base, points=(_xy(dxf.start), _xy(dxf.end))) + if kind in ("LWPOLYLINE", "POLYLINE"): + if kind == "POLYLINE" and (entity.is_poly_face_mesh or entity.is_polygon_mesh): + return EntityRecord(**base) + points, bulges, closed = _polyline(entity) + return EntityRecord(**base, points=points, bulges=bulges, closed=closed) + if kind in ("ARC", "CIRCLE"): + centre = tuple(to_wcs_2d(extrusion, dxf.center.x, dxf.center.y, dxf.center.z)) + angles = None + if kind == "ARC": + angles = wcs_arc_angles( + extrusion, dxf.center, dxf.radius, dxf.start_angle, dxf.end_angle + ) + return EntityRecord(**base, points=(centre,), radius=float(dxf.radius), angles=angles) + if kind == "TEXT": + insert = dxf.insert + return EntityRecord( + **base, + points=(tuple(to_wcs_2d(extrusion, insert.x, insert.y, insert.z)),), + text=plain(dxf.get("text", "")), + height=float(dxf.get("height", 0.0)), + rotation=float(dxf.get("rotation", 0.0)), + ) + if kind == "MTEXT": + return EntityRecord( + **base, + points=(_xy(dxf.insert),), + text=plain(entity.text), + height=float(dxf.get("char_height", 0.0)), + rotation=float(entity.get_rotation()), + ) + if kind == "INSERT": + insert = dxf.insert + attribs = tuple((str(a.dxf.tag), plain(a.dxf.get("text", ""))) for a in entity.attribs) + return EntityRecord( + **base, + points=(tuple(to_wcs_2d(extrusion, insert.x, insert.y, insert.z)),), + block=str(dxf.name), + attribs=attribs, + rotation=float(dxf.get("rotation", 0.0)), + scale=(float(dxf.get("xscale", 1.0)), float(dxf.get("yscale", 1.0))), + ) + if kind == "MULTILEADER": + context = entity.context + points: list[Pt] = [] + for leader in context.leaders: + for line in leader.lines: + points.extend(_xy(v) for v in line.vertices) + points.append(_xy(leader.last_leader_point)) + text = None + height = None + if context.mtext is not None: + text = plain(context.mtext.default_content) + points.append(_xy(context.mtext.insert)) + height = float(context.char_height) + return EntityRecord(**base, points=tuple(points), text=text, height=height) + if kind == "DIMENSION": + override = str(dxf.get("text", "") or "") + text = plain(override) if override not in ("", "<>") else None + middle = dxf.get("text_midpoint", dxf.defpoint) + points = (_xy(dxf.defpoint), tuple(to_wcs_2d(extrusion, middle.x, middle.y, middle.z))) + return EntityRecord(**base, points=points, text=text) + if kind == "LEADER": + return EntityRecord(**base, points=tuple(_xy(v) for v in entity.vertices)) + if kind == "POINT": + return EntityRecord(**base, points=(_xy(dxf.location),)) + if kind == "SPLINE": + return EntityRecord(**base, points=tuple(_xy(p) for p in entity.control_points)) + if kind == "ELLIPSE": + return EntityRecord(**base, points=(_xy(dxf.center),)) + return EntityRecord(**base) + + +def records_from_doc(doc, *, source: str) -> Snapshot: + """Every entity of every layout of an ``ezdxf`` document, as records. + + Model space first, then each paper layout in tab order. ``insunits`` is + ``$INSUNITS`` (0 when absent); ``extmin`` / ``extmax`` are the declared + ``$EXTMIN`` / ``$EXTMAX``, None when the header holds ezdxf's "never + computed" placeholder. Nothing in ``doc`` is modified. + """ + records: list[EntityRecord] = [] + layouts = tuple(doc.layouts.names_in_taborder()) + for name in layouts: + for entity in doc.layouts.get(name): + record = _record(entity, name) + if record is not None: + records.append(record) + layers = { + str(layer.dxf.name): { + "color": int(layer.dxf.get("color", 7)), + "linetype": str(layer.dxf.get("linetype", "Continuous")), + } + for layer in doc.layers + } + return Snapshot( + source=source, + insunits=int(doc.header.get("$INSUNITS", 0) or 0), + extmin=_header_point(doc, "$EXTMIN"), + extmax=_header_point(doc, "$EXTMAX"), + layers=layers, + layouts=layouts, + records=tuple(records), + ) + + +def _read(path: str, *, source: str) -> Snapshot: + try: + doc = ezdxf.readfile(path) + except (OSError, ezdxf.DXFError) as exc: + raise ValueError(f"{path} is not a readable DXF file: {exc}") from exc + return records_from_doc(doc, source=source) + + +def read_snapshot(path: str) -> Snapshot: + """The snapshot of a DXF file on disk, after the size check. + + Refuses a missing file (``FileNotFoundError``) and a file larger than + ``MAX_DXF_BYTES`` (``ValueError`` naming the size, the limit and the + variable; 0 disables the check) before reading a byte of it. + """ + target = Path(path) + if not target.is_file(): + raise FileNotFoundError(f"{path}: no such file") + limit = int(config.settings.max_dxf_bytes) + size = target.stat().st_size + if limit > 0 and size > limit: + raise ValueError( + f"{path} is {size:,} bytes ({size / 2**20:.1f} MB), over the MAX_DXF_BYTES " + f"limit of {limit:,} bytes ({limit / 2**20:.0f} MB). Raise MAX_DXF_BYTES " + "(0 disables the check) to read it." + ) + return _read(str(target), source=str(target)) + + +def _com_export(backend, path: str | None) -> tuple[str, str, str]: + """(snapshot file, its temporary folder, source) - runs on the COM thread.""" + from backends import com_backend as com + + app = com._acad_app() + opened = None + if path is None: + doc = com._acad_doc() + else: + opened = app.Documents.Open(str(path), True) # read-only + doc = opened + folder = tempfile.mkdtemp(prefix="acadmcp_snap_") + try: + name = str(backend._wait_out_rejected_call(lambda: doc.Name)) + selection = doc.SelectionSets.Add(f"_SNAP_{uuid.uuid4().hex[:8]}") + try: + selection.Select(com._AC_SELECTION_SET_ALL) + # "DXF", never ".dxf": the dotted spelling is refused with + # "Invalid argument" (measured, AutoCAD 2026). + doc.Export(os.path.join(folder, _SNAP_BASE), "DXF", selection) + finally: + try: + selection.Delete() + except Exception: # a selection set left behind is harmless; the export is not + pass + except Exception: + shutil.rmtree(folder, ignore_errors=True) + raise + finally: + if opened is not None: + opened.Close(False) + written = sorted(Path(folder).glob(f"{_SNAP_BASE}.*")) + if not written: + shutil.rmtree(folder, ignore_errors=True) + raise RuntimeError(f"AutoCAD reported the export of {name} done but wrote no file") + return str(written[0]), folder, f"live:{name}" + + +async def take_snapshot(backend, path: str | None = None) -> Snapshot: + """The snapshot of ``path``, or of the backend's current document. + + ``path`` given: a ``.dxf`` is read directly (on either engine); a ``.dwg`` + needs the live engine, which opens it read-only, exports it and closes it - + headlessly it is refused with the ``dwg`` capability. ``path`` None: the + headless engine reads its in-memory document under its own lock; the live + engine exports the active document to a temporary DXF (see the module + docstring for what was measured) and deletes it afterwards. The snapshot of + a live document is not size-checked: it is already in AutoCAD's memory. + """ + if path is not None: + suffix = Path(path).suffix.lower() + if suffix == ".dxf": + return await asyncio.to_thread(read_snapshot, str(path)) + if suffix != ".dwg": + raise ValueError(f"{path}: a snapshot reads a .dxf or a .dwg, not {suffix or 'this'}") + if not Path(path).is_file(): + raise FileNotFoundError(f"{path}: no such file") + if backend.name != "com": + raise UnsupportedCapabilityError( + "dwg", + f"{path} is a DWG, which the headless engine cannot read. Export it to " + "DXF (DXFOUT or SAVEAS in AutoCAD) and pass the .dxf, or run the server " + "on the live engine (AUTOCAD_MCP_BACKEND=com).", + ) + if backend.name == "com": + written, folder, source = await backend._run(_com_export, backend, path) + if path is not None: + await backend._ensure_document_state() + try: + return await asyncio.to_thread(_read, written, source=source) + finally: + shutil.rmtree(folder, ignore_errors=True) + source = backend._doc_path or "memory:untitled" + return await backend._async(lambda: records_from_doc(backend._doc, source=source)) + + +def length_of(rec: EntityRecord) -> float: + """Drawn length in drawing units: LINE, LWPOLYLINE / POLYLINE with their + bulges (``engineering.measure``, the same arithmetic ``analysis_measure_entity`` + reports), ARC, CIRCLE. Every other type is 0.0. + """ + if rec.type == "LINE" and len(rec.points) == 2: + return math.dist(rec.points[0], rec.points[1]) + if rec.type in ("LWPOLYLINE", "POLYLINE") and len(rec.points) >= 2: + bulges = rec.bulges or tuple(0.0 for _ in rec.points) + vertices = [(x, y, b) for (x, y), b in zip(rec.points, bulges, strict=False)] + return polygon_area_perimeter(vertices, rec.closed)[1] + if rec.type == "ARC" and rec.radius is not None and rec.angles is not None: + sweep = (rec.angles[1] - rec.angles[0]) % 360.0 + return rec.radius * math.radians(sweep) + if rec.type == "CIRCLE" and rec.radius is not None: + return 2.0 * math.pi * rec.radius + return 0.0 diff --git a/engineering/understand/vocab.py b/engineering/understand/vocab.py new file mode 100644 index 0000000..6803b24 --- /dev/null +++ b/engineering/understand/vocab.py @@ -0,0 +1,451 @@ +"""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", + "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 + + +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]} diff --git a/pyproject.toml b/pyproject.toml index 5243780..125db9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,6 +88,12 @@ pdf = [ # Also satisfies ezdxf's hard Pillow requirement, transitively. "matplotlib>=3.8", ] +office = [ + # XLSX output: the takeoff workbooks (pipe_takeoff / cable_takeoff) and + # data_extract(format="xlsx"). Without it every one of them still writes + # CSV and refuses XLSX with the `xlsx_write` capability, naming this extra. + "openpyxl>=3.1", +] full = [ # Same marker as `com` above, and for the same reason: `[full]` is the # extra this project recommends to contributors, and it was @@ -95,6 +101,7 @@ full = [ 'pywin32>=306; sys_platform == "win32"', "Pillow>=10.0", "matplotlib>=3.8", + "openpyxl>=3.1", ] [project.scripts] diff --git a/tests/test_office_extra.py b/tests/test_office_extra.py new file mode 100644 index 0000000..a7d112e --- /dev/null +++ b/tests/test_office_extra.py @@ -0,0 +1,30 @@ +"""The `office` extra: openpyxl for the XLSX workbooks, and in `full` too. + +Declared and locked is what is tested - not installed: the CI lane that +installs `[pdf]` has no openpyxl and must stay green, because every XLSX +writer refuses with the `xlsx_write` capability and writes CSV instead. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _extras() -> dict: + with open(ROOT / "pyproject.toml", "rb") as handle: + return tomllib.load(handle)["project"]["optional-dependencies"] + + +def test_the_office_extra_carries_openpyxl_and_full_carries_it_too(): + extras = _extras() + assert extras["office"] == ["openpyxl>=3.1"] + assert "openpyxl>=3.1" in extras["full"] + + +def test_the_lock_records_openpyxl_for_the_office_extra(): + lock = (ROOT / "uv.lock").read_text(encoding="utf-8") + assert '\nname = "openpyxl"\n' in lock + assert '{ name = "openpyxl" }' in lock diff --git a/tests/test_understand_labels.py b/tests/test_understand_labels.py new file mode 100644 index 0000000..51264b5 --- /dev/null +++ b/tests/test_understand_labels.py @@ -0,0 +1,238 @@ +"""Label parsers: every spelling a foreign drawing uses, read one way. + +Review Focus 1 (AutoCAD text codes) and 2 (Cyrillic look-alike tags) of the +track H plan live here: one test per spelling, because a parser that handles +four of five codes fails silently on the fifth drawing. +""" + +from __future__ import annotations + +import pytest + +from engineering.understand.labels import ( + CYRILLIC_LOOKALIKES, + normalize_panel, + parse_diameters, + parse_electrical, + parse_tag, + plain, + wiring_target, +) + +# -- plain(): AutoCAD codes and MTEXT format runs ------------------------------- + + +def test_plain_decodes_the_percent_codes(): + assert plain("%%c51") == "\u00d851" + assert plain("90%%d") == "90\u00b0" + assert plain("%%p0.1") == "\u00b10.1" + assert plain("50%%%") == "50%" + assert plain("%%uUNDER%%u") == "UNDER" + assert plain("%%065") == "A" + + +def test_plain_decodes_a_unicode_escape(): + assert plain("\\U+2205 51") == "\u2205 51" + assert plain("\\u+00D8") == "\u00d8" + + +def test_plain_drops_mtext_format_runs_and_keeps_the_content(): + assert plain("{\\fArial|b0|i0|c0|p34;\u00d851}") == "\u00d851" + assert plain("\\H2.5;\\C1;SMS51") == "SMS51" + assert plain("\\A1;\\W0.8;\\Q15;\\T1.1;DN20") == "DN20" + assert plain("\\LUNDERLINED\\l") == "UNDERLINED" + + +def test_plain_turns_paragraphs_spaces_and_stacks_into_text(): + assert plain("PUMP\\PM11") == "PUMP\nM11" + assert plain("a\\~b") == "a b" + assert plain("\\S1/2;") == "1/2" + assert plain("\\S+0.1^-0.2;") == "+0.1/-0.2" + + +def test_plain_keeps_escaped_backslashes_and_braces(): + assert plain("\\\\P") == "\\P" + assert plain("\\{x\\}") == "{x}" + + +def test_plain_of_nothing_is_empty(): + assert plain(None) == "" + assert plain("") == "" + + +# -- parse_diameters(): Review Focus 1, one spelling per test -------------------- + + +def test_the_autocad_percent_code_is_a_diameter(): + assert parse_diameters("%%c51") == ("\u00d851",) + + +def test_a_unicode_escaped_diameter_sign_is_a_diameter(): + assert parse_diameters("\\U+2205 51") == ("\u00d851",) + assert parse_diameters("\\U+220551") == ("\u00d851",) + + +def test_the_diameter_sign_u2300_is_a_diameter(): + assert parse_diameters("\u230051") == ("\u00d851",) + + +def test_a_lowercase_o_stroke_is_a_diameter(): + assert parse_diameters("\u00f851") == ("\u00d851",) + + +def test_a_diameter_inside_an_mtext_format_run_is_a_diameter(): + assert parse_diameters("{\\fArial|b0|i0|c0|p34;\u00d851}") == ("\u00d851",) + assert parse_diameters("{\\fArial|b0;\\U+2205}51") == ("\u00d851",) + + +def test_a_diameter_after_a_paragraph_break_is_found(): + assert parse_diameters("PRODUCT\\P%%c51") == ("\u00d851",) + + +def test_labels_are_kept_as_drawn_and_never_equated(): + assert parse_diameters("\u00d825,4") == ("\u00d825,4",) + assert parse_diameters("\u00d825") == ("\u00d825",) + assert parse_diameters("SMS 51") == ("SMS51",) + assert parse_diameters("dn 20") == ("DN20",) + assert parse_diameters("DN-25") == ("DN25",) + assert parse_diameters("20 x 27") == ("20x27",) + assert parse_diameters("20\u044527") == ("20x27",) # Cyrillic х + assert parse_diameters('1,5"') == ('1,5"',) + assert parse_diameters("1/2\u2033") == ('1/2"',) + + +def test_several_labels_in_one_text_come_in_order_and_once(): + assert parse_diameters("\u00d851 / SMS51 / \u00d851") == ("\u00d851", "SMS51") + + +def test_a_cable_cross_section_is_not_a_pipe_size(): + assert parse_diameters("5x4 mm\u00b2") == () + assert parse_diameters("3x2,5 mm2") == () + + +def test_text_without_a_size_yields_nothing(): + assert parse_diameters("CIP SUPPLY") == () + assert parse_diameters("T101") == () + + +# -- parse_tag(): Review Focus 2, one look-alike per test ------------------------- + + +@pytest.mark.parametrize(("cyrillic", "latin"), sorted(CYRILLIC_LOOKALIKES.items())) +def test_every_cyrillic_lookalike_folds_to_its_latin_twin(cyrillic, latin): + assert parse_tag(f"{cyrillic}101") == f"{latin}101" + + +def test_a_cyrillic_t_tag_matches_the_latin_one(): + assert parse_tag("\u04224100") == "T4100" + + +def test_a_cyrillic_m_tag_matches_the_latin_one(): + assert parse_tag("\u041c82") == "M82" + + +def test_a_tag_with_a_cyrillic_prefix_and_a_hyphen(): + assert parse_tag("\u0421\u0420-1") == "CP-1" # С Р typed in Cyrillic + + +def test_a_russian_word_is_never_folded_into_a_tag(): + assert parse_tag("\u041f\u041e\u0414\u0410\u0427\u0410") is None # ПОДАЧА + assert parse_tag("\u041f\u041e\u04171") is None # ПОЗ1: П has no Latin twin + + +def test_tag_shapes(): + assert parse_tag("T4100") == "T4100" + assert parse_tag("pump m41a") == "M41A" + assert parse_tag("M47.1") == "M47.1" + assert parse_tag("CP-1") == "CP-1" + assert parse_tag("CP-M82") == "CP-M82" + assert parse_tag("JB-2.") == "JB-2" + assert parse_tag("CIP TANK T101") == "T101" + + +def test_sizes_ratings_and_ip_codes_are_not_tags(): + assert parse_tag("DN20") is None + assert parse_tag("SMS51") is None + assert parse_tag("PN16") is None + assert parse_tag("IP65") is None + assert parse_tag("DN20 T101") == "T101" + + +def test_text_without_a_tag(): + assert parse_tag("ROOM 1 PROCESS HALL") is None + assert parse_tag(None) is None + + +# -- panels and wiring callouts ---------------------------------------------------- + + +def test_panel_spellings_become_one(): + assert normalize_panel("CP 1") == "CP-1" + assert normalize_panel("CP1") == "CP-1" + assert normalize_panel("cp-1") == "CP-1" + assert normalize_panel("CP M82") == "CP-M82" + assert normalize_panel("\u0421\u0420 2") == "CP-2" + assert normalize_panel("PANEL") is None + assert normalize_panel("") is None + + +def test_wiring_callouts_in_four_languages(): + assert wiring_target("Wiring to CP1") == "CP-1" + assert wiring_target("WIRING TO CP 2") == "CP-2" + assert wiring_target("wiring to the CP-M82") == "CP-M82" + # подключение к шкафу CP-1 + assert ( + wiring_target( + "\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 " + "\u043a \u0448\u043a\u0430\u0444\u0443 CP-1" + ) + == "CP-1" + ) + assert wiring_target("bedrading naar CP3") == "CP-3" + assert wiring_target("CP 2 panosuna") == "CP-2" + + +def test_a_callout_without_a_wiring_phrase_names_no_panel(): + assert wiring_target("CP1") is None + assert wiring_target("Wiring to") is None + + +# -- electrical data --------------------------------------------------------------- + + +def test_russian_electrical_text(): + # P = 18,5 кВт, 3 ф. 400 В + N + PE, ЧРП + text = "P = 18,5 \u043a\u0412\u0442, 3 \u0444. 400 \u0412 + N + PE, \u0427\u0420\u041f" + assert parse_electrical(text) == { + "kw": 18.5, + "phases": 3, + "voltage": 400, + "neutral": True, + "vfd": True, + } + + +def test_english_and_turkish_electrical_text(): + assert parse_electrical("5.5 kW 3ph 400V VFD") == { + "kw": 5.5, + "phases": 3, + "voltage": 400, + "neutral": False, + "vfd": True, + } + assert parse_electrical("2,2 kW 3 faz 400 V frekans konvert\u00f6r\u00fc")["vfd"] is True + assert parse_electrical("3/N/PE ~ 400 V")["neutral"] is True + assert parse_electrical("24 VDC")["voltage"] == 24 + + +def test_a_power_that_is_not_written_is_never_invented(): + # Нагреватель, 3 ф. 400 В + heater = parse_electrical( + "\u041d\u0430\u0433\u0440\u0435\u0432\u0430\u0442\u0435\u043b\u044c, 3 \u0444. 400 \u0412" + ) + assert heater["kw"] is None + assert heater["phases"] == 3 and heater["voltage"] == 400 + + +def test_energy_is_not_power(): + assert parse_electrical("1200 kWh")["kw"] is None diff --git a/tests/test_understand_snapshot.py b/tests/test_understand_snapshot.py new file mode 100644 index 0000000..a136b07 --- /dev/null +++ b/tests/test_understand_snapshot.py @@ -0,0 +1,326 @@ +"""The snapshot: one bulk read of a drawing into frozen WCS records. + +Every expected coordinate and length is worked out by hand in the comment +beside it. The live engine's export is exercised against a fake document that +behaves the way AutoCAD 2026 was measured to behave (the extension must be +"DXF", the file lands as snap.DXF, the document is not renamed). +""" + +from __future__ import annotations + +import math +from pathlib import Path + +import ezdxf +import pytest +from ezdxf.math import Vec2 + +import backends.com_backend as cb +import config +from backends.base import UnsupportedCapabilityError +from backends.com_backend import ComBackend +from backends.ezdxf_backend import EzdxfBackend +from engineering.understand.snapshot import ( + EntityRecord, + Snapshot, + length_of, + read_snapshot, + records_from_doc, + take_snapshot, +) + +EPS = 1e-9 + + +def _doc(): + """A drawing with one entity of each kind the readers use.""" + doc = ezdxf.new("R2018") + doc.header["$INSUNITS"] = 4 + doc.layers.add("PIPE", color=3, linetype="Continuous") + msp = doc.modelspace() + msp.add_line((0, 0), (3, 4), dxfattribs={"layer": "PIPE"}) + # a closed square whose first edge bulges into a semicircle (bulge 1) + msp.add_lwpolyline( + [(0, 0, 1.0), (10, 0, 0.0), (10, 10, 0.0), (0, 10, 0.0)], + format="xyb", + close=True, + dxfattribs={"layer": "PIPE"}, + ) + msp.add_polyline2d([(0, 0), (0, 5), (5, 5)], dxfattribs={"layer": "PIPE"}) + msp.add_arc((100, 0), 10, 0, 90, dxfattribs={"layer": "PIPE"}) + # the same quarter arc mirrored: extrusion (0, 0, -1), OCS centre (-200, 0) + # is WCS (200, 0), and the arc runs from 90 to 180 degrees in WCS + msp.add_arc((-200, 0), 10, 0, 90, dxfattribs={"layer": "PIPE", "extrusion": (0, 0, -1)}) + msp.add_circle((50, 50), 5) + msp.add_text("%%c51", dxfattribs={"insert": (20, 20), "height": 2.5, "layer": "TXT"}) + msp.add_mtext("{\\fArial|b0;SMS51}\\PDN20", dxfattribs={"insert": (30, 30), "char_height": 3.5}) + block = doc.blocks.new("TANK_V") + block.add_circle((0, 0), 600) + block.add_attdef("TAG", (0, 0), dxfattribs={"height": 250}) + insert = msp.add_blockref("TANK_V", (1000, 2000), dxfattribs={"rotation": 90}) + insert.add_auto_attribs({"TAG": "Т4100"}) # Cyrillic Т + leader = msp.add_multileader_mtext("Standard") + leader.set_content("Wiring to CP1", char_height=2.5) + leader.add_leader_line(ezdxf.render.mleader.ConnectionSide.left, [Vec2(0, 0)]) + leader.build(insert=Vec2(50, 20)) + doc.layouts.get("Layout1").add_line((1, 1), (2, 1)) + return doc + + +def _by_type(snap: Snapshot) -> dict[str, list[EntityRecord]]: + out: dict[str, list[EntityRecord]] = {} + for rec in snap.records: + out.setdefault(rec.type, []).append(rec) + return out + + +def test_records_carry_wcs_geometry_plain_text_and_the_space(): + snap = records_from_doc(_doc(), source="memory") + kinds = _by_type(snap) + assert snap.source == "memory" + assert snap.insunits == 4 + assert snap.layouts == ("Model", "Layout1") + assert snap.layers["PIPE"] == {"color": 3, "linetype": "Continuous"} + (line_model,) = [r for r in kinds["LINE"] if r.space == "Model"] + (line_paper,) = [r for r in kinds["LINE"] if r.space == "Layout1"] + assert line_model.points == ((0.0, 0.0), (3.0, 4.0)) + assert line_model.layer == "PIPE" + assert line_paper.points == ((1.0, 1.0), (2.0, 1.0)) + (square,) = kinds["LWPOLYLINE"] + assert square.closed is True + assert square.bulges == (1.0, 0.0, 0.0, 0.0) + (poly,) = kinds["POLYLINE"] + assert poly.points == ((0.0, 0.0), (0.0, 5.0), (5.0, 5.0)) and poly.closed is False + arcs = sorted(kinds["ARC"], key=lambda r: r.points[0][0]) + assert arcs[0].points == ((100.0, 0.0),) and arcs[0].angles == pytest.approx((0.0, 90.0)) + assert arcs[1].points[0] == pytest.approx((200.0, 0.0), abs=EPS) + assert arcs[1].angles == pytest.approx((90.0, 180.0), abs=EPS) + (text,) = kinds["TEXT"] + assert text.text == "Ø51" and text.height == 2.5 and text.points == ((20.0, 20.0),) + (mtext,) = kinds["MTEXT"] + assert mtext.text == "SMS51\nDN20" and mtext.height == 3.5 + (insert,) = kinds["INSERT"] + assert insert.block == "TANK_V" and insert.points == ((1000.0, 2000.0),) + assert insert.attribs == (("TAG", "Т4100"),) + assert insert.rotation == 90.0 + assert insert.bbox == pytest.approx((400.0, 1400.0, 1600.0, 2600.0), abs=1e-6) + (leader,) = kinds["MULTILEADER"] + assert leader.text == "Wiring to CP1" + assert leader.points[0] == (0.0, 0.0) # the arrow tip comes first + assert leader.points[-1] == (50.0, 20.0) # the text insertion comes last + assert snap.extmin is None and snap.extmax is None # never computed + + +def test_every_record_is_frozen(): + rec = records_from_doc(_doc(), source="memory").records[0] + with pytest.raises(AttributeError): + rec.layer = "OTHER" + + +def test_lengths(): + kinds = _by_type(records_from_doc(_doc(), source="memory")) + (line_model,) = [r for r in kinds["LINE"] if r.space == "Model"] + assert length_of(line_model) == pytest.approx(5.0, abs=EPS) # 3-4-5 + # semicircle of diameter 10 (5 pi) + three straight sides of 10 + (square,) = kinds["LWPOLYLINE"] + assert length_of(square) == pytest.approx(5.0 * math.pi + 30.0, abs=EPS) + (poly,) = kinds["POLYLINE"] + assert length_of(poly) == pytest.approx(10.0, abs=EPS) + for arc in kinds["ARC"]: # a quarter of a radius-10 circle + assert length_of(arc) == pytest.approx(5.0 * math.pi, abs=EPS) + (circle,) = kinds["CIRCLE"] + assert length_of(circle) == pytest.approx(10.0 * math.pi, abs=EPS) + assert length_of(kinds["TEXT"][0]) == 0.0 + + +def test_the_size_limit_defaults_to_512_mb(monkeypatch): + monkeypatch.delenv("MAX_DXF_BYTES", raising=False) + assert config.Settings().max_dxf_bytes == 512 * 1024 * 1024 + assert config.DEFAULT_MAX_DXF_BYTES == 536_870_912 + + +def test_read_snapshot_reads_a_file(tmp_path): + path = tmp_path / "plant.dxf" + _doc().saveas(path) + snap = read_snapshot(str(path)) + assert snap.source == str(path) + assert len(snap.records) == len(records_from_doc(_doc(), source="x").records) + + +def test_read_snapshot_refuses_a_file_over_the_limit_naming_size_and_variable( + tmp_path, monkeypatch +): + path = tmp_path / "big.dxf" + _doc().saveas(path) + size = path.stat().st_size + monkeypatch.setattr(config.settings, "max_dxf_bytes", size - 1) + with pytest.raises(ValueError) as excinfo: + read_snapshot(str(path)) + message = str(excinfo.value) + assert "MAX_DXF_BYTES" in message + assert f"{size:,} bytes" in message + monkeypatch.setattr(config.settings, "max_dxf_bytes", 0) # 0 disables the check + assert read_snapshot(str(path)).records + + +def test_read_snapshot_refuses_missing_and_unreadable_files(tmp_path): + with pytest.raises(FileNotFoundError): + read_snapshot(str(tmp_path / "nope.dxf")) + junk = tmp_path / "junk.dxf" + junk.write_text("not a dxf", encoding="utf-8") + with pytest.raises(ValueError) as excinfo: + read_snapshot(str(junk)) + assert "not a readable DXF" in str(excinfo.value) + + +# -- take_snapshot on the headless engine --------------------------------------- + + +@pytest.mark.asyncio +async def test_the_headless_engine_snapshots_its_current_document_without_touching_it(): + backend = EzdxfBackend() + await backend.connect() + await backend.drawing_new() + line = await backend.entity_create_line(0, 0, 10, 0, layer="PIPE") + before = len(await backend.entity_list(limit=1000)) + snap = await take_snapshot(backend) + assert snap.source == "memory:untitled" + assert [r.handle for r in snap.records if r.type == "LINE"] == [line.handle] + assert len(await backend.entity_list(limit=1000)) == before + + +@pytest.mark.asyncio +async def test_a_dxf_path_is_read_directly(tmp_path): + path = tmp_path / "plant.dxf" + _doc().saveas(path) + backend = EzdxfBackend() + await backend.connect() + snap = await take_snapshot(backend, str(path)) + assert snap.source == str(path) and snap.records + + +@pytest.mark.asyncio +async def test_a_dwg_is_refused_headlessly_with_the_dwg_capability(tmp_path): + path = tmp_path / "plant.dwg" + path.write_bytes(b"AC1032") + backend = EzdxfBackend() + await backend.connect() + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await take_snapshot(backend, str(path)) + assert excinfo.value.capability == "dwg" + assert "DXF" in str(excinfo.value) + with pytest.raises(FileNotFoundError): + await take_snapshot(backend, str(tmp_path / "missing.dwg")) + with pytest.raises(ValueError): + await take_snapshot(backend, str(tmp_path / "plant.txt")) + + +# -- take_snapshot on the live engine (a fake that behaves as measured) ---------- + + +class _Selection: + def __init__(self, log): + self.log = log + + def Select(self, mode, *args): # noqa: N802 - ActiveX member name + self.log.append(("select", mode)) + + def Delete(self): # noqa: N802 + self.log.append(("delete_selection",)) + + +class _Selections: + def __init__(self, log): + self.log = log + + def Add(self, name): # noqa: N802 + self.log.append(("add_selection", name)) + return _Selection(self.log) + + +class _Doc: + def __init__(self, name, log): + self.Name = name + self.log = log + self.SelectionSets = _Selections(log) + + def Export(self, base, extension, selection): # noqa: N802 + if extension != "DXF": + raise ValueError("Invalid argument") # what AutoCAD 2026 answers to ".dxf" + self.log.append(("export", Path(base).name, extension)) + _doc().saveas(base + ".DXF") # AutoCAD writes the upper-case extension + + def SaveAs(self, *args): # noqa: N802 + raise AssertionError("a snapshot must never rename the document") + + def Close(self, save): # noqa: N802 + self.log.append(("close", save)) + + +class _Documents: + def __init__(self, log): + self.log = log + + def Open(self, path, read_only): # noqa: N802 + self.log.append(("open", Path(path).name, read_only)) + return _Doc(Path(path).name, self.log) + + +class _App: + def __init__(self, log): + self.Documents = _Documents(log) + + +def _live(monkeypatch, log): + backend = ComBackend() + + async def run_inline(func, *args, **kwargs): # bypass the STA executor + return func(*args, **kwargs) + + async def ensure_state(): + log.append(("ensure_document_state",)) + + backend._run = run_inline + backend._ensure_document_state = ensure_state + active = _Doc("Drawing1.dwg", log) + monkeypatch.setattr(cb, "_acad_app", lambda: _App(log)) + monkeypatch.setattr(cb, "_acad_doc", lambda: active) + return backend, active + + +@pytest.mark.asyncio +async def test_the_live_engine_exports_a_dxf_snapshot_and_deletes_it(monkeypatch, tmp_path): + log: list[tuple] = [] + made: list[str] = [] + real_mkdtemp = __import__("tempfile").mkdtemp + + def mkdtemp(prefix=""): + folder = real_mkdtemp(prefix=prefix, dir=tmp_path) + made.append(folder) + return folder + + monkeypatch.setattr("engineering.understand.snapshot.tempfile.mkdtemp", mkdtemp) + backend, active = _live(monkeypatch, log) + snap = await take_snapshot(backend) + assert snap.source == "live:Drawing1.dwg" + assert active.Name == "Drawing1.dwg" + assert ("select", cb._AC_SELECTION_SET_ALL) in log + assert ("export", "snap", "DXF") in log + assert ("delete_selection",) in log + assert {r.type for r in snap.records} >= {"LINE", "INSERT", "MULTILEADER"} + assert made and not Path(made[0]).exists(), "the temporary snapshot must be deleted" + + +@pytest.mark.asyncio +async def test_a_dwg_on_the_live_engine_is_opened_read_only_and_closed_unsaved( + monkeypatch, tmp_path +): + path = tmp_path / "plant.dwg" + path.write_bytes(b"AC1032") + log: list[tuple] = [] + backend, _active = _live(monkeypatch, log) + snap = await take_snapshot(backend, str(path)) + assert snap.source == "live:plant.dwg" + assert ("open", "plant.dwg", True) in log + assert ("close", False) in log + assert log[-1] == ("ensure_document_state",) diff --git a/tests/test_understand_vocab.py b/tests/test_understand_vocab.py new file mode 100644 index 0000000..cd4a058 --- /dev/null +++ b/tests/test_understand_vocab.py @@ -0,0 +1,234 @@ +"""The multilingual vocabulary: layer names, direction words, room labels, block kinds. + +Every layer name below is one a drafter would write; the ones in the synthetic +plant pair (tests/fixtures/plant_pair.py) are here too, because that is what the +readers above this module are measured on. +""" + +from __future__ import annotations + +import pytest + +from engineering.understand.vocab import ( + DISCIPLINES, + EQUIPMENT_KINDS, + LANGUAGES, + SERVICES, + classify_layer, + equipment_kind, + fold, + room_label, + supply_return, +) + + +def test_the_closed_lists(): + assert LANGUAGES == ("en", "tr", "ru", "nl", "de") + assert SERVICES == ( + "product", + "cip_supply", + "cip_return", + "glycol_supply", + "glycol_return", + "steam", + "condensate", + "cold_water", + "hot_water", + "ice_water", + "compressed_air", + "drain", + "electrical", + "unknown", + ) + assert DISCIPLINES == ( + "piping", + "electrical", + "architecture", + "equipment", + "annotation", + "structure", + "unknown", + ) + assert EQUIPMENT_KINDS == ("tank", "pump", "valve", "reducer", "motor", "agitator", "panel") + + +def test_fold_strips_diacritics_and_the_turkish_dotless_i(): + assert fold("DÖNÜŞ") == "donus" + assert fold("GİDİŞ") == "gidis" + assert fold("çıkış") == "cikis" + assert fold("RückLauf") == "rucklauf" + assert fold("ПОДАЧА") == "подача" + assert fold(None) == "" + + +@pytest.mark.parametrize( + ("name", "service", "keyword", "language"), + [ + ("P_product piping", "product", "product", "en"), + ("P_cipsupplyline", "cip_supply", "cip", "en"), + ("P_cipreturnline", "cip_return", "cip", "en"), + ("P_ijswater", "ice_water", "ijswater", "nl"), + ("CIP ВОЗВРАТ", "cip_return", "cip", "en"), + ("glikol dönüş", "glycol_return", "glikol", "tr"), + ("GLYKOL-VORLAUF", "glycol_supply", "glykol", "de"), + ("buhar hattı", "steam", "buhar", "tr"), + ("КОНДЕНСАТ", "condensate", "конденсат", "ru"), + ("P_ICE WATER", "ice_water", "ice water", "en"), + ("sıcak su", "hot_water", "sicak su", "tr"), + ("DRUCKLUFT", "compressed_air", "druckluft", "de"), + ("air_line", "compressed_air", "air", "en"), + ("RIOOL", "drain", "riool", "nl"), + ], +) +def test_service_layers(name, service, keyword, language): + row = classify_layer(name) + assert row["service"] == service + assert row["discipline"] == "piping" + assert row["confidence"] == 0.9 + assert row["keyword"] == keyword + assert row["language"] == language + + +def test_electrical_layers_are_their_own_service(): + for name in ("E_power", "ELEKTRIK", "ЭЛЕКТРИКА", "kabel"): + row = classify_layer(name) + assert (row["discipline"], row["service"], row["confidence"]) == ( + "electrical", + "electrical", + 0.9, + ), name + + +def test_a_cip_layer_without_a_direction_is_not_guessed(): + row = classify_layer("P_CIP") + assert row == { + "discipline": "piping", + "service": "unknown", + "confidence": 0.6, + "keyword": "cip", + "language": "en", + } + + +def test_a_cip_layer_naming_both_directions_is_not_guessed_either(): + assert classify_layer("CIP supply return")["service"] == "unknown" + + +def test_a_water_layer_with_no_temperature_is_piping_of_unknown_service(): + row = classify_layer("P_WATER") + assert (row["discipline"], row["service"], row["confidence"]) == ("piping", "unknown", 0.6) + + +@pytest.mark.parametrize( + ("name", "discipline", "language"), + [ + ("KLEPPEN", "piping", "nl"), + ("VANALAR", "piping", "tr"), + ("WALLS", "architecture", "en"), + ("MUREN", "architecture", "nl"), + ("СТЕНЫ", "architecture", "ru"), + ("EQUIPMENT", "equipment", "en"), + ("POMPALAR", "equipment", "tr"), + ("DIM", "annotation", "en"), + ("ÖLÇÜ", "annotation", "tr"), + ("KOLONLAR", "structure", "tr"), + ], +) +def test_discipline_layers(name, discipline, language): + row = classify_layer(name) + assert (row["discipline"], row["service"], row["confidence"], row["language"]) == ( + discipline, + "unknown", + 0.9, + language, + ) + + +def test_a_short_keyword_must_start_a_word(): + assert classify_layer("STAIRS")["service"] != "compressed_air" + assert classify_layer("CHAIRS")["discipline"] == "unknown" + + +def test_a_layer_with_no_keyword(): + assert classify_layer("0") == { + "discipline": "unknown", + "service": "unknown", + "confidence": 0.0, + "keyword": None, + "language": None, + } + assert classify_layer("Defpoints")["discipline"] == "unknown" + + +@pytest.mark.parametrize( + ("text", "direction"), + [ + ("ПОДАЧА", "supply"), + ("ВОЗВРАТ", "return"), + ("обратка CIP", "return"), # обратка + ("BESLEME", "supply"), + ("DÖNÜŞ", "return"), + ("donus hatti", "return"), + ("CIP SUPPLY", "supply"), + ("return line", "return"), + ("AANVOER", "supply"), + ("RETOUR", "return"), + ("Vorlauf", "supply"), + ("RÜCKLAUF", "return"), + ], +) +def test_supply_and_return_words_in_five_languages(text, direction): + assert supply_return(text) == direction + + +def test_a_text_naming_both_or_neither_direction_is_none(): + assert supply_return("CIP SUPPLY / RETURN") is None + assert supply_return("Ø51") is None + assert supply_return(None) is None + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("ROOM 1 PROCESS HALL", {"number": "1", "name": "PROCESS HALL", "language": "en"}), + ("ODA 2", {"number": "2", "name": None, "language": "tr"}), + ("MAHAL 1.05 SOĞUK ODA", {"number": "1.05", "name": "SOĞUK ODA", "language": "tr"}), + ( + "ПОМЕЩЕНИЕ 2 МОЙКА", + {"number": "2", "name": "МОЙКА", "language": "ru"}, + ), + ("RUIMTE 3 opslag", {"number": "3", "name": "opslag", "language": "nl"}), + ("Raum 4", {"number": "4", "name": None, "language": "de"}), + ], +) +def test_room_labels_in_five_languages(text, expected): + assert room_label(text) == expected + + +def test_a_text_without_a_room_word_is_not_a_room_label(): + assert room_label("T101") is None + assert room_label("PROCESS HALL") is None + assert room_label("") is None + + +@pytest.mark.parametrize( + ("block", "kind", "language"), + [ + ("KLEP_VLINDER", "valve", "nl"), + ("VERLOOPSTUK", "reducer", "nl"), + ("REDUCER_CONC", "reducer", "en"), + ("TANK_V", "tank", "en"), + ("POMP", "pump", "nl"), + ("SCHAKELKAST", "panel", "nl"), + ("НАСОС", "pump", "ru"), + ("VANA_KELEBEK", "valve", "tr"), + ], +) +def test_block_names_say_their_kind(block, kind, language): + row = equipment_kind(block) + assert row["kind"] == kind and row["language"] == language + + +def test_an_anonymous_block_has_no_kind(): + assert equipment_kind("*U12") is None + assert equipment_kind(None) is None diff --git a/uv.lock b/uv.lock index dc2a74d..fd0f27c 100644 --- a/uv.lock +++ b/uv.lock @@ -82,9 +82,13 @@ com = [ ] full = [ { name = "matplotlib" }, + { name = "openpyxl" }, { name = "pillow" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, ] +office = [ + { name = "openpyxl" }, +] pdf = [ { name = "matplotlib" }, ] @@ -103,13 +107,15 @@ requires-dist = [ { name = "fastmcp", specifier = ">=3.4.5,<4" }, { name = "matplotlib", marker = "extra == 'full'", specifier = ">=3.8" }, { name = "matplotlib", marker = "extra == 'pdf'", specifier = ">=3.8" }, + { name = "openpyxl", marker = "extra == 'full'", specifier = ">=3.1" }, + { name = "openpyxl", marker = "extra == 'office'", specifier = ">=3.1" }, { name = "pillow", marker = "extra == 'com'", specifier = ">=10.0" }, { name = "pillow", marker = "extra == 'full'", specifier = ">=10.0" }, { name = "pydantic", specifier = ">=2.0,<3" }, { name = "pywin32", marker = "sys_platform == 'win32' and extra == 'com'", specifier = ">=306" }, { name = "pywin32", marker = "sys_platform == 'win32' and extra == 'full'", specifier = ">=306" }, ] -provides-extras = ["com", "pdf", "full"] +provides-extras = ["com", "pdf", "office", "full"] [package.metadata.requires-dev] dev = [ @@ -592,6 +598,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -1344,6 +1359,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.44.0"