브레인 일감 ③ 앞 절반. 기초값 열다섯 중 품셈 원문에서 옮겨 적은 열을 원문 표와 칸 단위로 맞댐 (토량환산 · 자재 할증 · 거푸집 전용 · 철근 갈래 · 목구조 갈래 · 돌쌓기 경사·뒷길이·돌 종류·갈래 · 기계 작업량). - 원문 = 산림 품셈 md 표(F0004·F0008~12·F0040·F0336·F0395·F0407·F0412·F0413·F0408·F0410·F0419·F0440 · 12-3 [주]①) · 10-12 덤프 식 글의 기호 자리(`t3=`·`γt=`·`E0=`) · 건설 품셈 제8장 md 불도저 속도·삽날 - 어긋남 7 → 알려진 목록(`fixtures/base_data_source_known.json`) 에 까닭과 함께 · ⚠ 거푸집 전용 비율 「timber(각재)」 여섯 값 = 원문 12-4 합판 줄 「노무비(%)」 열 — 원문엔 각재 비율이 없음 · 이형철근 주철근 원문 「6-7」 → 7 · 덤프 토사 E0 본문 0.75 대신 표 0.60 · 발파암 운반 E 원문에 없음(0.9 이어 씀) · 이름 줄임 3(「암괴나 호박돌이 섞인 모래」 L·C 둘 · 「교량의 슬래브」) - 칸마다 「잰다 · 못 잰다(까닭) · 글」 중 하나로 적힘 — 새 칸이 생기면 빨강 · 못 잼: 교본 값(뒤채움 몫 · 전면 기울기) · 건설품셈 참고자료(원문 md 없음) · 프로그램 이음 칸 · B09 계산값 - 열마다 값을 하나씩 바꿔 빨강 확인 · 못 잡는 것 하나: 철근 예시는 글 포함만 봄(「교량」처럼 원문에 있는 낱말을 보태면 통과) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
557 lines
25 KiB
Python
557 lines
25 KiB
Python
"""기초데이터 열(품셈 근거) ↔ 원문 맞대기 — 2026-09-18 브레인 일감(랩탑 메인).
|
||
|
||
기초값 열다섯 중 **품셈 원문에서 옮겨 적은 열**을 원문 표와 칸 단위로 맞댐. 나머지 다섯
|
||
(노임·기계·자재·유가·요율)은 `test_base_prices_source_match.py` 몫.
|
||
|
||
- 원문 = 산림 품셈 md(`test_work_item_master_source_match.MD`) · 건설 품셈 제8장 md(불도저 제원)
|
||
- 어긋남은 `fixtures/base_data_source_known.json` 에 — 목록 밖 새 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강
|
||
(고친 뒤 `python resources/tester/test_base_data_source_match.py --prune`, 지우기만 함)
|
||
- ⚠ **못 재는 칸은 못 잰다고 적음** — `NOT_CHECKED` 에 까닭과 함께. 자료에 새 칸이 생기면 빨강
|
||
(재든지 못 잰다고 적든지 하게).
|
||
- 표 읽기는 옆 시험의 `md_tables` 한 벌 — 빌더 코드는 안 씀.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from test_work_item_master_source_match import MD, ROOT, _without_why, md_tables
|
||
|
||
KNOWN = Path(__file__).resolve().parent / "fixtures/base_data_source_known.json"
|
||
CONST_MACHINE_MD = (
|
||
ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/01_공통부문/제8장_건설기계.md"
|
||
)
|
||
FILES = {
|
||
"coef": "data_cost_input_value/coef_2026.json",
|
||
"material_surcharge": "data_material_surcharge/material_surcharge_2026-01-01.json",
|
||
"formwork_reuse": "data_formwork/formwork_reuse_2026-01-01.json",
|
||
"rebar_complexity": "data_rebar/rebar_complexity_2026-01-01.json",
|
||
"timber_structure_class": "data_timber/timber_structure_class_2026-01-01.json",
|
||
"masonry_slope": "data_masonry/masonry_slope_2026-01-01.json",
|
||
"masonry_back_length": "data_masonry/masonry_back_length_2026-01-01.json",
|
||
"stone_kind": "data_masonry/stone_kind_2026-01-01.json",
|
||
"masonry_class": "data_masonry/masonry_class_2026-01-01.json",
|
||
"machine_productivity": "data_machine_productivity/machine_productivity_2026-01-01.json",
|
||
}
|
||
|
||
#: 값이 아니라 글·방침·출처 칸 — 맞댈 값이 없음.
|
||
TEXT_FIELDS = {
|
||
"schema_version", "dataset_id", "effective_date", "generated_at", "note", "why", "source",
|
||
"sources", "policy", "not_here", "no_folding", "option_key", "option_note", "original_tables",
|
||
"not_found", "candidates_pending_user", "observed_practice", "pending_user",
|
||
} # fmt: skip
|
||
#: 재는 칸.
|
||
CHECKED = {
|
||
"coef": {"variables"},
|
||
"material_surcharge": {"rates_pct"},
|
||
"formwork_reuse": {"reuse_by_class", "reuse_ratio_pct", "euroform_type"},
|
||
"rebar_complexity": {"classes"},
|
||
"timber_structure_class": {"classes"},
|
||
"masonry_slope": {"steps_m", "table"},
|
||
"masonry_back_length": {"steps_m", "table_cm"},
|
||
"stone_kind": {"kinds", "wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2", "back_lengths_cm"},
|
||
"masonry_class": {"back_length", "boulder_diameter", "bond"},
|
||
"machine_productivity": {"variables"},
|
||
}
|
||
#: ⚠ 못 재는 칸 — 까닭.
|
||
NOT_CHECKED = {
|
||
"formwork_reuse": {
|
||
"type_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)",
|
||
"shoring": "강관동바리 대상 메모 — 값 없음",
|
||
},
|
||
"rebar_complexity": {
|
||
"form_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)",
|
||
"price_hint_krw_per_ton": "B09 가 자재 단가로 셈한 참고값 — 원문에 없음",
|
||
},
|
||
"timber_structure_class": {
|
||
"basis_unit": "밑수 풀이 글(「목재 채적 ㎥」) — 원문은 「㎥당」만 적음",
|
||
"type_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)",
|
||
},
|
||
"stone_kind": {
|
||
"backfill_ratio_of_back_length": "교본 7-3 값(1/3·1/2) — 품셈 원문이 아님(교본 원본 대조 몫)",
|
||
"fallback": "건설품셈 [참고자료] 돌쌓기 규격별 소요량 — 원문 md 가 이 저장소에 없음",
|
||
},
|
||
"masonry_class": {
|
||
"face_slope": "교본 7-3 기준(지식DB 돌쌓기.md 요약) — 품셈 원문이 아님",
|
||
},
|
||
}
|
||
|
||
|
||
# ── 원문 읽기 ────────────────────────────────────────────────────────────
|
||
def _sq(text: str) -> str:
|
||
return re.sub(r"\s+", "", str(text))
|
||
|
||
|
||
def _num(text: str) -> float | None:
|
||
"""`0.30` · `1:0.30` → 수. `-`·빈칸 → `None`."""
|
||
t = _sq(text).split(":")[-1].replace(",", "")
|
||
return float(t) if re.fullmatch(r"\d+(\.\d+)?", t) else None
|
||
|
||
|
||
def _range(text: str) -> tuple[float | None, float | None]:
|
||
"""`1.70∼2.00` · `25~35` · `6-7` · `75이상` → (아래, 위)."""
|
||
t = _sq(text)
|
||
if m := re.fullmatch(r"(\d+(?:\.\d+)?)이상", t):
|
||
return float(m.group(1)), None
|
||
parts = re.split(r"[∼~~-]", t)
|
||
if len(parts) == 2 and all(re.fullmatch(r"\d+(\.\d+)?", p) for p in parts):
|
||
return float(parts[0]), float(parts[1])
|
||
return None, None
|
||
|
||
|
||
def _numbers(text: str) -> set[float]:
|
||
return {float(x) for x in re.findall(r"\d+(?:\.\d+)?", text)}
|
||
|
||
|
||
def _section(lines: list[str], heading: str) -> str:
|
||
"""`### {heading}` 부터 다음 같은 급 제목 앞까지 글."""
|
||
start = next(i for i, x in enumerate(lines) if x.startswith(f"### {heading}"))
|
||
end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("### ")), len(lines))
|
||
return "\n".join(lines[start:end])
|
||
|
||
|
||
def _f(value) -> float | None:
|
||
return None if value is None else float(value)
|
||
|
||
|
||
# ── 열마다 맞대기 — {열쇠: {"md": 원문, "data": 자료}} ─────────────────────
|
||
def _coef(doc: dict, md: dict) -> dict:
|
||
out = {}
|
||
rows = md["tables"]["F0004"]["rows"]
|
||
for col, var in ((1, "coef_soil_L"), (2, "coef_soil_C")):
|
||
records = doc["variables"][var]["records"]
|
||
if len(records) != len(rows):
|
||
out[f"{var}/줄 수"] = {"md": len(rows), "data": len(records)}
|
||
for i, (row, rec) in enumerate(zip(rows, records)):
|
||
name = re.sub(r"\([^)]*[一-鿿][^)]*\)", "", _sq(row[0]))
|
||
lo, hi = _range(row[col])
|
||
md_val = [name, lo, hi, None if lo is not None else _sq(row[col])]
|
||
data_name = re.sub(r"\([^)]*[一-鿿][^)]*\)", "", _sq(rec["soil_type"]))
|
||
data_val = [
|
||
data_name,
|
||
_f(rec.get("min")),
|
||
_f(rec.get("max")),
|
||
rec.get("rule") and _sq(rec["rule"]),
|
||
]
|
||
if md_val != data_val:
|
||
out[f"{var}/{i + 1}"] = {"md": md_val, "data": data_val}
|
||
# 원문 보관 표 — 원문 표와 글자까지 같아야 함
|
||
by_line = {t["line"]: t for t in md["tables"].values()}
|
||
for var in ("surcharge_labor", "surcharge_mat"):
|
||
for t in doc["variables"][var]["tables"]:
|
||
m = by_line.get(t["line"])
|
||
if m is None or [m["headers"], m["rows"]] != [t["headers"], t["rows"]]:
|
||
out[f"{var}/L{t['line']}"] = {
|
||
"md": m and [m["headers"], m["rows"]],
|
||
"data": [t["headers"], t["rows"]],
|
||
}
|
||
tool = doc["variables"]["rate_tool"]
|
||
if not {tool["min_percent"], tool["max_percent"]} <= _numbers(_section(md["lines"], "1-2-6.")):
|
||
out["rate_tool"] = {
|
||
"md": "1-2-6 글에 없음",
|
||
"data": [tool["min_percent"], tool["max_percent"]],
|
||
}
|
||
return out
|
||
|
||
|
||
#: 자재 → 원문 칸 자리 (표, 줄, 칸, 줄에 있어야 할 글). 줄이 밀리면 글 확인에서 걸림.
|
||
SURCHARGE_CELLS = {
|
||
"시멘트": [("F0008", 0, 1, "시멘트"), ("F0008", 0, 2, "시멘트")],
|
||
"잔골재": [("F0008", 1, 1, "잔골재"), ("F0008", 1, 2, "잔골재")],
|
||
"채움재": [("F0008", 1, 1, "채움재"), ("F0008", 1, 2, "채움재")],
|
||
"굵은골재": [("F0008", 2, 1, "굵은골재"), ("F0008", 2, 2, "굵은골재")],
|
||
"모래": [("F0009", 0, 1, "모래")],
|
||
"부순돌": [("F0009", 1, 1, "부순돌")],
|
||
"자갈": [("F0009", 1, 1, "자갈")],
|
||
"점질토": [("F0009", 3, 1, "점질토")],
|
||
"이형철근": [("F0011", 1, 1, "이형철근"), ("F0011", 2, 1, "주철근")],
|
||
"원형철근": [("F0011", 0, 1, "원형철근")],
|
||
"강판": [("F0011", 3, 1, "강판")],
|
||
"각재": [("F0012", 0, 2, "각재")],
|
||
"판재": [("F0012", 1, 1, "판재")],
|
||
"레미콘": [("F0012", 5, 2, "레디믹스트"), ("F0012", 6, 1, "철근구조물")],
|
||
"흄관": [("F0012", 16, 1, "원심력철근콘크리트관")],
|
||
"떼": [("F0012", 10, 1, "떼")],
|
||
"초화류": [("F0012", 10, 1, "초화류")],
|
||
"사방용 수목": [("F0012", 9, 1, "사방용수목")],
|
||
"원석": [("F0012", 8, 1, "마름돌용")],
|
||
}
|
||
|
||
|
||
def _material_surcharge(doc: dict, md: dict) -> dict:
|
||
out = {}
|
||
for rec in doc["rates_pct"]:
|
||
cells = SURCHARGE_CELLS.get(rec["material"])
|
||
data_val = [rec.get("rate"), rec.get("alt_rate")]
|
||
if cells is None:
|
||
out[rec["material"]] = {"md": "원문 자리 모름", "data": data_val}
|
||
continue
|
||
md_val = []
|
||
for table, r, c, label in cells:
|
||
row = md["tables"][table]["rows"][r]
|
||
md_val.append(row[c] if label in _sq("".join(row)) else f"줄 밀림({label})")
|
||
md_val += [None] * (2 - len(md_val))
|
||
norm = [_num(v) if v and _num(v) is not None else v for v in md_val]
|
||
if norm != [_f(v) for v in data_val]:
|
||
out[rec["material"]] = {"md": md_val, "data": data_val}
|
||
return out
|
||
|
||
|
||
def _formwork_reuse(doc: dict, md: dict) -> dict:
|
||
out = {}
|
||
rows = md["tables"]["F0040"]["rows"]
|
||
for rec, row in zip(doc["reuse_by_class"], rows):
|
||
if [int(_num(row[0][:-1])), rec["class"] in row[1]] != [rec["reuse_count"], True]:
|
||
out[f"reuse_by_class/{rec['class']}"] = {
|
||
"md": row,
|
||
"data": [rec["reuse_count"], rec["class"]],
|
||
}
|
||
t = md["tables"]["F0336"]
|
||
heads = t["rows"][0] # 횟수별 | 재료별(%) | 노무비(%)
|
||
series = {}
|
||
for row in t["rows"]:
|
||
label = _sq(row[0])
|
||
series[label] = {heads[1]: row[4].split(), heads[2]: row[5].split()}
|
||
ratio = doc["reuse_ratio_pct"]
|
||
for key, row_label in (("plywood", "합판"), ("timber", "각재")):
|
||
data_val = [ratio[key][str(n)] for n in range(1, 7)]
|
||
found = {h: [float(x) for x in v] for h, v in series.get(row_label, {}).items() if v}
|
||
if data_val not in found.values():
|
||
out[f"reuse_ratio_pct/{key}"] = {
|
||
"md": {row_label: found or "비율 칸 비어 있음", "합판": series["합판"]},
|
||
"data": data_val,
|
||
}
|
||
area = md["tables"]["F0395"]["rows"]
|
||
md_area = dict(zip([_sq(x) for x in area[0][:3]], [_num(x) for x in area[1][3:6]]))
|
||
for cls in doc["euroform_type"]["classes"]:
|
||
if md_area.get(cls["key"]) != cls["daily_area_m2"]:
|
||
out[f"euroform_type/{cls['key']}"] = {
|
||
"md": md_area.get(cls["key"]),
|
||
"data": cls["daily_area_m2"],
|
||
}
|
||
return out
|
||
|
||
|
||
def _rebar_complexity(doc: dict, md: dict) -> dict:
|
||
"""예시마다 12-3 [주]① 글에 있나.
|
||
|
||
ponytail: 글 포함만 봄 — 원문에 있는 낱말(「교량」)을 엉뚱한 갈래에 보태도 통과. 갈래별 문장 쪼개기는 틀릴 때.
|
||
"""
|
||
note = _sq(md["notes_12_3"])
|
||
out = {}
|
||
for cls in doc["classes"]:
|
||
missing = [ex for ex in cls["examples"] if _sq(ex) not in note]
|
||
if missing:
|
||
out[f"classes/{cls['key']}"] = {"md": "12-3 [주]① 에 없는 예시", "data": missing}
|
||
return out
|
||
|
||
|
||
def _timber_structure_class(doc: dict, md: dict) -> dict:
|
||
rows, found, group = md["tables"]["F0440"]["rows"], {}, ""
|
||
for row in rows:
|
||
cells = [_sq(c) for c in row]
|
||
if cells[0].endswith("구조"):
|
||
group, cells = cells[0], cells[1:]
|
||
grade = "" if _num(cells[0]) is not None else cells.pop(0)
|
||
found[f"{group} {grade}".strip()] = [_num(cells[0]), _num(cells[1])]
|
||
out = {}
|
||
data = {c["key"]: [c["carpenter"], c["laborer"]] for c in doc["classes"]}
|
||
for key in sorted(set(found) | set(data)):
|
||
if found.get(key) != data.get(key):
|
||
out[f"classes/{key}"] = {"md": found.get(key), "data": data.get(key)}
|
||
return out
|
||
|
||
|
||
def _steps(cells: list[str]) -> list[float]:
|
||
return [
|
||
float(_sq(c).lstrip("∼~~")) for c in cells if re.fullmatch(r"[∼~~]\d+(\.\d+)?", _sq(c))
|
||
]
|
||
|
||
|
||
def _masonry_slope(doc: dict, md: dict) -> dict:
|
||
t = md["tables"]["F0413"]
|
||
found, group = {}, ""
|
||
for row in t["rows"]:
|
||
cells = [c for c in row]
|
||
if _sq(cells[0]) in ("메쌓기", "찰쌓기"):
|
||
group, cells = _sq(cells[0]), cells[1:]
|
||
found[f"{group}/{_sq(cells[0])}"] = [_num(c) for c in cells[1:] if _sq(c)]
|
||
data = {f"{g}/{f}": v for g, faces in doc["table"].items() for f, v in faces.items()}
|
||
out = {
|
||
k: {"md": found.get(k), "data": data.get(k)}
|
||
for k in sorted(set(found) | set(data))
|
||
if found.get(k) != data.get(k)
|
||
}
|
||
if _steps(t["headers"]) != doc["steps_m"]:
|
||
out["steps_m"] = {"md": _steps(t["headers"]), "data": doc["steps_m"]}
|
||
return out
|
||
|
||
|
||
def _masonry_back_length(doc: dict, md: dict) -> dict:
|
||
t = md["tables"]["F0412"]
|
||
labels = [re.sub(r"\(.*", "", x) for x in t["rows"][1][0].split()] # 메쌓기(㎝) 찰쌓기(㎝)
|
||
found = {label: [] for label in labels}
|
||
for cell in t["rows"][1][1:6]:
|
||
for label, part in zip(labels, cell.split()):
|
||
lo, hi = _range(part)
|
||
found[label].append([lo, hi])
|
||
out = {}
|
||
for label in sorted(set(found) | set(doc["table_cm"])):
|
||
data = [[_f(a), _f(b)] for a, b in doc["table_cm"].get(label, [])]
|
||
if found.get(label) != data:
|
||
out[f"table_cm/{label}"] = {"md": found.get(label), "data": doc["table_cm"].get(label)}
|
||
if _steps(t["rows"][0]) != doc["steps_m"]:
|
||
out["steps_m"] = {"md": _steps(t["rows"][0]), "data": doc["steps_m"]}
|
||
return out
|
||
|
||
|
||
def _stone_grid(table: dict) -> dict[str, dict[str, float | None]]:
|
||
"""뭉친 칸 표(`야면석(㎥) 깬잡석(㎥)` | `0.06 0.09`) → {돌: {뒷길이: 값}}."""
|
||
lengths = [re.sub(r"[^\d]", "", h) for h in table["headers"][1:] if re.search(r"\d+㎝", h)]
|
||
grid = {}
|
||
for row in table["rows"]:
|
||
names = [re.sub(r"\(.*", "", n) for n in re.findall(r"[가-힣]+\s*[가-힣]*\(㎥\)", row[0])]
|
||
names = [_sq(n) for n in names]
|
||
for name in names:
|
||
grid[name] = {}
|
||
for length, cell in zip(lengths, row[1:]):
|
||
for name, part in zip(names, cell.split()):
|
||
grid[name][length] = _num(part)
|
||
return grid
|
||
|
||
|
||
def _stone_kind(doc: dict, md: dict) -> dict:
|
||
out = {}
|
||
first = {"야면석·호박돌": "야면석", "깬잡석": "깬잡석", "깬돌": "깬돌", "견치돌": "견치돌"}
|
||
for field, table in (("wedge_stone_m3_per_m2", "F0408"), ("fill_concrete_m3_per_m2", "F0410")):
|
||
grid = _stone_grid(md["tables"][table])
|
||
fill_rows = {
|
||
"깬돌": "깬돌",
|
||
"견치돌": "견치돌",
|
||
"깬잡석": "깬잡석",
|
||
"야면석·호박돌": "야면석",
|
||
}
|
||
for kind in doc["kinds"]:
|
||
name = first[kind] if field.startswith("wedge") else fill_rows[kind]
|
||
data = {k: _f(v) for k, v in doc[field][kind].items()}
|
||
if grid.get(name) != data:
|
||
out[f"{field}/{kind}"] = {"md": grid.get(name), "data": doc[field][kind]}
|
||
if field.startswith("fill") and grid.get("야면석") != grid.get("호박돌"):
|
||
out[f"{field}/호박돌"] = {"md": grid.get("호박돌"), "data": "야면석과 한 줄로 묶음"}
|
||
lengths = [int(re.sub(r"[^\d]", "", h)) for h in md["tables"]["F0408"]["headers"][1:]]
|
||
if lengths != doc["back_lengths_cm"]:
|
||
out["back_lengths_cm"] = {"md": lengths, "data": doc["back_lengths_cm"]}
|
||
if [first[k] for k in doc["kinds"]] != list(_stone_grid(md["tables"]["F0408"])):
|
||
out["kinds"] = {"md": list(_stone_grid(md["tables"]["F0408"])), "data": doc["kinds"]}
|
||
return out
|
||
|
||
|
||
def _masonry_class(doc: dict, md: dict) -> dict:
|
||
out = {}
|
||
back = [
|
||
int(_numbers(c).pop()) for c in md["tables"]["F0407"]["rows"][0] if _sq(c).endswith("이하")
|
||
]
|
||
if back != [c["max_cm"] for c in doc["back_length"]["classes"]]:
|
||
out["back_length"] = {
|
||
"md": back,
|
||
"data": [c["max_cm"] for c in doc["back_length"]["classes"]],
|
||
}
|
||
boulder = []
|
||
for cell in md["tables"]["F0419"]["rows"][0]:
|
||
if nums := re.findall(r"\d+", cell):
|
||
boulder.append(f"{nums[0]}~{nums[1]}")
|
||
if boulder != doc["boulder_diameter"]["classes"]:
|
||
out["boulder_diameter"] = {"md": boulder, "data": doc["boulder_diameter"]["classes"]}
|
||
for name, code in doc["bond"]["codes"].items():
|
||
number = code.removeprefix("FP-").replace("-0", "-").lstrip("0")
|
||
if not any(x.strip() == f"### {number}. {name}" for x in md["lines"]):
|
||
out[f"bond/{name}"] = {"md": f"### {number}. {name} 제목 없음", "data": code}
|
||
return out
|
||
|
||
|
||
def _machine_productivity(doc: dict, md: dict) -> dict:
|
||
out, var = {}, doc["variables"]
|
||
speed = {} # (궤도, 톤, 단) → [전진, 후진]
|
||
for track, table in zip(("무한궤도", "타이어"), md["dozer_tables"]):
|
||
gears = [_sq(c) for c in table["rows"][0]]
|
||
fwd = [i for i, h in enumerate(table["headers"]) if "전진" in h][0]
|
||
rev = [i for i, h in enumerate(table["headers"]) if "후진" in h][0]
|
||
for row in table["rows"][1:]:
|
||
ton = re.match(r"\d+", row[0]).group()
|
||
for g in range(1, rev - fwd + 1):
|
||
f, r = (
|
||
_num(row[fwd + g - 1]),
|
||
_num(row[rev + g - 1]) if rev + g - 1 < len(row) else None,
|
||
)
|
||
if f is not None and r is not None:
|
||
speed[(track, ton, g)] = [f, r]
|
||
assert gears[fwd] == "1단" or gears[fwd].startswith("1")
|
||
data = {
|
||
(r["track"], r["tonnage_ton"], r["gear"]): [
|
||
float(r["forward_m_per_min"]),
|
||
float(r["reverse_m_per_min"]),
|
||
]
|
||
for r in var["dozer_speed"]["records"]
|
||
}
|
||
for key in sorted(set(speed) | set(data)):
|
||
if speed.get(key) != data.get(key):
|
||
out["dozer_speed/" + "/".join(map(str, key))] = {
|
||
"md": speed.get(key),
|
||
"data": data.get(key),
|
||
}
|
||
# 삽날 용량 — 원문 md 가 표를 한 줄로 뭉갬(`0.51.11.5…`) · 톤 차례는 속도 표 둘에서 옴
|
||
tons = sorted({int(k[1]) for k in speed} | {int(k[1]) for k in data})
|
||
line = next(x for x in md["const_lines"] if "q" in x and "무한궤도" in x and "타 이 어" in x)
|
||
blades = {}
|
||
for track, part in zip(
|
||
("무한궤도", "타이어"), re.split(r"타\s*이\s*어", line.split("무한궤도", 1)[1])
|
||
):
|
||
for ton, token in zip(tons, re.findall(r"\d\.\d|-", part)):
|
||
if token != "-":
|
||
blades[(track, str(ton))] = float(token)
|
||
data = {
|
||
(r["track"], r["tonnage_ton"]): float(r["blade_m3"]) for r in var["dozer_blade"]["records"]
|
||
}
|
||
for key in sorted(set(blades) | set(data)):
|
||
if blades.get(key) != data.get(key):
|
||
out["dozer_blade/" + "/".join(key)] = {"md": blades.get(key), "data": data.get(key)}
|
||
# 덤프 — 원문이 표가 아니라 식 글(`t3=1.1`). **기호 자리**에 적힌 수와 맞댐
|
||
haul = _section(md["lines"], "10-12-1.")
|
||
for r in var["dump_haul"]["records"]:
|
||
found = _symbol_values(haul, DUMP_HAUL_SYMBOLS[r["key"]])
|
||
if float(r["value"]) not in found:
|
||
out[f"dump_haul/{r['key']}"] = {"md": sorted(found), "data": r["value"]}
|
||
for i, r in enumerate(var["dump_material"]["records"], 1):
|
||
text = _section(md["lines"], f"10-12-{i}.")
|
||
for field, pattern in DUMP_MATERIAL_SYMBOLS.items():
|
||
found = _symbol_values(text, pattern)
|
||
if float(r[field]) not in found:
|
||
out[f"dump_material/{r['label']}/{field}"] = {"md": sorted(found), "data": r[field]}
|
||
return out
|
||
|
||
|
||
#: 덤프 밑값 → 10-12-1 글의 기호 자리.
|
||
DUMP_HAUL_SYMBOLS = {
|
||
"truck_ton": r"덤프트럭\((\d+)ton\)",
|
||
"bucket_m3": r"q0=([\d.]+)",
|
||
"loader_cycle_sec": r"㎝s=(\d+)",
|
||
"speed_loaded_kmh": r"V1\(적재\)\s*\|\s*([\d.]+)",
|
||
"speed_empty_kmh": r"V2\(공차\)\s*\|\s*([\d.]+)",
|
||
"unload_min": r"t3=([\d.]+)",
|
||
"wait_min": r"t4=([\d.]+)",
|
||
"cover_min": r"t5=([\d.]+)",
|
||
"loading_cycle_sec": r"㎝=(\d+)\(180°\)",
|
||
}
|
||
#: 재료별 값 → 그 재료 절(10-12-1·2·3) 글의 기호 자리. `E=` 는 `Es=`·`E0=` 와 가름.
|
||
DUMP_MATERIAL_SYMBOLS = {
|
||
"unit_weight_ton_per_m3": r"γt=([\d.]+)",
|
||
"loose_factor": r"f=1/([\d.]+?)=?[,\s]",
|
||
"bucket_factor": r"(?<![A-Za-z0-9])K=([\d.]+)",
|
||
"loader_efficiency": r"Es=([\d.]+)",
|
||
"truck_efficiency": r"(?<![A-Za-z0-9])E=([\d.]+)",
|
||
"loading_efficiency": r"E0=([\d.]+)",
|
||
}
|
||
|
||
|
||
def _symbol_values(text: str, pattern: str) -> set[float]:
|
||
return {float(m.rstrip(".")) for m in re.findall(pattern, text)}
|
||
|
||
|
||
CHECKS = {
|
||
"coef": _coef,
|
||
"material_surcharge": _material_surcharge,
|
||
"formwork_reuse": _formwork_reuse,
|
||
"rebar_complexity": _rebar_complexity,
|
||
"timber_structure_class": _timber_structure_class,
|
||
"masonry_slope": _masonry_slope,
|
||
"masonry_back_length": _masonry_back_length,
|
||
"stone_kind": _stone_kind,
|
||
"masonry_class": _masonry_class,
|
||
"machine_productivity": _machine_productivity,
|
||
}
|
||
|
||
|
||
# ── 맞대기 ──────────────────────────────────────────────────────────────
|
||
def load_sources() -> dict:
|
||
lines = MD.read_text(encoding="utf-8").splitlines()
|
||
tables = {t["id"]: t for t in md_tables(lines)}
|
||
const_lines = CONST_MACHINE_MD.read_text(encoding="utf-8").splitlines()
|
||
dozer = [t for t in md_tables(const_lines) if "전진속도" in "".join(t["headers"])][:2]
|
||
notes_12_3 = []
|
||
for x in lines[tables["F0335"]["end"] :]:
|
||
if x.strip().startswith("[주]"):
|
||
notes_12_3.append(x)
|
||
break
|
||
return {
|
||
"md": {
|
||
"lines": lines,
|
||
"tables": tables,
|
||
"const_lines": const_lines,
|
||
"dozer_tables": dozer,
|
||
"notes_12_3": " ".join(notes_12_3),
|
||
},
|
||
"docs": {
|
||
k: json.loads((ROOT / "resources" / p).read_text(encoding="utf-8"))
|
||
for k, p in FILES.items()
|
||
},
|
||
}
|
||
|
||
|
||
def current_mismatches(src: dict) -> dict[str, dict]:
|
||
return {kind: check(src["docs"][kind], src["md"]) for kind, check in CHECKS.items()}
|
||
|
||
|
||
def load_known() -> dict[str, dict]:
|
||
return json.loads(KNOWN.read_text(encoding="utf-8"))
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def src() -> dict:
|
||
return load_sources()
|
||
|
||
|
||
@pytest.mark.parametrize("kind", list(FILES))
|
||
def test_칸마다_재거나_못잰다고_적힘(src: dict, kind: str) -> None:
|
||
"""자료의 칸이 셋 중 하나 — 재는 칸 · 못 재는 칸(까닭) · 글 칸. 새 칸이 생기면 빨강."""
|
||
fields = set(src["docs"][kind]) - TEXT_FIELDS
|
||
declared = CHECKED[kind] | set(NOT_CHECKED.get(kind, {}))
|
||
assert fields == declared, (
|
||
f"{kind} — 안 적힌 칸 {sorted(fields - declared)} · 없는 칸 {sorted(declared - fields)}"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("kind", list(FILES))
|
||
def test_알려진_어긋남_밖은_없음(src: dict, kind: str) -> None:
|
||
"""알려진 목록과 똑같아야 함 — 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강(`why` 는 뺌)."""
|
||
now = current_mismatches(src)[kind]
|
||
known = {k: _without_why(v) for k, v in load_known()[kind].items()}
|
||
msgs = [
|
||
f" 새로 어긋남 {k}: {json.dumps(now[k], ensure_ascii=False)}"
|
||
for k in sorted(set(now) - set(known))
|
||
]
|
||
msgs += [
|
||
f" 모습 바뀜 {k}: 목록 {json.dumps(known[k], ensure_ascii=False)}\n 지금 {json.dumps(now[k], ensure_ascii=False)}"
|
||
for k in sorted(set(now) & set(known))
|
||
if now[k] != known[k]
|
||
]
|
||
msgs += [f" 고쳐짐 {k} — 목록에서 지울 것(--prune)" for k in sorted(set(known) - set(now))]
|
||
assert not msgs, f"{kind} {len(msgs)}건 — 원문 ↔ 자료:\n" + "\n".join(msgs)
|
||
|
||
|
||
if __name__ == "__main__" and "--prune" in sys.argv:
|
||
# 고쳐졌거나 모습이 바뀐 줄만 지움 — 새 어긋남은 안 보탬(보태면 빨강이 조용히 묻힘).
|
||
current = current_mismatches(load_sources())
|
||
data = load_known()
|
||
for kind in FILES:
|
||
before = len(data[kind])
|
||
data[kind] = {
|
||
k: v for k, v in data[kind].items() if current[kind].get(k) == _without_why(v)
|
||
}
|
||
print(f"{kind}: {before} → {len(data[kind])}")
|
||
KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|