diff --git a/M02_MasterTemplete/M02_San_Basis.py b/M02_MasterTemplete/M02_San_Basis.py index e7d513f5e..1020809b8 100644 --- a/M02_MasterTemplete/M02_San_Basis.py +++ b/M02_MasterTemplete/M02_San_Basis.py @@ -13,6 +13,9 @@ · 품 다름 · 품 대조 못 함 · 까닭 표지) · 사람(후보 2~3) · 없음(M01 없음 의심 → `보강` = 794-2 근거). 자동 바꿈 없음 · 지어 넣는 값 없음(못 읽은 칸 · 글에 없는 감률 · 할증 = 입력필요). +794-2c — 같은 말 묶음(SYN) · 옛 번호 → 현행 절(`old_cands` · `M02_San_Basis_Map.OLD`) 번호 후보 · +Q 식 절은 줄 글에서 기호 끝값을 더 읽고(`_fill`) 값 · 제목 길이 품 검산으로 안 받쳐지면 그 기계 +「작업량당 기계경비」 로직(`by_machine` · 차례 「기계」). """ from __future__ import annotations @@ -27,6 +30,7 @@ from M02_MasterTemplete import M02_Hopyo_Solve as solve from M02_MasterTemplete import M02_San_Basis_Check as chk from M02_MasterTemplete import M02_San_Basis_Const as bc from M02_MasterTemplete import M02_San_Basis_Index as bi +from M02_MasterTemplete import M02_San_Basis_Map as bm from M02_MasterTemplete import M02_San_Basis_Value as bv TITLE_MIN, TITLE_GAP, TITLE_TOP = 0.35, 0.15, 5 @@ -48,6 +52,9 @@ SYN = [ ("콤펙터", "콤팩터"), ("레미콘", "레디믹스트콘크리트"), ("컨테이너", "콘테이너"), + ("휠라", "채움재"), + ("평떼", "평잔디"), + ("육샹", "육상"), ] ORDER = ["확정·전부", "확정·일부", "확정·일부(절 미확정)", "후보 2~3", "없음"] NONE = {"확정": [], "동점": [], "약함": []} @@ -64,6 +71,7 @@ class Ctx: **self.m.index.get("GX", {}), } self.grams = {k: grams(r.get("이름")) for k, r in self.logic.items()} + self.mach = bm.machines(self.logic) _CTX: list = [None, None] @@ -77,11 +85,15 @@ def ctx() -> Ctx: # ── 제목 → 로직 이름 후보 ─────────────────────────────────────────── -def grams(text: Any) -> set: +def syn(text: Any) -> str: t = str(text or "") for a, b in SYN: t = re.sub(re.escape(a), b, t, flags=re.I) - return clause._grams(re.sub(r"\d", " ", t)) + return t + + +def grams(text: Any) -> set: + return clause._grams(re.sub(r"\d", " ", syn(text))) def title_cands(c: Ctx, text: str, unit: Any) -> list[tuple[str, float]]: @@ -123,9 +135,12 @@ def parts_of(blk: dict) -> tuple[list[dict], dict, list]: paths = {id(n): p for p, n in clause._paths(nodes)} items = blk.get("항목") or [] usable = solve._usable(items) + lines = [str(t) for t in blk.get("글") or []] whole = {k: top[k] + solve._gather(nodes, k) for k in ("상수", "식", "선택값")} + _fill(whole, lines) if len(works) < 2: - one = {"제목": "(블록 통째)", "번": None, "길": None, "항목": usable, **whole} + one = {"제목": "(블록 통째)", "번": None, "길": None, "항목": usable, "글줄": lines} + one |= whole one["몫"] = works[0].get("몫") if works else None return [one], whole, usable out = [] @@ -136,8 +151,10 @@ def parts_of(blk: dict) -> tuple[list[dict], dict, list]: "번": w["번"], "길": paths.get(id(w)), "항목": [items[i] for i in at], + "글줄": lines[w["글"][0] : _end(w) + 1] if w.get("글") else [], } part |= {k: top[k] + solve._gather([w], k) for k in ("상수", "식", "선택값")} + _fill(part, part["글줄"]) part["몫"] = w.get("몫") out.append(part) if not any(p["항목"] for p in out): # STmate — 글행 없음 → 항목 절 이름으로 붙임 @@ -156,6 +173,43 @@ def parts_of(blk: dict) -> tuple[list[dict], dict, list]: return out, whole, usable +def _end(node: dict) -> int: + return max([node["글"][1], *(_end(n) for n in node.get("아래") or [] if n.get("글"))]) + + +def _fill(part: dict, lines: list[str]) -> None: + """기계 작업량 절(Q 식 줄) — 못 읽은 기호(q · K · f · E · Cm)를 줄 글 끝값으로 더함 · 고르는 + 차례(`is_machine`)는 채우기 전 상수로.""" + if bm.has_q(lines): + part["기계식"] = is_machine(part) + have = {chk.base_sym(x.get("기호") or "") for x in part["상수"]} + part["상수"] = part["상수"] + [ + x for x in bm.consts(lines) if chk.base_sym(x["기호"]) not in have + ] + + +def old_cands(c: Ctx, basis: dict, title: str, unit: Any) -> list[dict]: + """옛 번호 → 현행 절 로직(`M02_San_Basis_Map`) · 건설품셈(옛) 같은 번호는 같은 절 · 제목과 뜻 + 겹침만 · 결과단위 성격 거름.""" + mine, have, out = clause.nature(unit), {x["키"] for x in basis["후보"]}, [] + for book, no in basis["출처"]: + if not book.endswith(clause.OLD): + continue + rows = bm.old_rows(book, no) + if not rows and book == "건설품셈" + clause.OLD: + rows = [ + o + for o in clause.lookup("건설품셈", no) + if o["맞음"] == "같음" and dice(c, o["키"], title) > 0 + ] + for o in rows: + theirs = clause.nature(o["결과단위"]) + if o["키"] not in have and not (mine and theirs and mine != theirs): + have.add(o["키"]) + out.append({**o, "절": None}) + return out + + def value_ev(rec: dict | None, parts: list[dict]) -> tuple[dict, list[dict], dict]: """값 길 — 블록 {확정, 동점, 약함} · 절마다 · 로직별 옵션.""" if rec is None: @@ -248,6 +302,22 @@ def resolve(S: list, how: str, check) -> tuple[list, str]: return S, how +def by_machine(c: Ctx, p: dict, S: list, how: str, title: str, check) -> tuple[list, str]: + """기계 작업량 절(Q 식) — 번호 길이 아니고 고른 기초가 품 검산으로 안 받쳐지면 그 기계 + 「작업량당 기계경비」 로직(품 같음 · 또는 고른 것 없고 품 짝 있음).""" + if how.startswith("번호") or not bm.has_q(p["글줄"]) or not is_machine(p): + return S, how + names = " ".join(str(i.get("이름") or "") for i in p["항목"]) + mk = bm.machine_key(c.mach, syn(f"{p['제목']} {title} {names}")) + if not mk or mk in S or (len(S) == 1 and _good(check(S[0]))): + return S, how + v = check(mk) + cnt = v.get("셈") or {} + if _good(v) or ("오류" not in v and (cnt.get(solve.SAME) or cnt.get(solve.DIFF))): + return [mk], "기계" + return S, how + + def kind_of(S: list) -> str: return "확정" if len(S) == 1 else "후보 2~3" if 2 <= len(S) <= 3 else "없음" @@ -276,6 +346,7 @@ def run_block(c: Ctx, blk: dict, line: dict | None, same: list, rec: dict | None basis = clause.basis(blk) parts, whole, usable = parts_of(blk) title_b = f"{blk.get('이름') or ''} {blk.get('규격') or ''}".strip() + basis["후보"] += old_cands(c, basis, title_b, blk.get("단위")) src = {"줄": line, "같은줄": same, "블록": blk} src["제목줄"] = { "글": re.sub(r"[-_/()\[\],~]+", " ", f"{blk.get('이름') or ''} {blk.get('규격') or ''}"), @@ -306,11 +377,14 @@ def run_block(c: Ctx, blk: dict, line: dict | None, same: list, rec: dict | None text = f"{p['제목']} {title_b}" else: N, text = allN, title_b - N, mach = list(dict.fromkeys(N)), is_machine(p) + N, mach = list(dict.fromkeys(N)), p.get("기계식", is_machine(p)) T = title_cands(c, text, blk.get("단위")) S, how = pick(c, N, T, Vb, Vs, mach, len(parts) == 1, text) cons = {x: p[x] for x in ("상수", "식", "선택값")} S, how = resolve(S, how, lambda k, p=p, cons=cons: check(k, p["항목"], cons, share_of(p))) + S, how = by_machine( + c, p, S, how, title_b, lambda k, p=p, cons=cons: check(k, p["항목"], cons, share_of(p)) + ) secs.append( { "절": p["제목"], diff --git a/M02_MasterTemplete/M02_San_Basis_Check.py b/M02_MasterTemplete/M02_San_Basis_Check.py index 38d2cbed9..db01dc81a 100644 --- a/M02_MasterTemplete/M02_San_Basis_Check.py +++ b/M02_MasterTemplete/M02_San_Basis_Check.py @@ -20,6 +20,7 @@ from __future__ import annotations import itertools import math import re +from decimal import Decimal from typing import Any from M02_MasterTemplete import M02_Hopyo_Bind as hb @@ -113,13 +114,34 @@ def _conv(v: Any) -> Any: return v +_RANGE = re.compile(r"^\s*([\d.]+)\s*(?:[∼~]\s*([\d.]+))?\s*$") +_SIZE = re.compile(r"용량|규격|톤|출력") + + +def _ranges(opts: list, base: dict) -> list: + """고르기가 모두 범위(「0.6∼0.8」 · 수 하나)면 이미 든 용량 · 규격 값이 드는 범위만(794-2c · + 버킷용량 → 규격구분) · 하나로 안 좁혀지면 그대로.""" + spans = [_RANGE.match(str(o)) for o in opts] + if not all(spans): + return opts + nums = [ + float(v) + for n, v in base.items() + if _SIZE.search(n) and isinstance(v, (int, float, Decimal)) + ] + hit = [ + o for o, m in zip(opts, spans) if any(float(m[1]) <= x <= float(m[2] or m[1]) for x in nums) + ] + return hit if len(hit) == 1 else opts + + def probe( m: Any, key: str, specs: dict, hard: list, base: dict, items: list, share: Any ) -> dict | None: """못 읽은 고르기 칸을 고르기 값마다 넣어 엑셀 품과 맞댐 — 「검산」 · 「근접」 옵션.""" if not hard or not all(specs.get(n, {}).get("고르기") for n in hard): return None - opts = [specs[n]["고르기"] for n in hard] + opts = [_ranges(specs[n]["고르기"], base) for n in hard] if math.prod(len(o) for o in opts) > 60: return None exact, close = [], [] diff --git a/M02_MasterTemplete/M02_San_Basis_Map.py b/M02_MasterTemplete/M02_San_Basis_Map.py new file mode 100644 index 000000000..2ed29b1ef --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis_Map.py @@ -0,0 +1,121 @@ +"""근거 제안 5 — 옛 번호 대조표 · 기계 작업량 읽기(PLAN 794-2c · 요구 653 · 분석 652-794-2a 3 장) · +읽기만 · 지어 넣는 값 없음. + +옛 번호 → 현행 절 = 대조표 `OLD`(근거 = 원문 표 값 · 실무 글) › 사방품셈(옛) = 지식DB 판 대응표 +「2018 통합 산림사업 표준품셈」 확정 한 대응 › 건설품셈(옛) 같은 번호 = 현행 절 로직 이름과 블록 +제목(같은 말 묶음 뒤)이 뜻 낱말을 나눌 때만(엔진 몫). +기계 작업량 절 = 글에 「3600 … Cm」 Q 식 · 기호(q · K · f · E · Cm) 끝값을 줄 글에서 읽음(여러 대입 +한 줄 · 「q (버킷용량) = 0.7」 꼴) · 기계 낱말 → 그 기계 「작업량당 기계경비」 로직(이름에서 뽑음). +""" + +from __future__ import annotations + +import csv +import re +from functools import lru_cache +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Clause as clause + +# (옛 책, 옛 번호) → (현행 구분, 현행 번호, 근거) +OLD = { + ("건설품셈(옛)", "11-3"): ( + "건설품셈 공통", + "8-2-3", + "실무 글 「건설표준품셈 11-3(굴삭기)」(봉화 · 울진대흥 2024) = 현행 8-2-3 굴착기", + ), + ("건설품셈(옛)", "3-7"): ( + "건설품셈 공통", + "3-9-1", + "현행 3-9-1 벌목 표(5m미만 벌목부 2.14 · 보통인부 0.51 · 굴착기+집게 2.71) = 블록 글" + " 「건설표준품셈 3-7」(동부청영월 2024)", + ), + ("건설품셈(옛)", "8-2-35"): ( + "건설품셈 공통", + "8-2-32", + "현행 8-2-32 이동식 임목파쇄기 93.25㎾ Q 6.0 · 발전기 250㎾ · 보통인부 2인 · 굴착기 0.7㎥" + "+집게 = 블록 글 「표준품셈 8-2-35」(동부청영월 2024)", + ), +} +_SABANG = "2018 통합 산림사업 표준품셈" + + +@lru_cache(maxsize=1) +def sabang_map() -> dict[str, str]: + """사방품셈(옛) 번호 → 현행 산림 번호 — 판 대응표 「2018 통합」 판 · 확정 · 한 대응만.""" + with open(clause._ROAD_MAP, encoding="utf-8-sig", newline="") as f: + rows = list(csv.DictReader(f)) + out = {} + for row in rows: + old, new = clause._OLD_NO.match(row["과거_절"].strip()), row["현행_절"].strip() + if row["STmate_인용판"] != _SABANG or row["판정"] != "확정" or not old: + continue + if clause._OLD_NO.fullmatch(new) and " " not in new: + out[old.group(1)] = new + return out + + +def old_rows(book: str, number: str) -> list[dict]: + """옛 출처 하나 → 현행 로직 줄(`clause.lookup` 꼴) · 대조 없음 = [].""" + if (book, number) in OLD: + return clause.lookup(*OLD[(book, number)][:2]) + if book == "사방품셈" + clause.OLD and number in sabang_map(): + return clause.lookup("산림품셈", sabang_map()[number]) + return [] + + +# ── 기계 작업량 절 ────────────────────────────────────────────────── +_Q = re.compile(r"3,?600\.?\s*[×x*]") +_SYM = re.compile( + r"(? bool: + """Q = 3600 × q × K × f × E / Cm 식 줄이 있나.""" + text = " ".join(lines) + return bool(_Q.search(text)) and "Cm" in text + + +def consts(lines: list[str]) -> list[dict[str, Any]]: + """줄 글 → [{기호, 값, 덧?}] — 기호 마디마다 괄호 글 뺀 마지막 수(「f = 1/1.3 = 0.77」 = 0.77) + · Cm 괄호 각도 = 덧 · 첫 값만(같은 기호 둘째부터 뺌).""" + out, seen = [], set() + for line in lines: + hits = list(_SYM.finditer(line)) + for i, m in enumerate(hits): + body = line[m.end() : hits[i + 1].start() if i + 1 < len(hits) else len(line)] + plain = re.sub(r"\((?:[^()]|\([^()]*\))*\)", " ", body) + nums = [float(x.replace(",", "")) for x in _NUM.findall(plain.split("sec")[0])] + sym = _CANON.get(m.group(1), m.group(1)) + if not nums or sym in seen: + continue + ratio = _RATIO.fullmatch(plain) # 「f = 1/1.25」 끝값 없는 나눗셈 + one: dict[str, Any] = { + "기호": sym, + "값": round(nums[0] / nums[1], 6) if ratio else nums[-1], + } + if sym == "Cm" and (deg := re.search(r"\(\s*(\d+)\s*˚?\s*\)", body)): + one["덧"] = f"{deg.group(1)}˚" + seen.add(sym) + out.append(one) + return out + + +def machines(logic: dict[str, dict]) -> dict[str, str]: + """{기계 낱말: 「작업량당 기계경비」 로직 키} — 로직 이름 첫 낱말(괄호 앞).""" + out = {} + for k, r in sorted(logic.items()): + name = str(r.get("이름") or "") + if "작업량당 기계경비" in name: + out.setdefault(re.split(r"[\s(]", name)[0], k) + return out + + +def machine_key(table: dict[str, str], text: str) -> str | None: + """글(같은 말 묶음 뒤)에 기계 낱말이 하나뿐이면 그 로직 키.""" + found = {k for w, k in table.items() if w in text} + return found.pop() if len(found) == 1 else None diff --git a/resources/tester/test_793_3a_san_basis.py b/resources/tester/test_793_3a_san_basis.py index df521c8aa..fb207cbac 100644 --- a/resources/tester/test_793_3a_san_basis.py +++ b/resources/tester/test_793_3a_san_basis.py @@ -5,6 +5,8 @@ 야면석소운반 GC001140 석재류 · 불량 · 큰돌쌓기 ÷10 근접) · 지어 넣은 값 0 · 보정 자료 = 여덟 파일 · 저장본 길(임시 폴더). 엑셀 = `helper_m02_hopyo_golden.FILES`(읽기만 · 없으면 skip) · STmate 숨은 G 식 = 올림 길과 같은 `M02_San_Basis_Store.hidden`. +794-2c 넓힘 — 없음 85 → 58 · 확정 단 125 → 164 · 손 확인 견본 열(기계 작업량 길 · 섞기 오인 넷 · +옛 번호 대조 · 같은 말 묶음) · 기계 상수 읽기 · 옛 번호 대조표. """ from __future__ import annotations @@ -22,6 +24,7 @@ from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_San_Basis as sb from M02_MasterTemplete import M02_San_Basis_Const as bc from M02_MasterTemplete import M02_San_Basis_Index as bi +from M02_MasterTemplete import M02_San_Basis_Map as bm from M02_MasterTemplete import M02_San_Basis_Store as store from M02_MasterTemplete import M02_San_Book as san_book @@ -81,6 +84,9 @@ def test_counts_at_least_d2(): assert kind["확정·전부"] >= 52 assert same >= 141 assert tier["없음"] <= 94 and kind["없음"] <= 94 + # 794-2c — 같은 말 묶음 · 옛 번호 대조표 · 기계 작업량 길(전 85 · 125 · 260) + assert tier["없음"] <= 58 and tier["확정"] >= 164 + assert sum(v for k, v in kind.items() if k.startswith("확정")) >= 309 def _first(bid: str) -> dict: @@ -181,3 +187,73 @@ def test_hidden_g_lines(): rows = [[None] * 7, [None] * 6 + ["_x0007_L=20"], [None] * 6 + ["_x0007_"], [None] * 6 + ["x"]] assert store.hidden(book, {store.SHEET: rows}) == {"3": ["L=20"]} assert store.hidden({"꼴": "엑셀꼴", "단산": []}, {}) == {} + + +# ── 794-2c 손 확인 견본 ───────────────────────────────────────────── +HAND = [ + ("북부청임도#56", "GC001148", "확정"), # 잔토정리 Q 식 · 제목에 품셈 이름 없음 → 기계 작업량 길 + ("울진대흥#2264", "GC001148", "확정"), # 섞기 오인(GF000132) → 옛 11-3 · 굴착기 + ("울진대흥#2269", "GC001148", "확정"), # 섞기 오인 → 되메우기 굴착기 Q 식 + ("울진대흥#2290", "GC001148", "근접"), # 섞기 오인 → 적상 굴착기(운반 · 정지 절 미확정) + ("동부청영월#111", "GC001189", "근접"), # 섞기 오인 → 옛 8-2-35 = 현행 8-2-32 임목파쇄기 + ("북부청임도#41", "GC000227", "확정"), # 휠라 = 채움재 · 옛 6-3-10 같은 절 + ("울진소광#1329", "GC000143", "확정"), # 평떼 = 잔디 · 옛 4-1-1 같은 절 + ("울진소광#794", "GF000298", "근접"), # 사방(옛) 4-16-2 → 판 대응표 산림 5-16-1 + ("동부청영월#33", "GC000129", "근접"), # 옛 3-7 → 현행 3-9-1 벌목(표 값 같음) + ("울진대흥#2277", "GC001148", "확정"), # 옛 11-3 → 8-2-3 · 번호 + 검산 +] + + +@pytest.mark.parametrize("bid,key,tier", HAND) +def test_794_2c_hand(bid, key, tier): + r = results()[0][bid] + keys = [c["기초"] for c in r["근거후보"]] + assert key in keys and r["단"] == tier, (bid, r["단"], keys) + assert "GF000132" not in keys, bid # 섞기(굴착기 0.7) 값 우연 짝 없음 + + +def test_794_2c_no_invented_divisor(): + """「/ Q / 3」(1/3 적용) 블록은 검산 못 함 = 사람 · 1/3 을 지어 넣지 않음.""" + r = results()[0]["봉화#24"] + assert r["단"] == "사람" and {c["기초"] for c in r["근거후보"]} == {"GC001147", "GC001148"} + assert all(c["옵션"] == {} for c in r["근거후보"]) + + +def test_machine_consts_reader(): + got = bm.consts( + [ + "q = 0.7 , k = 0.7 , f =1/1.175= 0.85", + "E = 0.5 , Cm = 18 sec(90)", + "Q = 3600*q*k*E*f/Cm = 41.65 m3/hr", + ] + ) + assert got == [ + {"기호": "q", "값": 0.7}, + {"기호": "K", "값": 0.7}, + {"기호": "f", "값": 0.85}, + {"기호": "E", "값": 0.5}, + {"기호": "Cm", "값": 18.0, "덧": "90˚"}, + ] + got = bm.consts( + [ + "q (버킷용량(m3)) = 0.7 , f (체적환산계수) = 1/1.25 , K (버킷계수) = 0.9", + "Cm (1회 사이클 시간(초)) = 20 sec (135˚) , E = ( 0.55 + 0.45 ) / 2. - 0.05 = 0.45", + ] + ) + assert [(x["기호"], x["값"]) for x in got] == [ + ("q", 0.7), + ("f", 0.8), + ("K", 0.9), + ("Cm", 20.0), + ("E", 0.45), + ] + assert bm.has_q(["3,600. × q × K × f × E", "Cm 20."]) and not bm.has_q(["Q = 6 ㎥/hr"]) + + +def test_old_number_map(): + assert {o["키"] for o in bm.old_rows("건설품셈(옛)", "11-3")} >= {"GC001147", "GC001148"} + assert "GC000129" in {o["키"] for o in bm.old_rows("건설품셈(옛)", "3-7")} + assert "GC001189" in {o["키"] for o in bm.old_rows("건설품셈(옛)", "8-2-35")} + assert bm.sabang_map()["4-16-2"] == "5-16-1" + assert "3-7" not in bm.sabang_map() # 임도(2017) 줄은 사방에 안 씀 + assert bm.old_rows("건설품셈(옛)", "8-2") == [] # 대조 없는 번호 = 없음(윗마디 안 받음)