Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_4

This commit is contained in:
2026-09-21 19:52:45 +09:00
5 changed files with 869 additions and 0 deletions
@@ -0,0 +1,574 @@
# -*- 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", 2),
"m2": ("m", 2),
"": ("m", 3),
"": ("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))