feat(understand): the snapshot, the vocabulary and the label parsers; MAX_DXF_BYTES 512 MB; the office extra

Track H's foundation, landed on main before its three groups branch.

- engineering/understand/snapshot.py: one bulk read of a drawing into frozen
  WCS records (OCS translated through backends/ocs.py, text decoded, an
  INSERT boxed by its block geometry without its attribute texts). A DXF is
  read with ezdxf after the size check; the headless engine's document is
  read under its own lock; the live engine exports the active document with
  Document.Export(<tmp>/snap, "DXF", <set of all>) - never renamed, deleted
  afterwards - and a DWG is opened read-only, exported and closed. Measured on
  AutoCAD 2026: ".dxf" is refused with Invalid argument, the file lands as
  snap.DXF, model space, paper space and unused block definitions all reach
  it, and the selection set does not narrow a DXF export.
- engineering/understand/labels.py: %%c, \U+2205, the U+2300 sign, o-stroke
  and MTEXT format runs all read as one diameter spelling; sizes are kept as
  drawn and never equated; Cyrillic look-alike capitals fold into Latin tags;
  panels, wiring callouts and electrical data (kW, phases, volts, N, VFD) in
  EN / TR / RU / NL / DE. A power that is not written stays None.
- engineering/understand/vocab.py: layer classification with a confidence and
  the deciding keyword, supply / return words, room labels and block kinds in
  five languages. A CIP layer without a direction word is not guessed.
- MAX_DXF_BYTES defaults to 512 MB (a real layout was 188 MB); the refusal
  names the size and the variable. New `office` extra (openpyxl), also in
  `full`; uv.lock regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-24 11:38:59 +03:00
co-authored by Claude Opus 5.5
parent f0c50d772f
commit 9ab0ae1773
13 changed files with 2105 additions and 7 deletions
+30
View File
@@ -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
+238
View File
@@ -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
+326
View File
@@ -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",)
+234
View File
@@ -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