diff --git a/M02_MasterTemplete/M02_Hopyo_Bind.py b/M02_MasterTemplete/M02_Hopyo_Bind.py index abd3eeb74..9c8856c73 100644 --- a/M02_MasterTemplete/M02_Hopyo_Bind.py +++ b/M02_MasterTemplete/M02_Hopyo_Bind.py @@ -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"(? 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: diff --git a/M02_MasterTemplete/M02_Hopyo_Solve.py b/M02_MasterTemplete/M02_Hopyo_Solve.py index 6ee2a525e..33d5a5566 100644 --- a/M02_MasterTemplete/M02_Hopyo_Solve.py +++ b/M02_MasterTemplete/M02_Hopyo_Solve.py @@ -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 diff --git a/resources/tester/test_642_2_bind_solve.py b/resources/tester/test_642_2_bind_solve.py new file mode 100644 index 000000000..af46b81fa --- /dev/null +++ b/resources/tester/test_642_2_bind_solve.py @@ -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 없음