Files
Aislo/M02_MasterTemplete/M02_San_Basis_Check.py

447 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""근거 제안 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