447 lines
18 KiB
Python
447 lines
18 KiB
Python
"""근거 제안 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"(?<!\d)((?:20)?\d\d)\s*년\s*(?:도\s*)?[^\d\n]{0,12}?품셈")
|
||
_RATIO = re.compile(r"×([0-9.]+(?:e[-+]?\d+)?)")
|
||
|
||
|
||
def _year(m: re.Match | None) -> 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"(?<!\d)(\d\d)\s*년", s))
|
||
|
||
|
||
def text_year(blk: dict) -> 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
|