Files
Aislo/resources/master_data/scripts/master_doc_unit.py
T
eomsangdonandClaude Opus 5.5 b241702d23 fix(845-3): 단위 점검 마무리 — 채움 규칙이 안 닿던 길 · 표 줄 단위 · 나눔 역산(값 · 금액 불변)
- 자료 자리(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>
2026-11-16 01:04:59 +09:00

697 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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