Files
Aislo/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py
T
eomsangdonandClaude Opus 5 c3975f4ec1 refactor(B08): 측점별 단면유형 표를 공용 함수로 냄 (B07 표준도가 같이 쓰게)
기울기 판정이 `section_mode` 를 받아야 도는데, 그 표를 만드는 코드가 B08 라우터 안에만
있었음. B07 표준도는 `build_table` 을 직접 부르면서 그 인자를 못 줘 **늘 「가를 근거 없음」**
으로 떨어졌음(랩탑 보조 실측).

⇒ `section_modes_from_designs(designs)` 를 엔진에 두어 **부르는 쪽마다 다시 짜지 않게** 함.
B07 은 저장된 횡단 설계 목록을 이 함수에 넣어 `build_table(..., section_modes=…)` 로 주면 됨.

⚠ 동작은 그대로 — `section_mode` 가 없는 측점은 담지 않고, 못 가르면 여전히
「가를 근거 없음 + 종전값 1:0.3」으로 섬(성토로 눅이지 않음).

내 프로젝트 실측 유지 — 돌쌓기(메) H=2.0 **1:0.35 · 21.190㎡**,
근거 「… right_cut · 자동(성토 쪽) → 성토면」.

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

1039 lines
53 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 common_util.common_util_structure_face_role import structure_face_role
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 가 배합을 분해)
#: 버림 콘크리트 — **빠뜨리고 있던 줄**이다(2026-09-09 사용자 확정 ⑭).
#: 두께 근거: KDS 44 90 00 도로암거구조설계기준 「기초시공시 기초지반 다짐을 시행하고
#: 구조물 시공이 원활하도록 **100 mm 두께의 버림콘크리트**를 타설하도록 한다」.
#: 폭 근거: KCS 34 50 05 「버림 콘크리트의 두께는 설계도서에 따르며, **폭은 잡석다짐의
#: 폭과 동일**하게 한다」.
#: ⚠ 우리에게 **잡석다짐 폭이 아직 없다**(기초잡석은 품셈에 공종만 있고 두께·폭이 없음).
#: 그래서 **터파기 폭(평균두께 + 여유 0.2m)을 잠정으로** 쓰고 근거 문구에 그 사실을 적는다.
#: 잡석다짐 폭이 정해지면 이 한 줄만 바꾸면 된다.
#: 돌을 **사 오나 캐나** — 기본은 「캔다」(2026-09-09 사용자 확정 ②).
#: 법이 그쪽을 권한다 — 별표2 「석축 등에 필요한 야면석 등은 **가급적 현장에서
#: 채취·사용**하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 **구입 단가가
#: 0건**이고 울진 일위대가는 「구입/채집」 두 벌을 갖고 있다.
#: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다.
STONE_SUPPLY_KEYS = ("stone_supply", "stone_source")
STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"}
#: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도
#: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다).
#:
#: 채집석 공제는 사토에서 한 번만 뺀다.
#: B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
#: 빼는 자리는 유토곡선의 사토뿐이다 —
#: 실어 내는 몫(spoil_m3 natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
#:
#: ⚠ **부호를 넘기지 않는다.** 실무 시트가 `−274.66` 으로 적혀 있어 그대로 넘기면 두 번
#: 뒤집힌다. 우리는 **양수**로 주고, 빼는 것은 받는 쪽이 한다.
COLLECTED_STONE_KEY = "collected_stone_deduction_m3"
BLINDING_THICKNESS_M = 0.10
#: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭).
#: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다.
BLINDING_OPTION_KEYS = ("blinding_concrete", "base_blinding")
DESTINATION = {
"터파기": "earthwork",
"되메우기": "earthwork",
"잔토처리": "earthwork",
"돌쌓기": "unit_price",
"돌붙임": "unit_price",
"깬돌": "material",
"야면석": "material",
"고임돌": "material",
"막자갈": "material",
"콘크리트": "unit_price",
"채움콘크리트": "unit_price",
"버림콘크리트": "unit_price",
"모르터": "unit_price",
"거푸집": "unit_price",
"물구멍관": "material",
# ⚠ 자재도 토공도 아닌 자리 — **유토곡선이 사토에서 뺄 밑수**다. 자재총괄은
# `material` 만 모으므로 여기 섞이지 않는다.
"채집석": "haul_deduction", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(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
#: 표준경사표 — 품셈 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",)
#: 성토/절토를 아직 저장이 안 갖고 있다 — 어느 칸을 잠정으로 쓰는지 한 곳에 둔다.
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
def _blinding_component(base_width_m: float, length_m: float, options: dict[str, Any]):
"""버림 콘크리트 한 줄. 넣지 않기로 했으면 `None`."""
if not wants_blinding(options) or base_width_m <= 0 or length_m <= 0:
return None
return Component(
"버림콘크리트",
"㎥",
base_width_m * length_m * BLINDING_THICKNESS_M,
DESTINATION["버림콘크리트"],
f"기초 폭 {base_width_m:.2f}m × 연장 × 두께 {BLINDING_THICKNESS_M:.2f}m"
" · 두께는 KDS 44 90 00(100㎜) · ⚠ 폭은 잡석다짐 폭(KCS 34 50 05)이라야 하나"
" 그 값이 아직 없어 **터파기 폭을 잠정**으로 씀",
)
#: ⚠ **저장 제원의 실제 칸 이름**은 `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)}㎝ 중 하나를 고르면 값이 섭니다"
]
# ⚠ 큰돌쌓기는 **표준경사표 대상이 아니다** — 교본이 「1:0.3 **이상**」으로만 두었다.
# 저장 제원에 값이 있으면 그것을 쓰고, 없으면 하한 0.3 으로 둔다.
slope_ratio = _num(options.get("face_slope_ratio"), 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
# 버림 콘크리트 — 돌쌓기와 같은 자리(사용자 확정 ⑭). 빼려면 저장 제원에서 「안 넣음」.
blinding = _blinding_component(thickness + constants["excavation_extra_m"], length_m, options)
if blinding is not None:
components.append(blinding)
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㎝)이라 한 값으로 못 정함 — 미확보"
)
# ⚠ **채워져 있으면 그 사유를 붙이지 않는다**(2026-09-09). `bond` 는 레지스트리에 이미
# 있는 칸인데 조건 없이 붙이고 있어, 값을 고른 뒤에도 「못 고름」이 뜨고 있었다.
# ⚠ 얻는 것은 **노무 품 갈래뿐**이고 재료 원단위는 13-6 [주]⑦ 때문에 여전히 안 선다 —
# 그래도 고치는 까닭은 **사유가 거짓이면 사유 칸 전체를 못 믿게 되기 때문**이다.
if not str(options.get("bond") or "").strip():
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,
face: str | None = None,
face_reason: str = "",
) -> 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, slope_basis = face_slope_ratio(
options, wet=wet, height_m=height_m, face=face, face_reason=face_reason
)
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:g}²) — 비탈면적 · {slope_basis}",
),
]
# ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 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 — 막자갈에서 안 뺌
stone_body = masonry_area * (back_cm / 100.0) * body_ratio # 면석 몸통 체적
rubble = volume - (stone_body + 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개소 이상)",
)
)
# 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.**
if is_collected_stone(options):
collected = max(stone_body + wedge + max(rubble, 0.0), 0.0)
if collected > 0:
components.append(
Component(
"채집석",
"㎥",
collected,
DESTINATION["채집석"],
f"면석 몸통 {stone_body:.3f} + 고임돌 {wedge:.3f} + 막자갈"
f" {max(rubble, 0.0):.3f} ㎥ · 현장 채집분 ·"
" ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌",
)
)
# 버림 콘크리트 — 기초 바닥에 까는 얇은 층. 빼려면 저장 제원에서 「안 넣음」으로 둔다.
base_width = thickness + constants["excavation_extra_m"]
blinding = _blinding_component(base_width, length_m, options)
if blinding is not None:
components.append(blinding)
# 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다).
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, f=None, r="": stone_masonry(h, l, o, True, f, r),
"masonry_dry": lambda h, l, o, f=None, r="": stone_masonry(h, l, o, False, f, r),
# 큰돌쌓기는 표준경사 표 대상이 아니라 성절토를 안 쓴다(교본 「1:0.3 이상」).
"boulder_masonry": lambda h, l, o, f=None, r="": 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,
section_mode: str | 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
# 성토/절토 — **판정 한 벌**을 부른다(우리가 따로 짜지 않는다).
face, face_reason = structure_face_role(section_mode, options.get("side"))
result.components, notes = expander(height, length, options, face, face_reason)
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 section_modes_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, str]:
"""저장된 횡단 설계 목록 → `{측점: 단면유형}`.
⚠ **부르는 쪽마다 다시 짜지 말라고 여기 둔다.** 구조물 전개(B08)와 표준도(B07)가
같은 표를 써야 기울기 판정이 두 곳에서 갈리지 않는다.
`design.section_mode` 가 없는 측점은 담지 않는다 — 빈 값을 담으면 「가를 근거 없음」과
「모드가 빈 문자열」이 뒤섞인다.
"""
modes: dict[float, str] = {}
for item in designs or ():
design = item.get("design") if isinstance(item, dict) else None
mode = str((design or {}).get("section_mode") or "").strip()
if not mode:
continue
chainage = _num(item.get("chainage_m"))
modes[float(chainage)] = mode
return modes
def _section_mode_at(
structure: dict[str, Any], section_modes: dict[float, str] | None
) -> str | None:
"""구조물이 선 자리의 단면유형. **가장 가까운 측점**의 값을 쓴다.
⚠ 구조물은 구간(start~end)이고 단면유형은 측점 값이라 딱 맞는 측점이 없을 수 있다.
가장 가까운 측점을 쓰되, 목록이 없으면 `None`(가를 근거 없음)으로 둔다 —
**성토로 눅이지 않는다.**
"""
if not section_modes:
return None
center = _num(structure.get("chainage_m"))
if not center:
start, end = _num(structure.get("start_m")), _num(structure.get("end_m"))
center = (start + end) / 2.0 if (start or end) else 0.0
nearest = min(section_modes, key=lambda chainage: abs(float(chainage) - center))
return section_modes.get(nearest)
def build_table(
structures: Iterable[dict[str, Any]],
names: dict[str, str] | None = None,
section_modes: dict[float, 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, _section_mode_at(item, section_modes))
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),
# ⚠ 사토에서 뺄 밑수 — **양수**로 낸다. 빼는 것은 유토곡선(랩탑 메인) 몫이다.
COLLECTED_STONE_KEY: round(
sum(
component.amount
for item in quantities
for component in item.components
if component.name == "채집석"
),
3,
),
"amount_spread": spread_by_unit(totals.values(), value_key="amount"),
}