Files
Aislo/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py
T
eomsangdonandClaude Opus 5 2744f1bcbb fix(b05,b08): 구조물 목록 폼이 등록부 기본값을 값으로 안 채움 — 제안으로만(폼 ②)
- 칸에는 저장된 값만 · 기본값은 회색 글씨(숫자)·빈 보기 이름(고르기)으로 제안 · [제안값 넣기] 누른 때만 값
- 새 구조물 추가도 기본값을 안 실음(defaultOptions 로 채우던 자리) · 잠긴 칸(enabled:false)만 프로그램 값 유지
- C군(범위 계산 타입)은 길이처럼 높이도 비면 안 놓음 — 비면 횡단도 벽이 조용히 안 서던 길
- B08: 돌 조달을 안 정하면 「채집으로 셈」 사유를 보임(조용히 채집 되지 않게)
- ORCA 936be972: 돌쌓기(메) 고르면 높이·길이·전·설치측 빔 + 제안 · 단추로 채워짐 확인(저장 안 함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:21:27 +09:00

527 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""구조물 원단위 — **돌쌓기 제원·계수표·판정** · 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")
def _back_length(options: dict[str, Any]) -> 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
return DEFAULT_BACK_LENGTH_CM
def back_length_default_note(options: dict[str, Any], back_cm: int) -> str | None:
"""뒷길이를 안 골라 기본으로 섰을 때의 한 줄 — 표 사유·구조물도 그림이 **같은 말**을 씀."""
if any(options.get(key) not in (None, "") for key in BACK_LENGTH_KEYS):
return None
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}"