diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index d730a4ca..39ea57e7 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -258,6 +258,67 @@ class StructureQuantity: billing_quantity: float = 0.0 +#: 표준경사표 — 품셈 13-4-4 [주]⑪. 파일이 없으면 종전 기본값(0.3)으로 돈다. +SLOPE_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" +SLOPE_PREFIX = "masonry_slope_" +#: 사용자가 덮어쓰는 칸 — 값이 있으면 **그 값이 이긴다**(확정 ⑨). +FACE_SLOPE_KEYS = ("face_slope_ratio",) +#: 성토/절토를 아직 저장이 안 갖고 있다 — 어느 칸을 잠정으로 쓰는지 한 곳에 둔다. +DEFAULT_SLOPE_FACE = "성토" +LEGACY_FACE_SLOPE_RATIO = 0.3 + + +@lru_cache(maxsize=1) +def load_slope_table(path: str | None = None) -> dict[str, Any]: + """표준경사표. 없으면 빈 표 — 그때는 종전 기본값으로 돈다(값을 지어내지 않는다).""" + target = Path(path) if path else None + if target is None: + files = sorted(SLOPE_DIR.glob(SLOPE_PREFIX + "*.json")) if SLOPE_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 face_slope_ratio( + options: dict[str, Any], *, wet: bool, height_m: float, table: dict[str, Any] | None = None +) -> tuple[float, str]: + """(기울기 1:n 의 n, 근거 문구). + + ⚠ **사용자가 정했으면 그 값이 이긴다**(확정 ⑨). 안 정했으면 품셈 13-4-4 [주]⑪ + <표준경사>에서 **직고 · 메/찰 · 성토/절토** 셋으로 고른다. + ⚠ 자동으로 고른 값을 **저장에 박지 않는다** — 「정한 적 없음」과 「0.3 으로 정함」이 + 구별돼야 한다(오늘 패널 접힘에서 지킨 것과 같은 자리). + ⚠ 경계값은 **앞 칸**이다 — 원문이 `∼3` 이라 직고 3.0m 은 `~3` 칸이다. + """ + for key in FACE_SLOPE_KEYS: + given = _num(options.get(key), 0.0) + if options.get(key) is not None and given > 0: + return given, f"사용자 지정 1:{given:g}" + + found = table if table is not None else load_slope_table() + steps = found.get("steps_m") or [] + bond = "찰쌓기" if wet else "메쌓기" + # ⚠ 성토/절토는 **저장이 아직 안 갖고 있다.** 레지스트리에 없는 키를 읽지 않는다 + # (읽으면 「없는 칸을 읽는」 자리가 되어 옵션 키 검사가 잡는다). 값이 생기면 + # 여기 한 줄만 바꾸면 된다 — 그때까지 성토 열로 잠정 적용하고 근거에 적는다. + face = DEFAULT_SLOPE_FACE + row = ((found.get("table") or {}).get(bond) or {}).get(face) + if not row: + return LEGACY_FACE_SLOPE_RATIO, f"표준경사표를 못 읽어 종전값 1:{LEGACY_FACE_SLOPE_RATIO}" + + index = len(steps) + for i, limit in enumerate(steps): + if height_m <= float(limit): + index = i + break + ratio = float(row[min(index, len(row) - 1)]) + label = f"직고 {height_m:g}m" + (f" ≤{steps[index]:g}m" if index < len(steps) else " 7m 초과") + note = f"품셈 13-4-4 [주]⑪ 표준경사 · {bond} {face} · {label} → 1:{ratio:g}" + note += " · ⚠ 성토/절토가 저장에 없어 **성토 열로 잠정**" + return ratio, note + + def is_collected_stone(options: dict[str, Any]) -> bool: """이 구조물의 돌을 **캐서 쓰는가**. 정한 적이 없으면 「캔다」(확정 ②).""" for key in STONE_SUPPLY_KEYS: @@ -383,7 +444,9 @@ def boulder_masonry( f"{' · '.join(BOULDER_DIAMETERS)}㎝ 중 하나를 고르면 값이 섭니다" ] - slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 레지스트리에 칸 없음 — 기본 0.3 + # ⚠ 큰돌쌓기는 **표준경사표 대상이 아니다** — 교본이 「1:0.3 **이상**」으로만 두었다. + # 저장 제원에 값이 있으면 그것을 쓰고, 없으면 하한 0.3 으로 둔다. + slope_ratio = _num(options.get("face_slope_ratio"), 0.3) constants = BOULDER_MASONRY face_area = height_m * length_m # 기울기 몫은 **한 번만** — 13-4 와 같은 자리다(㉘). @@ -492,7 +555,8 @@ def stone_masonry( # **기본 0.3 의 근거** — 교본 7-3 돌흙막이 기준: 「돌 찰쌓기 3.0m 이하 **1:0.3** / # 돌 메쌓기 2.0m 이하 **1:0.3** / 큰돌쌓기 **1:0.3 이상**(전도 방지)」 # (`지식DB 02_상세설계/구조물/돌쌓기.md §1`, 값은 `data_masonry` 의 `face_slope`). - slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 전면 기울기 1:0.3 (교본 7-3) + # 전면 기울기 — 품셈 표준경사표로 자동 판정하고, 저장 제원에 값이 있으면 그것이 이긴다. + slope_ratio, slope_basis = face_slope_ratio(options, wet=wet, height_m=height_m) constants = STONE_MASONRY face_area = height_m * length_m # 정면적 @@ -511,7 +575,7 @@ def stone_masonry( "㎡", masonry_area, DESTINATION["돌쌓기"], - f"정면적 × √(1+{slope_ratio}²) — 비탈면적", + f"정면적 × √(1+{slope_ratio:g}²) — 비탈면적 · {slope_basis}", ), ] # ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25). diff --git a/resources/data_masonry/masonry_slope_2026-01-01.json b/resources/data_masonry/masonry_slope_2026-01-01.json new file mode 100644 index 00000000..b31b8100 --- /dev/null +++ b/resources/data_masonry/masonry_slope_2026-01-01.json @@ -0,0 +1,35 @@ +{ + "schema_version": "1.0", + "dataset_id": "masonry_standard_slope", + "effective_date": "2026-01-01", + "note": "돌쌓기 표준경사 — **직고 · 메/찰 · 성토/절토** 셋으로 갈린다. 우리는 늘 1:0.3 한 값으로 돌고 있었는데, 그것은 이 표의 「메쌓기·성토·직고 1.5m 이하」 한 칸일 뿐이었다(2026-09-09).", + "source": { + "doc": "산림사업 표준품셈(산림청고시 제2025-82호) 13-4-4 [주]⑪ <표 준 경 사>", + "line": "원문 L7182~7194", + "quote": "돌쌓기의 표준경사는 특수한 경우를 제외하고는 쌓기 높이의 최대치를 취하고 표준 뒷길이 상부에서부터 적용한다" + }, + "policy": { + "user_override": "저장 제원에 `face_slope_ratio` 가 있으면 **그 값이 이긴다**(사용자 확정 ⑨ 「자동 판정 + 사용자가 바꿀 수 있게」).", + "no_write_back": "⚠ 자동으로 고른 값을 **저장에 박지 않는다.** 「정한 적 없음」과 「0.3 으로 정함」이 구별돼야 한다.", + "boundary": "⚠ 원문 표기는 `∼1.5 · ∼3 · ∼5 · ∼7 · 7이상` 이다. 「∼」를 **이하**로 읽어 경계값은 **앞 칸**에 넣는다(직고 3.0m → `~3` 칸). 7.0m 은 `~7` 칸이고 그보다 커야 `7이상` 칸이다.", + "face_unknown": "⚠ 성토/절토를 **저장이 아직 안 갖고 있다.** 구조물 레지스트리의 설치 측 기본이 「자동(성토 쪽)」이고 사면 계열도 층따기가 성토 전용이라 **성토 열로 잠정 적용**하고, 근거 문구에 그 사실을 적는다 — 값이 들어오면 그 칸만 바꾸면 된다." + }, + "steps_m": [1.5, 3.0, 5.0, 7.0], + "table": { + "메쌓기": { + "성토": [0.3, 0.35, 0.4, 0.45, 0.5], + "절토": [0.25, 0.3, 0.35, 0.4, 0.45] + }, + "찰쌓기": { + "성토": [0.25, 0.3, 0.35, 0.4, 0.45], + "절토": [0.2, 0.25, 0.3, 0.35, 0.4] + } + }, + "not_here": { + "note": "이 표가 정하지 않는 것.", + "items": [ + "⚠ 교본 부록 4-5:752 는 **시설 종류**로 가른다 — 「돌 기슭막이의 돌쌓기 기울기는 찰쌓기 1:0.3, **메쌓기 1:0.5**」. 품셈은 직고·성절토 축이라 **축이 다르다.** 기슭막이로 놓은 벽은 사용자가 덮어쓰기로 처리한다(확정 ⑨).", + "큰돌쌓기(13-6)는 이 표의 대상이 아니다 — 교본이 「1:0.3 이상」으로만 두었다." + ] + } +}