Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2

This commit is contained in:
2026-11-05 19:56:33 +09:00
4 changed files with 298 additions and 5 deletions
+78 -4
View File
@@ -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["제목"],
+23 -1
View File
@@ -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 = [], []
+121
View File
@@ -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"(?<![A-Za-z가-힣/*×·])(q|K|k|f|F|E|Cm|CM)\d?\s*(?:\((?:[^()=]|\([^()]*\))*\))?\s*="
)
_NUM = re.compile(r"\d[\d,]*(?:\.\d*)?")
_RATIO = re.compile(r"\s*\d[\d.]*\s*/\s*\d[\d.]*\s*,?\s*")
_CANON = {"k": "K", "F": "f", "CM": "Cm"}
def has_q(lines: list[str]) -> 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
+76
View File
@@ -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") == [] # 대조 없는 번호 = 없음(윗마디 안 받음)