- 자료 자리(sub3 차림 도구) 3,836 → 3,299 · 이름 맞춤 2,065 → 1,823(무차원 579 = 흠 아님 · 진짜 빈 자리 1,643 → 1,244) - 「목록에 없음」 1,905 = 화면 이름 표기(위첨자 「표」 숨김 · 금액 줄 이름 별칭)로 같은 변수가 두 번 잡힌 것 — 자료 결손 0 - master_doc_unit.fill: 값칸 「수」 표는 줄 단위 칸 = 값 단위(한 가지 = 그 단위 · 여럿 = 줄 따름) · 고른 줄 단위 없음 + 계수 이름 = 빈 단위 · 덧글 괄호 뗌 · 나눔 역산 꼴 둘(V ÷ 시공량 → 줄 단위 · 계수 ÷ V → 결과 분모 ÷ 줄 단위) · 표 줄 단위가 금액 줄과 다르면 금액 줄 · 값칸 빈칸 표 줄 단위(무엇당)는 안 읽음 - 표 줄 단위 채움(원문이 또렷한 것만): ㎝(sec) · ㎝(초) · 적용원문 sec · 표 이름 0.7㎥ 의 q · 원문 22″ 를 「“」 로 옮긴 줄(소요량 · 계수 산림 표 6 파일) - 수식 문서 저장본 = 빌더 다시 지음(변수 단위 칸만 351 칸 · 단위 밖 다름 0 · --check 1,520 같음 · 옵션 표본 18,873) - 시험 test_845_3_doc_units 신설(저장본 단위 · 규칙 꼴 · 안 하는 꼴) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
697 lines
30 KiB
Python
697 lines
30 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""수식 문서 변수 단위 채움 · 단위 셈 (PLAN 845-1 · 요구 880).
|
||
|
||
보이는 변수 `단위` 칸만 고침 — 셈 · 연결(연결 안 `단위` = 재료 환산) 안 건드림.
|
||
빌더(`build_수식문서_802_2.py`)가 변환 뒤 문서마다 `fill` 을 부름 — 빈 단위만 채우고 적힌 단위는
|
||
그대로(자리표시 「줄 따름」 류 · 「수」 만 고침). 채움 근거는 원본 · 셈만(지어내지 않음):
|
||
- 표 칸 = 표 `값칸` 단위 · 「줄 따름」 류 = 고를 수 있는 행이 모두 같은 `단위` 칸일 때 그 단위.
|
||
- 요소 = 기계 칸 단위(`기계/*.json` 단위) · 노임 · 재료 · 유가 = 「원/요소 단위」.
|
||
- 단가산출 = 부른 문서 결과단위 · 수 고르기 = 이름 끝에 적힌 단위(「두께mm이하」).
|
||
- 식 = 식 속 변수 단위로 셈(`infer`) — 2 배 밖 수(× 8 · ÷ 15)가 끼면 단위를 품을 수 있어 안 채움.
|
||
- 금액 줄 역산 = 줄 단위가 단가 단위와 맞을 때 수량을 더하기로만 이은 변수 = 줄 단위.
|
||
무차원(계수 · 비 · 글 고르기)은 빈 단위가 맞음 — `dimensionless` 가 가름.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from fractions import Fraction
|
||
|
||
import master_doc as md
|
||
import master_unit_text as ut
|
||
|
||
# 행마다 단위가 다른 표 칸 — 행의 `단위` 칸이 참값
|
||
ROW_UNIT = (
|
||
"줄 따름",
|
||
"구분 따름",
|
||
"단위 따름",
|
||
"단위 칸 따름",
|
||
"줄 단위",
|
||
"단위 칸",
|
||
"수(단위는 줄의 단위 칸 따름)",
|
||
)
|
||
PLAIN_NUMBER = (
|
||
"수",
|
||
"배",
|
||
"수(또는 원문 비율표기)",
|
||
"수 또는 글자",
|
||
"수 또는 글",
|
||
) # 원본 「수」 칸 = 무차원
|
||
NOT_UNIT = (
|
||
"수",
|
||
"수(또는 원문 비율표기)",
|
||
"수 또는 글자",
|
||
"수 또는 글",
|
||
) # 단위 칸에 적힌 「수」 = 단위 아님 → 빈 단위
|
||
ROWS_ONE, ROWS_MANY = "고를 수 있는 행 단위 한 가지", "행마다 단위 다름"
|
||
ROWS_NONE = "고른 행에 단위 칸 없음(계수)"
|
||
ROWS_GAP = "고른 행에 단위 칸 없음"
|
||
ROWS_NUM = "값칸 「수」 · 고를 수 있는 행 단위 한 가지" # 845-3 새 길 — 금액 줄과 어긋나면 줄 단위(`_direct`)
|
||
ROW = "줄 따름" # 행마다 다른 단위 한 표기(화면이 안 그림 · `common_util_line_stack_pick`)
|
||
TEXT = ("글", "글자")
|
||
# 비교 · 셈 꼴 — 같은 단위의 다른 표기
|
||
ALIAS = {
|
||
"hr": "시간", "h": "시간", "HR": "시간", "Hr": "시간", "min": "분", "sec": "초",
|
||
"L": "ℓ", "l": "ℓ", "kg": "㎏", "KG": "㎏", "m3": "㎥", "M3": "㎥", "m2": "㎡", "M2": "㎡",
|
||
"km": "㎞", "mm": "㎜", "cm": "㎝", "t": "ton", "TON": "ton", "Ton": "ton", "톤": "ton",
|
||
"EA": "개", "ea": "개", "kWh": "㎾h", "kW": "㎾", "SET": "조", "set": "조", "세트": "조",
|
||
"명": "인",
|
||
} # fmt: skip
|
||
SCALE = {"천원": (Fraction(1000), "원"), "만㎡": (Fraction(10000), "㎡")}
|
||
# 기계 문서 칸 → 기계 자료 단위 칸(`master_machine_crew` 옛 칸 이름)
|
||
EQ_COLUMN = {"잡재료율": "잡품", "조종원": "인력"}
|
||
EQ_TEXT = ("조종원직종", "주연료종류", "주연료2종류")
|
||
CODE = re.compile(r"직종$|^기계$") # 고르는 값 = 노임 · 기계 번호(수 꼴 글)
|
||
NAMED = re.compile(
|
||
r"(mm|㎜|cm|㎝|ℓ)(?:이하|이상)?$"
|
||
) # 이름 끝에 단위를 적은 수 고르기(「두께mm이하」)
|
||
_NUM = re.compile(r"^(\d[\d,]*(?:\.\d+)?)(.+)$")
|
||
_POW = re.compile(r"^×?10\^(-?\d+)(.*)$")
|
||
|
||
|
||
def show(unit) -> str:
|
||
"""보이는 꼴 한 벌 — 원문 꼴(`master_unit_text.canon`) + ℓ · ㎾h(원문 8-4 · 13-1 꼴)."""
|
||
text = ut.canon(unit)
|
||
text = re.sub(r"(?<![A-Za-z])L(?![A-Za-z])", "ℓ", text)
|
||
return re.sub(r"(?<![A-Za-z])kWh(?![A-Za-z])", "㎾h", text)
|
||
|
||
|
||
# ── 단위 글 → (배, 차원) ─────────────────────────────────────────────
|
||
def _token(text: str):
|
||
"""낱말 하나 → (배, {단위: 지수}) · 못 읽음 None."""
|
||
t = text.strip()
|
||
if not t:
|
||
return Fraction(1), {}
|
||
m = _POW.match(t)
|
||
if m:
|
||
rest = _token(m.group(2))
|
||
return None if rest is None else (Fraction(10) ** int(m.group(1)) * rest[0], rest[1])
|
||
if t in SCALE:
|
||
n, u = SCALE[t]
|
||
return n, {u: 1}
|
||
m = _NUM.match(t)
|
||
if m and not m.group(2)[:1].isdigit():
|
||
rest = _token(m.group(2))
|
||
return None if rest is None else (Fraction(m.group(1).replace(",", "")) * rest[0], rest[1])
|
||
if t.startswith("%"):
|
||
return Fraction(1, 100), {}
|
||
if t in PLAIN_NUMBER:
|
||
return Fraction(1), {}
|
||
if re.search(r"\s", t):
|
||
return None
|
||
return Fraction(1), {ALIAS.get(t, t): 1}
|
||
|
||
|
||
def parse(unit):
|
||
"""단위 글 → (배, 차원) · 자리표시 · 여럿 나열(㎥·㎏·본) · 글 = None."""
|
||
text = str(unit or "").strip()
|
||
if not text:
|
||
return Fraction(1), {}
|
||
if text in ROW_UNIT or text in TEXT or "따름" in text:
|
||
return None
|
||
if (
|
||
text[:1] != "(" and "(" in text
|
||
): # 「m(블레이드의 길이 3.6m)」 · 「%(범위 내·인력품 대비)」 = 단위 + 덧글
|
||
text = re.sub(r"\([^()]*\)", "", text).strip() or text
|
||
head, *tails = text.split("/")
|
||
out_scale, out = Fraction(1), {}
|
||
parts = [(p, 1) for p in head.split("·")] + [
|
||
(p, -1) for tail in tails for p in re.split(r"[·,]", tail)
|
||
]
|
||
if len(head.split("·")) > 2:
|
||
return None # 「㎥·㎏·본」 = 줄마다 다른 단위 나열
|
||
for p, sign in parts:
|
||
got = _token(p)
|
||
if got is None:
|
||
return None
|
||
scale, dims = got
|
||
if not scale:
|
||
return None
|
||
out_scale *= scale**sign
|
||
for k, n in dims.items():
|
||
out[k] = out.get(k, 0) + n * sign
|
||
return out_scale, {k: n for k, n in out.items() if n}
|
||
|
||
|
||
def text_of(dims: dict) -> str:
|
||
"""차원 → 단위 글(보이는 꼴) · 노임 「원/일」 × 「인」 = 원(1 인 1 일)."""
|
||
if dims.get("원") and dims.get("인", 0) > 0 and dims.get("일", 0) < 0:
|
||
n = min(dims["인"], -dims["일"])
|
||
dims = {k: v for k, v in {**dims, "인": dims["인"] - n, "일": dims["일"] + n}.items() if v}
|
||
up = [k if n == 1 else f"{k}^{n}" for k, n in dims.items() if n > 0]
|
||
down = [k if n == -1 else f"{k}^{-n}" for k, n in dims.items() if n < 0]
|
||
text = "·".join(up) or ("1" if down else "")
|
||
return show(text + "".join("/" + d for d in down))
|
||
|
||
|
||
def same(a, b) -> bool:
|
||
pa, pb = parse(a), parse(b)
|
||
return pa is not None and pa == pb
|
||
|
||
|
||
# ── 식 단위 셈 ───────────────────────────────────────────────────────
|
||
class Unknown(Exception):
|
||
"""단위를 못 셈 — 까닭 글."""
|
||
|
||
|
||
_TEXTV = ("글",)
|
||
_ZERO = ("영",)
|
||
CAP = 64 # 배 갈래 수 윗끝(만약 갈래가 곱으로 불어남)
|
||
FACTOR = (Fraction(1, 2), Fraction(2)) # 식 속 수가 곱한 정도 — 이 안 = 보정 계수(무차원)
|
||
|
||
|
||
def _val(node, units: dict):
|
||
"""마디 → (배 모음, 차원) · 글 = _TEXTV · 수 0 = _ZERO(아무 단위와 맞음).
|
||
배 = 식 값 × 배 → 차원의 기준 값(천원 × 1000 · % ÷ 100 = 1) · 만약 갈래마다 배가 달라 모음."""
|
||
kind = node[0]
|
||
if kind == "num":
|
||
return _ZERO if node[1] == 0 else (frozenset([1 / Fraction(str(node[1]))]), {})
|
||
if kind == "str":
|
||
return _TEXTV
|
||
if kind == "var":
|
||
if node[1] not in units:
|
||
raise Unknown(f"「{node[1]}」 단위 모름")
|
||
got = units[node[1]]
|
||
if got is None:
|
||
raise Unknown(f"「{node[1]}」 단위가 행마다 다름")
|
||
return got if got == _TEXTV else (frozenset([got[0]]), got[1])
|
||
if kind == "pct":
|
||
inner = _val(node[1], units)
|
||
return inner if inner in (_ZERO, _TEXTV) else (_times(inner[0], [Fraction(100)]), inner[1])
|
||
if kind == "neg":
|
||
inner = _val(node[1], units)
|
||
return inner if inner in (_ZERO, _TEXTV) else (frozenset(-f for f in inner[0]), inner[1])
|
||
if kind == "bin":
|
||
op = node[1]
|
||
if op not in ("+", "-", "*", "/"): # 비교 · 그리고 · 또는 = 참거짓
|
||
return _TEXTV
|
||
a, b = _val(node[2], units), _val(node[3], units)
|
||
if op in ("*", "/"):
|
||
if _TEXTV in (a, b):
|
||
raise Unknown("글을 곱함")
|
||
if a == _ZERO or (b == _ZERO and op == "*"):
|
||
return _ZERO
|
||
if b == _ZERO:
|
||
raise Unknown("0 으로 나눔")
|
||
sign = 1 if op == "*" else -1
|
||
dims = dict(a[1])
|
||
for k, n in b[1].items():
|
||
dims[k] = dims.get(k, 0) + n * sign
|
||
return _times(a[0], [f**sign for f in b[0]]), {k: n for k, n in dims.items() if n}
|
||
return _same(a, b, "더하기 · 빼기")
|
||
if kind == "call":
|
||
if node[1] == "만약": # 조건은 단위와 무관
|
||
return _same(_val(node[2][1], units), _val(node[2][2], units), "만약 두 갈래")
|
||
args = [_val(x, units) for x in node[2]]
|
||
if node[1] in ("올림", "버림", "반올림"):
|
||
return args[0]
|
||
if node[1] in ("최소", "최대"):
|
||
out = args[0]
|
||
for x in args[1:]:
|
||
out = _same(out, x, node[1])
|
||
return out
|
||
raise Unknown(f"함수 「{node[1]}」")
|
||
if kind == "field":
|
||
base = node[1]
|
||
if base[0] == "var" and base[1] in units:
|
||
return _val(base, units)
|
||
raise Unknown("칸 읽기")
|
||
raise Unknown(f"마디 「{kind}」")
|
||
|
||
|
||
def _times(fs, gs) -> frozenset:
|
||
out = frozenset(f * g for f in fs for g in gs)
|
||
if len(out) > CAP:
|
||
raise Unknown("갈래가 너무 많음")
|
||
return out
|
||
|
||
|
||
def _same(a, b, where: str):
|
||
if a == _ZERO:
|
||
return b
|
||
if b == _ZERO:
|
||
return a
|
||
if a == _TEXTV and b == _TEXTV:
|
||
return _TEXTV
|
||
if _TEXTV in (a, b) or a[1] != b[1]:
|
||
raise Unknown(f"{where} 단위 다름")
|
||
if not a[1]: # 무차원끼리(1 - 8 ÷ 100 · 만약(…, 0.85, 1)) — 2 배 안 수는 보정 계수 하나로
|
||
a, b = (_small(a[0]), a[1]), (_small(b[0]), b[1])
|
||
return a[0] | b[0], a[1]
|
||
|
||
|
||
def _small(fs: frozenset) -> frozenset:
|
||
return frozenset([Fraction(1)]) if all(abs(1 / f) <= FACTOR[1] for f in fs) else fs
|
||
|
||
|
||
def infer(text, units: dict, named: dict | None = None) -> tuple[str | None, str]:
|
||
"""식 글 → (단위 글 | None, 까닭) — 「」 = 무차원 · 「글」 = 글 값 · None = 못 셈(까닭)."""
|
||
try:
|
||
got = _val(md.parse(text), units)
|
||
except (Unknown, md.DocError, ArithmeticError) as e:
|
||
return None, str(e)
|
||
if got == _TEXTV:
|
||
return "글", ""
|
||
if got == _ZERO:
|
||
return None, "값 0 뿐"
|
||
fs, dims = got
|
||
if any(abs(n) > 1 for n in dims.values()):
|
||
return None, "제곱 단위"
|
||
if fs == {1}:
|
||
return text_of(dims), ""
|
||
if len(fs) == 1:
|
||
hit = (named or {}).get((next(iter(fs)), tuple(sorted(dims.items()))))
|
||
if hit:
|
||
return hit, ""
|
||
if not dims and fs == {Fraction(1, 100)}:
|
||
return "%", ""
|
||
if not any(dims.values()) and all(f != 1 for f in fs):
|
||
return None, "변수 없이 수만 — 수가 단위를 품을 수 있음"
|
||
if all(FACTOR[0] <= abs(1 / f) <= FACTOR[1] for f in fs):
|
||
return text_of(dims), "" # 0.85 · 1.1 · 1.3 = 보정 계수
|
||
return None, "단위 없는 수가 단위를 품을 수 있음(× " + " · ".join(
|
||
sorted({str(1 / f) for f in fs})[:3]
|
||
) + ")"
|
||
|
||
|
||
# ── 원본 단위 ───────────────────────────────────────────────────────
|
||
def _rows_of(master, ref: str, choices: dict | None = None) -> list[dict]:
|
||
"""키 · 「ID:원문번호」 · 「ID:{빈칸}…」 → 맞을 수 있는 원본 줄들(빈칸 = 고를 수 있는 값만)."""
|
||
if "{" not in ref:
|
||
try:
|
||
return [master.get(ref)]
|
||
except Exception: # noqa: BLE001 — 없는 키 = 근거 없음
|
||
return []
|
||
tid, _, number = ref.partition(":")
|
||
|
||
def hole(m) -> str:
|
||
vals = (choices or {}).get(m.group(1))
|
||
return "(?:" + "|".join(re.escape(str(v)) for v in vals) + ")" if vals else ".+"
|
||
|
||
parts = re.split(r"(\{[^{}]+\})", number)
|
||
pat = "".join(md.HOLE.sub(hole, x) if x.startswith("{") else re.escape(x) for x in parts)
|
||
pat = re.compile("^" + pat + "$")
|
||
return [r for n, r in (master.numbers.get(tid) or {}).items() if pat.match(n)]
|
||
|
||
|
||
def choices_of(master, doc: dict) -> dict:
|
||
"""기호 → 고를 수 있는 값 — 선택 목록 · 기계 글 칸(주연료종류 …) = 고를 수 있는 기계의 그 칸."""
|
||
out = {
|
||
md.var_id(v): {str(x) for x in (v["연결"].get("목록") or [])}
|
||
for v in doc.get("변수") or []
|
||
if (v.get("연결") or {}).get("갈래") == "선택"
|
||
}
|
||
for v in doc.get("변수") or []:
|
||
link = v.get("연결") or {}
|
||
if link.get("갈래") == "요소" and link.get("칸") in EQ_TEXT:
|
||
rows = _rows_of(master, str(link.get("키") or ""), out)
|
||
vals = {str((r.get("값") or {}).get(link["칸"])) for r in rows}
|
||
if rows and "None" not in vals:
|
||
out[md.var_id(v)] = vals
|
||
return out
|
||
|
||
|
||
def _one(units) -> str | None:
|
||
"""모두 같은 단위 하나 — 빈 것 · 단위로 못 읽는 글(「1일 1대당」)이 끼면 None."""
|
||
got = {show(u) for u in units if u not in (None, "")}
|
||
if len(got) != 1 or any(u in (None, "") for u in units):
|
||
return None
|
||
one = got.pop()
|
||
bare = re.sub(
|
||
r"\s*\([^()]*\)$", "", one
|
||
).strip() # 「시간(h)」 · 「m/분(2단)」 = 단위 + 덧글(845-3)
|
||
one = bare if bare and parse(bare) == parse(one) else one
|
||
return one if parse(one) is not None else None
|
||
|
||
|
||
def element_unit(master, link: dict, choices: dict | None = None) -> tuple[str | None, str]:
|
||
"""요소 연결 → (보일 단위 | None, 근거) — 「글」 = 글 값."""
|
||
if link.get("재료") is not None:
|
||
unit = str(link.get("단위") or "")
|
||
return (f"원/{show(unit)}", "재료 고르기 단위") if unit else (None, "재료 단위 없음")
|
||
ref, col = str(link.get("키") or ""), link.get("칸")
|
||
rows = _rows_of(master, ref, choices)
|
||
if not rows:
|
||
return None, f"요소 「{ref}」 없음"
|
||
tid = ref[:2]
|
||
if tid == "EQ":
|
||
if col in EQ_TEXT:
|
||
return "글", f"기계 「{col}」 = 글"
|
||
if col is None:
|
||
return None, "기계 칸 없음"
|
||
units = [(r.get("단위") or {}).get(EQ_COLUMN.get(col, col)) for r in rows]
|
||
units = [u for u, r in zip(units, rows) if EQ_COLUMN.get(col, col) in (r.get("값") or {})]
|
||
got = _one(units)
|
||
return got, f"기계 단위 「{EQ_COLUMN.get(col, col)}」"
|
||
if col is not None:
|
||
return None, f"요소 칸 「{col}」"
|
||
if tid == "MT":
|
||
got = _one([r.get("단위") for r in rows])
|
||
return (f"원/{got}" if got else None), "자재 단위"
|
||
if tid in ("LB", "MO"):
|
||
return _one([r.get("단위") for r in rows]), "노임 · 유가 단위"
|
||
return None, f"요소 「{tid}」"
|
||
|
||
|
||
COEF = re.compile(r"^[kKfEe](\(|$|\s)|계수|효율|율") # 단위 없는 계수 줄 이름
|
||
|
||
|
||
def _coef(row: dict) -> bool:
|
||
return any(COEF.search(str(row.get(k) or "")) for k in ("구분", "항목", "이름"))
|
||
|
||
|
||
def _row_unit(master, link: dict, choices: dict) -> tuple[str | None, str]:
|
||
"""표 칸 단위 — 값칸 단위 · 자리표시면 고를 수 있는 행이 모두 같은 `단위` 칸."""
|
||
rows = _rows_of(master, str(link.get("표") or ""))
|
||
if len(rows) != 1:
|
||
return None, "표 하나로 안 정해짐"
|
||
table, col = rows[0], link.get("칸")
|
||
head = (table.get("값칸") or {}).get(col)
|
||
if head is None:
|
||
return None, f"칸 「{col}」 없음"
|
||
if head in TEXT:
|
||
return "글", "값칸 = 글"
|
||
picked = table.get("줄") or []
|
||
rows = any(
|
||
"단위" in r for r in picked
|
||
) # 줄마다 `단위` 칸 = 값 칸의 참 단위(845-3 — 값칸 「수」 도)
|
||
# 값칸 빈칸 표의 줄 `단위` = 「무엇당」(케이싱 m 당 인) 일 수 있어 안 읽음
|
||
if head not in ROW_UNIT and not (rows and (parse(head) is None or head in PLAIN_NUMBER)):
|
||
return show(head), "값칸 단위"
|
||
for k, cond in (link.get("조건") or {}).items():
|
||
cond = str(cond).strip()
|
||
if cond[:1] == "'" and cond[-1:] == "'":
|
||
picked = [r for r in picked if str(r.get(k)) == cond[1:-1]]
|
||
elif cond in choices:
|
||
picked = [r for r in picked if str(r.get(k)) in choices[cond]]
|
||
if picked and all(r.get("단위") in (None, "") for r in picked): # 845-3
|
||
if all(_coef(r) for r in picked):
|
||
return "", ROWS_NONE # 고른 행에 단위 칸 없음 + 계수 이름(k · f · E · 소모율)
|
||
return None, ROWS_GAP # 표 자료 빈칸 — 금액 줄 역산에 맡김
|
||
got = _one([r.get("단위") for r in picked]) if picked else None
|
||
return got, ((ROWS_NUM if head in PLAIN_NUMBER else ROWS_ONE) if got else ROWS_MANY)
|
||
|
||
|
||
def dimensionless(var: dict) -> bool:
|
||
"""빈 단위가 맞는 변수 — 글 고르기 · 글 값 · 원본이 「수」 로 적은 칸은 `fill` 이 「」 로 둠."""
|
||
link = var.get("연결") or {}
|
||
if CODE.search(str(var.get("이름") or "")):
|
||
return link.get("갈래") in ("선택", "입력")
|
||
if link.get("갈래") == "선택":
|
||
return not all(_is_num(x) for x in link.get("목록") or [])
|
||
return link.get("갈래") == "입력" and not _is_num(link.get("값"))
|
||
|
||
|
||
def _is_num(x) -> bool:
|
||
try:
|
||
float(str(x).replace(",", ""))
|
||
return True
|
||
except ValueError:
|
||
return False
|
||
|
||
|
||
def source_unit(master, doc: dict, var: dict) -> tuple[str | None, str]:
|
||
"""변수 하나의 원본 단위 — (단위 | 「글」 | 「」 무차원 | None 모름, 근거)."""
|
||
link = var.get("연결") or {}
|
||
kind = link.get("갈래")
|
||
choices = choices_of(master, doc)
|
||
if kind == "표":
|
||
return _row_unit(master, link, choices)
|
||
if kind == "요소":
|
||
return element_unit(master, link, choices)
|
||
if kind == "단가산출":
|
||
try:
|
||
unit = master.logic(str(link.get("키") or "")).get("결과단위") or ""
|
||
except Exception: # noqa: BLE001
|
||
return None, "부른 문서 없음"
|
||
return (show(unit), "부른 문서 결과단위") if unit else (None, "부른 문서 결과단위 없음")
|
||
if dimensionless(var):
|
||
return "", "글 고르기 · 글 값"
|
||
return None, ""
|
||
|
||
|
||
def decide(master, doc: dict, v: dict) -> tuple[str | None, str]:
|
||
"""변수 하나 — (바꿀 단위 | 「글」 | None 그대로, 근거). 식 변수는 `fill` 이 식 셈으로."""
|
||
now = str(v.get("단위") or "")
|
||
kind = (v.get("연결") or {}).get("갈래")
|
||
if kind in ("선택", "입력") and not now and not dimensionless(v):
|
||
hit = NAMED.search(str(v.get("이름") or ""))
|
||
return (show(hit.group(1)), "이름에 적힌 단위") if hit else (None, "")
|
||
if kind not in ("표", "요소", "단가산출"):
|
||
return None, ""
|
||
src, why = source_unit(master, doc, v)
|
||
if src == "글":
|
||
return ("글", why) if not now else (None, why)
|
||
if kind == "표" and why == ROWS_NONE and now in (*ROW_UNIT, *NOT_UNIT):
|
||
return "", why
|
||
if (
|
||
kind == "표" and why == ROWS_GAP
|
||
): # 표 빈칸 — 옛 꼴 그대로(자리표시 한 표기 · 「수」 = 빈 단위)
|
||
if now in ROW_UNIT and now != ROW:
|
||
return ROW, "행마다 다른 단위 표기 하나로"
|
||
return ("", "단위 칸 「수」 = 단위 아님") if now in NOT_UNIT else (None, why)
|
||
if kind == "표" and (now in NOT_UNIT or not now) and why in (ROWS_ONE, ROWS_NUM, ROWS_MANY):
|
||
if src: # 값칸 「수」 · 빈칸이어도 줄 `단위` 칸이 있으면 그것(845-3)
|
||
return (src if src != now else None), why
|
||
if why == ROWS_MANY:
|
||
return ROW, "행마다 다른 단위 표기 하나로(줄 `단위` 칸)"
|
||
if kind == "표" and now in NOT_UNIT:
|
||
return "", "단위 칸 「수」 = 단위 아님"
|
||
if not now or (kind == "표" and (now in ROW_UNIT or parse(now) is None)):
|
||
if src and src not in PLAIN_NUMBER and src != now and parse(src) is not None:
|
||
return src, why
|
||
if now and now != ROW and why == "행마다 단위 다름":
|
||
return ROW, "행마다 다른 단위 표기 하나로"
|
||
return None, why
|
||
if kind == "표" and src and show(now) != now and same(now, src):
|
||
return show(now), "표기 한 벌"
|
||
return None, why
|
||
|
||
|
||
def fill(doc: dict, master) -> list[tuple[str, str, str, str]]:
|
||
"""빈 단위(· 자리표시)를 원본 · 식 셈 · 금액 줄 역산으로 채움 — [(기호, 전, 후, 근거)]."""
|
||
changed, texts = [], set()
|
||
by_id = {md.var_id(v): v for v in doc.get("변수") or []}
|
||
rowed = set() # 이번에 표 줄 `단위` 칸으로 채운 변수(845-3) — 금액 줄과 어긋나면 줄 단위
|
||
for v in doc.get("변수") or []:
|
||
got, why = decide(master, doc, v)
|
||
if got == "글":
|
||
texts.add(md.var_id(v))
|
||
elif got is not None:
|
||
changed.append((md.var_id(v), str(v.get("단위") or ""), got, why))
|
||
v["단위"] = got
|
||
if why == ROWS_NUM: # 값칸 「수」 길(845-3)만
|
||
rowed.add(md.var_id(v))
|
||
steps = {line["기호"]: line["식"] for line in doc.get("식") or []}
|
||
for vid, (unit, why) in _direct(doc, by_id, known(doc, texts), rowed).items():
|
||
changed.append((vid, str(by_id[vid].get("단위") or ""), unit, why))
|
||
by_id[vid]["단위"] = unit
|
||
_forward(doc, by_id, steps, texts, changed)
|
||
for vid, (unit, why) in _back(doc, by_id, steps, known(doc, texts)).items():
|
||
changed.append((vid, "", unit, why))
|
||
by_id[vid]["단위"] = unit
|
||
_forward(doc, by_id, steps, texts, changed)
|
||
for vid, (unit, why) in _solve(doc, by_id, known(doc, texts)).items():
|
||
changed.append((vid, "", unit, why))
|
||
by_id[vid]["단위"] = unit
|
||
_forward(doc, by_id, steps, texts, changed)
|
||
return changed
|
||
|
||
|
||
def _forward(doc, by_id, steps, texts, changed) -> None:
|
||
for _ in range(len(steps) + 1): # 식이 뒤 식을 부름 — 안 바뀔 때까지
|
||
units, more = known(doc, texts), False
|
||
for sid, text in steps.items():
|
||
v = by_id.get(sid)
|
||
if v is None or v.get("단위") or sid in texts:
|
||
continue
|
||
got, _ = infer(text, units, _texts(doc))
|
||
if got == "글":
|
||
texts.add(sid)
|
||
more = True
|
||
elif got:
|
||
changed.append((sid, "", got, f"식 셈 — {text}"))
|
||
v["단위"], more = got, True
|
||
if not more:
|
||
return
|
||
|
||
|
||
def line_ok(line: dict, units: dict) -> bool:
|
||
"""금액 줄 단위가 단가 단위(원/X)의 X 와 같음 — 노임 「원/일」 은 「인」(1 인 1 일)."""
|
||
price = units.get(str(line.get("단가") or "").strip())
|
||
mine = parse(line.get("단위"))
|
||
if price in (None, _TEXTV) or mine is None or price[1].get("원") != 1:
|
||
return False
|
||
per = {k: -n for k, n in price[1].items() if k != "원"}
|
||
return price[0] == 1 and (mine[1] == per or (per == {"일": 1} and mine[1] == {"인": 1}))
|
||
|
||
|
||
def _back(doc, by_id, steps, units) -> dict:
|
||
"""금액 줄 역산 — 단가와 맞는 줄 단위 U 의 수량을 더하기(· 보정 계수 곱 · 만약 갈래)로만
|
||
이은 표 · 식 변수 = U.
|
||
한 변수에 두 단위가 나오면 안 채움."""
|
||
got: dict[str, set] = {}
|
||
|
||
def walk(node, unit, line, depth=0):
|
||
kind = node[0]
|
||
if kind == "var":
|
||
v = by_id.get(node[1])
|
||
if v is None or v.get("단위"):
|
||
return
|
||
link = (v.get("연결") or {}).get("갈래")
|
||
if link == "표" or link == "식":
|
||
got.setdefault(node[1], set()).add((unit, line))
|
||
if link == "식" and node[1] in steps and depth < 8:
|
||
walk(md.parse(steps[node[1]]), unit, line, depth + 1)
|
||
elif kind == "bin" and node[1] in ("+", "-"):
|
||
walk(node[2], unit, line, depth)
|
||
walk(node[3], unit, line, depth)
|
||
elif kind == "bin" and node[1] in ("*", "/"):
|
||
for me, other in ((node[2], node[3]), (node[3], node[2])):
|
||
if (node[1] == "*" or other is node[3]) and _factor(other):
|
||
walk(me, unit, line, depth)
|
||
elif kind == "call" and node[1] == "만약":
|
||
for x in node[2][1:]:
|
||
walk(x, unit, line, depth)
|
||
elif kind == "neg":
|
||
walk(node[1], unit, line, depth)
|
||
|
||
for line in doc.get("금액") or []:
|
||
if line.get("수량") and str(line.get("단위") or "") != "식" and line_ok(line, units):
|
||
try:
|
||
walk(md.parse(line["수량"]), show(line["단위"]), str(line.get("이름") or ""))
|
||
except md.DocError:
|
||
continue
|
||
out = {}
|
||
for vid, hits in got.items():
|
||
kinds = {u for u, _ in hits}
|
||
if len(kinds) == 1:
|
||
unit = kinds.pop()
|
||
names = " · ".join(sorted({n for _, n in hits})[:3])
|
||
out[vid] = (
|
||
unit,
|
||
f"금액 줄 「{names}」 단위 {unit} 역산(단가 단위와 맞음 · 더하기로 이음)",
|
||
)
|
||
return out
|
||
|
||
|
||
def _direct(doc, by_id, units, rowed=()) -> dict:
|
||
"""금액 줄 수량 = 변수 하나(표) · 단가와 맞는 줄 — 이번에 표 줄 `단위` 칸으로 채운 변수가 줄 단위와 다르면
|
||
줄 단위(표 줄 단위 = 자료 흠 · 근거 글에 남김 · 845-3)."""
|
||
got: dict[str, set] = {}
|
||
for line in doc.get("금액") or []:
|
||
vid = str(line.get("수량") or "").strip()
|
||
v = by_id.get(vid)
|
||
if v is None or str(line.get("단위") or "") == "식" or not line_ok(line, units):
|
||
continue
|
||
if (v.get("연결") or {}).get("갈래") in ("표", "식"):
|
||
got.setdefault(vid, set()).add(show(line["단위"]))
|
||
out = {}
|
||
for vid, kinds in got.items():
|
||
have = str(by_id[vid].get("단위") or "")
|
||
if len(kinds) != 1 or have in ROW_UNIT:
|
||
continue
|
||
want = kinds.pop()
|
||
if vid in rowed and parse(have) is not None and not same(have, want):
|
||
out[vid] = (
|
||
want,
|
||
f"금액 줄 수량 그대로 — 줄 단위 {want}(표 줄 단위 「{have}」 와 다름)",
|
||
)
|
||
return out
|
||
|
||
|
||
_V = "□" # 풀 변수 자리(차원 이름)
|
||
|
||
|
||
def _solve(doc, by_id, units) -> dict:
|
||
"""금액 줄 나눔 역산(845-3) — 수량에 단위 모르는 표 · 식 변수가 하나 · 나머지 단위 앎 · 수가 단위를 안 품음:
|
||
수량 참 단위 = 줄 단위 ÷ 결과 분모(원/X — 품 값은 X 하나당)로 그 변수를 풂 · 꼴 둘만(지어내지 않음)
|
||
㉮ 「V ÷ 시공량[X]」(× 보정 계수) → V = 줄 단위(「Q_특별인부^표 ÷ Q_시공」 → 인)
|
||
㉯ 「계수 ÷ V」(r ÷ 100 · 2 배 안 수) → V = X / 줄 단위(「r_기계 ÷ 100 ÷ Q_작업」 → ㎥/시간).
|
||
한 변수에 두 단위가 나오면 안 채움."""
|
||
res = str(doc.get("결과단위") or "")
|
||
per = parse(res.split("/", 1)[1]) if res.startswith("원/") else None
|
||
if per is None or per[0] != 1 or not per[1]:
|
||
return {}
|
||
got: dict[str, set] = {}
|
||
for line in doc.get("금액") or []:
|
||
if not line.get("수량") or str(line.get("단위") or "") == "식" or not line_ok(line, units):
|
||
continue
|
||
try:
|
||
node = md.parse(line["수량"])
|
||
except md.DocError:
|
||
continue
|
||
loose = [
|
||
n
|
||
for n in md.names(node)
|
||
if n in by_id
|
||
and n not in units
|
||
and not by_id[n].get("단위")
|
||
and (by_id[n].get("연결") or {}).get("갈래") in ("표", "식")
|
||
]
|
||
if len(loose) != 1:
|
||
continue
|
||
try:
|
||
val = _val(node, {**units, loose[0]: (Fraction(1), {_V: 1})})
|
||
except (Unknown, ArithmeticError):
|
||
continue
|
||
if val in (_ZERO, _TEXTV) or not all(FACTOR[0] <= abs(1 / f) <= FACTOR[1] for f in val[0]):
|
||
continue
|
||
e, rest = val[1].get(_V), {k: n for k, n in val[1].items() if k != _V}
|
||
mine = parse(line.get("단위"))[1]
|
||
if e == 1 and rest == {k: -n for k, n in per[1].items()}:
|
||
dims = dict(mine) # ㉮
|
||
elif e == -1 and not rest:
|
||
dims = {**per[1]} # ㉯ X / 줄 단위
|
||
for k, n in mine.items():
|
||
dims[k] = dims.get(k, 0) - n
|
||
dims = {k: n for k, n in dims.items() if n}
|
||
else:
|
||
continue
|
||
if not dims or any(abs(n) > 1 for n in dims.values()):
|
||
continue
|
||
got.setdefault(loose[0], set()).add((text_of(dims), str(line.get("이름") or "")))
|
||
out = {}
|
||
for vid, hits in got.items():
|
||
kinds = {u for u, _ in hits}
|
||
if len(kinds) == 1:
|
||
names = " · ".join(sorted({n for _, n in hits})[:3])
|
||
out[vid] = (kinds.pop(), f"금액 줄 「{names}」 나눔 역산(줄 단위 ÷ 결과 단위 {res})")
|
||
return out
|
||
|
||
|
||
def _factor(node) -> bool:
|
||
"""변수 없는 수 · 만약(…, 수, 수) — 2 배 안 보정 계수."""
|
||
try:
|
||
got = _val(node, {})
|
||
except (Unknown, ArithmeticError):
|
||
return False
|
||
if got in (_ZERO, _TEXTV) or got[1]:
|
||
return False
|
||
return all(FACTOR[0] <= abs(1 / f) <= FACTOR[1] for f in got[0])
|
||
|
||
|
||
def _texts(doc: dict) -> dict:
|
||
"""(배, 차원) → 그 단위를 적은 변수 단위 글 — 식이 변수 하나를 옮길 때(「천원」 그대로)."""
|
||
out = {}
|
||
for v in doc.get("변수") or []:
|
||
got = parse(v.get("단위")) if v.get("단위") else None
|
||
if got is not None:
|
||
out.setdefault((got[0], tuple(sorted(got[1].items()))), show(v["단위"]))
|
||
return out
|
||
|
||
|
||
def known(doc: dict, texts=()) -> dict:
|
||
"""기호 → (배, 차원) | 글 | None(행마다 다름) — 식 셈 재료.
|
||
빈 단위 식 변수 · 수 입력 · 원본 칸이 빈 표는 모름(안 넣음)."""
|
||
out = {}
|
||
for v in doc.get("변수") or []:
|
||
vid, unit = md.var_id(v), str(v.get("단위") or "")
|
||
if vid in texts or (not unit and dimensionless(v)):
|
||
out[vid] = _TEXTV
|
||
elif unit:
|
||
out[vid] = parse(unit)
|
||
for k in md.COSTS:
|
||
out.setdefault(k, None)
|
||
return out
|