feat(845-1): 수식 문서 변수 단위 채움 — 원본 · 식 셈 · 금액 줄 역산 규칙 한 벌(변환 끝 · 금액 무관)

- 새 규칙 `master_doc_unit.fill` — 변환(`master_doc_convert.convert`) 끝에서 부름 · 빌더 · 실행 중 변환(SK 사본) 한 벌 · 보이는 변수 `단위` 칸만(연결 안 단위 · 셈 안 건드림)
- 근거: 표 값칸 단위 · 「줄 따름」 류 = 고를 수 있는 행 단위 한 가지 · 기계 칸 = 기계 표 단위(천원 · 10^-7/시간 · ℓ/hr · ㎾h/hr · % · 인/일) · 노임 원/일 · 재료 · 유가 원/단위 · 부른 문서 결과단위 · 식 셈(천원 × 1000 · % ÷ 100 · 2 배 안 보정 계수) · 금액 줄 역산(줄 단위 = 단가 단위일 때 더하기로만 이은 변수)
- 저장본 다시 지음: 1,520 문서 중 1,486 문서의 변수 단위 칸만 바뀜(채움 7,816 · 자리표시 「구분 따름」 · 「수」 · 나열 고침 982 · 표기 25) · 금액 대조 기본 1,520 같음 · 옵션 표본 18,873 중 다름 3 = 채택 1 원 셋 그대로
- 짐작은 안 채움(목록 tmp/845_1 · 시간환산 1/8 · 측량 직종 칸 · 식 속 수 상수 등)
- 지킴 시험 test_845_1: 다시 채워도 바뀔 단위 0 · 자리표시 0 · 금액 줄 단위 = 단가 단위 · 사용자 그림 문서 · 단위 셈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-15 22:32:39 +09:00
co-authored by Claude Opus 5.5
parent 0377e45d6c
commit 54d49c1a82
61 changed files with 9496 additions and 8824 deletions
@@ -0,0 +1,565 @@
# -*- 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 = (
"수",
"수(또는 원문 비율표기)",
"수 또는 글자",
"수 또는 글",
) # 단위 칸에 적힌 「수」 = 단위 아님 → 빈 단위
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()
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}」"
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 []
if head not in ROW_UNIT and not (parse(head) is None and any("단위" in r for r in picked)):
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]]
got = _one([r.get("단위") for r in picked]) if picked else None
return got, ("고를 수 있는 행 단위 한 가지" if got else "행마다 단위 다름")
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 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 []}
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
steps = {line["기호"]: line["식"] for line in doc.get("식") or []}
_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)
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 _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