Files
Aislo/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py
T
eomsangdonandClaude Opus 5 510337557b fix(B08): 자재 이름 「물구멍」 → 「물구멍관」 (카탈로그 규약)
자재 카탈로그가 이름+규격 두 문자열로 줄을 찾고, 그 규약이 「공백 없는 한 낱말」임
(2026-09-08 B09 실측 — 키는 나라장터 물품식별번호라 우리가 맞출 것이 아님).
전개·관측 원단위표·할증표 세 곳을 함께 고침 — 한쪽만 고치면 이름이 갈림.

⚠ 고임돌·야면석 규격에 뒷길이를 넣지 않음 — 뒷길이는 **수량이 갈리는 축**이지
단가가 갈리는 축이 아님(야면석 톤당 단가는 뒷길이와 무관). B09 가 앞 제안을 물림.

곁들여 — 물구멍관 길이 0.5 가 구조물마다 다른 값임을 주석에 적음
(옹벽 0.32 m/m · 돌기슭막이 0.39 m · 돌골막이 0.5 m/개소, 실무 관측).

시험 739 통과 (B05 코리도 1건 기존 깨짐, 무관).

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

786 lines
40 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.
"""구조물 원단위 전개식 — 치수에서 성분 물량을 낸다 (B08 일감 6 · PLAN 8-6·8-8·8-15).
식을 발명하지 않는다 — 실무 원본을 옮긴다
울진 설계원본 `5. 구조도(기번3).xlsx` 에 구조물 31종의 계산식이 **살아 있는 수식**으로
남아 있다(PLAN 8-15). 여기 옮긴 것은 그 식이며, 식 안에 상수로 박혀 있던 값
(돌 뒷길이 0.45 · 공극률 0.77 · 돌 비중 2.65 · 고임돌 0.15 등)은 **계수표로 뺐다**.
그래야 뒷길이가 바뀔 때 식을 안 고친다 — 실무 방식의 약점을 여기서 고친다.
치수 정본은 하나다 (PLAN 8-6 ② 필수 조건)
전개식은 **저장된 구조물 제원**(`structures.json` 의 `type_id`·`options`)을 읽어 계산한다.
자기 치수표를 따로 들지 않는다 — 도면은 H=1.5 인데 수량은 옛 치수로 도는 사고를 막는다.
⚠⚠ 이중계상 셋 — 이 파일이 지켜야 할 규칙
㉢ **배합을 분해하지 않는다.** 산출물은 `콘크리트 ㎥` · `모르터 ㎥` 에서 **멈춘다**.
시멘트·모래·자갈로 쪼개는 것은 B09 일위대가 몫이다. 양쪽이 쪼개면 시멘트가 두 배가 된다.
실무 원단위 라이브러리에 배합이 이미 분해돼 있어도 **그 줄은 버린다**(PLAN 8-8 ②).
`verify_no_mix_components()` 가 이 규칙을 코드로 지킨다.
㉠ **할증을 붙이지 않는다.** 여기 값은 전부 할증 **전**이다. 할증은 자재총괄 한 곳뿐(PLAN 8-7).
· **터파기·되메우기·잔토는 토공으로 합산된다.** 내역 줄의 실체는 작업 공종
(`돌쌓기(찰) H=1.5 · 70m`)이고 그 전개인 터파기는 토공 대분류로 합쳐진다
(울진 토적집계 D12~D14 실증). 둘 다 내역에 올리면 이중계상이다 —
그래서 성분마다 `destination` 을 달아 어디로 갈 값인지 표시한다.
⚠ 공제 규칙 (품셈 1-2-1 원문)
「말뚝머리, 볼트 구멍, 모따기ㆍ물구멍, 이음줄눈 간격, 포장 1개소당 0.1 ㎡ 이하 구조물 자리,
리벳 구멍, **철근콘크리트 중의 철근** 등」은 **공제하지 않는다.**
치수를 곧이곧대로 빼면 실무값과 어긋난다. 전개식에서 빼는 것은 **관 통과 단면**처럼
실제로 비어 있는 자리뿐이다.
"""
from __future__ import annotations
import json
import math
from pathlib import Path
from functools import lru_cache
from dataclasses import dataclass, field
from typing import Any, Iterable
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import (
BASIS_DERIVED,
BASIS_OBSERVED,
ObservedUnitTable,
billing_of,
expand_observed,
load_observed_table,
)
from B08_Quantity.B08_Quantity_Engine_Formwork import annotate as annotate_formwork
from B08_Quantity.B08_Quantity_Engine_Formwork import shoring_status
from common_util.common_util_quantity_spread import spread_by_unit
# ── 계수표 — 식에 박지 않고 여기서 고른다 ─────────────────────────────
# 돌 뒷길이(㎝)별 원단위. 출처: `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
#: 돌 종류별 계수표 — 품셈 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 {}
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㎝)에 없어 계수가 없습니다")
if not kind:
fallback = table.get("fallback") 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": "",
}, str(fallback.get("message") or "")
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
# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것.
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 에서 시트값과 맞는지 대조하는 자리
"thickness_base_m": 0.45, # 평균두께 식의 밑돌 두께
"thickness_top_coeff": 0.10, # 상부 두께 계수 (0.45 + 0.10·H)
"thickness_bottom_coeff": 0.40, # 하부 두께 계수 (0.45 + 0.40·H)
# ⚠ 미결 — 법은 「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 하나로 돌고 있으니 그 사실을
# 적어 둔다 — 관측 원단위가 있는 종류는 그 표가 이기고, 없는 종류만 이 값으로 선다.
"weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이
"mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만)
"excavation_extra_m": 0.2, # 터파기 폭 여유
"backfill_thickness_m": 0.2, # 되메우기 두께
}
# 성분이 어디로 가는가 — 이중계상을 막는 표시.
# `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증)
# `material` = 자재총괄로 감(할증은 거기서 한 번만)
# `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해)
DESTINATION = {
"터파기": "earthwork",
"되메우기": "earthwork",
"잔토처리": "earthwork",
"돌쌓기": "unit_price",
"돌붙임": "unit_price",
"깬돌": "material",
"야면석": "material",
"고임돌": "material",
"막자갈": "material",
"콘크리트": "unit_price",
"채움콘크리트": "unit_price",
"모르터": "unit_price",
"거푸집": "unit_price",
"물구멍관": "material", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약)
}
# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다.
# ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로
# 오탐된다 — 개발 중 실제로 걸렸던 자리다.
MIX_COMPONENTS = frozenset(
{"시멘트", "모래", "자갈", "친모래", "친자갈", "잔골재", "굵은골재", "부순돌"}
)
@dataclass(slots=True)
class Component:
"""전개 결과 한 성분. `basis` 는 어떤 식으로 나왔는지 사람이 읽는 근거다."""
name: str
unit: str
amount: float
destination: str
basis: str = ""
# ⚠ 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표).
# 두 근거가 한 표에 섞이므로 줄마다 단다. 안 적으면 나중에 못 되짚는다.
basis_kind: str = BASIS_DERIVED
source: str = ""
@dataclass(slots=True)
class StructureQuantity:
"""구조물 하나의 원단위 전개 결과."""
structure_id: str | None
type_id: str
name: str
length_m: float = 0.0
height_m: float = 0.0
# 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계).
start_m: float | None = None
end_m: float | None = None
# 저장된 제원 — **형식(반중력식…)처럼 뒤 단계가 봐야 하는 값**이 여기 있다.
# 치수를 다시 쓰라는 뜻이 아니라 **읽으라고** 실어 나른다(치수 정본은 여전히 하나).
options: dict[str, Any] = field(default_factory=dict)
components: list[Component] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
#: 내역 줄이 설 **단위와 그 단위로 센 수량**. 관측 원단위가 「개소당」·「㎡당」인
#: 종류는 연장(m)으로 세면 축이 어긋난다(2026-09-08 ㉕ 실증).
#: 비어 있으면 종전대로 「m · 연장」으로 선다.
billing_unit: str = ""
billing_quantity: float = 0.0
#: ⚠ **저장 제원의 실제 칸 이름**은 `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 _num(value: Any, fallback: float = 0.0) -> float:
return float(value) if isinstance(value, (int, float)) else fallback
#: 큰돌쌓기(품셈 13-6) 직경 갈래 — **저장 제원 `stone_cm` 과 글자까지 같다**(레지스트리 확인).
#: ⚠ 돌쌓기(13-4)의 **뒷길이** 축과 섞지 않는다. 앞서 섞여 있어 직경 60~80 짜리가
#: 「뒷길이 45㎝」 계수로 돌던 자리다.
BOULDER_DIAMETERS = ("40~60", "60~80", "80~100")
#: 큰돌쌓기 전개 상수. **재료 원단위는 품셈에 없다** — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」.
#: 그래서 여기서 내는 것은 **면적과 터파기 계열까지**이고 큰돌 자체는 미확보로 둔다.
BOULDER_MASONRY = {
# ⚠ 검산 참고값 — 곱하지 않는다. 까닭은 `STONE_MASONRY` 의 같은 칸 주석을 볼 것.
# 큰돌쌓기는 전면 기울기가 「1:0.3 **이상**」이라 특히 하드코딩하면 안 된다.
"sheet_check_factor_at_0_3": 1.04,
"excavation_extra_m": 0.2, # 터파기 폭 여유
"backfill_thickness_m": 0.2, # 되메우기 두께
}
def _boulder_diameter(options: dict[str, Any]) -> str | None:
"""저장 제원의 직경 갈래. 표에 없는 값이면 **지어내지 않고 `None`**."""
raw = options.get("stone_cm")
text = str(raw).strip() if raw is not None else ""
return text if text in BOULDER_DIAMETERS else None
def boulder_masonry(
height_m: float, length_m: float, options: dict[str, Any]
) -> tuple[list[Component], list[str]]:
"""큰돌쌓기(품셈 13-6) 전개 — **치수로 낼 수 있는 것까지만** 낸다.
⚠ 재료 원단위가 품셈에 없다 — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」.
돌쌓기(13-4)처럼 「뒷길이별 돌중량·고임돌」 표가 **없으므로** 큰돌·고임돌·막자갈을
내지 않는다. 대신 **왜 못 내는지**를 알림으로 남긴다.
⚠ 고임돌·채움콘크리트 품은 **품에 포함**돼 있다(13-6-1 [주]①·13-6-2 [주]①) —
따로 세우면 이중계상이다.
⚠ 뒤채움(조약돌)은 **13-3 을 적용**한다(13-6 [주]⑧) — 이 표에서 찾지 않는다.
두께는 13-4-3 [주]⑨ 를 준용하는데 **직고별 범위값**(상부 20~40㎝ / 하부 30~140㎝)이라
한 값으로 못 정한다. 미확보로 둔다.
"""
notes: list[str] = []
if height_m <= 0 or length_m <= 0:
return [], ["높이·연장이 없어 전개하지 않음"]
diameter = _boulder_diameter(options)
if diameter is None:
return [], [
"큰돌쌓기 돌 직경이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 "
f"{' · '.join(BOULDER_DIAMETERS)}㎝ 중 하나를 고르면 값이 섭니다"
]
slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 레지스트리에 칸 없음 — 기본 0.3
constants = BOULDER_MASONRY
face_area = height_m * length_m
# 기울기 몫은 **한 번만** — 13-4 와 같은 자리다(㉘).
masonry_area = face_area * math.hypot(1.0, slope_ratio)
components = [
Component(
"큰돌쌓기",
"㎡",
masonry_area,
DESTINATION["돌쌓기"],
f"정면적 × √(1+{slope_ratio}²) · 직경 {diameter}㎝ (품셈 13-6)",
)
]
# 터파기·되메우기·잔토 — 치수에서 나온다. 두께는 **직경 갈래의 위 끝**을 벽 두께로 본다.
# ⚠ 품셈에 큰돌쌓기 터파기 폭 규정이 없어 **돌쌓기와 같은 방식**(벽 두께 + 여유 0.2m)으로
# 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다.
upper_cm = float(str(diameter).split("~")[-1])
thickness = upper_cm / 100.0
excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m
backfill = height_m * constants["backfill_thickness_m"] * length_m
components.extend(
[
Component(
"터파기",
"㎥",
excavation,
DESTINATION["터파기"],
f"높이 × (벽두께 {thickness:g}m + 0.2) × 연장 · ⚠ 큰돌쌓기 터파기 폭 규정이"
" 품셈에 없어 돌쌓기 방식을 준용",
),
Component("되메우기", "㎥", backfill, DESTINATION["되메우기"], "높이 × 0.2 × 연장"),
Component(
"잔토처리",
"㎥",
excavation - backfill,
DESTINATION["잔토처리"],
"터파기 되메우기",
),
]
)
notes.append(
"재료(큰돌) 원단위 미확보 — 품셈 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」라 "
"돌쌓기(13-4)의 뒷길이별 돌중량 표에 해당하는 것이 없음"
)
notes.append("고임돌·채움콘크리트는 **품에 포함**(13-6 [주]①) — 따로 세우지 않음")
notes.append(
"뒤채움(조약돌)은 13-3 적용(13-6 [주]⑧). 두께는 13-4-3 [주]⑨ 준용인데 "
"직고별 범위값(상부 20~40㎝ / 하부 30~140㎝)이라 한 값으로 못 정함 — 미확보"
)
notes.append("⚠ 메/찰 구분이 저장 제원에 없어 13-6-1(메)·13-6-2(찰) 중 어느 쪽인지 못 고름")
return components, notes
def stone_masonry(
height_m: float, length_m: float, options: dict[str, Any], wet: bool
) -> tuple[list[Component], list[str]]:
"""돌쌓기(찰/메) 1구간 전개 — 실무 `기슭막이(찰쌓기, H=1.5, 기초무)` 시트의 식.
실측 대조(m당, H=1.5, 뒷길이 45㎝, 기울기 1:0.3):
정면적 1.5 · 비탈면적 1.57 · 평균두께 0.83 · 입적 1.245
터파기 1.55 · 되메우기 0.30 · 잔토 1.25
"""
notes: list[str] = []
if height_m <= 0 or length_m <= 0:
return [], ["높이·연장이 없어 전개하지 않음"]
back_cm = _back_length(options)
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])
# ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3).
# 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고
# 멈추면 이미 저장된 프로젝트가 통째로 빈다.
picked, kind_note = stone_coefficients(options, back_cm)
if kind_note:
notes.append(kind_note)
# ⚠ **고른 종류의 빈 칸은 빈 칸으로 덮는다.** `None` 이라고 안 덮으면 종전 값(깬돌 계열)이
# 남아 「야면석 75㎝」처럼 **원문에 「-」인 칸에 값이 서는** 일이 생긴다(만들다 잡음).
if picked.get("kind"):
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
table[key] = picked.get(key)
else:
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
if picked.get(key) is not None:
table[key] = picked[key]
#: ⚠ **표는 「뒤채움 몫」, 우리 식은 「빼는 몫」** — 뜻이 반대라 1 에서 뺀다.
#: 교본은 「뒤채움 = 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)」이고, 우리 식이 입적에서
#: 빼는 것은 **돌 몸통**이라 `1 − 뒤채움몫` 이다. 종전 2/3 이 곧 야면석(1 − 1/3)이었다.
#: ⚠ 그대로 넣었더니 미지정 값이 15.130 → 19.045 로 바뀌었다(만들다 잡음).
backfill_share = picked.get("backfill_ratio")
body_ratio = 1.0 - float(backfill_share) if backfill_share is not None else 2.0 / 3.0
kind_label = str(picked.get("kind") or "")
# ⚠ `face_slope_ratio` 는 **레지스트리에 없는 키**다 — 즉 지금은 늘 기본 0.3 으로 돈다.
# 상수로 두는 것이 아니라 「칸이 생기면 바로 받는다」는 뜻으로 남겨 둔다.
# (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것 — 이쪽은 **칸 자체가 없다**.)
# **기본 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)
constants = STONE_MASONRY
face_area = height_m * length_m # 정면적
# 돌쌓기 면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다. **기울기 몫은 한 번만.**
# 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04).
masonry_area = face_area * math.hypot(1.0, slope_ratio)
thickness = (
(constants["thickness_base_m"] + constants["thickness_top_coeff"] * height_m)
+ (constants["thickness_base_m"] + constants["thickness_bottom_coeff"] * height_m)
) / 2.0
volume = face_area * thickness # 입적
components = [
Component(
"돌쌓기",
"㎡",
masonry_area,
DESTINATION["돌쌓기"],
f"정면적 × √(1+{slope_ratio}²) — 비탈면적",
),
]
# ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25).
# 그 규격에 그 돌을 안 쓴다는 뜻이라 **0 줄을 만들지 않는다** — 0 은 「없음」과
# 구별이 안 되고, 받는 쪽이 「값이 0 인 자재」로 읽는다.
if table["wedge_stone_m3_per_m2"] is None:
notes.append(
f"고임돌 계수가 품셈 표에 없습니다 — 뒷길이 {back_cm}㎝"
+ (f" · {kind_label}" if kind_label else "")
+ " 칸이 「-」입니다"
)
else:
components.append(
Component(
"고임돌",
"㎥",
masonry_area * _num(table["wedge_stone_m3_per_m2"]),
DESTINATION["고임돌"],
f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)"
+ (f" · {kind_label}" if kind_label else ""),
)
)
stone_ton = table["stone_ton_per_m2"]
if stone_ton is None:
# 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮).
notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 야면석을 내지 못함")
else:
components.append(
Component(
"야면석",
"ton",
masonry_area * stone_ton,
DESTINATION["야면석"],
f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝)",
)
)
# 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌).
# 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 1/2 · 야면석 1/3 (교본 7-3).
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 — 막자갈에서 안 뺌
rubble = volume - (masonry_area * (back_cm / 100.0) * body_ratio + wedge)
if rubble > 0:
components.append(
Component(
"막자갈",
"㎥",
rubble,
DESTINATION["막자갈"],
f"입적 (면적×뒷길이×{body_ratio:.4g} + 고임돌)"
+ (f" · {kind_label}" if kind_label else ""),
)
)
if wet:
components.append(
Component(
"채움콘크리트",
"㎥",
masonry_area * _num(table["fill_concrete_m3_per_m2"]),
DESTINATION["채움콘크리트"],
f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)",
)
)
components.append(
Component(
"모르터",
"㎥",
masonry_area * constants["mortar_m3_per_m2"],
DESTINATION["모르터"],
f"돌쌓기 × {constants['mortar_m3_per_m2']} ㎥/㎡ (줄눈)",
)
)
# ⚠ 여기서 멈춘다 — 모르터를 시멘트·모래로 쪼개지 않는다(㉢).
# 물구멍 — 벽면적 2㎡당 1개소, 개소당 0.5m.
# ⚠ 이것은 **관(파이프) 자재**이지 공제 대상이 아니다. 품셈 1-2-1 이 「공제하지 않는다」고
# 말하는 물구멍은 **콘크리트 체적에서 뺄 구멍**이고, 여기 값은 그 구멍에 넣는 **관 길이**다.
# ⚠⚠ **㉥ 이중계상** — 품셈 13-6-2·13-7-2 [주]③ 은 제잡비 **윗단** 값에
# 「물빼기 파이프 설치에 관계되는 노무비, 재료비를 포함한다」고 한다. 그 쪽을 쓰면
# 이 줄과 겹친다. **우리 선택은 이 줄을 세우고 제잡비는 아랫단(미설치)** 이다
# (`structure_unit_observed` 의 `double_count_rules`).
# 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인).
# ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면
# 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다.
components.append(
Component(
"물구멍관",
"m",
masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"],
DESTINATION["물구멍관"],
# ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
"돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 · ⚠ 잠정: 관 Ø 미정(법 3~6㎝ / 실무 Ø50) ·"
" 간격 2.0㎡당 1개소(법 2~3㎡당 1개소 이상)",
)
)
# 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다).
excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m
backfill = height_m * constants["backfill_thickness_m"] * length_m
components.extend(
[
Component(
"터파기", "㎥", excavation, DESTINATION["터파기"], "높이 × (평균두께+0.2) × 연장"
),
Component("되메우기", "㎥", backfill, DESTINATION["되메우기"], "높이 × 0.2 × 연장"),
Component(
"잔토처리",
"㎥",
excavation - backfill,
DESTINATION["잔토처리"],
"터파기 되메우기",
),
]
)
return components, notes
# 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다).
# ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다.
# 값의 키(규격)를 저장 제원의 어느 칸에서 읽는지 여기 적는다. 표에 규격이 없으면
# 「원단위 미확보」로 드러난다 — 가까운 값을 갖다 쓰지 않는다.
OBSERVED_SPEC_KEYS: dict[str, tuple[str, ...]] = {
"retaining_wall": ("form", "height_m"),
"ford_pavement": ("thickness_cm",),
# 배수관의 유입부 집수정은 관 자체와 **다른 줄**이다 — 관은 관대로 서고 집수정이 따로 선다.
"pipe_inlet_basin": ("inlet_basin_form", "inlet_basin_material", "pipe_diameter_mm"),
}
EXPANDERS = {
"masonry_wet": lambda h, l, o: stone_masonry(h, l, o, wet=True),
"masonry_dry": lambda h, l, o: stone_masonry(h, l, o, wet=False),
"boulder_masonry": lambda h, l, o: boulder_masonry(h, l, o),
# ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견).
# 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.**
# 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝).
# 앞서 `stone_masonry(dry)` 로 전개하고 있었는데, 그러면 직경 60~80㎝ 짜리가
# **「뒷길이 45㎝」 계수로 돌아 조용히 틀린 값**이 나온다(고임돌 0.15·야면석 0.88 …).
# ⚠ 값이 나오기는 하므로 어떤 시험도 안 잡던 자리다 — 「값이 있기는 하니 안 보이는」 그것.
# 전개식·관측 원단위가 설 때까지 **미확보로 드러낸다.**
}
#: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다.
EXPANDER_WITHHELD: dict[str, str] = {}
#: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다
#: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다.
ATTACHMENTS: dict[str, tuple[tuple[str, str, str], ...]] = {
# (붙는 종류, 그것이 있는지 보는 옵션 칸, 줄 이름 꼬리)
"pipe": (("pipe_inlet_basin", "inlet_basin_form", "유입부 집수정"),),
}
def attachments_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
"""구조물에 딸린 **별도 줄**을 만든다. 제원은 원본을 그대로 물려준다(치수 두 벌 금지)."""
rows: list[dict[str, Any]] = []
options = structure.get("options") or {}
for type_id, gate_key, label in ATTACHMENTS.get(str(structure.get("type_id") or ""), ()):
if not options.get(gate_key):
continue # 그 부속이 없는 배치다 — 빈 줄을 만들지 않는다
rows.append(
{
**structure,
"structure_id": f"{structure.get('structure_id')}-{type_id}",
"type_id": type_id,
"attachment_of": structure.get("structure_id"),
"attachment_parent_type": structure.get("type_id"),
"attachment_label": label,
}
)
return rows
def _observed_components(
type_id: str,
structure: dict[str, Any],
observed: ObservedUnitTable | None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""관측 원단위표에서 꺼낸다. 규격 키가 정해져 있지 않은 종류는 건드리지 않는다."""
keys = OBSERVED_SPEC_KEYS.get(type_id)
if keys is None:
return [], []
options = structure.get("options") or {}
spec = {key: options[key] for key in keys if options.get(key) is not None}
if not spec:
from B08_Quantity.B08_Quantity_Wording import option_missing
return [], [option_missing(keys[0], type_id)]
return expand_observed(type_id, spec, structure, observed)
def _observed_billing(
type_id: str,
structure: dict[str, Any],
observed: ObservedUnitTable | None,
) -> tuple[str, float] | None:
"""관측표가 정한 **내역 단위와 개수**. 규격 키가 없는 종류는 건드리지 않는다."""
keys = OBSERVED_SPEC_KEYS.get(type_id)
if keys is None:
return None
options = structure.get("options") or {}
spec = {key: options[key] for key in keys if options.get(key) is not None}
if not spec:
return None
return billing_of(type_id, spec, structure, observed)
def expand(
structure: dict[str, Any],
names: dict[str, str] | None = None,
observed: ObservedUnitTable | None = None,
) -> StructureQuantity:
"""구조물 하나를 전개한다. 치수는 저장된 제원에서만 읽는다(치수 두 벌 금지)."""
type_id = str(structure.get("type_id") or "")
options = structure.get("options") or {}
start = _num(structure.get("start_m"))
end = _num(structure.get("end_m"))
length = _num(options.get("length_m")) or abs(end - start)
height = _num(options.get("height_m"))
label = (names or {}).get(type_id, type_id)
if structure.get("attachment_label"):
# 「배수관 · 유입부 집수정」처럼 어디에 딸린 줄인지 이름에 남긴다.
parent = (names or {}).get(str(structure.get("attachment_parent_type") or ""), "")
label = f"{parent or label} · {structure['attachment_label']}".strip(" ·")
result = StructureQuantity(
structure_id=structure.get("structure_id"),
type_id=type_id,
name=label,
length_m=length,
height_m=height,
start_m=start if structure.get("start_m") is not None else None,
end_m=end if structure.get("end_m") is not None else None,
options=dict(options),
)
withheld = EXPANDER_WITHHELD.get(type_id)
if withheld:
result.notes.append(f"전개식 미확보 — {withheld}")
return result
expander = EXPANDERS.get(type_id)
if expander is None:
# 전개식이 없으면 **관측 원단위표**를 본다(치수가 저장돼 있지 않은 종류).
components, notes = _observed_components(type_id, structure, observed)
if components or notes:
result.components = [Component(**item) for item in components]
result.notes.extend(notes)
billing = _observed_billing(type_id, structure, observed)
if billing is not None:
result.billing_unit, result.billing_quantity = billing
return result
from B08_Quantity.B08_Quantity_Wording import type_label
result.notes.append(
f"{type_label(type_id, names)}의 수량 산출식이 아직 없습니다 — 물량이 서지 않습니다"
)
return result
result.components, notes = expander(height, length, options)
result.notes.extend(notes)
return result
def verify_no_mix_components(quantities: Iterable[StructureQuantity]) -> list[str]:
"""⚠ 배합 성분이 산출물에 섞이면 알린다 (㉢ 이중계상 방어).
시멘트·모래·자갈은 **B09 일위대가**가 배합표로 분해할 값이다. 여기서 내면 두 배가 된다.
실무 원단위 라이브러리를 베끼다 딸려 들어오기 쉬운 자리라 코드로 막는다.
"""
found: list[str] = []
for item in quantities:
for component in item.components:
if component.name.strip() in MIX_COMPONENTS:
found.append(f"{item.name}({item.type_id}) 의 '{component.name}'")
return found
def build_table(
structures: Iterable[dict[str, Any]], names: dict[str, str] | None = None
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다."""
observed = load_observed_table()
# 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다.
expanded_inputs: list[dict[str, Any]] = []
for item in structures:
expanded_inputs.append(item)
expanded_inputs.extend(attachments_of(item))
quantities = [expand(item, names, observed) for item in expanded_inputs]
violations = verify_no_mix_components(quantities)
totals: dict[str, dict[str, Any]] = {}
for item in quantities:
for component in item.components:
key = f"{component.name}|{component.unit}"
entry = totals.setdefault(
key,
{
"name": component.name,
"unit": component.unit,
"amount": 0.0,
"destination": component.destination,
},
)
entry["amount"] += component.amount
payload_structures = [
{
"structure_id": item.structure_id,
"type_id": item.type_id,
"name": item.name,
"length_m": item.length_m,
"height_m": item.height_m,
"start_m": item.start_m,
"end_m": item.end_m,
# 내역 줄이 설 단위·수량 — 관측 원단위가 「개소당」인 종류는 연장으로 못 센다.
"billing_unit": item.billing_unit,
"billing_quantity": item.billing_quantity,
# 저장된 제원 — 형식(반중력식…)처럼 **뒤 단계가 읽어야 하는** 값이 여기 있다.
"options": item.options,
"notes": item.notes,
"components": [
{
"name": component.name,
"unit": component.unit,
"amount": component.amount,
"destination": component.destination,
"basis": component.basis,
"basis_kind": component.basis_kind,
"source": component.source,
}
for component in item.components
],
}
for item in quantities
]
# 거푸집 줄에 **몇 회짜리인지**를 달아 준다. 횟수별 재료 환산은 하지 않는다(B09 몫).
formwork_notes, formwork_missing = annotate_formwork(payload_structures)
return {
"structures": payload_structures,
"formwork_notes": formwork_notes,
"formwork_reuse_missing": formwork_missing,
# 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다.
"shoring": shoring_status(),
# ⚠ 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다.
# 「무엇을 정해야 하는지」만으로는 부족하고 **「정하면 얼마나 달라지는지」**까지.
"pending_choices": (observed.pending_choices or {}).get("items") or [],
"totals": sorted(totals.values(), key=lambda entry: entry["name"]),
# 할증 전 값임을 응답에 못 박는다 — 자재총괄이 한 번만 붙인다(㉠).
"surcharge_applied": False,
"mix_components_found": violations,
"structure_count": len(quantities),
"amount_spread": spread_by_unit(totals.values(), value_key="amount"),
}