"""근거 제안 4 — 기초 하나 검산 · 옵션 · 까닭(PLAN 793-3a · 분석 648-D2 1 장) · 읽기만 · 지어 넣는 값 없음. 옵션 = 기존 `M02_Hopyo_Bind.bind`(B1 부르는 줄 규격 · B2 선택값 + 블록 제목 낱말 · B3 기호표 · B4 대입식 · B5 표 거꾸로 · B6 레미콘 규격) → 못 읽은 칸 = 값 길 옵션(「V」) → 그래도 고르기 칸이면 고르기 값마다 넣어 엑셀 품과 맞댐(조합 60 까지 · 다 같음 하나 = 「검산」 · ±15 % 근접 하나 = 「근접」) → 남은 칸 = 입력 필요. 감률 · 할증 · 가산 · 보정 · 구멍땜 수 칸은 셈 때만 0(검산 몫) · 옵션으로 안 냄(입력 필요 · 요구 647-1). 품 대조 = `M02_Hopyo_Solve.pair`(엑셀 표시 자릿수 반올림 폭) · 기계 상수(q · K · f · E · E0 · Cm) = 그 로직(하위 포함)이 닿는 표의 같은 기호 칸 · 같은 기호 줄 값과 대조(`mach_check`). 까닭(`causes`) = b 판 차이 · c 현장 값 · d 감률 · 할증 배수 · e 단위 기준 차이 · f 그 밖(분석 648-D2 5-2 규칙). """ 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 from M02_MasterTemplete import M02_Hopyo_Solve as solve from M02_MasterTemplete import M02_San_Basis_Index as bi RATIOS = { 8: "일↔시간 ×8", 1 / 8: "일↔시간 ÷8", 100: "% ×100", 0.01: "% ÷100", 1000: "t↔kg ×1000", 0.001: "t↔kg ÷1000", 10: "×10", 0.1: "÷10", 60: "분↔시간 ×60", 1 / 60: "분↔시간 ÷60", } CAUSE = { "b": "판 차이", "c": "현장 값", "d": "감률 · 할증 배수", "e": "단위 기준 차이", "f": "그 밖", } ZERO = re.compile(r"감률|할증|가산|구멍땜|보정|증가|추가") # ── 기계 상수 ↔ 그 로직 표 ────────────────────────────────────────── def _norm(s: Any) -> str: s = re.sub(r"\(.*?\)", "", str(s or "").replace("㎝", "cm").lower()) return re.sub(r"[^a-z0-9]", "", s) def base_sym(s: str) -> str: m = re.match(r"(cm|e0|q|k|f|e)\d*$", _norm(s)) return m.group(1) if m else "" def mach_consts(cons: dict) -> list[dict]: out = list(cons.get("상수", [])) for f in cons.get("식", []): # 식 끝값(Cm = t1+…+t4 = 10.7)은 셈 결과 — 대입 수만 out += [{"기호": k, "값": v} for k, v in (f.get("대입") or {}).items()] return out def mach_check(idx: bi.Index, key: str, consts: list[dict]) -> list[dict]: out, seen = [], set() tables = idx.item_tables.get(key, set()) for c in consts: sym, v = base_sym(c.get("기호") or ""), c.get("값") if not sym or not isinstance(v, (int, float)) or (c.get("기호"), v) in seen: continue seen.add((c.get("기호"), v)) pool = [] for t in tables: tb = idx.tables[t] for row in tb.get("줄", []): row_sym = any(isinstance(x, str) and base_sym(x) == sym for x in row.values()) for col in tb.get("값칸", {}): if base_sym(col) == sym or row_sym: pool += bi.nums_of(row.get(col)) if not pool: out.append({"기호": c["기호"], "값": v, "대조": "칸 없음"}) continue d = len(str(v).split(".")[1]) if "." in str(v) else 0 near = min(pool, key=lambda x: abs(x - v)) hit = abs(near - v) <= max(0.5 * 10 ** (-d), abs(v) * 0.005) out.append( { "기호": c["기호"], "값": v, "대조": "표에 있음" if hit else "표에 없음", "가까운": near, } ) return out # ── 옵션 · 검산 ───────────────────────────────────────────────────── def zeroable(m: Any, key: str, name: str) -> bool: """글에 없으면 0(없음)으로 볼 만한 수 칸 — 고르기 없음 · 이름이 감률 · 할증 · 가산 · 보정 류.""" spec = next((x for x in hb.specs_of(m.logic(key)) if x["이름"] == name), {}) return not spec.get("고르기") and bool(ZERO.search(name)) def _conv(v: Any) -> Any: if isinstance(v, list): ends = [x for x in v if isinstance(x, (int, float))] return sum(ends) / len(ends) if ends else None 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 = [_ranges(specs[n]["고르기"], base) for n in hard] if math.prod(len(o) for o in opts) > 60: return None exact, close = [], [] def sig(rr): return tuple(round(r["우리"]["품"] or 0, 6) for r in rr if r.get("우리")) for combo in itertools.product(*opts): g2 = dict(zip(hard, combo)) try: rr = solve.pair(hb.quantities(m, key, {**base, **g2}), solve._usable(items), share) except Exception: # noqa: BLE001 — 그 조합은 셈 못 함 continue same = sum(r["판정"] == solve.SAME for r in rr) if any(r["판정"] == solve.NEED for r in rr) or not same: continue ratios = [ r["엑셀"]["품"] / r["우리"]["품"] for r in rr if r["판정"] == solve.DIFF and r["엑셀"]["품"] and r["우리"]["품"] ] if not ratios: exact.append((g2, rr)) elif all(abs(x - 1) <= 0.15 for x in ratios): close.append((same, -len(ratios), g2, rr)) if len(exact) == 1: return {"값": exact[0][0], "줄": exact[0][1], "길": "검산"} if len(exact) > 1 and len({sig(rr) for _, rr in exact}) == 1: return {"값": exact[0][0], "줄": exact[0][1], "길": "검산(같은 품 여럿)"} if not exact and close: close.sort(key=lambda x: (-x[0], -x[1])) top = [c for c in close if (c[0], c[1]) == (close[0][0], close[0][1])] if len(top) == 1 or len({sig(c[3]) for c in top}) == 1: return { "값": close[0][2], "줄": close[0][3], "길": "근접" + ("" if len(top) == 1 else "(같은 품 여럿)"), } return None def verify(m: Any, key: str, items: list, consts: dict, src: dict, share: Any, vopts: dict) -> dict: """기초 하나 검산 — bind(B1~B6) · 값 길 옵션 · 고르기 맞댐 · 엑셀 품 대조.""" mine = {**src, **consts} if src.get("제목줄"): mine["선택값"] = list(consts.get("선택값") or []) + [src["제목줄"]] try: b = hb.bind(m, key, mine, solve.fitter(m, key, items, share)) specs = {s["이름"]: s for s in hb.specs_of(m.logic(key))} given = {**b["입력값"], **{n: v[0]["값"] for n, v in b["엇갈림"].items()}} fromv = { n: _conv(vopts[n]) for n in b["입력필요"] + b["안씀"] if n in vopts and _conv(vopts[n]) is not None } need = [n for n in b["입력필요"] if n not in fromv] zero = {n: 0 for n in need if zeroable(m, key, n)} idle = { n: b["기본"][n] for n in b["안씀"] if b["기본"].get(n) is not None and n not in fromv } base = {**idle, **given, **fromv, **zero} rows = solve.pair(hb.quantities(m, key, base), solve._usable(items), share) got = probe(m, key, specs, [n for n in need if n not in zero], base, items, share) if got: fromv |= got["값"] need = [n for n in need if n not in got["값"]] rows = got["줄"] same_q = [] # 엇갈림인데 어느 값이든 품이 같음 = 문제 아님 for n, alts in b["엇갈림"].items(): sigs = set() for alt in alts: q = hb.quantities(m, key, {**idle, **given, **fromv, **zero, n: alt["값"]}) sigs.add( tuple(round(float(x["품"]), 6) if x.get("품") is not None else None for x in q) ) if len(sigs) == 1: same_q.append(n) except Exception as e: # noqa: BLE001 — 검산 못 함 = 오류 표지(확정 안 함) return {"키": key, "오류": repr(e)[:160]} blk = src["블록"] text = " ".join( [str(blk.get("이름") or ""), str(blk.get("규격") or "")] + [str(t) for t in blk.get("글") or []] ) words = {"소량": "소량|소형|소규모", "높이": "높이|수직고"} absorbed = [ [n, hb.plain(given[n])] for n, w in b["출처"].items() if w["길"] == "B5" and n.endswith("감률") and n in given and not re.search(words.get(n.replace("감률", ""), n.replace("감률", "") or "소량"), text) ] cnt = { v: sum(r["판정"] == v for r in rows) for v in (solve.SAME, solve.DIFF, solve.NEED, solve.EXCEL, solve.OURS) } diff = [] for r in rows: if r["판정"] == solve.DIFF: ex, us = r["엑셀"]["품"], r["우리"]["품"] diff.append( { "줄": r["엑셀"].get("이름") or r["우리"].get("이름"), "엑셀": ex, "우리": us, "배": round(ex / us, 4) if ex and us else None, "묶음": r["엑셀"].get("묶음"), } ) outside = [ r["엑셀"].get("이름") for r in rows if r["판정"] == solve.EXCEL and r["엑셀"] and r["엑셀"].get("품") ] way = {n: alts[0]["길"] for n, alts in b["엇갈림"].items()} # 엇갈림 = 첫 값 way |= {k: v["길"] for k, v in b["출처"].items()} way |= {k: "V" for k in fromv if not got or k not in got["값"]} way |= {k: got["길"] for k in got["값"]} if got else {} return { "키": key, "셈": cnt, "기초밖": outside, "감률흡수": absorbed, "입력값": { **{k: hb.plain(v) for k, v in given.items()}, **{k: hb.plain(v) for k, v in fromv.items()}, }, "길": way, "입력필요": [n for n in need if n not in zero], "0간주": list(zero), "엇갈림": { k: [hb.plain(x["값"]) for x in v] for k, v in b["엇갈림"].items() if k not in same_q }, "품다름": diff, } def judge_one(v: dict, mach: list[dict]) -> tuple[str, list, list]: """(확정·전부 | 확정·일부, 글에 없음, 값이 M01 과 다름).""" if "오류" in v: return "확정·일부", [], [f"검산 오류 {v['오류'][:60]}"] missing = list(v["입력필요"]) + [f"옵션 여럿 {k} {vals}" for k, vals in v["엇갈림"].items()] missing += [f"기초 밖 줄 {x}" for x in v.get("기초밖", [])] differ = [ f"품 {d['줄']} 엑셀 {d['엑셀']:.4g} ≠ 우리 {d['우리']:.4g}" for d in v["품다름"] if d["엑셀"] and d["우리"] ] differ += [ f"기계 {x['기호']}={x['값']} 표에 없음(가까운 {x['가까운']:g})" for x in mach if x["대조"] == "표에 없음" ] differ += [f"감률 {n}={x} 로 흡수(글에 그 말 없음) — 품 배수" for n, x in v.get("감률흡수", [])] differ += [ f"근접 옵션 {k}={v['입력값'].get(k)}" for k, w in (v.get("길") or {}).items() if str(w).startswith("근접") ] if not v["셈"].get(solve.SAME) and not v["셈"].get(solve.DIFF): missing.append("(품 짝 없음)") return ("확정·전부" if not missing and not differ else "확정·일부"), missing, differ def causes(v: dict, mach: list[dict], rec: dict | None) -> list[list[str]]: """값 다름 → [갈래 b~f, 글] 【추정 규칙 · 648-D2 5-2】.""" out = [] user = bool(rec) and ( bool(rec.get("입력")) or any( "kg/" in str(k.get("단위", "")).lower() or "㎏/" in str(k.get("단위", "")) for k in rec.get("K", []) ) ) labor = [d["배"] for d in v.get("품다름", []) if d["배"] and d["묶음"] in ("L", "X")] uniform = len(labor) >= 2 and all(abs(x / labor[0] - 1) <= 0.02 for x in labor) for d in v.get("품다름", []): r = d["배"] if not r: out.append(["f", f"{d['줄']} 품 0"]) continue norm = next((name for k, name in RATIOS.items() if abs(r / k - 1) <= 0.01), None) if norm: out.append(["e", f"{d['줄']} {norm}"]) elif uniform and abs(r - 1) > 0.15: out.append(["d", f"{d['줄']} 같은 배 ×{r:g}"]) elif abs(r - 1) <= 0.15: out.append(["c" if user else "b", f"{d['줄']} ×{r:g}"]) else: out.append(["c" if user else "f", f"{d['줄']} ×{r:g}"]) for n, x in v.get("감률흡수", []): out.append(["b" if isinstance(x, (int, float)) and x < 15 else "d", f"감률 {n}={x} 흡수"]) for x in mach: if x["대조"] == "표에 없음": out.append( [ "b" if x["가까운"] and abs(x["값"] / x["가까운"] - 1) <= 0.15 else "c", f"기계 {x['기호']}={x['값']}", ] ) return out # ── 판 표지(PLAN 794-2b · 분석 652-794-2a 5 장) — 근거후보 까닭 「판 차이」에 판 번호 ────────── STRONG = ("판 차이", "현장 값", "그 밖") # 둘 이상 파일 같은 배면 「판 차이 강함」 으로 올림 _TEXT_YEAR = re.compile(r"(? int | None: y = int(m.group(1)) % 100 if m else None return None if y is None else y + (2000 if y < 50 else 1900) def file_year(name: Any) -> int | None: """파일 이름 연도 — 「2024년 …」 · 「00.25년_…」 · 「20250527 2025년 …」.""" s = str(name or "") return _year(re.search(r"(20\d\d)\s*년", s) or re.search(r"(? int | None: """블록 글 속 판 — 「2017년 품셈」 · 「24년 건설표준품셈」(가장 옛 것).""" text = " ".join(str(t) for t in [blk.get("이름"), blk.get("규격"), *(blk.get("글") or [])]) found = [_year(m) for m in _TEXT_YEAR.finditer(text)] return min(found) if found else None def edition_tag(key: str, rev: list, year: int, by: str) -> str: """기초 절 개정 가운데 블록 판 뒤 것 — 「'24 보완 · 파일 2024」 · 없으면 빈 글. 글 속 판 해 = 그 해 개정을 담음(뒤 것만) · 파일 해 = 같은 해 개정이 앞일 수도(같은 해 포함).""" later = [int(y) for y in rev or [] if int(y) > year or (by == "파일" and int(y) == year)] if not later: return "" word = "제정" if key.startswith("GF") else "보완" return f"{' · '.join(f'{chr(39)}{y % 100:02d}' for y in later)} {word} · {by} {year}" def edition(out: dict, file_name: Any, blk: dict) -> dict: """제안 근거후보마다 판 표지 `판`(덧칸) · 까닭 「판 차이」 → 「판 차이(판 표지)」. 셈 · 단 · 꼴 안 바뀜(표지 글만).""" by_text, by_file = text_year(blk), file_year(file_name) if by_text and (not by_file or by_text <= by_file): year, by = by_text, "글" else: year, by = by_file, "파일" if not year: return out index = hb.m01.cm.master().index for c in out.get("근거후보") or []: key = str(c.get("기초") or "") tag = edition_tag(key, (index.get(key[:2], {}).get(key) or {}).get("개정"), year, by) if tag: c["판"] = tag c["까닭"] = [ [f"판 차이({tag})" if lab == "판 차이" else lab, txt] for lab, txt in c["까닭"] ] return out def _ratios(c: dict) -> list[float]: return [ float(m.group(1)) for lab, txt in c.get("까닭") or [] if lab.startswith(STRONG) and (m := _RATIO.search(str(txt))) ] def strong(got: dict, others: list[dict]) -> dict: """같은 기초 · 같은 배(±2 %)가 다른 파일에도 판 표지와 함께 있으면 「판 차이 강함(…)」.""" seen = [ (c["기초"], r) for o in others for p in o.values() for c in p.get("근거후보") or [] if c.get("판") for r in _ratios(c) ] if not seen: return got def hit(key: str, lab: str, text: Any) -> bool: m = _RATIO.search(str(text)) if lab.startswith(STRONG) else None return bool(m) and any( k == key and r and abs(float(m.group(1)) / r - 1) <= 0.02 for k, r in seen ) out = {} for no, p in got.items(): cs = [] for c in p.get("근거후보") or []: if c.get("판"): why = [ [f"판 차이 강함({c['판']})", t] if hit(c["기초"], lab, t) else [lab, t] for lab, t in c["까닭"] ] c = {**c, "까닭": why} cs.append(c) out[no] = {**p, "근거후보": cs} return out