㉘ 자기 감사 후속. 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 곧 기울기 몫임(1:0.3 → √(1+0.3²) = 1.0440). 시트가 반올림해 적은 것을 별도 계수로 오해해 hypot 위에 또 곱하고 있었음. - 돌쌓기·큰돌쌓기 모두 기울기 몫을 한 번만 곱함 - 상수는 지우지 않고 `sheet_check_factor_at_0_3` 으로 성격을 바꿈 — 1:0.3 에서 시트값과 맞는지 대조하는 검산 참고값 - 하드코딩 금지 이유를 주석에 남김: 큰돌쌓기는 「1:0.3 이상」이라 기울기가 바뀌면 1.04 는 틀린 값이 되지만 hypot 은 따라감 값 변화 (실노선): 고임돌 -3.85 % · 야면석 -3.85 % · 물구멍 -3.85 % 막자갈 +3.20 % (입적에서 빼는 몫이 줄어 늘어남 — 식대로임) 시험 - 옛 시험이 덧곱을 계약으로 못 박고 있어 정정 - 거울 시험 추가: 기울기 0.3·0.5·1.0 에서 정면적 × hypot 과 일치 - 시트 대조 추가: 정면적 18.87 → 돌쌓기 19.62, 오차 0.5 % 안 - NULLABLE_SETTING_KEYS 계약 시험 추가 (홑 숫자 칸만 좁게 봄) 곁들여 타설 방식 안내 한 줄 — 고른 값이 아직 금액에 안 닿는다는 사실을 화면에 적음. 인계에 타설 공종 줄을 세울지는 이중계상 확인 뒤 결정. 시험: 666 passed · 24 skipped (B05 코리도 1건 기존 깨짐, 무관) · tsc 오류 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
641 lines
32 KiB
Python
641 lines
32 KiB
Python
"""구조물 원단위 전개식 — 치수에서 성분 물량을 낸다 (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 math
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Iterable
|
||
|
||
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import (
|
||
BASIS_DERIVED,
|
||
BASIS_OBSERVED,
|
||
ObservedUnitTable,
|
||
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 ㉮).
|
||
STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | 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},
|
||
}
|
||
DEFAULT_BACK_LENGTH_CM = 45
|
||
|
||
# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것.
|
||
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개소당 벽면적
|
||
"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 일위대가가 배합표로 분해한다.
|
||
# ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로
|
||
# 오탐된다 — 개발 중 실제로 걸렸던 자리다.
|
||
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)
|
||
|
||
|
||
#: ⚠ **저장 제원의 실제 칸 이름**은 `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:
|
||
"""뒷길이(㎝). 표에 없는 값이면 **가장 가까운 아래 칸**이 아니라 기본으로 간다.
|
||
|
||
⚠ 표는 35·45·55·60 네 칸뿐인데 레지스트리 선택지는 25·30·35·45·55·60·75 일곱이다.
|
||
25·30 은 35 로, 75 는 60 으로 접는다 — **원단위표가 그 구간을 그렇게 덮는다**
|
||
(품셈 13-4 뒷길이 표준). 접은 사실은 근거 문구에 적는다.
|
||
"""
|
||
for key in BACK_LENGTH_KEYS:
|
||
raw = options.get(key)
|
||
if raw is None:
|
||
continue
|
||
try:
|
||
value = int(float(raw))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if value in STONE_BACK_LENGTH_TABLE:
|
||
return value
|
||
# 표에 없는 값 — 그 값을 덮는 **가장 가까운 위 칸**으로 접는다(작은 돌이 큰 칸에 들어감).
|
||
larger = [cm for cm in sorted(STONE_BACK_LENGTH_TABLE) if cm >= value]
|
||
if larger:
|
||
return larger[0]
|
||
return max(STONE_BACK_LENGTH_TABLE)
|
||
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)
|
||
table = STONE_BACK_LENGTH_TABLE[back_cm]
|
||
# ⚠ `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}²) — 비탈면적",
|
||
),
|
||
Component(
|
||
"고임돌",
|
||
"㎥",
|
||
masonry_area * _num(table["wedge_stone_m3_per_m2"]),
|
||
DESTINATION["고임돌"],
|
||
f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)",
|
||
),
|
||
]
|
||
|
||
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}㎝)",
|
||
)
|
||
)
|
||
|
||
# 막자갈 = 입적 − (면적 × 뒷길이 × 2/3 + 고임돌). 실무 식 그대로.
|
||
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"])
|
||
rubble = volume - (masonry_area * (back_cm / 100.0) * 2.0 / 3.0 + wedge)
|
||
if rubble > 0:
|
||
components.append(
|
||
Component(
|
||
"막자갈", "㎥", rubble, DESTINATION["막자갈"], "입적 − (면적×뒷길이×2/3 + 고임돌)"
|
||
)
|
||
)
|
||
|
||
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 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)
|
||
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,
|
||
# 저장된 제원 — 형식(반중력식…)처럼 **뒤 단계가 읽어야 하는** 값이 여기 있다.
|
||
"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"),
|
||
}
|