"""구조물 원단위 — **돌쌓기 제원·계수표·판정** · 2026-09-14 `UnitQuantity` 에서 가름. 뒷길이 계수표 · 돌 종류 계수 · 돌 무게 · 물구멍 · 채움 강도 · 버림·기초잡석 상수 · 표준경사 판정 · 벽 두께. 성분을 세우지 않음 — 세우는 것은 `_Masonry`(전개식)·`UnitQuantity.build_table`. """ from __future__ import annotations import json from functools import lru_cache from pathlib import Path from typing import Any from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import _num # ── 계수표 — 식에 박지 않고 여기서 고른다 ───────────────────────────── # 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」. # ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮). #: ⚠ **일곱 규격**이다 — 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 를 다 준다. #: 앞서 네 칸(35·45·55·60)만 들고 25·30 을 35 로, 75 를 60 으로 **접고** 있었다. #: `stone_ton_per_m2`(돌중량)는 **실무 관측값**이라 그 넷에만 있다 — 없는 칸은 `None`. STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = { 25: {"fill_concrete_m3_per_m2": 0.11, "wedge_stone_m3_per_m2": None, "stone_ton_per_m2": None}, 30: {"fill_concrete_m3_per_m2": 0.14, "wedge_stone_m3_per_m2": 0.10, "stone_ton_per_m2": None}, 35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575}, 45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88}, 55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10}, 60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None}, 75: {"fill_concrete_m3_per_m2": 0.34, "wedge_stone_m3_per_m2": 0.25, "stone_ton_per_m2": None}, } DEFAULT_BACK_LENGTH_CM = 45 #: ⑤ 돌 중량이 어디서 온 값인가 (2026-09-08 사용자 확정 2차 ⑤ 「DB 확인 → 없으면 현재 유지, #: 나중에 프로그램에서 확인할 수 있게 근거를 남길 것」). #: ⚠ **품셈·교본에는 돌중량표가 없다** — 지식DB 전수 확인 결과 이 값이 나오는 곳은 #: 실무 원단위 라이브러리 한 곳뿐이다(기술문서에는 그 라이브러리를 인용한 줄만 있음). #: 그래서 값을 바꾸지 않고 **어디서 온 값인지**를 줄마다 달아 화면에서 되짚게 한다. STONE_WEIGHT_SOURCE = "uljin_library" STONE_WEIGHT_BASIS = ( "⚠ 실무 관측값 — 울진 소광 원단위 라이브러리 「돌뒷길이별 원단위표」(35·45·55㎝만 있음)." " 품셈·교본에는 돌중량표가 없음" ) #: ⭐ 사용자 확정 5차 큰 것 7 — **돌 무게는 계산식**: `뒷길이 × 0.77 × 2.65`. #: 정본 여섯 탭이 모두 그 식을 그대로 적어 둔다(「2.09 × 0.45 × 0.77 × 2.65 ton/m3」). #: ⚠ **야면석은 그 식이 안 맞는다** — 공극이 커 ㎥당 무게가 달라 원문이 값을 직접 준다 #: (35→0.575 · 45→0.880 · 55→1.100 ton/㎡). 그래서 **종류로 갈래를 둔다.** #: ⚠ **잠정이다** — 확정 문구가 「나중에 실무자 협의 후 명확히」이므로 화면 근거에 적는다. STONE_PACKING_RATIO = 0.77 # 채움률 — 돌 사이 공극을 뺀 몫 STONE_DENSITY_TON_M3 = 2.65 # 석재 비중 #: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우는 **계산식**으로 선다(`stone_masonry` 주석). STONE_WEIGHT_OBSERVED_KINDS = frozenset({"야면석·호박돌", "야면석"}) STONE_WEIGHT_FORMULA_BASIS = ( "⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값" ) #: 물구멍 칸 — 2026-09-09 에 등록부에 생겼다(랩탑 메인 `0b0763ed`). **빈 칸이 기본**이다. #: ⚠ 기본 Ø50 은 **국가기준**이고 2㎡당 1개소는 실무 관측값이다(법은 「2~3㎡당 1개소 이상」). WEEP_HOLE_DIAMETER_KEY = "weep_hole_diameter_mm" WEEP_HOLE_AREA_KEY = "weep_hole_area_m2" WEEP_HOLE_DIAMETER_DEFAULT_MM = 50 def weep_hole_spec(options: dict[str, Any]) -> tuple[float, int, str]: """(개소당 벽면적 ㎡, 관 지름 ㎜, 근거). 안 정하면 기본으로 서고 그 사실이 근거에 남는다.""" area = _num(options.get(WEEP_HOLE_AREA_KEY), 0.0) diameter = _num(options.get(WEEP_HOLE_DIAMETER_KEY), 0.0) area_given = area > 0 dia_given = diameter > 0 if not area_given: area = STONE_MASONRY["weep_hole_area_m2"] if not dia_given: diameter = WEEP_HOLE_DIAMETER_DEFAULT_MM basis = ( f"{area:g}㎡당 1개소 · Ø{int(diameter)}" + ("" if area_given else " · ⚠ 면적 안 정함 — 실무 관측 2㎡(법은 2~3㎡당 1개소 이상)") + ("" if dia_given else " · ⚠ 지름 안 정함 — 국가기준 Ø50") ) return area, int(diameter), basis def stone_weight_per_m2( back_cm: int, kind: str, observed: float | None ) -> tuple[str, float | None, str, str]: """(줄 이름, ton/㎡, 근거, source) — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다. 돌쌓기·골막이가 같은 규칙을 쓰도록 여기 한 벌만 둔다. """ if kind in STONE_WEIGHT_OBSERVED_KINDS: basis = ( f"× {observed} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}" if observed is not None else "" ) return kind, observed, basis, STONE_WEIGHT_SOURCE ton = back_cm / 100.0 * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3 basis = ( f"× {ton:.3f} ton/㎡ = 뒷길이 {back_cm / 100.0:g} × {STONE_PACKING_RATIO}" f" × {STONE_DENSITY_TON_M3}" + (f" · {kind}" if kind else " · ⚠ 돌 종류를 안 골랐음") + f" · {STONE_WEIGHT_FORMULA_BASIS}" ) return kind or "돌", ton, basis, "" #: 뒷채움(막자갈) 폭 — ⭐ 사용자 확정 5차 작은 것 3 「막자갈 = (상 + 하) ÷ 2 × H」. #: ⚠⚠ **여기 상·하는 벽 두께가 아니라 뒷채움 폭이다.** 벽 두께(0.75/0.9)를 끌어다 쓰면 #: H=2.0 에서 실무 0.75 가 1.66(입적)으로 튄다 — 옛 식이 곧 그 자리였다. #: 정본 `04.구조도(기슭막이).xls` **여섯 탭 전부** 상 0.30 · 하 0.45 로 같다 #: (H=1.0~3.0 · 메/찰 · 기초유/버림 전수 확인, 2026-09-09). #: ⓘ 소광리는 같은 식에 **0.30 / 0.60** 을 쓴다(그래서 H=2.0 에서 0.900). 문서마다 폭이 #: 다르므로 **값이 적힌 구조물은 그 값**을 쓰고, 없으면 이 정본 값으로 서되 그 사실을 알린다. STONE_BACKFILL_WIDTH_M = {"top": 0.30, "bottom": 0.45} #: ⚠⚠ 정본 전수 확인이 **H=1.0~3.0** 까지다 — 그보다 높은 벽은 **근거가 없다.** #: 그리고 그 자리에서 품셈 13-4-4 [주]⑨ <뒤채움 조약돌 두께> 범위와 **갈린다**: #: H 3.0 두께 0.359 (품셈 0.33~0.58 안) · H 3.5 0.354 (품셈 0.40~0.70 **아래**) #: 까닭은 이 식의 두께(= 막자갈 ÷ 비탈면적)가 `0.375 ÷ √(1+경사²)` 로 거의 붙박이인데, #: 품셈 표는 직고와 함께 두꺼워지기 때문이다 — **방향이 반대다.** #: ⚠ 교본 7-3 은 「찰 3.0m 이하 · 메 2.0m 이하」로 두지만 **코드가 높이를 막지 않고** #: 표준경사표(13-4-4 [주]⑪)는 직고 7m 까지 칸을 준다 — 즉 **높은 벽이 실제로 설 수 있다.** #: 값을 눅이거나 막지 않고 **그 사실을 사유로 드러낸다**(임의 확정 금지). STONE_BACKFILL_VERIFIED_HEIGHT_M = 3.0 #: 석적 — ⭐ 확정 5차 작은 것 4. **정본에는 없고 소광리에만 있는 줄**이라 사유에 적는다. #: 소광리 식 `H × ℓ3 × 0.77`(면적 × 뒷길이 × 채움률) 그대로. STONE_PILE_BASIS = "⚠ 정본에 없는 줄 — 소광리 시트에만 있음(정면적 × 뒷길이 × 0.77)" #: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3. #: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다. #: 그 값이 「깬돌」 계열이라, **자재로는 야면석을 내면서 계수는 깬돌**을 쓰는 어긋남이 #: 있었다(2026-09-08 지식DB 대조). 랩탑 창이 `stone_kind` 칸을 만들어 축이 생겼다. STONE_KIND_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" STONE_KIND_PREFIX = "stone_kind_" STONE_KIND_OPTION = "stone_kind" @lru_cache(maxsize=1) def load_stone_kind_table() -> dict[str, Any]: """돌 종류별 계수표. 파일이 없으면 **빈 표** — 그러면 종전 값으로 돈다.""" folder = STONE_KIND_DIR if not folder.is_dir(): return {} files = sorted(folder.glob(STONE_KIND_PREFIX + "*.json")) if not files: return {} try: return json.loads(files[-1].read_text(encoding="utf-8")) except (OSError, ValueError): return {} #: ⑨ 계수를 **어느 열로 볼 것인가** (2026-09-08 사용자 확정 2차 ⑨). #: 기본은 **품셈 열**(야면석 고임돌 0.11 · 채움 0.15)이고, 「실무 관행」으로 두면 #: 건설품셈 참고자료 한 벌(깬돌 계열 0.15 · 0.20)로 덮어쓴다. #: ⚠ **빈 칸은 「안 정함」**이고 그때 기본(품셈)으로 선다 — 등록부 default 는 비어 있다. #: 기본값을 등록부가 아니라 여기가 갖는 것은 `face_slope_ratio` 와 같은 결이다. COEFF_BASIS_KEY = "stone_coeff_basis" COEFF_BASIS_PRACTICE = {"실무 관행", "실무관행", "practice", "관행"} def wants_practice_coefficients(options: dict[str, Any]) -> bool: """계수를 실무 관행 열로 볼 것인가. 빈 칸이면 **아니오**(= 품셈 열).""" return str(options.get(COEFF_BASIS_KEY) or "").strip() in COEFF_BASIS_PRACTICE def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]: """(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다. ⚠ 값을 못 낸다고 멈추지 않는다 — 이미 저장된 프로젝트가 통째로 비어 버린다. ⚠ 표에 「-」(그 규격에 그 돌을 안 씀)면 **지어내지 않고** 사유를 낸다. """ table = load_stone_kind_table() key = str(back_cm) kind = str(options.get(STONE_KIND_OPTION) or "").strip() if not table: return {}, "" if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]: return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다") practice = wants_practice_coefficients(options) if not kind or practice: fallback = table.get("fallback") or {} message = ( f"「{kind}」이지만 계수를 **실무 관행 열**(건설품셈 참고자료)로 덮어썼습니다 —" " 기본은 품셈 열입니다(확정 ⑨)" if practice and kind else str(fallback.get("message") or "") ) return { "wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key), "fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key), "backfill_ratio": fallback.get("backfill_ratio_of_back_length"), "kind": "", }, message if kind not in (table.get("kinds") or []): return {}, f"「{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다" wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key) fill = ((table.get("fill_concrete_m3_per_m2") or {}).get(kind) or {}).get(key) ratio = (table.get("backfill_ratio_of_back_length") or {}).get(kind) note = "" if wedge is None: note = f"품셈 13-4-3 에 「{kind} · 뒷길이 {back_cm}㎝」 칸이 비어 있습니다 — 그 규격에 그 돌을 쓰지 않습니다" return { "wedge_stone_m3_per_m2": wedge, "fill_concrete_m3_per_m2": fill, "backfill_ratio": ratio, "kind": kind, }, note # 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. #: ⚠⚠ **기슭막이 계열(돌쌓기 찰·메) 전용이다. 다른 구조물로 넓히지 말 것**(2026-09-09). #: 골막이는 **더하는 몫이 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26: #: 골막이 평균두께 = {(ℓ3 + 0.1×H) + (ℓ3 + 0.4×H)} ÷ 2 #: 기슭막이 상부 = ℓ3 + 0.30 · 하부 = 상부 + 0.30(H − 1) #: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을 #: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어 #: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는** #: 결함이 조용히 남는다. #: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.** #: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) · #: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**. STONE_MASONRY = { # ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침). # 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 **곧 기울기 몫**이다 # (1:0.3 → √(1+0.3²) = 1.0440 ≈ 1.04). 시트가 반올림해 적은 것을 우리가 # **별도 계수로 오해해 `hypot` 위에 또 곱하고 있었다** — 면적이 4 % 부풀었고 # 그 면적이 고임돌·야면석·채움콘크리트·모르터·물구멍 **전부의 밑수**였다. # ⚠ 하드코딩하면 안 되는 값이다 — 큰돌쌓기는 「1:0.3 **이상**」이라 기울기가 # 바뀔 수 있고, 그때 1.04 는 틀린 값이 되지만 `hypot` 은 따라간다. "sheet_check_factor_at_0_3": 1.04, # 1:0.3 에서 시트값과 맞는지 대조하는 자리 # ⭐ 2026-09-09 확정 2차 ② — **실무 구조물도 식**으로 갈았음(랩탑 보조가 소광리 원본 # 네 탭에서 찾아 실측과 소수점까지 대조). # 상부 두께 = 뒷길이(ℓ3) + 0.30 # 하부 두께 = 상부 + 0.30 × (H − 1.0) # ⚠ **옛 식에는 뒷길이가 없었다**(0.45 + 0.10·H / 0.45 + 0.40·H) — 뒷길이를 35 로 # 바꿔도 두께가 안 바뀌어 돌 부피·막자갈·터파기가 다 같은 값으로 섰다. # ⚠ 정본 xls 의 「상부 0.75 · 하부 0.90」은 **H=1.5 규칙값을 다른 높이 탭에 복사**한 # 흔적이라 그대로 쓰면 H=1.5 밖에서 틀린다. # ⚠ 돌조공(H=0.5 · 0.55/0.65)은 이 규칙 밖 — 다른 시설이라 여기서 다루지 않는다. "thickness_top_add_m": 0.30, # 상부 = 뒷길이 + 0.30 "thickness_slope_per_m": 0.30, # 하부 = 상부 + 0.30 × (H − 1.0) "thickness_height_base_m": 1.0, # 그 식의 기준 높이 # ⚠ 미결 — 법은 「2~3 ㎡당 1개소 **이상**」(구조물_수량.md §물구멍)이고 2.0 은 **실무 관측값**이다. # 범위의 한쪽 끝을 쓰는 것이라 사용자 확정 전까지 잠정이다. 식이 아니라 여기 있으니 갈아끼우면 된다. "weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적 # ⚠ **구조물마다 다른 값이다** — 실무 관측: 반중력식 옹벽 0.32 m/m · 돌기슭막이 0.39 m · # 돌골막이 0.5 m/개소(2026-09-08 랩탑 보조). 여기 0.5 하나로 돌고 있으니 그 사실을 # 적어 둔다 — 관측 원단위가 있는 종류는 그 표가 이기고, 없는 종류만 이 값으로 선다. # ⓘ 2026-09-13 — 돌쌓기(기슭막이)는 **평균두께**로 갈음(브레인 판정). # 이 값은 골막이 정본용으로만 남음. "weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이 — 골막이 "mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만) # ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다 # (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다). # 여기서 다시 두면 그림과 물량이 갈린다. } # 성분이 어디로 가는가 — 이중계상을 막는 표시. # `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증) # `material` = 자재총괄로 감(할증은 거기서 한 번만) # `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해) #: 버림 콘크리트 — **빠뜨리고 있던 줄**이다(2026-09-09 사용자 확정 ⑭). #: 두께 근거: KDS 44 90 00 도로암거구조설계기준 「기초시공시 기초지반 다짐을 시행하고 #: 구조물 시공이 원활하도록 **100 mm 두께의 버림콘크리트**를 타설하도록 한다」. #: 폭 근거: KCS 34 50 05 「버림 콘크리트의 두께는 설계도서에 따르며, **폭은 잡석다짐의 #: 폭과 동일**하게 한다」. #: ⭐ 그 잡석다짐 폭이 **정해졌다** — 「기준 구조물에서 유도, 같은 값을 공유」 #: (2026-09-08 사용자 확정 2차 ⑪ 「기슭막이면 **하단 길이** × 종단방향 폭 × 높이」). #: ⇒ 벽에서 **하단 길이 = 하부 두께**다. 종전의 「터파기 폭 잠정」을 그 값으로 바꾼다. #: ⚠ **기초잡석(품셈 12-25) 물량은 아직 못 센다** — 폭은 이제 있으나 **두께가 어느 #: 원문에도 없다**(품셈은 ㎥당 품만 줌). 실무 관측은 관보호공 날개벽 T=0.2 하나뿐이라 #: **다른 구조물 값을 옮겨 쓰지 않고** 사유로 드러낸다. #: 돌을 **사 오나 캐나** — 기본은 「캔다」(2026-09-09 사용자 확정 ②). #: 법이 그쪽을 권한다 — 별표2 「석축 등에 필요한 야면석 등은 **가급적 현장에서 #: 채취·사용**하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 **구입 단가가 #: 0건**이고 울진 일위대가는 「구입/채집」 두 벌을 갖고 있다. #: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다. STONE_SUPPLY_KEYS = ("stone_supply", "stone_source") STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"} #: 조달을 안 정한 구조물의 사유 — 셈은 확정 ② 「기본은 캔다」대로 채집이되 **안 정한 사실**을 보임. STONE_SUPPLY_DEFAULT_NOTE = "돌 조달(채집·구입)을 안 정해 「채집」으로 셈 — 사용자 확정 ② 기본 · 구조물 상세에서 고르면 바뀜" #: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도 #: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다). #: #: 채집석 공제는 사토에서 한 번만 뺀다. #: B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며, #: 빼는 자리는 유토곡선의 사토뿐이다 — #: 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다. #: #: ⚠ **부호를 넘기지 않는다.** 실무 시트가 `−274.66` 으로 적혀 있어 그대로 넘기면 두 번 #: 뒤집힌다. 우리는 **양수**로 주고, 빼는 것은 받는 쪽이 한다. COLLECTED_STONE_KEY = "collected_stone_deduction_m3" #: ⑩ 채움 콘크리트 강도 (2026-09-08 사용자 확정 2차 ⑩ 「기본 210, 고를 수 있게」). #: ⚠ **빈 칸은 「안 정함」**이고 그때 210 으로 선다 — 등록부 default 는 비어 있다. #: 180 은 국가기준 하한(돌쌓기 전용 값)이라 고르면 그 값이 이긴다. FILL_CONCRETE_MPA_KEY = "fill_concrete_mpa" FILL_CONCRETE_MPA_DEFAULT = "210" FILL_CONCRETE_MPA_CHOICES = ("180", "210") def fill_concrete_mpa(options: dict[str, Any]) -> tuple[str, str]: """(강도, 근거). 안 정했으면 기본 210 이고 그 사실이 근거에 적힌다.""" value = str(options.get(FILL_CONCRETE_MPA_KEY) or "").strip() if value in FILL_CONCRETE_MPA_CHOICES: return value, f"강도 {value} — 저장 제원에서 고른 값" if value: return ( FILL_CONCRETE_MPA_DEFAULT, f"강도 {FILL_CONCRETE_MPA_DEFAULT} — ⚠ 저장값 「{value}」은 180·210 중에" " 없어 기본으로 섬", ) return ( FILL_CONCRETE_MPA_DEFAULT, f"강도 {FILL_CONCRETE_MPA_DEFAULT} — 안 정해 기본값(확정 ⑩)", ) BLINDING_THICKNESS_M = 0.10 #: 기초잡석 두께(m) — 2026-09-09 사용자 확정 3차 ② 「0.2 m」. #: ⚠ 품셈 12-25 는 **㎥당 품만** 주고 두께를 정하지 않는다(전 출처 소진). 폭은 **버림 폭과 #: 같고**(KCS 34 50 05 「버림 콘크리트의 폭은 잡석다짐의 폭과 동일」) 그 폭은 확정 ⑪ 로 #: 하단 길이다. ⇒ **기초잡석 = 버림 × (잡석두께 ÷ 버림두께)** 로 나온다 — 같은 폭·연장이라 #: 두께 비만 곱하면 된다. 관측 원단위로 오는 구조물(옹벽)도 같은 식으로 선다 #: (버림 0.15㎥/m ÷ 0.1 = 폭 1.5m ⇒ 잡석 0.30㎥/m — 확정 3차 ② 의 그 값). RUBBLE_BASE_THICKNESS_M = 0.2 #: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭). #: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다. BLINDING_OPTION_KEYS = ("blinding_concrete", "base_blinding") #: 표준경사표 — 품셈 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",) #: 성토/절토는 **측점 단면유형(`design.section_mode`) + 설치 측**으로 판정한다 #: (`common_util_structure_face_role` 한 벌). 못 가르는 자리(`both_cut` + 「자동」)만 남아 #: 그때 쓸 칸을 여기 한 곳에 둔다 — **눅여 쓰는 것이 아니라 종전값으로 서고 사유를 적는다.** 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, face: str | None = None, face_reason: str = "", 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 "메쌓기" # ⚠ 성토/절토는 **판정 한 벌**(`common_util_structure_face_role`)이 준다. 못 가르면 # `None` 이 오는데, **성토로 눅이지 않는다** — 임의값이 금액으로 굳으면 안 된다. # 그때는 종전값으로 서되 **왜 못 갈랐는지**를 근거에 적는다. if not face: why = face_reason or "성절토를 가를 근거 없음" return ( LEGACY_FACE_SLOPE_RATIO, f"⚠ {why} — 표준경사표를 못 골라 종전값 1:{LEGACY_FACE_SLOPE_RATIO} 로 섰음", ) 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}" if face_reason: note += f" · {face_reason}" return ratio, note def is_collected_stone(options: dict[str, Any]) -> bool: """이 구조물의 돌을 **캐서 쓰는가**. 정한 적이 없으면 「캔다」(확정 ②).""" for key in STONE_SUPPLY_KEYS: raw = options.get(key) if raw is None: continue if str(raw).strip().lower() in STONE_SUPPLY_PURCHASED: return False return True def wants_blinding(options: dict[str, Any]) -> bool: """버림 콘크리트를 넣을지. **정한 적이 없으면 넣는다**(사용자 확정 ⑭). 「안 넣음」·「제외」·`false` 로 적혀 있을 때만 뺀다 — 빈 칸을 「빼기」로 읽으면 저장해 둔 적 없는 프로젝트에서 줄이 통째로 사라진다. """ for key in BLINDING_OPTION_KEYS: raw = options.get(key) if raw is None: continue text = str(raw).strip().lower() if text in {"false", "0", "no", "제외", "안 넣음", "안넣음", "빼기"}: return False return True #: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인). #: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다** #: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다. #: 옛 이름도 함께 본다(다른 곳에서 그 이름으로 넣어 줄 수 있다). BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm") #: 메쌓기 높이 — **두 기준이 공존**(2026-09-14 브레인 판정 ㉰ · 지식DB 대조 정합): #: 임도기술교본 7-3:27 「돌흙막이 높이는 원칙적으로 찰쌓기는 3.0m 이하, 메쌓기는 2.0m 이하」 ↔ #: 품셈 13-4-4 [주]⑪ 원문 L7192 「돌쌓기는 높이 3m 이상이면 전부 또는 하부를 찰쌓기로 한다」. #: 막지 않고 사유만(판정 ① 「놓기를 막지 말 것」과 한 결). DRY_MASONRY_GUIDE_M = 2.0 DRY_MASONRY_LIMIT_M = 3.0 def dry_masonry_height_note(height_m: float) -> str | None: """메쌓기 높이가 기준을 넘으면 사유 한 줄 — 두 기준을 다 적음.""" if height_m >= DRY_MASONRY_LIMIT_M: return ( f"⚠ 메쌓기 높이 {height_m:g}m — 품셈 13-4-4 [주]⑪(원문 L7192) 「돌쌓기는 높이 3m 이상이면" " 전부 또는 하부를 찰쌓기로 한다」 · 임도기술교본 7-3:27 은 메쌓기 2.0m 이하 — 막지 않음(설계자 확인)" ) if height_m > DRY_MASONRY_GUIDE_M: return ( f"⚠ 메쌓기 높이 {height_m:g}m — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」를 넘음" " · 품셈 13-4-4 [주]⑪(원문 L7192)은 3m 이상부터 찰쌓기 — 두 기준이 다름 · 막지 않음" ) return None #: 뒷길이 표준표 — 품셈 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 등)은 **계수가 없다고 드러낸다** — 접으면 다른 규격 계수가 조용히 돈다. """ for key in BACK_LENGTH_KEYS: raw = options.get(key) if raw is None: continue try: value = int(float(raw)) except (TypeError, ValueError): continue # ⚠ **접지 않는다.** 앞서 「가장 가까운 위 칸」으로 접고 있었는데, 그러면 40㎝ 가 # 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측). # 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다. return value 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, *, 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}㎝ 로 섰습니다 " "— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다" ) #: 벽 두께를 **사용자가 덮어쓰는 칸** — 비우면 실무 구조물도 식으로 돈다(확정 2차 ② 후반 #: 「사용자가 값을 바꿀 수 있게」). 야면석 계수·채움 강도와 같은 자리다. WALL_THICKNESS_KEYS: tuple[str, str] = ("thickness_top_m", "thickness_bottom_m") def wall_thickness( options: dict[str, Any], *, back_cm: float, height_m: float ) -> tuple[float, float, str]: """(상부 두께 m, 하부 두께 m, 근거 문구) — **사용자가 넣은 값이 식을 이긴다.** ⚠ 빈 칸은 「안 정함」이다 — 자동으로 나온 값을 저장에 박지 않는다(`face_slope_ratio` 와 같은 태도). 상부만 넣으면 하부는 **그 상부 위에서** 식을 이어 간다 — 벽 모양 (아래로 벌어지는 기울기)을 사용자 값 위에 그대로 얹는 것이 자연스럽다. """ def given(key: str) -> float | None: raw = options.get(key) if raw in (None, ""): return None try: value = float(raw) except (TypeError, ValueError): return None return value if value > 0 else None constants = STONE_MASONRY back_m = back_cm / 100.0 top_given = given(WALL_THICKNESS_KEYS[0]) top = top_given if top_given is not None else back_m + constants["thickness_top_add_m"] rise = constants["thickness_slope_per_m"] * max( height_m - constants["thickness_height_base_m"], 0.0 ) bottom_given = given(WALL_THICKNESS_KEYS[1]) bottom = bottom_given if bottom_given is not None else top + rise top_basis = ( f"상부 {top:.2f}(사용자 입력)" if top_given is not None else f"상부 {top:.2f}(뒷길이 {back_cm:g}㎝ + {constants['thickness_top_add_m']:g})" ) bottom_basis = ( f"하부 {bottom:.2f}(사용자 입력)" if bottom_given is not None else ( f"하부 {bottom:.2f}(상부 + {constants['thickness_slope_per_m']:g}" f"×(H−{constants['thickness_height_base_m']:g}))" ) ) return top, bottom, f"실무 구조물도 식 — {top_basis} · {bottom_basis}"