test(z01): 기초데이터 열(품셈 근거) 원문 맞대기 — 칸 단위 대조 · 못 재는 칸은 까닭과 함께

브레인 일감 ③ 앞 절반. 기초값 열다섯 중 품셈 원문에서 옮겨 적은 열을 원문 표와 칸 단위로 맞댐
(토량환산 · 자재 할증 · 거푸집 전용 · 철근 갈래 · 목구조 갈래 · 돌쌓기 경사·뒷길이·돌 종류·갈래 · 기계 작업량).

- 원문 = 산림 품셈 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
This commit is contained in:
2026-09-18 00:42:02 +09:00
co-authored by Claude Opus 5
parent 92fc6d3159
commit fdc81e7520
2 changed files with 672 additions and 0 deletions
@@ -0,0 +1,116 @@
{
"_설명": [
"기초데이터 열(품셈 근거) ↔ 원문 — 지금 알고 있는 어긋남(2026-09-18 랩탑 메인이 시험으로 처음 뜸).",
"시험: resources/tester/test_base_data_source_match.py — 이 목록 밖에서 어긋나면 빨강.",
"고치면 지울 것: python resources/tester/test_base_data_source_match.py --prune (지우기만 함).",
"md=원문에서 읽은 값 · data=자료 값 · why=사람이 적은 까닭(시험은 맞대기에서 뺌).",
"못 재는 칸은 시험 파일 NOT_CHECKED 에 까닭과 함께."
],
"coef": {
"coef_soil_C/11": {
"md": [
"암괴나호박돌이섞인모래",
0.9,
0.95,
null
],
"data": [
"암괴ㆍ호박돌섞인모래",
0.9,
0.95,
null
],
"why": "위와 같음"
},
"coef_soil_L/11": {
"md": [
"암괴나호박돌이섞인모래",
1.4,
1.45,
null
],
"data": [
"암괴ㆍ호박돌섞인모래",
1.4,
1.45,
null
],
"why": "이름을 「암괴ㆍ호박돌 섞인 모래」로 줄여 적음 — 값은 원문과 같음"
}
},
"material_surcharge": {
"이형철근": {
"md": [
"3",
"6-7"
],
"data": [
3,
7
],
"why": "원문 복잡 구조물 주철근 「6-7」 범위 중 위 끝 7 을 씀 — 범위를 살릴지 사람 확인"
}
},
"formwork_reuse": {
"reuse_ratio_pct/timber": {
"md": {
"각재": "비율 칸 비어 있음",
"합판": {
"재료별(%)": [
"100.0",
"57.0",
"46.1",
"40.1",
"37.1",
"34.7"
],
"노무비(%)": [
"100.0",
"60.0",
"47.1",
"40.0",
"34.2",
"32.0"
]
}
},
"data": [
100.0,
60.0,
47.1,
40.0,
34.2,
32.0
],
"why": "⚠ 원문 12-4 는 각재 줄 비율 칸이 비어 있음 — 이 여섯 값은 합판 줄 「노무비(%)」 열인데 각재(timber)라는 이름이 붙음 · 쓰는 곳 확인 필요"
}
},
"rebar_complexity": {
"classes/복잡": {
"md": "12-3 [주]① 에 없는 예시",
"data": [
"교량 슬래브"
],
"why": "원문 「교량의 슬래브」를 줄여 적음"
}
},
"timber_structure_class": {},
"masonry_slope": {},
"masonry_back_length": {},
"stone_kind": {},
"masonry_class": {},
"machine_productivity": {
"dump_material/발파암/truck_efficiency": {
"md": [],
"data": "0.9",
"why": "10-12-3 운반 식에 E 가 없음 — 토사·암절취의 0.9 를 이어 씀"
},
"dump_material/토사/loading_efficiency": {
"md": [
0.75
],
"data": "0.60",
"why": "본문 E0=0.75 대신 10-12-1 [주]⑤ 표 「임도 0.60(불량)」 을 씀 — 자료 loading_note 에 사유"
}
}
}
@@ -0,0 +1,556 @@
"""기초데이터 열(품셈 근거) ↔ 원문 맞대기 — 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.702.00` · `2535` · `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")