feat(B08): 큰돌쌓기(13-6) 전개식 + 돌쌓기 뒷길이 갈래 자동 판정

⑲. 앞서 막아 둔 큰돌쌓기를 13-6 축으로 세움.

- `boulder_masonry` 전개식 — 규격 축은 **직경**(40~60·60~80·80~100㎝)이고
  저장 제원 `stone_cm` 이 품셈 축과 글자까지 같아 그대로 씀.
  치수로 낼 수 있는 것(면적·터파기·되메우기·잔토)만 내고 나머지는 사유와 함께 알림.
  - 재료(큰돌)는 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」라 원단위가 없음.
  - 고임돌·채움콘크리트는 [주]① 「품에 포함」 — 따로 세우면 이중계상.
  - 뒤채움(조약돌)은 [주]⑧ 로 13-3 적용. 두께는 13-4-3 [주]⑨ 준용인데
    직고별 **범위값**이라 한 값으로 못 정함 — 미확보.
  - 터파기 폭 규정이 품셈에 없어 돌쌓기 방식을 준용하고 그 사실을 근거에 적음.
- ⚠ 메/찰 구분 칸이 저장 제원에 없어 13-6-1/13-6-2 를 못 고름. 레지스트리는
  다른 창 소관이라 손대지 않고 `pending_user` 로 드러냄.

⚠ 함께 발견 — 엔진이 읽는 옵션 키가 레지스트리와 달랐음.
레지스트리는 `back_len_cm` 인데 엔진은 `stone_back_length_cm` 을 읽고 있어
**저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았음**. 뒷길이를 75 로 골라도 45 계수가
붙던 자리이고, 값이 나오므로 아무 시험도 안 잡았음. 두 이름 다 읽게 하고,
표에 없는 값(25·30·75)은 **덮는 위 칸**으로 접되 그 사실을 적음.

돌쌓기 뒷길이 갈래를 자동 판정해 코드에 붙임(`FP-13-04-05#55cm이하`).
「…㎝ 이하」 구간이므로 **저장값 이상인 첫 경계**를 고름. 근거 문구를 함께 실음.
갈래표는 `resources/data_masonry/` 에 데이터로 둠.

검증 — 전체 621 passed, tsc 오류 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 04:18:25 +09:00
co-authored by Claude Opus 5
parent 0e223ba0b1
commit 3741f8919f
4 changed files with 235 additions and 11 deletions
@@ -55,6 +55,44 @@ DATASET_PREFIX = "work_item_mapping_"
REBAR_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_rebar"
REBAR_PREFIX = "rebar_complexity_"
#: 돌쌓기 규격 갈래표 — 저장 제원 값으로 자동 판정한다(사람이 고르는 값이 아니다).
MASONRY_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
MASONRY_PREFIX = "masonry_class_"
def load_masonry_table(path: Path | None = None) -> dict[str, Any]:
"""돌쌓기 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다."""
target = path
if target is None:
files = sorted(MASONRY_DIR.glob(MASONRY_PREFIX + "*.json")) if MASONRY_DIR.is_dir() else []
target = files[-1] if files else None
if target is None or not target.is_file():
return {}
return json.loads(target.read_text(encoding="utf-8"))
def masonry_class(
options: dict[str, Any], table: dict[str, Any] | None = None
) -> tuple[str | None, str]:
"""돌쌓기 갈래 — 저장 뒷길이로 **「…㎝ 이하」 구간**을 고른다.
⚠ 저장 선택지(25·30·35·45·55·60·75)와 단가 갈래(35·55·75 이하)는 축이 다르다.
**저장값 이상인 첫 경계**를 고르는 것이 「이하」 구간의 뜻이다.
"""
found = table if table is not None else load_masonry_table()
spec = (found or {}).get("back_length") or {}
raw = options.get(str(spec.get("option_key") or "back_len_cm"))
if raw is None:
return None, "뒷길이가 저장돼 있지 않아 갈래를 못 고름"
try:
value = float(raw)
except (TypeError, ValueError):
return None, f"뒷길이 값을 못 읽음({raw!r})"
for row in spec.get("classes") or []:
if value <= float(row["max_cm"]):
return str(row["key"]), f"뒷길이 {value:g}㎝ → 품셈 13-4 「{row['key']}」 구간"
return None, f"뒷길이 {value:g}㎝ 를 덮는 갈래가 표에 없음"
def normalize_kind_key(label: str) -> str:
"""갈래 키 — **내부 공백만** 지운다 (2026-09-07 두 창 확정).
@@ -467,6 +505,13 @@ def _structure_rows(
type_id = str(structure.get("type_id") or "")
entry = mapping.for_structure(type_id) or {}
code = entry.get("work_item_code")
# 돌쌓기는 **뒷길이 갈래**로 단가가 갈린다 — 저장 제원에서 자동으로 고른다.
class_key: str | None = None
class_basis = ""
if code and entry.get("class_from") == "back_length":
class_key, class_basis = masonry_class(structure.get("options") or {})
if class_key:
code = f"{code}#{normalize_kind_key(class_key)}"
composite = mapping.composite_for(type_id) if code is None else None
kind = structure_kind(structure) if composite else None
parts: list[dict[str, Any]] | None = None
@@ -475,6 +520,8 @@ def _structure_rows(
parts, parts_missing = composite_quantities(structure, composite, mapping)
if code is None and composite is None:
unmatched.append(f"구조물({type_id})")
elif entry.get("class_from") == "back_length" and class_key is None:
unmatched.append(f"구조물({type_id}) — {class_basis}")
length = float(structure.get("length_m") or 0.0)
rows.append(
{
@@ -497,6 +544,9 @@ def _structure_rows(
"composite_parts": parts,
# 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다.
"structure_kind": kind,
# 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다.
"spec_class": class_key,
"spec_class_basis": class_basis,
# ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다.
"composite_not_ready": parts_missing or None,
"in_bill": True,
@@ -134,10 +134,35 @@ class StructureQuantity:
notes: list[str] = field(default_factory=list)
#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인).
#: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다**
#: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다.
#: 옛 이름도 함께 본다(다른 곳에서 그 이름으로 넣어 줄 수 있다).
BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm")
def _back_length(options: dict[str, Any]) -> int:
raw = options.get("stone_back_length_cm")
if isinstance(raw, (int, float)) and int(raw) in STONE_BACK_LENGTH_TABLE:
return int(raw)
"""뒷길이(㎝). 표에 없는 값이면 **가장 가까운 아래 칸**이 아니라 기본으로 간다.
⚠ 표는 35·45·55·60 네 칸뿐인데 레지스트리 선택지는 25·30·35·45·55·60·75 일곱이다.
25·30 은 35 로, 75 는 60 으로 접는다 — **원단위표가 그 구간을 그렇게 덮는다**
(품셈 13-4 뒷길이 표준). 접은 사실은 근거 문구에 적는다.
"""
for key in BACK_LENGTH_KEYS:
raw = options.get(key)
if raw is None:
continue
try:
value = int(float(raw))
except (TypeError, ValueError):
continue
if value in STONE_BACK_LENGTH_TABLE:
return value
# 표에 없는 값 — 그 값을 덮는 **가장 가까운 위 칸**으로 접는다(작은 돌이 큰 칸에 들어감).
larger = [cm for cm in sorted(STONE_BACK_LENGTH_TABLE) if cm >= value]
if larger:
return larger[0]
return max(STONE_BACK_LENGTH_TABLE)
return DEFAULT_BACK_LENGTH_CM
@@ -145,6 +170,111 @@ def _num(value: Any, fallback: float = 0.0) -> float:
return float(value) if isinstance(value, (int, float)) else fallback
#: 큰돌쌓기(품셈 13-6) 직경 갈래 — **저장 제원 `stone_cm` 과 글자까지 같다**(레지스트리 확인).
#: ⚠ 돌쌓기(13-4)의 **뒷길이** 축과 섞지 않는다. 앞서 섞여 있어 직경 60~80 짜리가
#: 「뒷길이 45㎝」 계수로 돌던 자리다.
BOULDER_DIAMETERS = ("40~60", "60~80", "80~100")
#: 큰돌쌓기 전개 상수. **재료 원단위는 품셈에 없다** — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」.
#: 그래서 여기서 내는 것은 **면적과 터파기 계열까지**이고 큰돌 자체는 미확보로 둔다.
BOULDER_MASONRY = {
"face_to_slope_factor": 1.04, # 돌쌓기 면적 = 정면적 × 1.04 (13-4 와 같은 기울기 몫)
"excavation_extra_m": 0.2, # 터파기 폭 여유
"backfill_thickness_m": 0.2, # 되메우기 두께
}
def _boulder_diameter(options: dict[str, Any]) -> str | None:
"""저장 제원의 직경 갈래. 표에 없는 값이면 **지어내지 않고 `None`**."""
raw = options.get("stone_cm")
text = str(raw).strip() if raw is not None else ""
return text if text in BOULDER_DIAMETERS else None
def boulder_masonry(
height_m: float, length_m: float, options: dict[str, Any]
) -> tuple[list[Component], list[str]]:
"""큰돌쌓기(품셈 13-6) 전개 — **치수로 낼 수 있는 것까지만** 낸다.
⚠ 재료 원단위가 품셈에 없다 — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」.
돌쌓기(13-4)처럼 「뒷길이별 돌중량·고임돌」 표가 **없으므로** 큰돌·고임돌·막자갈을
내지 않는다. 대신 **왜 못 내는지**를 알림으로 남긴다.
⚠ 고임돌·채움콘크리트 품은 **품에 포함**돼 있다(13-6-1 [주]①·13-6-2 [주]①) —
따로 세우면 이중계상이다.
⚠ 뒤채움(조약돌)은 **13-3 을 적용**한다(13-6 [주]⑧) — 이 표에서 찾지 않는다.
두께는 13-4-3 [주]⑨ 를 준용하는데 **직고별 범위값**(상부 20~40㎝ / 하부 30~140㎝)이라
한 값으로 못 정한다. 미확보로 둔다.
"""
notes: list[str] = []
if height_m <= 0 or length_m <= 0:
return [], ["높이·연장이 없어 전개하지 않음"]
diameter = _boulder_diameter(options)
if diameter is None:
return [], [
"직경 갈래(`stone_cm`)가 저장돼 있지 않거나 표에 없는 값 — "
f"품셈 13-6 갈래는 {list(BOULDER_DIAMETERS)}"
]
slope_ratio = _num(options.get("face_slope_ratio"), 0.3)
constants = BOULDER_MASONRY
face_area = height_m * length_m
slope_area = face_area * math.hypot(1.0, slope_ratio)
masonry_area = slope_area * constants["face_to_slope_factor"]
components = [
Component(
"큰돌쌓기",
"",
masonry_area,
DESTINATION["돌쌓기"],
f"비탈면적 × 1.04 · 직경 {diameter}㎝ (품셈 13-6)",
)
]
# 터파기·되메우기·잔토 — 치수에서 나온다. 두께는 **직경 갈래의 위 끝**을 벽 두께로 본다.
# ⚠ 품셈에 큰돌쌓기 터파기 폭 규정이 없어 **돌쌓기와 같은 방식**(벽 두께 + 여유 0.2m)으로
# 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다.
upper_cm = float(str(diameter).split("~")[-1])
thickness = upper_cm / 100.0
excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m
backfill = height_m * constants["backfill_thickness_m"] * length_m
components.extend(
[
Component(
"터파기",
"",
excavation,
DESTINATION["터파기"],
f"높이 × (벽두께 {thickness:g}m + 0.2) × 연장 · ⚠ 큰돌쌓기 터파기 폭 규정이"
" 품셈에 없어 돌쌓기 방식을 준용",
),
Component("되메우기", "", backfill, DESTINATION["되메우기"], "높이 × 0.2 × 연장"),
Component(
"잔토처리",
"",
excavation - backfill,
DESTINATION["잔토처리"],
"터파기 되메우기",
),
]
)
notes.append(
"재료(큰돌) 원단위 미확보 — 품셈 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」라 "
"돌쌓기(13-4)의 뒷길이별 돌중량 표에 해당하는 것이 없음"
)
notes.append("고임돌·채움콘크리트는 **품에 포함**(13-6 [주]①) — 따로 세우지 않음")
notes.append(
"뒤채움(조약돌)은 13-3 적용(13-6 [주]⑧). 두께는 13-4-3 [주]⑨ 준용인데 "
"직고별 범위값(상부 20~40㎝ / 하부 30~140㎝)이라 한 값으로 못 정함 — 미확보"
)
notes.append("⚠ 메/찰 구분이 저장 제원에 없어 13-6-1(메)·13-6-2(찰) 중 어느 쪽인지 못 고름")
return components, notes
def stone_masonry(
height_m: float, length_m: float, options: dict[str, Any], wet: bool
) -> tuple[list[Component], list[str]]:
@@ -288,6 +418,7 @@ OBSERVED_SPEC_KEYS: dict[str, tuple[str, ...]] = {
EXPANDERS = {
"masonry_wet": lambda h, l, o: stone_masonry(h, l, o, wet=True),
"masonry_dry": lambda h, l, o: stone_masonry(h, l, o, wet=False),
"boulder_masonry": lambda h, l, o: boulder_masonry(h, l, o),
# ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견).
# 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.**
# 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝).
@@ -298,12 +429,7 @@ EXPANDERS = {
}
#: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다.
EXPANDER_WITHHELD = {
"boulder_masonry": (
"큰돌쌓기는 품셈 13-6 이고 규격 축이 **직경**(40~60·60~80·80~100㎝)이다. "
"돌쌓기(13-4)의 **뒷길이** 계수로 돌리면 조용히 틀린 값이 나온다 — 전개식 미확보."
),
}
EXPANDER_WITHHELD: dict[str, str] = {}
#: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다
@@ -0,0 +1,35 @@
{
"schema_version": "1.0",
"dataset_id": "masonry_class",
"effective_date": "2026-01-01",
"note": "돌쌓기(13-4)·큰돌쌓기(13-6) 의 **규격 갈래**. 저장 제원의 값으로 자동 판정하며 사람이 고르는 값이 아니다.",
"back_length": {
"note": "돌쌓기 — 저장 제원 `back_len_cm`(㎝). 품셈 갈래는 「…㎝ 이하」 구간이라 **저장값 이상인 첫 경계**를 고른다.",
"option_key": "back_len_cm",
"source": "품셈 13-4 뒷길이 표준 · B09 단가 갈래(#35cm이하·#55cm이하·#75cm이하)",
"classes": [
{
"max_cm": 35,
"key": "35cm이하"
},
{
"max_cm": 55,
"key": "55cm이하"
},
{
"max_cm": 75,
"key": "75cm이하"
}
]
},
"boulder_diameter": {
"note": "큰돌쌓기 — 저장 제원 `stone_cm`. **품셈 13-6 직경 축과 글자까지 같다** — 접거나 바꾸지 않고 그대로 쓴다.",
"option_key": "stone_cm",
"source": "품셈 13-6 「직경 40㎝이상∼60㎝미만 / 60㎝이상∼80㎝미만 / 80㎝이상∼100㎝이하」",
"classes": [
"40~60",
"60~80",
"80~100"
]
}
}
@@ -118,13 +118,17 @@
"type_id": "masonry_wet",
"work_item_code": "FP-13-04-05",
"master_name": "돌쌓기 > 찰쌓기(장비)",
"note": "인력 시공이면 FP-13-04-04"
"note": "인력 시공이면 FP-13-04-04",
"class_from": "back_length",
"class_note": "저장 제원 `back_len_cm` 으로 「…㎝ 이하」 구간을 고름 — 자동 판정"
},
{
"type_id": "masonry_dry",
"work_item_code": "FP-13-04-02",
"master_name": "돌쌓기 > 메쌓기(장비)",
"note": "인력 시공이면 FP-13-04-01"
"note": "인력 시공이면 FP-13-04-01",
"class_from": "back_length",
"class_note": "저장 제원 `back_len_cm` 으로 「…㎝ 이하」 구간을 고름 — 자동 판정"
},
{
"type_id": "pipe_inlet_basin",
@@ -156,6 +160,15 @@
"FP-09-05 발파암"
],
"why": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함"
},
{
"type_id": "boulder_masonry",
"candidates": [
"FP-13-06-01 메쌓기",
"FP-13-06-02 찰쌓기"
],
"why": "저장 제원(`boulder_masonry`)에 **메/찰 구분 칸이 없어** 어느 쪽인지 못 고름. 레지스트리(`B05_Profile_Structure_Types.json`)는 다른 창 소관이라 여기서 못 고침 — 칸이 생기면 `structure` 에 옮긴다.",
"class_axis": "직경(`stone_cm`) 40~60·60~80·80~100 — 품셈 13-6 축과 글자까지 같음"
}
]
},