feat(b08): 안 고른 돌쌓기 뒷길이를 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한으로 — 범위 안 첫 규격(메 ~3m 36→45) · 전개·양식·구조물도 한 벌 · 사유에 원문 줄 번호(B1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-14 12:28:36 +09:00
co-authored by Claude Opus 5
parent f9ccc8f6fb
commit 7e036ddd51
13 changed files with 241 additions and 26 deletions
+1 -1
View File
@@ -1945,7 +1945,7 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_kind",
@@ -27,7 +27,7 @@ from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Pipe import NOTE_DEFAULT_WALL
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_is_wet, slope_of
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
_back_length,
back_length_default_note,
@@ -94,9 +94,14 @@ def back_length_cm(sheet: dict[str, Any]) -> tuple[int, str | None]:
양식 장은 양식이 푼 값(`formula_sheet.vars.L3`), 전개 장은 `_back_length` — 표와 한 벌.
"""
options = sheet.get("options") or {}
wet, height = sheet_is_wet(sheet), float(sheet.get("height_m") or 0.0)
solved = ((sheet.get("formula_sheet") or {}).get("vars") or {}).get("L3")
back = int(float(solved)) if isinstance(solved, (int, float)) else _back_length(options)
return back, back_length_default_note(options, back)
back = (
int(float(solved))
if isinstance(solved, (int, float))
else _back_length(options, wet=wet, height_m=height)
)
return back, back_length_default_note(options, back, wet=wet, height_m=height)
def figure_reason(sheet: dict[str, Any]) -> str | None:
@@ -103,23 +103,28 @@ SLOPE_TYPE_IDS: frozenset[str] = frozenset(
REVETMENT_STONE_FORMS: dict[str, bool] = {"돌쌓기(찰)": True, "돌쌓기(메)": False}
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
""" 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다.
def sheet_is_wet(sheet: dict[str, Any]) -> bool:
"""의 메/찰 — 표준경사·뒷길이 표준이 같은 갈래로 읽음.
메/찰은 종류에서 온다. 큰돌쌓기는 `bond` 칸(메쌓기/찰쌓기)이 그것이고, 안 고르면
찰쌓기로 본다 — 품셈 13-6 은 둘 다 「1:0.3 **이상**」이라 그림 기울기가 갈리지 않는다.
큰돌쌓기는 `bond` 칸(메쌓기/찰쌓기)이 그것이고, 안 고르면 찰쌓기로 본다 — 품셈 13-6 은
둘 다 「1:0.3 **이상**」이라 그림 기울기가 갈리지 않는다.
"""
options = sheet.get("options") or {}
type_id = str(sheet.get("type_id") or "")
if type_id == "masonry_dry":
wet = False
elif type_id == "boulder_masonry":
wet = options.get("bond") != "메쌓기"
elif type_id == "revetment":
return False
if type_id == "boulder_masonry":
return options.get("bond") != "메쌓기"
if type_id == "revetment":
# 표(기슭막이 전개)가 넘긴 그 갈래 — 형태 「돌쌓기(메)」만 메.
wet = REVETMENT_STONE_FORMS.get(str(options.get("form") or "").strip(), True)
else:
wet = True
return REVETMENT_STONE_FORMS.get(str(options.get("form") or "").strip(), True)
return True
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
"""장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다."""
options = sheet.get("options") or {}
wet = sheet_is_wet(sheet)
return face_slope_ratio(
options,
wet=wet,
@@ -16,7 +16,11 @@ import json
from pathlib import Path
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import standard_back_length
ROOT = Path(__file__).resolve().parents[1]
#: 양식 칸 `default_from` — 빈 뒷길이를 뒷길이 표준표 하한으로(전개와 한 벌).
STANDARD_BACK_LENGTH = "standard_back_length"
TEMPLATE_DIR = ROOT / "resources" / "library_structure"
#: 사용자가 고친 식이 사는 칸 — B08 산출 조건 구획(`project_settings.json` 의 `quantity`).
@@ -75,6 +79,12 @@ def template_vars(
raw = options.get(spec["option"])
else:
raw = (settings or {}).get(spec.get("setting"))
if raw in (None, "") and spec.get("default_from") == STANDARD_BACK_LENGTH:
# 안 고른 뒷길이 — 전개와 같은 표준표 하한(`standard_back_length`, 브레인 판정 ㉮).
wet = template.get("type_id") != "masonry_dry"
height = float(structure.get("height_m") or 0.0)
values[name] = float(standard_back_length(wet=wet, height_m=height)[0])
continue
values[name] = default if raw in (None, "") else _typed(raw, default)
return values
@@ -260,14 +260,14 @@ def stone_masonry(
if height_m <= 0 or length_m <= 0:
return [], ["높이·연장이 없어 전개하지 않음"]
back_cm = _back_length(options)
back_cm = _back_length(options, wet=wet, height_m=height_m)
if back_cm not in STONE_BACK_LENGTH_TABLE:
# ⚠ 접지 않는다 — 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다.
return [], [
f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 물량이 서지 않습니다"
]
table = dict(STONE_BACK_LENGTH_TABLE[back_cm])
default_note = back_length_default_note(options, back_cm)
default_note = back_length_default_note(options, back_cm, wet=wet, height_m=height_m)
if default_note:
notes.append(default_note)
# ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3).
@@ -444,8 +444,57 @@ def wants_blinding(options: dict[str, Any]) -> bool:
BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm")
def _back_length(options: dict[str, Any]) -> int:
"""저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 기본 45.
#: 뒷길이 표준표 — 품셈 13-4-4 [주]⑩. 안 고른 돌쌓기는 붙박이 45 가 아니라 이 표의 하한
#: (2026-09-14 브레인 판정 ㉮ 「범위 안에 드는 규격 중 가장 작은 것」). 파일이 없으면 종전 45.
BACK_LENGTH_PREFIX = "masonry_back_length_"
@lru_cache(maxsize=1)
def load_back_length_table() -> dict[str, Any]:
"""뒷길이 표준표. 없으면 빈 표 — 그때는 종전 기본 45㎝ 로 돎."""
files = sorted(SLOPE_DIR.glob(BACK_LENGTH_PREFIX + "*.json")) if SLOPE_DIR.is_dir() else []
return json.loads(files[-1].read_text(encoding="utf-8")) if files else {}
def standard_back_length(*, wet: bool, height_m: float) -> tuple[int, str]:
"""(제안 뒷길이 ㎝, 근거) — 높이·메/찰 칸 범위 안 품셈 규격 중 가장 작은 것.
⚠ 하한이 규격(25·30·35·45·55·60·75)에 없으면 **그 위 첫 규격** — 메쌓기 ~3m 하한 36 은 45.
내리면 품셈 범위 밖이 됨. 경계값은 표준경사표와 같이 **앞 칸**(높이 3.0m → `~3`).
"""
table = load_back_length_table()
steps = table.get("steps_m") or []
bond = "찰쌓기" if wet else "메쌓기"
rows = (table.get("table_cm") or {}).get(bond) or []
if not rows:
return DEFAULT_BACK_LENGTH_CM, f"뒷길이 표준표를 못 읽어 종전 {DEFAULT_BACK_LENGTH_CM}"
index = next((i for i, limit in enumerate(steps) if height_m <= float(limit)), len(steps))
low, high = rows[min(index, len(rows) - 1)]
label = f"직고 {height_m:g}m" + (f"{steps[index]:g}m" if index < len(steps) else " 7m 초과")
span = f"{low}~{high}" if high is not None else f"{low}㎝ 이상"
picked = next(
(
size
for size in sorted(STONE_BACK_LENGTH_TABLE)
if size >= low and (high is None or size <= high)
),
None,
)
head = f"품셈 13-4-4 [주]⑩ 뒷길이 표준(원문 L7172~7181) · {bond} {label} {span}"
if picked is None:
return (
DEFAULT_BACK_LENGTH_CM,
f"{head} 안에 품셈 규격이 없어 종전 {DEFAULT_BACK_LENGTH_CM}",
)
tail = "" if picked == low else f"(하한 {low}㎝ 는 규격 일곱(원문 L7152)에 없어 그 위 첫 규격)"
return picked, f"{head} → 하한 {picked}{tail}"
def _back_length(
options: dict[str, Any], *, wet: bool | None = None, height_m: float | None = None
) -> int:
"""저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 뒷길이 표준표 하한(메/찰·높이를 받으면),
못 받는 자리(골막이 등)는 종전 기본 45.
⚠ **접지 않는다.** 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을
다 주므로 접을 까닭이 없다. 그 밖의 값(40 등)은 **계수가 없다고 드러낸다** —
@@ -463,13 +512,26 @@ def _back_length(options: dict[str, Any]) -> int:
# 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측).
# 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다.
return value
return DEFAULT_BACK_LENGTH_CM
if wet is None or height_m is None:
return DEFAULT_BACK_LENGTH_CM
return standard_back_length(wet=wet, height_m=height_m)[0]
def back_length_default_note(options: dict[str, Any], back_cm: int) -> str | None:
def back_length_default_note(
options: dict[str, Any],
back_cm: int,
*,
wet: bool | None = None,
height_m: float | None = None,
) -> str | None:
"""뒷길이를 안 골라 기본으로 섰을 때의 한 줄 — 표 사유·구조물도 그림이 **같은 말**을 씀."""
if any(options.get(key) not in (None, "") for key in BACK_LENGTH_KEYS):
return None
if wet is not None and height_m is not None:
return (
f"⚠ 뒷길이를 안 골라 {standard_back_length(wet=wet, height_m=height_m)[1]} 로 섰습니다 "
"— 구조물 상세에서 고르면 바뀜 · 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
)
return (
f"⚠ 뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 "
"— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
@@ -0,0 +1,40 @@
{
"schema_version": "1.0",
"dataset_id": "masonry_back_length_standard",
"effective_date": "2026-01-01",
"note": "돌쌓기 뒷길이 표준 — **높이 × 메/찰** 로 범위가 갈린다. 뒷길이를 안 고른 돌쌓기는 붙박이 45㎝ 로 서고 있었는데, 그 자리를 이 표의 **하한**으로 바꿈(2026-09-14 브레인 판정). 사용자가 칸에서 고르면 그 값이 이김.",
"source": {
"doc": "산림사업 표준품셈(산림청고시 제2025-82호) 13-4-4 [주]⑩ <뒷길이 표준>",
"line": "원문 L7172~7181",
"quote": "높은 메쌓기는 밑으로 내려갈수록 뒷길이를 길게 하는 것이 원칙이다 · 높이는 첨단으로부터 기준 한다 · 특수한 조건에서는 본 기준과 달리 적용할 수 있다"
},
"policy": {
"suggestion": "⭐ 제안값 = **범위 안에 드는 품셈 규격 중 가장 작은 것**(브레인 판정 ㉮). 하한이 규격(25·30·35·45·55·60·75)에 없으면 그 위 첫 규격 — 메쌓기 ~3m·~5m 하한 36㎝ 는 45㎝. 35 로 내리면 품셈 범위 아래로 나가 안 됨.",
"boundary": "⚠ 원문 표기 `∼1.5 · ∼3 · ∼5 · ∼7 · 7이상` — 표준경사 표(`masonry_slope`)와 같이 「∼」를 **이하**로 읽어 경계값은 앞 칸(높이 3.0m → `~3` 칸).",
"no_write_back": "⚠ 제안값을 저장에 박지 않음 — 「안 고름」과 「45 로 고름」이 구별돼야 함(표준경사와 같은 자리)."
},
"steps_m": [1.5, 3.0, 5.0, 7.0],
"table_cm": {
"메쌓기": [
[25, 35],
[36, 45],
[36, 60],
[45, 75],
[75, null]
],
"찰쌓기": [
[25, 35],
[30, 35],
[35, 45],
[35, 55],
[45, 60]
]
},
"not_here": {
"note": "이 표가 쓰지 않는 칸.",
"items": [
"돌붙이기 열(메 25~60 · 찰 20~40)은 높이로 안 갈림 — 바닥막이 돌붙임은 뒷길이가 붙박이(`_UnitQuantity_Revetment.BED_SILL_FORMS`)라 여기서 안 씀",
"골막이는 뒷길이 기본이 종전대로 45㎝ — 이 표의 대상(돌쌓기 13-4)이 아님"
]
}
}
+7 -1
View File
@@ -19,7 +19,13 @@
"source": "judged_face_slope",
"note": "사용자 값이 있으면 그 값, 없으면 품셈 13-4-4 [주]⑪ 표준경사를 전개가 판정해 넘김"
},
"L3": { "label": "뒷길이(㎝)", "option": "back_len_cm", "default": 45 },
"L3": {
"label": "뒷길이(㎝)",
"option": "back_len_cm",
"default": 45,
"default_from": "standard_back_length",
"note": "안 고르면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·메/찰 · 범위 안 가장 작은 규격) — 전개와 한 벌(2026-09-14 브레인 판정 ㉮). 45 는 표를 못 읽을 때만"
},
"돌종류": { "label": "돌 종류", "option": "stone_kind", "default": "" },
"계수기준": { "label": "야면석 계수", "option": "stone_coeff_basis", "default": "" },
"TOP_IN": {
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""뒷길이 표준(품셈 13-4-4 [주]⑩) — 안 고른 돌쌓기의 뒷길이는 **표의 하한** (2026-09-14 브레인 판정 ㉮).
앞서 붙박이 45㎝ 로 섰음. 표는 높이 × 메/찰 로 범위를 줌 — 하한이 규격(25·30·35·45·55·60·75)에
없으면 **범위 안 그 위 첫 규격**(메쌓기 ~3m·~5m 하한 36 → 45). 경계값은 앞 칸(표준경사와 같음).
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: E402
_back_length,
back_length_default_note,
standard_back_length,
)
# (높이, 메쌓기 제안, 찰쌓기 제안) — 원문 L7174~7177
CELLS = [
(1.0, 25, 25),
(1.5, 25, 25), # 경계값은 앞 칸
(2.5, 45, 30), # 메 36~45 → 45 · 찰 30~35 → 30
(3.0, 45, 30),
(4.0, 45, 35), # 메 36~60 → 45 · 찰 35~45 → 35
(6.0, 45, 35), # 메 45~75 · 찰 35~55
(7.5, 75, 45), # 메 75이상 · 찰 45~60
]
@pytest.mark.parametrize("height, dry, wet", CELLS)
def test_표_하한(height: float, dry: int, wet: int) -> None:
assert standard_back_length(wet=False, height_m=height)[0] == dry
assert standard_back_length(wet=True, height_m=height)[0] == wet
def test_하한이_규격에_없으면_그_위_첫_규격_사유() -> None:
cm, basis = standard_back_length(wet=False, height_m=2.5)
assert cm == 45
assert "36~45㎝" in basis and "하한 36㎝ 는 규격 일곱(원문 L7152)에 없어" in basis
assert "원문 L7172~7181" in basis # 사용자가 되짚을 줄 번호(브레인 2026-09-14)
def test_고른_값이_이긴다() -> None:
assert _back_length({"back_len_cm": "55"}, wet=True, height_m=2.5) == 55
assert back_length_default_note({"back_len_cm": 55}, 55, wet=True, height_m=2.5) is None
def test_메찰_높이를_모르는_자리는_종전_45() -> None:
assert _back_length({}) == 45
assert "기본 45㎝" in (back_length_default_note({}, 45) or "")
@@ -38,6 +38,9 @@ def _structures() -> list[dict]:
"length_m": 10,
"foundation": "기초유",
"stone_kind": "야면석·호박돌",
# 2026-09-14 — 안 고르면 뒷길이 표준 하한(찰 ≤3m 30㎝)이라 야면석 관측 무게(35·45·55)가
# 없어 돌 줄이 안 섬. 돌 줄을 보는 시험이라 45 로 고름 · 빈 칸 길은 아래 시험.
"back_len_cm": 45,
},
},
{
@@ -76,6 +79,21 @@ def test_양식_갈음은_값을_안_움직인다() -> None:
assert stone["source"] == "uljin_library"
def test_뒷길이_안_고르면_양식도_전개와_같은_표준_하한() -> None:
"""품셈 13-4-4 [주]⑩ 하한(찰 ≤3m 30㎝)을 전개와 양식이 한 벌로 씀(브레인 판정 ㉮)."""
structures = _structures()[:1]
structures[0]["options"].pop("back_len_cm")
structures[0]["options"].pop("stone_kind")
names = {"masonry_wet": "돌쌓기(찰)"}
before = build_table(structures, names, None, None, 0.2, use_templates=False)["structures"][0]
after = build_table(structures, names, None, None, 0.2)["structures"][0]
assert after["library_item"] == "돌쌓기(찰)"
assert [c["name"] for c in after["components"]] == [c["name"] for c in before["components"]]
for got, want in zip(after["components"], before["components"]):
assert got["amount"] == pytest.approx(want["amount"], rel=1e-9, abs=1e-12), got["name"]
assert any("하한 30㎝" in note for note in before["notes"])
def test_자재총괄_갈래와_배합_성분() -> None:
names = {"masonry_wet": "돌쌓기(찰)", "masonry_dry": "돌쌓기(메)"}
before = build_material_table(
@@ -89,7 +89,9 @@ def test_기본값으로_그린_장은_표_사유와_같은_말을_적는다() -
options = {"height_m": 2.0}
_rows, table_notes = stone_masonry(2.0, 1.0, options, wet=False)
table_default = [note for note in table_notes if "뒷길이를 안 골라" in note]
assert table_default == [back_length_default_note(options, 45)]
# 2026-09-14 — 기본은 뒷길이 표준 하한(메 ≤3m 36~45㎝ → 45) · 표·그림이 같은 말.
assert table_default == [back_length_default_note(options, 45, wet=False, height_m=2.0)]
assert "하한 36㎝ 는 규격 일곱" in table_default[0]
texts = _texts(build_figure(_sheet()))
assert table_default[0] in texts
@@ -61,7 +61,16 @@ CASES = [
dict(height=2.0, length=7.5, back_len_cm=45, face_slope_ratio=0.35, foundation="기초유"),
dict(height=2.0, length=10.0, back_len_cm=55, thickness_top_m=0.9, foundation="기초버림"),
dict(height=2.0, length=10.0, back_len_cm=45, thickness_bottom_m="1.4", stone_kind="깬잡석"),
dict(height=2.0, length=10.0, stone_kind="야면석·호박돌", stone_coeff_basis="실무 관행"),
# 뒷길이 45 — 안 고르면 표준 하한 30㎝ 라 야면석 관측 무게(35·45·55)가 없어 양식만 오류 줄(명세 13장 Ⓐ)
dict(
height=2.0,
length=10.0,
back_len_cm=45,
stone_kind="야면석·호박돌",
stone_coeff_basis="실무 관행",
),
# 뒷길이 안 고름 — 전개·양식이 같은 표준 하한(찰 ≤3m 30㎝, 브레인 판정 ㉮)
dict(height=2.0, length=10.0, stone_kind="깬돌", foundation="기초유"),
dict(height=1.5, length=10.0, back_len_cm=35, weep_hole_area_m2=3, foundation="기초유"),
dict(height=2.5, length=12.0, back_len_cm=60, stone_kind="견치돌", rubble=0.3),
# 줄이 안 서는 갈래 — `when`
+3 -2
View File
@@ -125,7 +125,8 @@ def test_실무_시트_m당_값_재현() -> None:
품셈 1-2-2 소수 자리는 표기 규칙이고 계산은 전정밀로 둔다(PLAN 8-16).
맞추려고 식을 비틀지 않는다 허용오차를 차이만큼 둔다.
"""
result = expand(돌쌓기찰(height=1.5, length=1.0))
# 실무 시트가 뒷길이 45 — 2026-09-14 부터 안 고르면 표준 하한(찰 ≤1.5m 25㎝)이라 고름.
result = expand(돌쌓기찰(height=1.5, length=1.0, back_len_cm=45))
assert result.height_m == 1.5
assert result.length_m == 1.0
assert 성분(result, "터파기").amount == pytest.approx(1.55, abs=0.02)
@@ -135,7 +136,7 @@ def test_실무_시트_m당_값_재현() -> None:
def test_평균두께는_전정밀로() -> None:
"""실무가 0.83 으로 반올림해 재계산한 자리 — 우리는 0.825 를 그대로 쓴다."""
result = expand(돌쌓기찰(height=1.5, length=1.0))
result = expand(돌쌓기찰(height=1.5, length=1.0, back_len_cm=45))
# 터파기 = 높이 × (평균두께 + 0.2) × 연장 = 1.5 × 1.025 = 1.5375
assert 성분(result, "터파기").amount == pytest.approx(1.5375)