feat(785-2): Bind B2 · B3 · B4 785-1 꼴 맞춤 · Solve 풂 절 parts · 지킴 시험 test_642_2

- Bind: B2 선택값 줄 = 낱말 같음 고름(「사용횟수」 ≠ 「사용」) · B3 상수 이름 · 덧(90˚) · 단위 맞춤(sec = 초 · km → m) · 경사도 → 경사각도 · B4 식 분자 · 분모 {수|기호} + 대입
- Solve.parts: Formula.works 절 + Clause 후보 {키, 절} → 풂 절(절 표 없는 후보뿐이면 블록 통째)
- 새 test_642_2_bind_solve: 예시 1 · 2 · 3 + 단산46 · 39 입력값 · 출처 꼬리표 · 입력 필요 · 엇갈림 · 지어 넣은 값 0 · 품 같음 · defaults 규칙

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 22:23:07 +09:00
co-authored by Claude Opus 5.5
parent b0f22ba7b1
commit d516d09fd9
3 changed files with 254 additions and 4 deletions
+32 -4
View File
@@ -26,6 +26,7 @@ mf = m01.cm.mf
mp = mf.mp
mm = mp.mm
import master_auto as ma # noqa: E402 — mf 를 불러 scripts 경로가 깔린 뒤
import master_unit as mu # noqa: E402
SKIP = ("지역", "자재지역", "조종원직종")
NEG = ("아님", "해당 없음", "없음", "미적용", "사용 안 함")
@@ -37,7 +38,10 @@ SYMBOLS = {
"L": ("운반거리", "거리"),
"n": ("운반회수",),
"T": ("실작업시간",),
"경사도": ("경사각도",),
}
_WORDS = re.compile(r"[\s::,/()※*·\[\]{}]+")
_SAME_UNIT = {"sec": "초", "s": "초", "min": "분", "hr": "시간", "h": "시간"}
_ANGLE = re.compile(r"\(\s*(\d+)\s*[˚°]\s*\)")
_MIX = re.compile(r"(?<!\d)(\d{2})-(\d{2,3})-(\d{2,3})(?!\d)")
MIX_NAMES = ("굵은골재최대치수", "강도", "슬럼프")
@@ -268,8 +272,12 @@ def _b6(got: _Got, line: dict | None, same: list[dict]) -> None:
def _b2(got: _Got, picks: list[dict], text: str, keys: dict) -> None:
"""선택값 줄 · 글에 없는 켜기 칸 = 아님(표 줄을 고르는 찾기 조건 칸은 B5 몫 · 뺌)."""
for one in picks or []:
_b1(got, {"이름": "", "규격": one.get("글"), "행": one.get("행")}, "B2")
for one in picks or []: # 고름 글 = 줄의 낱말 하나와 같음(「사용횟수」 ≠ 「사용」)
words = {mm.plain_spec(w) for w in _WORDS.split(str(one.get("글") or "")) if w}
for name, spec in got.specs.items():
hits = [o for o in spec.get("고르기") or [] if mm.plain_spec(str(o)) in words]
if len(hits) == 1:
got.put(name, hits[0], "B2", one.get("행"), one.get("글"))
flat = mm.plain_spec(text)
for name, spec in got.specs.items():
opts = spec.get("고르기") or []
@@ -281,15 +289,35 @@ def _b2(got: _Got, picks: list[dict], text: str, keys: dict) -> None:
got.put(name, neg, "B2", None, f"글에 「{name}」 없음")
def _unit_ok(have: Any, want: Any) -> bool:
"""상수 단위 ↔ 입력 단위 — 한쪽이 없음 · 같음(「회/일」 = 회) · 같은 성격(길이 등)."""
a, b = (mm.plain_unit(str(u or "").split("/")[0]) for u in (have, want))
a, b = _SAME_UNIT.get(a, a), _SAME_UNIT.get(b, b)
return not a or not b or a == b or mu.same_kind(a, b)
def _b3(got: _Got, consts: list[dict], master: mf.Master, uses: dict) -> None:
for c in consts or []:
sym, value, at, text = str(c.get("기호") or ""), c.get("값"), c.get("행"), c.get("글") or ""
angle = _ANGLE.search(f"({c.get('덧') or ''}) {c.get('단위') or ''} {text}")
if angle:
got.put("선회각도", angle.group(1), "B3", at, text)
names = [n for n in (c.get("이름"), sym, *SYMBOLS.get(sym, ())) if n and n in got.specs]
names = [
n
for n in (c.get("이름"), sym, *SYMBOLS.get(sym, ()))
if n in got.specs and _unit_ok(c.get("단위"), got.specs[n].get("단위"))
]
if names:
got.put(names[0], value, "B3", at, f"{sym} = {plain(_num(value))}")
unit = got.specs[names[0]].get("단위")
rate = mu.factor(c.get("단위"), unit) if c.get("단위") and unit else None
value = _num(value) * rate if rate and _num(value) is not None else value
got.put(
names[0],
value,
"B3",
at,
f"{sym} = {plain(_num(c.get('값')))}{c.get('단위') or ''}",
)
continue
for name, refs in uses.items(): # 표 칸 이름 = 기호 → 그 줄 조건값(K = 0.55 → 버킷조건)
if name in got.value or name not in got.specs:
+28
View File
@@ -249,3 +249,31 @@ def block(
else:
verdict = BASE_PLUS if extra else BY_BASE
return {"번": blk.get("번"), "판정": verdict, "고를수": n, "절": secs, "덧": extra}
def _gather(nodes: list[dict], key: str) -> list:
return [x for one in nodes for x in [*one.get(key, []), *_gather(one.get("아래", []), key)]]
def parts(blk: dict, cands: list[Any]) -> list[dict]:
"""절 나무(785-1 `Formula.works`) + 후보(키 · `Clause` 후보 {키, 절}) → 풂 절 — 절 표 있는
후보가 있으면 일 절마다 그 절 후보 + 절 표 없는 후보 · 없으면 블록 통째 한 절 ·
단위 줄 · 상수 · 식 · 선택값(머리 것 더함) · 몫."""
from M02_MasterTemplete import M02_Hopyo_Formula as fo
nodes = blk.get("절") or fo.tree(blk.get("글") or [], blk.get("글행"), blk.get("항목") or [])
top = fo.head(blk.get("글") or [], blk.get("글행"))
cands = [c if isinstance(c, dict) else {"키": c} for c in cands]
items = blk.get("항목") or []
work = fo.works(nodes) if any(c.get("절") for c in cands) else []
if not work: # 블록 통째 한 일(절 표 있는 후보가 없으면 한 기초가 블록 전부)
whole = {"제목": blk.get("이름", ""), "후보": [c["키"] for c in cands], "항목": None}
return [whole | {k: top[k] + _gather(nodes, k) for k in ("상수", "식", "선택값")}]
out = []
for one in work:
mine = [c["키"] for c in cands if c.get("절") in (None, one["번"])]
at = [i for i in _gather([one], "항목") if not items[i].get("밖")]
part = {"제목": one["제목"], "번": one["번"], "후보": mine, "항목": [items[i] for i in at]}
part |= {k: top[k] + _gather([one], k) for k in ("상수", "식", "선택값")}
out.append(part | {"몫": one.get("몫")})
return out
+194
View File
@@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
"""642-2(PLAN 785-2) 옵션값 읽기 · 검산 · 판정 — 북부청 임도 예시 1 · 2 · 3 + 단산46 · 39 기대표
(설계 642-A 2-10 · 5-3) · 출처 꼬리표 · 지어 넣은 값 0 · 입력 필요 · 품 같음 · master_auto.defaults.
읽기만(엑셀 · M01) · storage 안 씀."""
from decimal import Decimal
from pathlib import Path
import pytest
from M02_MasterTemplete import M02_Hopyo_Bind as hb
from M02_MasterTemplete import M02_Hopyo_Excel as hx
from M02_MasterTemplete import M02_Hopyo_Solve as hs
NORTH = (
Path(__file__).resolve().parents[1]
/ "knowledge/original/실무문서/2026_실무일위대가 모음"
/ "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls"
)
PATHS = {"B1", "B2", "B3", "B4", "B5", "B6"}
# 후보 = 785-1 Clause 몫(조항 색인) — 여기선 설계 2-10 후보 열 그대로
CANDS = {
10: ["GC001140", "GC001141", "GC001142"],
24: ["GC000211"],
46: ["GC000198"],
47: [{"키": "GC000206", "절": "1"}, {"키": "GC000207", "절": "2"}],
39: ["GF000224", "GF000225"],
}
@pytest.fixture(scope="module")
def master():
return hb.m01.cm.master()
@pytest.fixture(scope="module")
def book():
return hx.read_book(NORTH.name, NORTH.read_bytes())
@pytest.fixture(scope="module")
def solved(master, book):
blocks = {b["번"]: b for b in book["단산"]}
out = {}
for no, cands in CANDS.items():
hopyo = next(
h for h in book["호표"] if any((x.get("참조") or {}).get("번") == no for x in h["줄"])
)
line = next(x for x in hopyo["줄"] if (x.get("참조") or {}).get("번") == no)
same = [x for x in hopyo["줄"] if x is not line]
blk = blocks[no]
out[no] = hs.block(master, blk, hs.parts(blk, cands), {"줄": line, "같은줄": same})
return out
def _cand(solved, no, key, sec=0):
return next(c for c in solved[no]["절"][sec]["후보"] if c["키"] == key)
def _where(cand):
return {k: v["길"] for k, v in cand["바인딩"]["출처"].items()}
def test_예시1_단산10_소운반(solved):
got = solved[10]
assert got["판정"] == "기초로 풂" and got["고를수"] == 0
assert got["절"][0]["고름"] == "GC001140"
c = _cand(solved, 10, "GC001140")
b = c["바인딩"]
assert b["입력값"] == {
"운반무게": 575,
"회당운반량": 25,
"운반거리": 6,
"경사각도": 35,
"운반회수": 42,
}
assert _where(c) == {
"운반무게": "B4",
"회당운반량": "B4",
"운반거리": "B3",
"경사각도": "B3",
"운반회수": "B3",
}
assert b["입력필요"] == [] and b["엇갈림"] == {}
assert set(b["안씀"]) == {"재료종별", "재료형상", "재료갈래", "운반로", "실작업시간"}
(row,) = c["줄"]
assert row["판정"] == "품 같음"
assert Decimal(str(row["우리"]["품"])).quantize(Decimal("0.000001")) == Decimal("0.547619")
assert round(c["금액"]["계"], 1) == 94227.7
def test_예시2_단산47_절_둘(solved):
got = solved[47]
one, two = got["절"]
assert (one["고름"], two["고름"]) == ("GC000206", "GC000207")
a = _cand(solved, 47, "GC000206", 0)["바인딩"]
assert a["입력값"] == {"Type": "Type-Ⅰ"} and a["출처"]["Type"]["길"] == "B1"
c = _cand(solved, 47, "GC000207", 1)
b = c["바인딩"]
assert b["입력값"] == {"Type": "Type-Ⅰ", "호": "Ⅰ-1", "소량감률": 50}
assert _where(c) == {"Type": "B1", "호": "B5", "소량감률": "B5"}
assert [x["값"] for x in b["엇갈림"]["구분"]] == ["토목", "건축"]
assert b["기본"]["구분"] == "토목"
assert [r["판정"] for r in c["줄"]] == ["품 같음"] * 4
assert got["판정"] == "고를 것" and got["고를수"] == 1 # 구분 하나만 사용자
def test_예시3_단산24_합판거푸집(solved, master, book):
c = _cand(solved, 24, "GC000211")
b = c["바인딩"]
assert b["입력값"] == {
"사용횟수": "4회",
"유형": "보통",
"지붕슬래브": "아님",
"폼타이": "아님",
}
assert _where(c) == {"사용횟수": "B1", "유형": "B2", "지붕슬래브": "B2", "폼타이": "B2"}
assert b["입력필요"] == ["높이감률", "구멍땜"]
assert b["기본"]["높이감률"] is None and b["기본"]["구멍땜"] is None # 규칙에 맞는 칸 없음
assert set(b["안씀"]) == {"폼타이규격", "측압"} and b["비움"] == ["자재지역"]
assert solved[24]["판정"] == "고를 것" and solved[24]["고를수"] == 2
# 사용자가 0 넣음 → 노무 줄 품 같음 · 노무비 38,291 = 엑셀
given = {**b["입력값"], "높이감률": 0, "구멍땜": 0}
items = [x for x in next(b for b in book["단산"] if b["번"] == 24)["항목"] if not x.get("밖")]
rows = hs.pair(hb.quantities(master, "GC000211", given), items)
assert {r["판정"] for r in rows if r["엑셀"]} == {"품 같음"}
fill = {n: b["기본"][n] for n in b["안씀"]} | {"자재지역": None}
money = hb.mf.run(master, "GC000211", {**fill, **given})
assert round(money["노무비"]) == 38291
def test_단산46_레미콘타설(solved):
got = solved[46]
c = _cand(solved, 46, "GC000198")
b = c["바인딩"]
assert b["입력값"] == {"타설방법": "장비사용타설", "구조물": "철근구조물", "소량감률": 50}
assert _where(c) == {"타설방법": "B1", "구조물": "B1", "소량감률": "B5"}
assert b["입력필요"] == ["굴착기"] and b["비움"] == ["지역"] # 굴착기 규격 = B3 · 사용자
mach = next(r for r in c["줄"] if r["엑셀"] and r["엑셀"]["묶음"] == "X")
assert mach["판정"] == "품 같음" and mach["표지"] == "기계 갈래 엇갈림"
assert [d["이름"] for d in got["덧"]] == ["레미콘"] # 별도계상 = 덧 줄
def test_단산39_찰붙임(solved):
sec = solved[39]["절"][0]
assert sec["고름"] == "GF000225" and solved[39]["판정"] == "기초로 풂"
c = _cand(solved, 39, "GF000225")
assert c["바인딩"]["입력값"] == {
"뒷길이": 35,
"굵은골재최대치수": 25,
"강도": 21,
"슬럼프": "120",
"채움콘크리트량": 0.12,
"보정작업": "해당 없음",
}
assert _where(c)["뒷길이"] == "B1" and _where(c)["채움콘크리트량"] == "B6"
assert _cand(solved, 39, "GF000224")["판정"] == "품 다름"
def test_지어_넣은_값_없음(solved):
for got in solved.values():
for sec in got["절"]:
for c in sec["후보"]:
b = c["바인딩"]
assert set(b["입력값"]) == set(b["출처"]) # 출처 없는 값 0
assert {v["길"] for v in b["출처"].values()} <= PATHS
@pytest.mark.parametrize(
"excel, ours, want",
[
(0.67, 0.6667, True),
(1.18, 1.1765, True),
(3.53, 3.5294, True),
(0.1111111111, 0.11111, True),
(0.67, 0.69, False),
(0.547619, 0.5476190476, True),
(None, 1, False),
],
)
def test_품_같음(excel, ours, want):
assert hs.same(excel, ours) is want
def test_defaults_규칙(master):
ma = hb.ma
jige = ma.defaults(master, master.logic("GC001140"))
assert jige["실작업시간"] == 0 and jige["운반무게"] == 0 # 가드
assert jige["경사각도"] == 0 and jige["운반거리"] is None # 고르기 첫째 · 못 정함
pour = ma.defaults(master, master.logic("GC000198"))
assert pour["소량감률"] == 0 and pour["타설방법"] == "인력운반타설" # 0 을 품은 범위
form = ma.defaults(master, master.logic("GC000211"))
assert form["높이감률"] is None and form["구멍땜"] is None
assert 1 not in [v for v in jige.values() if not isinstance(v, str)] # 지어 넣는 1 없음