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
+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