"""The takeoff workbook: Metraj, Özet, Kontrol and Metodoloji, as XLSX and as CSV. The sheets follow the user's own takeoff workbook (spec §8): *Metraj* holds the rows, *Özet* the sums, *Kontrol* what needs a person's eye and *Metodoloji* how every number was reached - generated from the result, never typed. The sheet names stay as the user's workbook names them; the headers, the service names and the method text follow `lang` (tr / en / ru). CSV is always written, one file per sheet beside the workbook (``_metraj.csv`` ...), UTF-8 with a byte-order mark so a spreadsheet opens the Cyrillic and Turkish letters, a point as the decimal separator. XLSX needs openpyxl (the `office` extra); without it the CSV files are still written and the result carries the `xlsx_write` refusal instead of a path. Numbers are written rounded to 1 mm (three decimals of a metre). """ from __future__ import annotations import csv from pathlib import Path from backends.capability import UnsupportedCapabilityError from engineering.sheet.extract import xlsx_available __all__ = ["CSV_SLUGS", "LABELS", "LANGS", "SHEETS", "check_lang", "write_workbook"] LANGS = ("tr", "en", "ru") SHEETS = ("Metraj", "Özet", "Kontrol", "Metodoloji") CSV_SLUGS = {"Metraj": "metraj", "Özet": "ozet", "Kontrol": "kontrol", "Metodoloji": "metodoloji"} _INDEX = {lang: i for i, lang in enumerate(LANGS)} #: Every word the workbook prints, as (tr, en, ru). LABELS: dict[str, tuple[str, str, str]] = { # Metraj "room": ("Mahal", "Room", "Помещение"), "service": ("Hizmet", "Service", "Среда"), "diameter": ("Çap", "Diameter", "Диаметр"), "direct_m": ("Doğrudan (m)", "Direct (m)", "Прямо (м)"), "continuity_m": ("Süreklilik (m)", "Continuity (m)", "По непрерывности (м)"), "unassigned_m": ("Atanmamış (m)", "Unassigned (m)", "Не назначено (м)"), "net_m": ("Net (m)", "Net (m)", "Нетто (м)"), "allowance": ("Pay oranı", "Allowance", "Запас"), "allowance_m": ("Pay (m)", "Allowance (m)", "Запас (м)"), "total_m": ("Paylı toplam (m)", "Total incl. allowance (m)", "Итого с запасом (м)"), "schematic_m": ("Şematik (m)", "Schematic (m)", "По схеме (м)"), "status": ("Durum", "Status", "Статус"), "runs": ("Hatlar", "Runs", "Линии"), # Özet, Kontrol, Metodoloji "group": ("Grup", "Group", "Группа"), "key": ("Anahtar", "Key", "Ключ"), "run": ("Hat", "Run", "Линия"), "kind": ("Tür", "Kind", "Вид"), "tags": ("Etiketler", "Tags", "Теги"), "reason": ("Neden", "Reason", "Причина"), "topic": ("Konu", "Topic", "Раздел"), "value": ("Değer", "Value", "Значение"), "by_service": ("Hizmete göre", "By service", "По среде"), "by_diameter": ("Çapa göre", "By diameter", "По диаметру"), "by_room": ("Mahale göre", "By room", "По помещению"), "total": ("TOPLAM", "TOTAL", "ИТОГО"), "routed": ("Yönlendirilen", "Routed", "Трассировано"), "unroutable_total": ("Yönlendirilemeyen (C)", "Unroutable (C)", "Без трассы (C)"), "yes": ("evet", "yes", "да"), "no": ("hayır", "no", "нет"), # services "product": ("Ürün", "Product", "Продукт"), "cip_supply": ("CIP besleme", "CIP supply", "CIP подача"), "cip_return": ("CIP dönüş", "CIP return", "CIP возврат"), "glycol_supply": ("Glikol besleme", "Glycol supply", "Гликоль подача"), "glycol_return": ("Glikol dönüş", "Glycol return", "Гликоль возврат"), "steam": ("Buhar", "Steam", "Пар"), "condensate": ("Kondens", "Condensate", "Конденсат"), "cold_water": ("Soğuk su", "Cold water", "Холодная вода"), "hot_water": ("Sıcak su", "Hot water", "Горячая вода"), "ice_water": ("Buzlu su", "Ice water", "Ледяная вода"), "compressed_air": ("Basınçlı hava", "Compressed air", "Сжатый воздух"), "drain": ("Drenaj", "Drain", "Дренаж"), "electrical": ("Elektrik", "Electrical", "Электрика"), "unknown": ("Bilinmeyen", "Unknown", "Неизвестно"), # Kontrol kinds and reasons "unassigned_diameter": ("Çap atanmamış", "Diameter unassigned", "Диаметр не назначен"), "unroutable": ("Yönlendirilemez", "Unroutable", "Трассировка невозможна"), "end_not_on_equipment": ( "Bir uç ekipmanda değil", "An end is on no equipment", "Конец не на оборудовании", ), "single_tag": ("Tek etiket", "Only one tag", "Только один тег"), "tag_not_on_layout": ( "Etiket yerleşimde yok", "Tag not on the layout", "Тега нет на планировке", ), "tag_ambiguous": ( "Etiket yerleşimde birden çok kez yazılı", "Tag written more than once on the layout", "Тег повторяется на планировке", ), "no_diameter_label": ("Çap etiketi yok", "No diameter label", "Нет обозначения диаметра"), # Metodoloji topics "source_pid": ("Kaynak: P&ID", "Source: P&ID", "Источник: P&ID"), "source_layout": ("Kaynak: yerleşim", "Source: layout", "Источник: планировка"), "length_source": ("Uzunluk kaynağı", "Length source", "Источник длины"), "choice": ("Seçim gerekçesi", "Why this source", "Почему этот источник"), "scale_verdict": ("Ölçek kontrolü: sonuç", "Scale check: verdict", "Проверка масштаба: вывод"), "scale_factor": ( "Ölçek kontrolü: oran (P&ID mm / yerleşim mm)", "Scale check: factor (P&ID mm per layout mm)", "Проверка масштаба: коэффициент (мм P&ID на мм планировки)", ), "scale_iqr": ("Ölçek kontrolü: IQR", "Scale check: IQR", "Проверка масштаба: IQR"), "scale_within": ( "Ölçek kontrolü: ±%10 içindeki çiftler", "Scale check: pairs within ±10 %", "Проверка масштаба: пары в пределах ±10 %", ), "scale_pairs": ("Ölçek kontrolü: çift sayısı", "Scale check: pairs", "Проверка масштаба: пар"), "scale_matched": ( "Ölçek kontrolü: eşleşen etiketler", "Scale check: matched tags", "Проверка масштаба: совпавшие теги", ), "scale_ambiguous": ( "Ölçek kontrolü: belirsiz etiketler", "Scale check: ambiguous tags", "Проверка масштаба: неоднозначные теги", ), "scale_note": ("Ölçek kontrolü: not", "Scale check: note", "Проверка масштаба: примечание"), "scale_verified": ("Ölçek doğrulandı", "Scale verified", "Масштаб подтверждён"), "units_pid": ("Birim: P&ID", "Units: P&ID", "Единицы: P&ID"), "units_layout": ("Birim: yerleşim", "Units: layout", "Единицы: планировка"), "rule_route": ("Kural: güzergâh", "Rule: route", "Правило: трасса"), "rule_vertical": ( "Kural: bağlantı başına düşey pay (m)", "Rule: vertical allowance per connection (m)", "Правило: вертикальный запас на соединение (м)", ), "rule_allowance": ("Kural: pay oranı", "Rule: allowance", "Правило: запас"), "rule_diameter": ("Kural: çap", "Rule: diameter", "Правило: диаметр"), "rule_service": ("Kural: hizmet", "Rule: service", "Правило: среда"), "rule_status": ("Kural: durum", "Rule: status", "Правило: статус"), "rule_rooms": ("Kural: mahaller", "Rule: rooms", "Правило: помещения"), "rule_multi": ( "Kural: çok çaplı hat", "Rule: several diameters", "Правило: несколько диаметров", ), "tol": ("Tolerans (çizim birimi)", "Tolerance (drawing units)", "Допуск (ед. чертежа)"), "label_search": ( "Etiket arama mesafesi (çizim birimi)", "Label search distance (drawing units)", "Радиус поиска обозначений (ед. чертежа)", ), "overlap": ( "Çift çizilmiş, bir kez sayılan boru (m)", "Double-drawn pipe counted once (m)", "Дважды начерченная труба, учтена один раз (м)", ), "services": ("Kapsanan hizmetler", "Services taken off", "Учтённые среды"), "warning": ("Uyarı", "Warning", "Предупреждение"), } #: The method sheet's sentences, as (tr, en, ru). TEXTS: dict[str, tuple[str, str, str]] = { "requested": ("çağıran seçti", "chosen by the caller", "выбран вызывающим"), "forced": ( "ölçek kontrolü P&ID'yi şematik bulduğu halde istek üzerine (force=True) P&ID'den ölçüldü", "measured on the P&ID on request (force=True) although the scale check calls it schematic", "измерено по P&ID по запросу (force=True), хотя проверка масштаба считает его схемой", ), "no_layout": ( "yerleşim verilmedi: P&ID'den ölçüldü, ölçek doğrulanmadı", "no layout given: measured on the P&ID, scale not verified", "планировка не задана: измерено по P&ID, масштаб не подтверждён", ), "auto_to_scale": ( "ölçek kontrolü P&ID'yi ölçekli buldu: oran üzerinden P&ID'den ölçüldü", "the scale check calls the P&ID to scale: measured on it through the factor", "проверка масштаба считает P&ID масштабным: измерено по нему через коэффициент", ), "auto_layout": ( "ölçek kontrolü P&ID'yi ölçekli bulmadı: yerleşimden ölçüldü", "the scale check does not call the P&ID to scale: measured on the layout", "проверка масштаба не считает P&ID масштабным: измерено по планировке", ), "scale_note": ( "Etiketler ekipman merkezi değildir: ±%10 bant ve 2 m alt sınır bunu karşılar.", "Tag labels are not equipment centres: the ±10 % band and the 2 m floor absorb that.", "Теги — не центры оборудования: полоса ±10 % и порог 2 м это учитывают.", ), "rule_route": ( "Yerleşim uzunluğu = hattın bağladığı etiket konumlarının dik açılı (Manhattan) en küçük " "kapsayan ağacı; her bağlantı önce x, sonra y boyunca.", "Layout length = the rectilinear (Manhattan) minimum spanning tree of the tag positions " "the run joins; each connection along x first, then y.", "Длина по планировке = прямоугольное (манхэттенское) минимальное остовное дерево " "позиций тегов, которые соединяет линия; каждое соединение сначала по x, затем по y.", ), "rule_diameter": ( "Çap etiketleri çizildiği gibi alınır (Ø51, SMS51, DN20 farklı ölçülerdir). Doğrudan: " "arama mesafesindeki etiket; süreklilik: etiketli parçadan hat boyunca taşınır, " "redüksiyondan geçmez; atanmamış: ikisi de değil (Kontrol sayfasında).", "Diameter labels are kept as drawn (Ø51, SMS51 and DN20 are different sizes). Direct: a " "label within the search distance; continuity: carried along the run from a labelled " "piece, never across a reducer; unassigned: neither (listed on Kontrol).", "Обозначения диаметров сохраняются как на чертеже (Ø51, SMS51 и DN20 — разные " "размеры). Прямо: обозначение в радиусе поиска; по непрерывности: перенос вдоль линии " "от обозначенного участка, но не через переход; не назначено: ни то, ни другое (лист " "Kontrol).", ), "rule_service": ( "Hizmet katmandan alınır; hattın yakınındaki besleme / dönüş sözcüğü (ПОДАЧА, ВОЗВРАТ, " "BESLEME, DÖNÜŞ, SUPPLY, RETURN, AANVOER, RETOUR) tüm hat için onu geçersiz kılar.", "Service comes from the layer; a supply / return word near the run (ПОДАЧА, ВОЗВРАТ, " "BESLEME, DÖNÜŞ, SUPPLY, RETURN, AANVOER, RETOUR) overrides it for the whole run.", "Среда определяется по слою; слово подачи / возврата рядом с линией (ПОДАЧА, ВОЗВРАТ, " "BESLEME, DÖNÜŞ, SUPPLY, RETURN, AANVOER, RETOUR) переопределяет её для всей линии.", ), "rule_status": ( "A: her uç yerleşimde bulunan bir etikette ve her çap doğrudan. B: süreklilikle ya da " "atanmamış çap. C: yönlendirilemez - yalnız şematik uzunluk, Kontrol sayfasında, " "toplama katılır ve işaretlenir.", "A: every end on a tag placed on the layout and every diameter direct. B: a diameter by " "continuity or unassigned. C: not routable - schematic length only, on Kontrol, counted " "in the total and flagged.", "A: каждый конец на теге, найденном на планировке, и каждый диаметр прямо. B: диаметр " "по непрерывности или не назначен. C: трассировка невозможна — только длина по схеме, " "на листе Kontrol, входит в итог и помечена.", ), "rule_rooms": ( "Yönlendirilen uzunluk, güzergâhın geçtiği yerleşim mahallerine paylaştırılır (duvar " "yüzleri ve mahal etiketlerinden çokgenler); çokgen yoksa hatta en yakın mahal etiketine.", "A routed length is shared among the layout rooms its route crosses (room polygons from " "the wall faces and room labels); without polygons, the room label nearest the run.", "Трассированная длина распределяется по помещениям планировки, через которые проходит " "трасса (контуры из граней стен и обозначений помещений); без контуров — ближайшему " "обозначению помещения.", ), "rule_multi": ( "Birden çok çaplı bir hat, uzunluğunu P&ID üzerindeki çizili uzunlukları oranında " "paylaştırır.", "A run holding several diameters shares its length among them in proportion to their " "drawn length on the P&ID.", "Линия с несколькими диаметрами делит длину между ними пропорционально их длине на " "схеме P&ID.", ), } def check_lang(lang: str) -> str: """Refuse a language the workbook does not speak, naming the three it does.""" if lang not in LANGS: raise ValueError(f"lang: {lang!r} unknown; choose from {', '.join(LANGS)}") return lang def _t(key: str, lang: str) -> str: """A label in `lang`; a key with no label (a diameter, a room number) prints as itself.""" entry = LABELS.get(key) return entry[_INDEX[lang]] if entry else key def _say(key: str, lang: str) -> str: """One of the method sheet's sentences in `lang`.""" return TEXTS[key][_INDEX[lang]] def _num(value): return None if value is None else round(float(value), 3) def _yes(value, lang: str): return None if value is None else _t("yes" if value else "no", lang) def _pipe_sheets(result: dict, lang: str) -> dict[str, tuple[list, list]]: columns = ( "room", "service", "diameter", "direct_m", "continuity_m", "unassigned_m", "net_m", "allowance", "allowance_m", "total_m", "schematic_m", "status", "runs", ) rows = [ [ row["room"], _t(row["service"], lang), row["diameter"], _num(row["direct_m"]), _num(row["continuity_m"]), _num(row["unassigned_m"]), _num(row["net_m"]), row["allowance"], _num(row["allowance_m"]), _num(row["total_m"]), _num(row["schematic_m"]), row["status"], ", ".join(row["runs"]), ] for row in result["rows"] ] totals = result["totals"] a = totals["allowance"] rows.append( [ _t("routed", lang), None, None, _num(sum(r["direct_m"] for r in result["rows"])), _num(sum(r["continuity_m"] for r in result["rows"])), _num(sum(r["unassigned_m"] for r in result["rows"])), _num(totals["routed_m"]), a, _num(totals["routed_m"] * a), _num(totals["routed_m"] * (1.0 + a)), _num(sum(r["schematic_m"] for r in result["rows"])), None, None, ] ) rows.append( [ _t("unroutable_total", lang), None, None, None, None, None, _num(totals["unroutable_m"]), a, _num(totals["unroutable_m"] * a), _num(totals["unroutable_m"] * (1.0 + a)), _num(totals["unroutable_m"]), "C", str(totals["flagged_runs"]), ] ) rows.append( [ _t("total", lang), None, None, None, None, None, _num(totals["net_m"]), a, _num(totals["allowance_m"]), _num(totals["total_m"]), None, None, None, ] ) summary = [] for group, field in ( ("by_service", "service"), ("by_diameter", "diameter"), ("by_room", "room"), ): for item in result["summary"][group]: key = _t(item["key"], lang) if field == "service" else item["key"] summary.append([_t(group, lang), key, _num(item["net_m"]), _num(item["total_m"])]) summary.append([_t("total", lang), None, _num(totals["net_m"]), _num(totals["total_m"])]) control = [ [ c["run"], _t(c["kind"], lang), _t(c["service"], lang), ", ".join(c["tags"]), _num(c["schematic_m"]), _t(c["reason"], lang), ] for c in result["control"] ] return { "Metraj": ([_t(c, lang) for c in columns], rows), "Özet": ([_t(c, lang) for c in ("group", "key", "net_m", "total_m")], summary), "Kontrol": ( [_t(c, lang) for c in ("run", "kind", "service", "tags", "schematic_m", "reason")], control, ), "Metodoloji": ([_t("topic", lang), _t("value", lang)], _pipe_method(result, lang)), } def _unit_text(unit: dict | None) -> str: if not unit: return "—" return f"INSUNITS {unit['declared'] or '—'} → {unit['inferred']}" def _pipe_method(result: dict, lang: str) -> list[list]: method, scale = result["method"], result["scale"] reason = method["reason"] if reason.startswith("auto_") and reason != "auto_to_scale": reason = "auto_layout" rows = [ ["source_pid", method["sources"]["pid"]], ["source_layout", method["sources"]["layout"] or "—"], ["length_source", method["length_source"]], ["choice", _say(reason, lang)], ["scale_verdict", scale.get("verdict")], ["scale_factor", None if scale.get("factor") is None else round(float(scale["factor"]), 6)], ["scale_iqr", None if scale.get("iqr") is None else round(float(scale["iqr"]), 6)], [ "scale_within", None if scale.get("within_10pct") is None else round(float(scale["within_10pct"]), 4), ], ["scale_pairs", scale.get("pair_count")], ["scale_matched", len(scale.get("matched", {}))], ["scale_ambiguous", ", ".join(scale.get("ambiguous", [])) or "—"], ["scale_note", _say("scale_note", lang)], ["scale_verified", _yes(result["scale_verified"], lang)], ["units_pid", _unit_text(result["units"]["pid"])], ["units_layout", _unit_text(result["units"]["layout"])], ["services", ", ".join(_t(s, lang) for s in method["services"])], ["rule_route", _say("rule_route", lang)], ["rule_vertical", method["vertical_allowance"]], ["rule_allowance", method["allowance"]], ["rule_diameter", _say("rule_diameter", lang)], ["rule_service", _say("rule_service", lang)], ["rule_status", _say("rule_status", lang)], ["rule_rooms", _say("rule_rooms", lang)], ["rule_multi", _say("rule_multi", lang)], ["tol", method["tol"]], ["label_search", round(float(method["label_search"]), 6)], ["overlap", _num(method["overlap_length_m"])], ] rows += [["warning", warning] for warning in result["warnings"]] return [[_t(topic, lang), value] for topic, value in rows] _BUILDERS = {"pipe": _pipe_sheets} def write_workbook(result: dict, *, kind: str, lang: str, path: str) -> dict: """Write the takeoff: CSV always, XLSX when openpyxl is installed. ``{"xlsx": path | None, "csv": [paths], "refused": {...} | None}``. Refused before a file is written: a `kind` it builds no sheets for, a `lang` other than tr / en / ru, and a `path` with a suffix other than .xlsx. Without openpyxl the CSV files are written and `refused` carries the `xlsx_write` capability refusal naming the `office` extra. """ if kind not in _BUILDERS: raise ValueError(f"kind: {kind!r} unknown; choose from {', '.join(_BUILDERS)}") check_lang(lang) target = Path(path) if target.suffix.lower() not in ("", ".xlsx"): raise ValueError( f"path: the workbook is written as .xlsx with CSV files beside it; got {target.suffix!r}" ) sheets = _BUILDERS[kind](result, lang) xlsx = target.with_suffix(".xlsx") xlsx.parent.mkdir(parents=True, exist_ok=True) written = [] for name, (header, rows) in sheets.items(): csv_path = xlsx.with_name(f"{xlsx.stem}_{CSV_SLUGS[name]}.csv") with open(csv_path, "w", newline="", encoding="utf-8-sig") as handle: writer = csv.writer(handle) writer.writerow(header) writer.writerows([["" if cell is None else cell for cell in row] for row in rows]) written.append(str(csv_path)) if not xlsx_available(): refusal = UnsupportedCapabilityError( "xlsx_write", "The takeoff workbook needs openpyxl for XLSX, which is not installed. Install it " 'with: pip install -e ".[office]" (or pip install openpyxl). The same sheets were ' f"written as CSV: {', '.join(written)}", ) return {"xlsx": None, "csv": written, "refused": refusal.to_dict()} from openpyxl import Workbook book = Workbook() for i, (name, (header, rows)) in enumerate(sheets.items()): sheet = book.active if i == 0 else book.create_sheet() sheet.title = name sheet.append(header) for row in rows: sheet.append(row) book.save(xlsx) return {"xlsx": str(xlsx), "csv": written, "refused": None}