# -*- coding: utf-8 -*- """줄로 적은 식 → 변수 뽑기 · 검사 · 단위 맞춤 · 로직 틀로 옮기기 (`_화면_계약.md` 7장). 수량 = 길이 * 폭 자재비 = 수량 * 시멘트 · 줄마다 「이름 = 식」 · 빈 줄과 `#` 는 건너뜀 · 마지막 줄 왼쪽이 결과 변수 · 식 글자는 `_틀.md` 8장 범위만 — 읽기는 `master_formula` 가 함 · 표 찾기는 글자로 치지 않음 — 변수 하나로 두고 「무엇」 을 `표찾기` 로 정할 때 이음 · 값은 안 씀 — 검사만 하고 `옮기기` 가 로직 한 줄을 만듦(저장은 `Store_Make.logic_new`) """ from __future__ import annotations import re import master_formula as mf import master_keys as mk WHAT = ("마스터요소", "설계입력", "고정값", "표찾기") COST_ITEMS = mf.COST_ITEMS BUNDLE = mf.BUNDLE # 노무비 · 재료비 · 경비 · 계 — 덧줄에서만 쓰는 비목 합 MONEY = {"원": 1} # 같은 단위의 다른 표기 — 넓이·부피는 길이의 제곱으로 펴서 「m * m」 과 「㎡」 가 같아지게 ALIAS = { "㎡": ("m", 2), "m²": ("m", 2), "m2": ("m", 2), "㎥": ("m", 3), "m³": ("m", 3), "m3": ("m", 3), "M": ("m", 1), } SQUARED = {2: "㎡", 3: "㎥"} DIMLESS = ("%", "할") # 비율은 단위가 없음 — 「1 + 증가율 / 100」 이 안 걸리게 _ASSIGN = re.compile(r"^\s*([\w가-힣]+)\s*=(?!=)\s*(.*)$") # ── 자리를 적어 두는 읽기 ────────────────────────────────────────────── class _Pos(mf._Parser): """`master_formula` 와 같은 식 문법 · 단위가 어긋난 자리를 짚으려고 마디에 글자 자리를 붙임.""" def add(self): node = self.mul() while True: self.ws() at = self.i if self.take("+"): node = ("bin", "+", node, self.mul(), at) elif self.take("-"): node = ("bin", "-", node, self.mul(), at) else: return node def compare(self): node = self.add() for op in mf._OPS: self.ws() at = self.i if self.take(op): return ("bin", op, node, self.add(), at) return node def primary(self): self.ws() at = self.i node = super().primary() return (*node, at) if node[0] == "call" else node def _at(node): """마디에 붙여 둔 글자 자리 — 자리를 안 적은 마디는 None.""" want = {"bin": 4, "call": 3}.get(node[0]) return node[want] if want is not None and len(node) > want else None # ── 단위 셈 ─────────────────────────────────────────────────────────── def unit_of(text) -> dict[str, int] | None: """단위 글 → {밑 단위: 제곱} — 「원/㎥」 = {원: 1, ㎥: -1}. 빈 글은 무차원 · 안 정했으면 None. """ if text is None: return None out: dict[str, int] = {} sign = 1 for token in re.split(r"([*/])", str(text).replace("·", "*")): token = token.strip() if token in ("*", "/"): sign = 1 if token == "*" else -1 elif token and token not in DIMLESS: base, power = ALIAS.get(token, (token, 1)) out[base] = out.get(base, 0) + sign * power return {k: v for k, v in out.items() if v} def _one_text(base: str, power: int) -> str: if base == "m" and power in SQUARED: return SQUARED[power] return base if power == 1 else f"{base}{power}" def unit_text(unit) -> str | None: """{밑 단위: 제곱} → 단위 글 · 무차원은 빈 글.""" if unit is None: return None up = [_one_text(k, v) for k, v in unit.items() if v > 0] down = [_one_text(k, -v) for k, v in unit.items() if v < 0] if not up and not down: return "" return ("*".join(up) or "1") + ("/" + "/".join(down) if down else "") def _show(unit) -> str: return "모름" if unit is None else (unit_text(unit) or "없음") def _merge(a: dict, b: dict, sign: int) -> dict: out = dict(a) for k, v in b.items(): out[k] = out.get(k, 0) + sign * v return {k: v for k, v in out.items() if v} def _trouble(line: int, at, kind: str, say: str) -> dict: return {"줄": line, "자리": at, "갈래": kind, "말": say} def _same(units: list, line: int, at, where: str, out: list): """여럿이 같은 단위여야 하는 자리 — 다르면 문제 한 줄 · 모르는 것은 넘김.""" known = [u for u in units if u is not None] if known and any(u != known[0] for u in known): out.append( _trouble( line, at, "단위", f"「{where}」 의 단위가 서로 다름 — {' · '.join(_show(u) for u in known)}", ) ) return known[0] if known else None def _unit(node, units: dict, line: int, head: int, out: list): """식 나무의 단위 — 모르면 None · 어긋나면 `out` 에 그 줄과 자리를 적음.""" kind = node[0] if kind == "num": return {} if kind == "name": return units.get(node[1]) if kind == "neg": return _unit(node[1], units, line, head, out) if kind in ("str", "ref", "field", "find", "logic"): return None # 글자 · 요소 · 표 찾기 · 로직 — 단위는 마스터가 앎 if kind == "bin": op = node[1] a = _unit(node[2], units, line, head, out) b = _unit(node[3], units, line, head, out) at = None if _at(node) is None else head + _at(node) if op in ("*", "/"): return None if a is None or b is None else _merge(a, b, 1 if op == "*" else -1) if op in ("+", "-"): if a is None or b is None: return a if b is None else b if a != b: out.append( _trouble( line, at, "단위", f"「{op}」 양쪽 단위가 다름 — {_show(a)} {op} {_show(b)}" ) ) return a if op in mf._OPS and a and b: # 맨 수와 대보는 것은 늘 됨(「높이 <= 3」) _same([a, b], line, at, op, out) return {} if kind == "call": name, items = node[1], node[2] at = None if _at(node) is None else head + _at(node) kids = [_unit(x, units, line, head, out) for x in items] if name == "만약": return _same(kids[1:], line, at, "만약", out) if name in ("최소", "최대"): return _same(kids, line, at, name, out) return kids[0] # 올림 · 버림 · 반올림 — 자리 인자는 무차원 return None # ── 식 되쓰기 ───────────────────────────────────────────────────────── def _text(node) -> str: """나무 → 식 글자(다시 읽으면 같은 나무) — 사칙은 괄호로 묶음.""" kind = node[0] if kind == "num": return format(node[1].normalize(), "f") if kind == "str": return f"'{node[1]}'" if kind in ("name", "ref"): return node[1] if kind == "neg": return f"-{_text(node[1])}" if kind == "field": tail = node[2] if mf._NAME.fullmatch(node[2]) else f"'{node[2]}'" return f"{_text(node[1])}.{tail}" if kind == "bin": return f"({_text(node[2])} {node[1]} {_text(node[3])})" if kind == "call": return f"{node[1]}({', '.join(_text(x) for x in node[2])})" if kind in ("find", "logic"): args = "".join(f", {k}={_text(v)}" for k, v in node[2]) return f"{'찾기' if kind == 'find' else '로직'}({node[1]}{args})" raise mf.FormulaError(f"모르는 마디 {kind}") def _expr(node) -> str: """식 한 벌 — 맨 바깥 괄호는 뺌(사람이 읽는 자리에 그대로 들어감).""" text = _text(node) return text[1:-1] if node[0] == "bin" else text def find_text(pick: dict) -> str: """표 찾기 자리 → `찾기(표, 조건=값).값칸` — 모달이 채운 묶음을 글자로.""" conds = "".join(f", {k}={v}" for k, v in (pick.get("조건") or {}).items()) col = str(pick.get("값칸") or "") tail = col if mf._NAME.fullmatch(col) else f"'{col}'" return f"찾기({pick.get('표')}{conds}).{tail}" # ── 읽기 · 검사 ─────────────────────────────────────────────────────── def _cut(text: str): """(줄 번호, 왼쪽 이름, 식, 식이 시작하는 자리) — 빈 줄과 `#` 는 건너뜀.""" for at, raw in enumerate(str(text).split("\n"), 1): if not raw.strip() or raw.lstrip().startswith("#"): continue m = _ASSIGN.match(raw) yield ( at, (m.group(1) if m else ""), (m.group(2).strip() if m else raw.strip()), (m.start(2) if m else len(raw) - len(raw.lstrip())), ) def _order(defined: dict, deps: dict) -> list[str]: """중간 차례 — 먼저 쓰는 값이 앞에 오게(돌고 도는 것은 검사가 이미 잡음).""" out, seen = [], set() def visit(name: str, path: tuple): if name in seen or name in path or name not in defined: return for nxt in deps.get(name, ()): visit(nxt, path + (name,)) seen.add(name) out.append(name) for name in sorted(defined, key=lambda n: defined[n]): visit(name, ()) return out def _scan(text: str, 정함: dict | None = None) -> dict: """줄 가르기 · 변수 · 검사 · 단위 — `read` 와 `옮기기` 가 같이 씀(나무를 들고 있음).""" 정함 = 정함 or {} lines, 문제 = [], [] for at, name, body, head in _cut(text): one = {"줄": at, "이름": name, "식": body, "앞": head} if not name: 문제.append(_trouble(at, head, "못읽음", "「이름 = 식」 이 아님")) elif not body: 문제.append(_trouble(at, head, "못읽음", f"「{name}」 오른쪽이 빔")) else: try: one["나무"] = _Pos(body).parse() except mf.FormulaError as e: 문제.append(_trouble(at, head, "못읽음", str(e))) lines.append(one) 정의: dict[str, int] = {} for one in lines: if "나무" not in one: continue if one["이름"] in BUNDLE: 문제.append( _trouble( one["줄"], 0, "이름", f"「{one['이름']}」 은 비목 합 이름 — 다른 이름을 쓸 것" ) ) continue if one["이름"] in 정의: 문제.append( _trouble( one["줄"], 0, "두번정의", f"「{one['이름']}」 을 {정의[one['이름']]}줄에서 이미 정함", ) ) continue 정의[one["이름"]] = one["줄"] 쓰임: dict[str, list[int]] = {} deps: dict[str, set[str]] = {} for one in lines: if "나무" not in one: continue used = {p[1] for p in mf.walk(one["나무"]) if p[0] == "name"} if 정의.get(one["이름"]) == one["줄"]: # 두 번 정의한 뒷줄은 딸림에 안 넣음 deps[one["이름"]] = used for name in sorted(used): 쓰임.setdefault(name, []).append(one["줄"]) 돎 = mf.find_loops({k: v & set(정의) for k, v in deps.items()}) for loop in 돎: 문제.append( _trouble(정의.get(loop[0], 0), 0, "돌고돎", "서로 물고 돎 — " + " → ".join(loop)) ) 성함 = [one for one in lines if "나무" in one] 결과 = 성함[-1]["이름"] if 성함 else "" if not 성함: 문제.append(_trouble(0, 0, "빔", "읽을 식이 없음")) for name, at in sorted(정의.items(), key=lambda kv: kv[1]): if name != 결과 and not [x for x in 쓰임.get(name, []) if x != at]: 문제.append( _trouble(at, 0, "안쓰임", f"「{name}」 을 어디서도 안 씀 — 결과는 「{결과}」 하나") ) units: dict[str, dict | None] = {k: MONEY for k in BUNDLE} for name in set(쓰임) | set(정의): if name in 정의 or name in BUNDLE: continue spec = 정함.get(name) or {} one = unit_of(spec.get("단위")) # 마스터요소 변수는 식에서 단가로 셈 — 정한 단위가 「포」 면 식에서는 「원/포」 units[name] = ( _merge(MONEY, one, -1) if one is not None and spec.get("무엇") == "마스터요소" else one ) loops = {n for loop in 돎 for n in loop} order = _order(정의, deps) by = {one["이름"]: one for one in 성함} for name in order: if name in loops: continue pinned = unit_of((정함.get(name) or {}).get("단위")) one = by[name] got = _unit(one["나무"], units, one["줄"], one["앞"], 문제) units[name] = pinned if pinned is not None else got 변수 = [] for name in sorted( set(쓰임) | set(정의), key=lambda n: (min([쓰임.get(n, [10**9])[0], 정의.get(n, 10**9)]), n) ): spec = 정함.get(name) or {} 갈래 = "비목합" if name in BUNDLE else ("정의됨" if name in 정의 else "미정") 변수.append( { "이름": name, "처음": min([*쓰임.get(name, []), 정의.get(name, 10**9)]), "갈래": 갈래, "정의줄": 정의.get(name), "쓰임": 쓰임.get(name, []), "무엇": spec.get("무엇") if 갈래 == "미정" else None, "단위": unit_text(units.get(name)), "비목": spec.get("비목"), "정할것": _todo(갈래, spec, units.get(name)), } ) return {"줄": lines, "변수": 변수, "결과": 결과, "문제": 문제, "차례": order, "정의": 정의} def _todo(갈래: str, spec: dict, unit) -> list[str]: """그 변수에 아직 안 정한 것 — 무엇 · 요소·찾기·값 · 단위 · 비목.""" if 갈래 != "미정": return [] what, need = spec.get("무엇"), [] if what not in WHAT: need.append("무엇") elif what == "마스터요소": if not spec.get("요소"): need.append("요소") if spec.get("비목") not in COST_ITEMS: need.append("비목") elif what == "표찾기" and not (spec.get("찾기") or {}).get("표"): need.append("찾기") elif what == "고정값" and spec.get("값") in (None, ""): need.append("값") if unit is None: need.append("단위") return need def read(text: str, 정함: dict | None = None) -> dict: """줄로 적은 식 → `{줄, 변수, 결과, 문제, 다됨}` — 화면이 그대로 받는 모양(나무는 뺌).""" got = _scan(text, 정함) 다됨 = not got["문제"] and not any(v["정할것"] for v in got["변수"]) return { "줄": [{k: v for k, v in one.items() if k != "나무"} for one in got["줄"]], "변수": got["변수"], "결과": got["결과"], "문제": got["문제"], "다됨": 다됨, } # ── 로직 틀로 옮기기 ─────────────────────────────────────────────────── def _terms(node) -> list: return _terms(node[2]) + _terms(node[3]) if node[0] == "bin" and node[1] == "+" else [node] def _factors(node) -> list: return _factors(node[2]) + _factors(node[3]) if node[0] == "bin" and node[1] == "*" else [node] def _spread(node, by: dict, 정함: dict, 편: set) -> list: """결과 항 — 항이 이름 하나이고 비목을 안 정했으면 그 식으로 펼침. 호표 줄을 항 안에서 찾으려는 것. """ out = [] for term in _terms(node): name = term[1] if term[0] == "name" else "" if name in by and name not in 편 and not (정함.get(name) or {}).get("비목"): 편.add(name) out += _spread(by[name]["나무"], by, 정함, 편) else: out.append(term) return out def _kind_of(요소) -> str: if isinstance(요소, dict): return "재료" return mk.GROUP_OF.get(mk.id_of(str(요소)), "") def _input_of(name: str, spec: dict, unit) -> dict: one = {"이름": name} for k in ("설명", "단위", "고르기", "범위", "출처"): if spec.get(k) not in (None, ""): one[k] = spec[k] one.setdefault("단위", unit_text(unit) or "") return {k: one[k] for k in ("이름", "설명", "단위", "고르기", "범위", "출처") if k in one} def 옮기기(text: str, 정함: dict | None = None, 머리: dict | None = None) -> tuple[dict, list]: """(로직 한 줄, 문제) — 다 정해졌으면 `_틀.md` 7장 모양 · 문제가 있으면 줄은 빈 묶음. 돈 로직(`결과단위` 가 「원」 으로 시작)은 결과 식을 `+` 로 갈라 항마다 — 마스터요소 변수 하나면 호표 줄(나머지 곱이 수량) · 비목만 정한 변수면 덧줄. 돈 아닌 로직은 호표 없이 `중간` + `결과` 식. """ 정함, 머리 = 정함 or {}, dict(머리 or {}) got = _scan(text, 정함) 문제 = list(got["문제"]) for v in got["변수"]: if v["정할것"]: 문제.append( _trouble( v["처음"], 0, "덜정함", f"「{v['이름']}」 에 {' · '.join(v['정할것'])} 안 정함" ) ) if 문제: return {}, 문제 by, 결과 = {one["이름"]: one for one in got["줄"] if "나무" in one}, got["결과"] 돈 = str(머리.get("결과단위") or "").startswith("원") 입력, 중간, 호표, 덧줄 = [], [], [], [] for v in got["변수"]: spec = 정함.get(v["이름"]) or {} if v["갈래"] != "미정": continue if spec["무엇"] == "설계입력": 입력.append(_input_of(v["이름"], spec, unit_of(v["단위"]))) elif spec["무엇"] == "고정값": 중간.append({"이름": v["이름"], "식": str(spec["값"])}) elif spec["무엇"] == "표찾기": 중간.append({"이름": v["이름"], "식": find_text(spec["찾기"])}) 덧줄이름, 편 = set(), set() if 돈: for term in _spread(by[결과]["나무"], by, 정함, 편): facts = _factors(term) picks = [ f for f in facts if f[0] == "name" and (정함.get(f[1]) or {}).get("무엇") == "마스터요소" ] if len(picks) > 1: 문제.append( _trouble( by[결과]["줄"], 0, "항", f"한 항에 마스터요소가 둘 — {' · '.join(p[1] for p in picks)}", ) ) elif picks: spec = 정함[picks[0][1]] rest = [f for f in facts if f is not picks[0]] 호표.append( { "종류": _kind_of(spec["요소"]), "요소": spec["요소"], "이름": spec.get("이름") or picks[0][1], "단위": spec.get("단위") or "", "수량": ( _expr(rest[0]) if len(rest) == 1 else " * ".join(_text(f) for f in rest) ) or "1", "비목": spec["비목"], } ) elif ( len(facts) == 1 and facts[0][0] == "name" and (정함.get(facts[0][1]) or {}).get("비목") ): name = facts[0][1] if name not in by: 문제.append(_trouble(by[결과]["줄"], 0, "항", f"덧줄 「{name}」 의 식이 없음")) continue 덧줄.append( {"이름": name, "식": _expr(by[name]["나무"]), "비목": 정함[name]["비목"]} ) 덧줄이름.add(name) else: 문제.append( _trouble( by[결과]["줄"], 0, "항", f"「{_text(term)}」 을 호표·덧줄로 못 옮김 — 마스터요소나 비목을 정할 것", ) ) 쓰임 = {v["이름"]: v["쓰임"] for v in got["변수"]} for name in got["차례"]: if name in 덧줄이름 or name == 결과: # 결과 줄은 호표·덧줄로 갈랐거나 「결과」 식이 됨 continue if name in 편 and 쓰임.get(name) == [by[결과]["줄"]]: continue # 결과 항으로 펼쳐 넣었고 다른 데서 안 씀 — 중간에 두지 않음 쓴비목 = {p[1] for p in mf.walk(by[name]["나무"]) if p[0] == "name"} & set(BUNDLE) if 쓴비목: 문제.append( _trouble( by[name]["줄"], 0, "비목합", f"「{' · '.join(sorted(쓴비목))}」 은 덧줄에서만 씀", ) ) 중간.append({"이름": name, "식": _expr(by[name]["나무"])}) if 문제: return {}, 문제 row = { "원문번호": 머리.get("원문번호", ""), "이름": 머리.get("이름", ""), "결과단위": 머리.get("결과단위", ""), "출처": 머리.get("출처", "자체"), "입력": 입력, "중간": 중간, } row.update({"호표": 호표, "덧줄": 덧줄} if 돈 else {"결과": {"식": _expr(by[결과]["나무"])}}) row["끝수"] = 머리.get("끝수") # 틀이 늘 두는 칸 — 안 정했으면 null for k in ("비고", "시험입력"): if 머리.get(k) not in (None, ""): row[k] = 머리[k] return row, [] if __name__ == "__main__": # 견본 한 벌 — `python scripts/master_draft.py` import json import sys sys.stdout.reconfigure(encoding="utf-8") print(json.dumps(read(sys.stdin.read()), ensure_ascii=False, indent=2, default=str))