Files
Aislo/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py
T
eomsangdonandClaude Opus 5 418c7c57b7 docs(B08): 엑셀 자름·반올림을 걷어 내지 말라는 근거를 주석에 남김
정본이 줄마다 INT(x*100)/100 으로 자르고 다음 줄이 그 잘린 값을 받으므로
표시용이 아니라 계산의 일부임. 안 자르면 막자갈 10.55 · 잔토 2.75 로
정본과 갈림. round(4.905) 가 짝수 반올림으로 4.90 이 되는 자리(물구멍관)도
함께 적음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:00:01 +09:00

580 lines
29 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.
"""기슭막이 전개 — **형태로 갈라 돌쌓기 식을 그대로 쓴다**(3단계, 2026-09-09).
실무 정본이 근거다 — `구조물도/기슭막이/04.구조도(기슭막이).xls` 의 탭 제목이
**「돌기슭막이(H=2.0m, 찰쌓기, 기초유)」** 이고, 그 안의 계산이 돌쌓기와 **같은 식**이다.
정면적 → 비탈면적(×√(1+n²)) → 평균두께 → 입적 → 돌 · 막자갈 · 고임돌 →
터파기(기초 + 비탈) → 되메우기 → 잔토
⇒ **기슭막이는 새 식이 아니라 「형태가 돌쌓기면 돌쌓기 식」**이다. 여기서 식을 다시 짜면
같은 계산이 두 벌이 되어 돌쌓기와 갈린다(CLAUDE.md 5장).
⚠ **형태가 돌쌓기가 아닌 갈래는 식이 없다** — 콘크리트·돌망태·통나무/목재틀·바자.
지어내지 않고 **왜 없는지**를 줄로 남긴다.
⚠ **뒷길이·돌 종류 칸이 기슭막이 등록부에 아직 없다**(2026-09-09 실측). 돌쌓기 식은 그 둘로
계수가 갈리는데, 없으면 `_back_length` 가 **조용히 45㎝ 로 돈다**. 값이 나오므로 아무도
못 알아채는 자리라 **그 사실을 줄로 드러낸다** — 「값이 있기는 하니 안 보이는」 그것.
"""
from __future__ import annotations
import math
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _num, stone_masonry
#: 형태 → 돌쌓기 갈래(찰이면 True). 여기 없는 형태는 전개식이 없다.
STONE_FORMS: dict[str, bool] = {
"돌쌓기(찰)": True,
"돌쌓기(메)": False,
}
#: 식이 없는 형태와 **왜 없는지**. 품셈 장이 다르거나 원단위가 원문에 없다.
WITHHELD_FORMS: dict[str, str] = {
"콘크리트": (
"콘크리트 기슭막이는 돌쌓기(품셈 13-4)가 아니라 콘크리트 구조물이라 전개식이 다름 "
"— 벽 두께·저판이 정본에 없어 물량이 서지 않음"
),
"돌망태": ("돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보"),
"통나무·목재틀": (
"목재틀은 품셈 13-13 이고 밑수가 ㎥당 목공 품이라 돌쌓기 표를 못 씀 "
"— 각재·판재 자재가 카탈로그에 없어 값이 모자람"
),
"바자": "바자얽기(품셈 5-15)는 별도 일위대가를 만들어 잇기로 확정(2026-09-09 ⑧-4)",
}
#: 돌붙임 원단위 — 소광리 정본 「돌붙임L3=45(사방)」(찰) · 「돌붙임L3=30(야면석메붙임)」(메).
#: ⚠⚠ **돌쌓기 표(13-4)와 합치지 말 것.** 값이 겹쳐 보여 합치고 싶어지는 자리다:
#:
#: 돌쌓기(13-4) 밑수 **비탈면적** · 뒷길이 **일곱 규격** · **기울기 몫 √(1+n²)**
#: 돌붙임(13-5) 밑수 **평면적** · 뒷길이 **넷** · **기울기 몫 없음**
#: 메는 모르타르·채움콘크리트가 통째로 빠진다
#:
#: ⚠ 45㎝ 에서 고임돌 0.15 · 채움 0.20 이 **우연히 같다** — 그것이 합치고 싶어지는 까닭이고,
#: 합치면 뒷길이 25·30·60·75 에서 조용히 틀린다(돌붙임 표에 없는 규격이다).
#:
#: ⚠ **뒷길이는 지금 붙박이다** — 찰 0.45m · 메 0.30m. 정본 탭이 그 둘로만 있고 바닥막이
#: 등록부에 뒷길이 칸이 없다. 칸이 생기면 이 표를 열면 된다.
#: ⓘ 돌중량은 **관측값이 아니라 유도값**이다 — `뒷길이 × 0.77(채움률) × 2.65(비중)`.
#: 찰 0.45 × 0.77 × 2.65 = 0.918 ton/㎡. 메(야면석)는 0.42 로 **원문에 값이 직접 적혀 있어**
#: 같은 유도식이 안 맞는다(야면석은 공극이 커 ㎥당 무게가 다르다) — 그대로 옮긴다.
BED_SILL_FORMS: dict[str, dict[str, Any]] = {
"돌붙임(찰)": {
"back_len_m": 0.45,
"stone_name": "돌",
"stone_spec": "30×30×45㎝",
"stone_ton_per_m2": 0.45 * 0.77 * 2.65,
"wedge_stone_m3_per_m2": 0.15,
"mortar_m3_per_m2": 0.009,
"fill_concrete_m3_per_m2": 0.2,
"blinding_m3_per_m2": 0.1,
},
"돌붙임(메)": {
"back_len_m": 0.30,
"stone_name": "야면석",
"stone_spec": "20×20×30㎝",
"stone_ton_per_m2": 0.42,
"wedge_stone_m3_per_m2": 0.07,
# 메붙임은 모르타르·채움콘크리트·버림이 **없다**(정본 탭에 줄 자체가 없음).
"mortar_m3_per_m2": None,
"fill_concrete_m3_per_m2": None,
"blinding_m3_per_m2": None,
},
}
#: 돌쌓기 식이 계수를 가르는 데 쓰는 칸 — 기슭막이 등록부에 아직 없는 것을 알린다.
STONE_SPEC_KEYS: tuple[tuple[str, str], ...] = (
("back_len_cm", "뒷길이"),
("stone_kind", "돌 종류"),
)
def revetment(
height_m: float,
length_m: float,
options: dict[str, Any],
face: str | None = None,
face_reason: str = "",
) -> tuple[list[Component], list[str]]:
"""기슭막이 1구간 전개 — 형태가 돌쌓기면 그 식, 아니면 사유만."""
form = str(options.get("form") or "").strip()
if form not in STONE_FORMS:
why = WITHHELD_FORMS.get(form)
if why:
return [], [f"기슭막이 형태 「{form}」 — {why}"]
return [], [
f"기슭막이 형태가 정해지지 않았습니다(지금 「{form or '빈 값'}」) "
"— 형태를 골라야 물량이 섭니다"
]
components, notes = stone_masonry(
height_m, length_m, options, STONE_FORMS[form], face, face_reason
)
# ⚠ 계수를 가르는 칸이 없으면 **기본값으로 조용히 돈다** — 그 사실을 드러낸다.
missing = [label for key, label in STONE_SPEC_KEYS if not options.get(key)]
if missing and components:
notes.append(
f"⚠ 기슭막이 제원에 {' · '.join(missing)} 칸이 없어 **돌쌓기 기본값으로 섰음** "
"— 그 값이 고임돌·야면석·채움콘크리트 계수를 가름"
)
return components, notes
#: 돌골막이 원단위 — 소광리 정본 「골막이(찰)(치수조서연결)」, **개소당**.
#: 「돌-골막이치수」 탭이 개소별 상장·하장·높이를 받아 **평균치수**를 내고, 이 표가 그 평균
#: 하나로 개소당 수량을 낸다. 우리는 개소마다 제 치수로 돌린다 — 밑수가 같으므로 식은 그대로다.
#:
#: ⚠⚠ **돌쌓기(기슭막이) 식과 합치지 말 것.** 넷이 다르다:
#:
#: 정면적 **사다리꼴** (상장+하장)÷2×H − 파형강관 ↔ 기슭막이 직사각 H×1
#: 두께식 (3+0.1H) / (3+0.4H) 의 평균 ↔ 기슭막이 3+0.30 / +0.30(H1)
#: 밑수 **돌쌓기 + 돌붙임** ↔ 기슭막이 돌쌓기만
#: 단위 **개소당** ↔ 기슭막이 m당 (확정 ⑦ 「통일 안 함」)
#:
#: ⓘ 앞서 이 두께식을 「0.45 + 0.1H」로 적어 두었으나 **0.45 는 상수가 아니라 뒷길이 ℓ3**
#: 이다(정본 C26 이 `P6/100` 을 읽는다 — P6 = 45㎝). 두 식 모두 뒷길이 기반이고
#: **더하는 몫만 다르다.**
#: ⓘ 같은 것 — 막자갈 **2/3** · 야면석 **0.88 ton/㎡** · 물구멍 **2㎡/개소 × 0.5m** ·
#: 고임돌 **0.15** · 채움콘크리트 **0.2** 는 두 시트가 같은 값이라 표를 함께 쓴다.
#:
#: ⚠ **엑셀의 자름·반올림을 그대로 옮긴다** — 정본이 줄마다 `INT(x*100)/100` 으로 자르고
#: **다음 줄이 그 잘린 값을 받는다**(막자갈·잔토가 그렇다). 안 자르면 정본과 갈린다.
#:
#: ⚠ **방수로 파형강관을 늘 뺀다** — 정본이 상수 `(0.4 × 0.4) × 3.14` 로 박아 두었고
#: (Ø0.8 파형강관, 울진 관급 목록에 있는 규격) 방수로 치수가 0 인 개소에서도 뺀다.
#: **「방수로 없음」을 고를 칸이 등록부에 없어** 그대로 따른다 — 그 사실을 줄로 낸다.
EROSION_CHECK_DAM = {
"spillway_pipe_r_m": 0.4, # 정본 상수 — 0.4 × 0.4 × 3.14
"spillway_pipe_pi": 3.14,
"thickness_top_per_m": 0.1, # 상부 두께 = 3 + 0.1H
"thickness_bottom_per_m": 0.4, # 하부 두께 = 3 + 0.4H
"rubble_body_ratio": 2 / 3, # 막자갈에서 뺄 몸통 몫
"trench_extra_m": 0.3, # 바닥파기 폭 = 평균두께 + 0.3
"trench_depth_m": 0.5, # 바닥파기 깊이
"default_slope_ratio": 0.3, # 반수면 비탈 1:0.3 — 정본 붙박이
}
#: 골막이가 세우는 형식. 정본 탭이 **찰쌓기 하나**뿐이다.
EROSION_CHECK_FORMS: frozenset[str] = frozenset({"돌"})
#: 나머지 형식과 **왜 없는지**. 지어내지 않는다.
EROSION_CHECK_WITHHELD: dict[str, str] = {
"돌망태": "돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보",
"콘크리트": "콘크리트 골막이는 몸체 두께·저판이 정본에 없어 물량이 서지 않음",
"통나무": (
"통나무 골막이는 품셈 13-13(목재틀)이고 밑수가 ㎥당 목공 품이라 축이 다름 — 원단위 미확보"
),
"바자": "바자얽기(품셈 5-15)는 별도 일위대가를 만들어 잇기로 확정(2026-09-09 ⑧-4)",
"흙": "흙골막이는 정본에 탭이 없고 품셈 장도 다름 — 원단위 미확보",
}
# ── 엑셀 자름·반올림 ────────────────────────────────────────────────────────
# ⚠⚠ **걷어 내지 말 것.** 「자릿수 맞추기」로 보여 지우고 싶어지는 자리인데, 정본이
# **줄마다 자르고 다음 줄이 그 잘린 값을 받는다** — 표시용이 아니라 **계산의 일부**다.
# 입적 = INT(정면적 × 평균두께) ← 평균두께가 이미 잘린 값(1.09)
# 막자갈 = INT(입적 (…)) ← 입적이 이미 잘린 값(20.56)
# 잔토 = ROUND(바닥파기 − (…)) ← 바닥파기가 이미 잘린 값(8.61)
# 안 자르면 막자갈이 10.55, 잔토가 2.75 로 나와 **정본과 갈린다.**
# 「정본과 같은 값」의 뜻을 **소수점까지**로 잡은 것이고, 거울 시험이 그것을 잡는다.
def _floor2(value: float) -> float:
"""엑셀 `INT(x*100)/100` · `ROUNDDOWN(x,2)`."""
return math.floor(value * 100) / 100
def _round2(value: float) -> float:
"""엑셀 `ROUND(x,2)`.
⚠ 파이썬 `round` 는 **짝수 반올림**이라 4.905 를 4.90 으로 내린다(엑셀은 4.91).
물구멍관이 정확히 그 값이라 바꾸면 정본과 갈린다.
"""
return float(Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def _ceil2(value: float) -> float:
"""엑셀 `ROUNDUP(x,2)` — 야면석만 이쪽이다(정본이 그 줄만 올림)."""
return math.ceil(value * 100) / 100
def erosion_check_dam(
height_m: float, options: dict[str, Any]
) -> tuple[list[Component], list[str]]:
"""돌골막이 1개소 전개 — 정본 「골막이(찰)(치수조서연결)」의 식을 그대로 돌린다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
DESTINATION,
STONE_BACK_LENGTH_TABLE,
STONE_MASONRY,
_back_length,
fill_concrete_mpa,
)
form = str(options.get("form") or "").strip()
if form not in EROSION_CHECK_FORMS:
why = EROSION_CHECK_WITHHELD.get(form)
if why:
return [], [f"골막이 형식 「{form}」 — {why}"]
return [], [
f"골막이 형식이 정해지지 않았습니다(지금 「{form or '빈 값'}」) "
"— 형식을 골라야 물량이 섭니다"
]
top_m = _num(options.get("top_length_m"), 0.0)
bottom_m = _num(options.get("bottom_length_m"), 0.0)
if top_m <= 0 or bottom_m <= 0 or height_m <= 0:
return [], [
"골막이 정면적이 **사다리꼴**이라 상장ⓐ·하장ⓑ·높이가 다 있어야 셈이 섭니다 "
f"(지금 상장 {top_m:g}m · 하장 {bottom_m:g}m · 높이 {height_m:g}m)"
]
const = EROSION_CHECK_DAM
notes: list[str] = []
back_cm = _back_length(options)
coeff = STONE_BACK_LENGTH_TABLE.get(back_cm)
if coeff is None:
return [], [f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다"]
if not any(options.get(key) is not None for key in ("back_len_cm", "stone_back_length_cm")):
notes.append(
f"⚠ 골막이 제원에 **뒷길이 칸이 없어** 기본 {back_cm}㎝ 로 섰습니다 "
"— 그 값이 두께식·고임돌·야면석·채움콘크리트 계수를 모두 가릅니다"
)
back_m = back_cm / 100.0
given = _num(options.get("face_slope_ratio"), 0.0)
if given > 0:
slope, slope_note = given, f"사용자 지정 1:{given:g}"
else:
slope = const["default_slope_ratio"]
slope_note = f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)"
# ① 정면적(사다리꼴) − ② 방수로 파형강관 단면
trapezoid = _floor2((top_m + bottom_m) / 2 * height_m)
pipe = const["spillway_pipe_r_m"] ** 2 * const["spillway_pipe_pi"]
front_area = trapezoid - pipe
notes.append(
f"방수로 파형강관 {pipe:.4f}㎡ 를 정면적에서 뺐습니다 — 정본 붙박이(Ø0.8) "
"⚠ 「방수로 없음」을 고를 칸이 등록부에 없어 늘 뺍니다"
)
masonry = _floor2(front_area * _round2(math.hypot(slope, 1.0)))
top_t = _floor2(back_m + const["thickness_top_per_m"] * height_m)
thickness = _floor2(
(
(back_m + const["thickness_top_per_m"] * height_m)
+ (back_m + const["thickness_bottom_per_m"] * height_m)
)
/ 2
)
volume = _floor2(front_area * thickness)
facing = top_m * (top_t - back_m)
base_area = masonry + facing
rows: list[tuple[str, str, float, str]] = [
("돌쌓기", "㎡", masonry, f"정면적 {front_area:.4f}× √(1+n²) · 기울기 {slope_note}"),
("돌붙임", "㎡", facing, f"상장 {top_m:g}m × (상부두께 {top_t:g} 뒷길이 {back_m:g})"),
(
"입적",
"㎥",
volume,
f"정면적 × 평균두께 {thickness:g}m "
f"[{{(3+0.1H)+(3+0.4H)}}÷2 · 3={back_m:g} · H={height_m:g}]",
),
]
stone_ton = coeff["stone_ton_per_m2"]
if stone_ton is None:
notes.append(f"뒷길이 {back_cm}㎝ 에 야면석 중량 칸이 비어 있어 돌을 못 세웠습니다")
else:
rows.append(
(
"야면석",
"ton",
_ceil2(base_area * stone_ton),
f"(돌쌓기+돌붙임) {base_area:.4f}× {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝)",
)
)
wedge_per = coeff["wedge_stone_m3_per_m2"]
wedge = _round2(base_area * _num(wedge_per)) if wedge_per is not None else 0.0
if wedge_per is None:
notes.append(f"뒷길이 {back_cm}㎝ 에 고임돌 칸이 비어 있습니다 — 막자갈에서 안 뺐습니다")
else:
rows.append(("고임돌", "㎥", wedge, f"(돌쌓기+돌붙임) × {wedge_per} ㎥/㎡"))
rows.append(
(
"막자갈",
"㎥",
_floor2(volume - (base_area * back_m * const["rubble_body_ratio"] + wedge)),
"입적 − ((돌쌓기+돌붙임) × 뒷길이 × 2/3 + 고임돌)",
)
)
rows.append(
(
"물구멍관",
"m",
_round2(
masonry / STONE_MASONRY["weep_hole_area_m2"] * STONE_MASONRY["weep_hole_length_m"]
),
f"돌쌓기 ÷ {STONE_MASONRY['weep_hole_area_m2']:g}㎡/개소 × "
f"{STONE_MASONRY['weep_hole_length_m']:g}m/개소",
)
)
# 바닥파기 — 비탈 사면장 두 쪽 + 하장 한 쪽, 폭은 평균두께 + 0.3.
slant = _floor2(math.hypot((top_m - bottom_m) / 2, height_m))
width = thickness + const["trench_extra_m"]
depth = const["trench_depth_m"]
trench = _floor2(slant * depth * width * 2 + bottom_m * depth * width)
spoil = _round2(trench - (slant * depth * width + bottom_m * depth * width))
rows.extend(
[
(
"터파기",
"㎥",
trench,
f"(사면장 {slant:g} × {depth:g} × {width:g}) × 2 + (하장 {bottom_m:g} × "
f"{depth:g} × {width:g}) · 정본 이름 「바닥파기」",
),
("잔토처리", "㎥", spoil, f"바닥파기 − (사면장 + 하장) × {depth:g} × {width:g}"),
("되메우기", "㎥", trench - spoil, "바닥파기 잔토"),
]
)
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
for name, unit, amount, basis in rows
]
mpa, mpa_basis = fill_concrete_mpa(options)
fill_per = coeff["fill_concrete_m3_per_m2"]
components.append(
Component(
"채움콘크리트",
"㎥",
_round2(base_area * _num(fill_per)),
DESTINATION["채움콘크리트"],
f"(돌쌓기+돌붙임) × {fill_per} ㎥/㎡ · {mpa_basis}",
spec=f"{mpa}",
)
)
if not options.get("stone_kind"):
notes.append(
"돌 종류 칸이 골막이 등록부에 없어 **야면석**으로 섰습니다 — 정본 탭이 그 돌입니다"
)
return components, notes
#: 개거(겉도랑) 원단위 — 소광리 정본 「개거(150-200) (2)」·「L형수로-(201)」, **둘 다 m당**.
#: ⚠⚠ **두 표가 담는 것이 다르다.** 값이 비슷해 보여 합치고 싶어지는 자리다:
#:
#: 콘크리트 개거 150×200 터파기 · 유로폼 · 면목 **세 줄뿐** — **콘크리트 본체 줄이 없다**
#: 콘크리트 L형수로 H=0.2 터파기 · 되메우기 · 잔토 · **콘크리트** · PVC Φ50 · 이형철근 D13 ·
#: 거푸집 · 면목 **여덟 줄**
#:
#: ⓘ 같은 것 — **둘 다 m당**이고 거푸집 계열(유로폼·거푸집)과 면목이 있다.
#: ⚠ 개거 표에 콘크리트가 없는 것은 **빠뜨린 것이 아니라 원문 그대로**다. 0 으로 때우거나
#: L형수로 값을 옮겨 채우지 않는다(2026-09-09 판단 — 돌붙임(메)에서 세 줄을 안 만든 그 자리).
#:
#: ⓘ **「다른 탭에 본체가 있나」를 찾아봤고 없었다**(2026-09-09 53탭 전수):
#: · 「개거」 글자가 나오는 탭은 **둘뿐** — 이 탭과 「콘크리트개거」.
#: 뒤엣것은 **제목만 「콘크리트개거 (물넘이형)」이고 내용이 국가지점번호판 표**다.
#: · 탭 이름의 「(2)」는 **짝 번호가 아니라 엑셀 사본 표식**이다 — `Φ1000` ↔ `Φ1000(2)`,
#: `떼수로(윤주1.08)` ↔ `…(2)` 가 **내용까지 똑같다**(대조 확인). 「(1)」은 없다.
#: · 같은 시트의 「측구수로400/500」도 제목이 **「터파기 계산서」**이고 본체가 없다 —
#: **한 구조물을 부분 계산서로 쪼개는 버릇**이 있는 시트다.
#: ⇒ 그러므로 이 표는 **부분 계산서일 가능성이 남아 있다.** 다만 이 원본 안에서는
#: 본체 표를 못 찾았으므로 **「원문에 없음」으로 두고 그 사실을 화면에 적는다** —
#: 「콘크리트 개거인데 콘크리트가 0」이 우리 결함으로 읽히지 않게.
#:
#: ⚠ **규격이 붙박이다** — 정본에 「150×200」 하나뿐이고 `open_ditch` 등록부에 **옵션이 하나도
#: 없다**(규격 칸 없음). 칸이 생기면 이 표를 열면 된다.
OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
"콘크리트 개거 150×200": {
"rows": (
("터파기", "㎥", 0.3 * 0.1 * 1.0, "0.3 × 0.1 × 1"),
("유로폼", "㎡", (0.2 + 0.2 + 0.15) * 1.0, "(0.2 + 0.2 + 0.15) × 1"),
("면목", "m", 2.0 * 1.0, "2 × 1"),
),
},
"콘크리트 L형수로 H=0.2": {
"rows": (
("터파기", "㎥", 0.31645, "[{(1.11+1.51)÷2×0.2} + {(0.33×0.33)÷2}] × 1.0"),
("되메우기", "㎥", 0.129975, "(0.2+0.30)÷2×0.25×1 + (0.2+0.19)÷2×0.33×1.0"),
("잔토처리", "㎥", 0.186475, "터파기 되메우기"),
(
"콘크리트",
"㎥",
0.1508,
"{(0.2+0.15)÷2×0.7} + (0.21×0.13)×1 + {(0.15+0.21)÷2×0.20}×1",
),
("이형철근 D13", "kg", 0.398, "0.2 × 2 × 0.995"),
("거푸집", "㎡", 0.738, "0.2 + 0.33 + √(0.2² + 0.06²)"),
("면목", "m", 1.0, "1"),
),
# ⚠ 정본에 줄은 있으나 **수량 칸이 비어 있다**(PVC Φ50). 0 으로 만들지 않고 알린다.
"blank_rows": (("PVC 파이프 Φ50㎜", "m"),),
},
}
#: 떼흙막이 원단위 — 소광리 정본 「떼흙막이」, **개소당**. 치수는 전부 **평균 붙박이**다
#: (상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54 — 원문에 「(평균)」이라 적혀 있다).
#: ✅ **형식 칸에 「떼」가 들어와 이 표가 선다**(2026-09-09 랩탑 메인 `513aff3e`).
#: ⚠ 나머지 일곱 형식은 **정본에 탭이 없다.** 돌(찰)/(메)에 기슭막이 식을 빌려 쓰지 않는다 —
#: 흙막이는 교본상 앞면 1:0.3·뒷면 수직·천단 30㎝ 라 축이 다르고, 무엇보다 **근거가 없다.**
#: ⚠⚠ **사용자 확정 4차**(2026-09-09) — 「흙막이는 횡단도에서 표현방식·옵션이 기슭막이와
#: 동일, 형상도 동일. 다만 **데이터는 분리하여 계산**」. ⇒ **그림·옵션은 한 벌로 쓰되
#: 수량은 여기서 제 것으로 낸다.** 그림이 같다고 기슭막이 원단위를 끌어오면 그 확정을 어긴다.
SOIL_GUARD_SOD: dict[str, Any] = {
"unit_label": "개소당",
"rows": (
(
"떼",
"㎡",
1.39,
"머리떼 1.5×0.2 + 바닥떼 1.1×0.2 + 정면떼 (1.5+1.1)÷2×0.5 "
"+ 양기슭바닥떼 0.54×0.2×2 · 규격 20×20㎝",
),
# ⚠ 이름을 **터파기**로 낸다 — 정본 이름은 「바닥파기」이나 그대로 두면 토공 축에
# 안 실린다(`DESTINATION` 이 이름으로 가른다). 골막이도 같은 자리를 그렇게 냈다.
("터파기", "㎥", 0.17, "(0.54×0.4×0.2×2) + (1.1×0.4×0.2) · 정본 이름 「바닥파기」"),
),
}
def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"""흙막이 전개 — **「떼」만 선다**(정본 「떼흙막이」, 개소당). 나머지는 사유만.
⚠ 치수가 **전부 평균 붙박이**다 — 정본 표에 「(평균)」이라 적혀 있고 개소별 치수조서가
따로 없다. 그래서 높이·길이를 받지 않는다. 개소별로 갈리게 하려면 치수 칸이 먼저다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
form = str(options.get("form") or "").strip()
if not form:
return [], ["흙막이 형식이 정해지지 않았습니다 — 형식을 골라야 물량이 섭니다"]
if form != "떼":
return [], [
f"흙막이 형식 「{form}」 — 정본(소광리 53탭)에 그 형식의 산출식이 없습니다"
"(있는 것은 **떼흙막이** 하나). ⚠ 돌(찰)·돌(메)에 기슭막이 식을 빌려 쓰지 않습니다 "
"— 흙막이는 앞면 1:0.3·뒷면 수직·천단 30㎝(교본)라 축이 다르고 근거가 없습니다"
]
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
for name, unit, amount, basis in SOIL_GUARD_SOD["rows"]
]
return components, [
f"떼흙막이는 **{SOIL_GUARD_SOD['unit_label']}**이고 치수가 정본 평균 붙박이입니다"
"(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다"
]
def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"""개거(겉도랑) 전개 — **m당** 원단위를 연장에 곱한다(소광리 정본 두 탭).
⚠ 규격을 고를 칸이 없어 지금은 **「콘크리트 개거 150×200」 붙박이**다. 그 사실을 줄로 낸다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
if length_m <= 0:
return [], ["연장이 없어 전개하지 않음"]
spec = str(options.get("ditch_spec") or "콘크리트 개거 150×200").strip()
table = OPEN_DITCH_FORMS.get(spec)
if table is None:
return [], [
f"개거 규격 「{spec}」 원단위가 없습니다 — 있는 것: {' · '.join(OPEN_DITCH_FORMS)}"
]
components = [
Component(
name,
unit,
per_m * length_m,
DESTINATION.get(name, "quantity"),
f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})",
)
for name, unit, per_m, basis in table["rows"]
]
notes: list[str] = []
if "ditch_spec" not in options:
notes.append(f"규격이 「{spec}」 붙박이입니다 — 개거 제원에 규격 칸이 없습니다")
if spec == "콘크리트 개거 150×200":
notes.append(
"⚠ 이 표에는 **콘크리트 본체 줄이 없습니다** — 정본 원문 그대로입니다"
"(터파기·유로폼·면목 세 줄뿐). 같은 원본 53탭에서 본체 표를 찾았으나 없었고"
"(「콘크리트개거」 탭은 제목만 그 이름이고 내용이 다른 표), 이 시트가"
"「측구수로 터파기 계산서」처럼 **부분 계산서를 쓰는 버릇**이 있어"
"**다른 문서에 본체가 있을 수 있습니다.** 지어내지 않았습니다"
)
for name, unit in table.get("blank_rows", ()):
notes.append(f"{name}({unit}) 은 정본에 줄은 있으나 **수량이 비어 있어** 안 세웠습니다")
return components, notes
def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"""바닥막이 전개 — **돌붙임 ㎡당** 원단위를 면적에 곱한다(소광리 정본 두 탭).
⚠ 밑수가 **면적**이다. 돌쌓기처럼 높이·연장으로 셈하지 않는다 — 정본 표 제목이 「㎡당」이고
등록부 밑수도 `area_m2` 라 축이 그대로 맞는다.
⚠ 기울기 몫이 **없다** — 평면에 붙이는 것이라 √(1+n²) 를 곱하지 않는다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
form = str(options.get("form") or "").strip()
table = BED_SILL_FORMS.get(form)
if table is None:
return [], [
f"바닥막이 형태 「{form or '빈 값'}」 — 돌붙임(찰)·돌붙임(메)만 원단위가 있습니다"
]
if area_m2 <= 0:
return [], ["면적이 없어 전개하지 않음"]
back = table["back_len_m"]
rows: list[tuple[str, str, float, str]] = [
("돌붙임", "㎡", area_m2, f"면적 {area_m2:g}㎡ (평면적 — 기울기 몫 없음)"),
("입적", "㎥", area_m2 * back, f"면적 × 두께 {back:g}m"),
(
table["stone_name"],
"ton",
area_m2 * table["stone_ton_per_m2"],
f"면적 × {table['stone_ton_per_m2']:.3f} ton/㎡ · {table['stone_spec']}"
+ (
f" (= 뒷길이 {back:g} × 0.77 × 2.65)"
if form == "돌붙임(찰)"
else " (원문 직접값 — 야면석은 유도식이 안 맞음)"
),
),
(
"고임돌",
"㎥",
area_m2 * table["wedge_stone_m3_per_m2"],
f"면적 × {table['wedge_stone_m3_per_m2']} ㎥/㎡",
),
]
for name, key, unit in (
("모르터", "mortar_m3_per_m2", "㎥"),
("채움콘크리트", "fill_concrete_m3_per_m2", "㎥"),
("버림콘크리트", "blinding_m3_per_m2", "㎥"),
):
value = table[key]
if value is not None:
rows.append((name, unit, area_m2 * value, f"면적 × {value} ㎥/㎡"))
# 터파기 — 정본은 「두께 × 1 × 1」이라 **면적 × 두께**와 같다.
rows.append(("터파기", "㎥", area_m2 * back, f"면적 × 두께 {back:g}m"))
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
for name, unit, amount, basis in rows
]
notes = [f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"]
if form == "돌붙임(찰)":
notes.append(
"버림 콘크리트 0.1 ㎥/㎡ 포함 — 정본 주기 「바닥 10㎝ 이상 콘크리트(버림) 포설 후 "
"돌붙임」(KDS 44 90 00 의 100㎜ 와 같음)"
)
return components, notes