Files
Aislo/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py
T
eomsangdonandClaude Opus 5 e40f4c9fd3 feat(b05·b08): BOX암거 구체 터파기 — 평균 터파기고 칸 하나 · 토공 축으로만 · 기초잡석 사유(브레인 판정 ①③)
- 등록부 trench_depth_m(평균 터파기고 · 기본값 없음 · 비면 사유)
- 터파기 = (밑폭 버림 폭 + 여유 0.5×2 + 윗폭 1:0.5) ÷ 2 × 깊이 × 연장(울진 「암거구체 토공(2×2)」) · 되메우기 = 터파기 − 든 것(잡석·버림·구체) · 잔토처리 = 든 것 — destination earthwork(묶음에 안 듦 · 토공집계로만)
- 울진 원문은 잔토에 구체 전체 · 되메우기 한쪽만 — 옹벽 식(터파기 − 든 것)으로 맞추고 사유에 적음
- 기초잡석 사유 「근거 둘(예제·울진 집계표)에는 잡석 줄이 없음 — 사용자 확정 3차 ② 로 세움」

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

678 lines
33 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
from typing import Any, Iterable
from B08_Quantity.B08_Quantity_Engine_Formwork import annotate as annotate_formwork
from B08_Quantity.B08_Quantity_Engine_Formwork import shoring_status
# ⓘ 2026-09-14 쪼갬 — 성분 계약 `_Base` · 돌 제원·계수 `_StoneSpec` · 돌쌓기 전개식 `_Masonry`.
# 밖에서 이 모듈 이름으로 가져가던 것은 그대로 닿게 다시 내보냄(부르는 쪽 44곳 불변).
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import (
BASIS_DERIVED, # noqa: F401 — 다시 내보냄
ObservedUnitTable,
billing_of,
expand_observed,
load_observed_table,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import ( # noqa: F401 — 다시 내보냄
DESTINATION,
MIX_COMPONENTS,
RUBBLE_BASE_NAME,
Component,
StructureQuantity,
_num,
is_unconfirmed,
missing_height_reason,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Masonry import ( # noqa: F401 — 다시 내보냄
boulder_masonry,
stone_masonry,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: F401 — 다시 내보냄
BACK_LENGTH_KEYS,
BLINDING_THICKNESS_M,
COLLECTED_STONE_KEY,
FILL_CONCRETE_MPA_DEFAULT,
RUBBLE_BASE_THICKNESS_M,
STONE_BACK_LENGTH_TABLE,
STONE_KIND_OPTION,
STONE_MASONRY,
_back_length,
back_length_default_note,
face_slope_ratio,
fill_concrete_mpa,
is_collected_stone,
load_stone_kind_table,
stone_weight_per_m2,
wall_thickness,
wants_blinding,
wants_practice_coefficients,
weep_hole_spec,
)
from common_util.common_util_quantity_spread import spread_by_unit
from common_util.common_util_structure_face_role import structure_face_role_of
# 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다).
# ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다.
# 값의 키(규격)를 저장 제원의 어느 칸에서 읽는지 여기 적는다. 표에 규격이 없으면
# 「원단위 미확보」로 드러난다 — 가까운 값을 갖다 쓰지 않는다.
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"),
# 관보호공 날개벽 — **개소당**이고 치수가 형식마다 붙박이다(원본 탭 다섯). 형식을 고르지
# 않으면 날개벽 줄 자체가 안 선다(안 놓은 것과 같다).
"pipe_wing_wall": ("wing_wall_type", "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),
# 기슭막이는 **형태가 돌쌓기면 돌쌓기 식**이다 — 실무 정본 탭 제목이 「돌기슭막이(…찰쌓기…)」
# 이고 그 안의 계산이 같다. 갈래·사유는 `_UnitQuantity_Revetment` 가 든다(늦게 부른다 —
# 그쪽이 이 모듈의 `stone_masonry` 를 쓰므로 위에서 부르면 맞물린다).
"revetment": lambda h, l, o, f=None, r="": _revetment()(h, l, o, f, r),
# 골막이는 **개소당**이고 정면적이 사다리꼴이라 상장·하장·높이를 본다
# (정본 「골막이(찰)(치수조서연결)」). 돌쌓기 식을 빌려 쓰지 않는다 — 까닭은 그 모듈에 적었다.
"erosion_check": lambda h, l, o, f=None, r="": _erosion_check()(h, o),
# 개거는 **m당** 원단위라 연장이 밑수다(정본 「개거(150-200)」·「L형수로-(201)」).
"open_ditch": lambda h, l, o, f=None, r="": _open_ditch()(l, o),
# 흙막이는 **「떼」만 섬**(정본 「떼흙막이」 개소당). 나머지 일곱 형식은 원단위 미확보.
# ⚠ 확정 4차 — 그림·옵션은 기슭막이와 한 벌이되 **수량 데이터는 분리**.
"soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o),
# 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」).
"bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o),
# BOX암거 — m당 × 연장(산림과임업기술 예제 · 울진 2×2 · 2026-09-15 브레인 12장 D).
"box_culvert": lambda h, l, o, f=None, r="": _box_culvert()(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 …).
# ⚠ 값이 나오기는 하므로 어떤 시험도 안 잡던 자리다 — 「값이 있기는 하니 안 보이는」 그것.
# 전개식·관측 원단위가 설 때까지 **미확보로 드러낸다.**
}
def _box_culvert():
"""BOX암거 전개 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Box import box_culvert
return box_culvert
def _soil_guard():
"""흙막이 사유 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import soil_guard
return soil_guard
def _open_ditch():
"""개거 전개 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import open_ditch
return open_ditch
def _bed_sill():
"""바닥막이 전개 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import bed_sill
return bed_sill
def _erosion_check():
"""골막이·바닥막이 사유 함수 — 늦게 가져온다(서로 부르는 것을 푸는 자리)."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import erosion_check_dam
return erosion_check_dam
def _revetment():
"""기슭막이 전개 함수를 늦게 가져온다 — 서로 부르는 것을 풀기 위한 자리."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import revetment
return revetment
#: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다.
EXPANDER_WITHHELD: dict[str, str] = {
# 2026-09-15 브레인 판정 ⑥ — 찾아봤으나 없음(「아직 안 만듦」과 다름).
"ford_bridge": (
"세월교 수량 근거 없음 — 법령·임도기술교본은 설치 조건만(그림 3-18 은 배치 모식도)"
" · 실무 원본에 세월교 시트·도면 없음 · 관은 배수관 줄로 따로 섬"
),
}
#: 전개식이 **개소당·㎡당**으로 값을 내는 종류의 내역 단위와 수량(2026-09-14 구조물도 그림에서 잡음).
#: ⚠ 안 적으면 「m · 연장」으로 읽혀 — 골막이·바닥막이(점 배치라 연장 0)는 **수량 0 이라 내역에 안 서고**
#: 구조물도가 전 줄 「단위당 못 냄」, 떼흙막이는 개소당 값이 연장으로 나뉘어 **떼 0.139/m** 로 보였음.
EXPANDER_BILLING: dict[str, Any] = {
"erosion_check": lambda options: ("개소", 1.0), # 정본 「골막이(찰)(치수조서연결)」 개소당
"soil_guard": lambda options: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이)
"bed_sill": lambda options: ("㎡", _num(options.get("area_m2"), 0.0)), # 정본 돌붙임 ㎡당
"box_culvert": lambda options: ("m", _num(options.get("length_m"), 0.0)), # 점 배치라 연장 칸
}
#: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다
#: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다.
ATTACHMENTS: dict[str, tuple[tuple[str, str, str], ...]] = {
# (붙는 종류, 그것이 있는지 보는 옵션 칸, 줄 이름 꼬리)
"pipe": (
("pipe_inlet_basin", "inlet_basin_form", "유입부 집수정"),
("pipe_wing_wall", "wing_wall_type", "관보호공 날개벽"),
),
}
#: 날개벽 「A-TYPE+집수정」은 집수정을 **품은** 형식이다 — 집수정 줄을 따로 세우면 한 개소를
#: 두 번 센다(원단위표 `double_count_rules` 와 짝).
WING_WALL_WITH_BASIN = "A-TYPE+집수정"
WING_WALL_DOUBLE_COUNT = (
"⚠ 날개벽을 「A-TYPE+집수정」으로 골랐는데 집수정 형식도 차 있음 — "
"그 날개벽 값이 집수정을 이미 품고 있어 **두 번 셀 수 있음**. 한쪽을 비울 것"
)
def wing_wall_double_count(options: dict[str, Any]) -> str | None:
"""겹쳐 세는 자리면 사유 한 줄. 값을 고치지 않고 **드러내기만** 한다."""
if str(options.get("wing_wall_type") or "") != WING_WALL_WITH_BASIN:
return None
return WING_WALL_DOUBLE_COUNT if options.get("inlet_basin_form") else None
def child_row(structure: dict[str, Any], type_id: str, label: str, suffix: str = "") -> dict:
"""딸린 줄 한 벌 — 이름에 「어디에 딸렸나」가 남음(`expand` 가 부모 이름 · 꼬리로 붙임)."""
return {
**structure,
"structure_id": f"{structure.get('structure_id')}-{suffix or type_id}",
"type_id": type_id,
"attachment_of": structure.get("structure_id"),
"attachment_parent_type": structure.get("type_id"),
"attachment_label": label,
}
def attachments_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
"""구조물에 딸린 **별도 줄**을 만든다. 제원은 원본을 그대로 물려준다(치수 두 벌 금지)."""
options = structure.get("options") or {}
return [
child_row(structure, type_id, label)
for type_id, gate_key, label in ATTACHMENTS.get(str(structure.get("type_id") or ""), ())
if options.get(gate_key) # 그 부속이 없는 배치다 — 빈 줄을 만들지 않는다
]
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)]
components, notes = expand_observed(type_id, spec, structure, observed)
warning = wing_wall_double_count(options)
if warning:
notes.append(warning)
return components, notes
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"))
# ⚠ 레지스트리 이름이 없으면 **코드값(`retaining_wall`)이 그대로 내역에 뜬다** —
# `_Wording.type_label` 이 이미 대비표를 들고 있으므로 그것을 쓴다(2026-09-09 감사).
from B08_Quantity.B08_Quantity_Wording import type_label as _type_label
label = _type_label(type_id, names)
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` 면 값을 안 세움.
result.notes.extend(str(note) for note in structure.get("notes") or [])
if structure.get("withheld"):
return result
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_of(section_mode, options)
result.components, notes = expander(height, length, options, face, face_reason)
result.notes.extend(notes)
billing = EXPANDER_BILLING.get(type_id)
if billing is not None and result.components:
result.billing_unit, result.billing_quantity = billing(options)
return result
def _shoring_of(quantities: Iterable[StructureQuantity]) -> dict[str, Any]:
"""동바리 상태 — 전개식이 동바리 줄을 낸 구조물이 있으면 대상(2026-09-15 BOX암거)."""
status = shoring_status()
found = sorted({q.name for q in quantities if any(c.name == "동바리" for c in q.components)})
if found:
status.update(applicable=True, reason=f"슬래브 받침 동바리(12-20) — {' · '.join(found)}")
return status
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
#: 저장된 지반 갈래 ↔ 품셈 9-13 토질 3구분. **새 칸을 만들지 않는다** — 측점마다 이미
#: `design.ground_type` 이 저장돼 있고(재생성 사고 때 이 값이 비어 B08 이 통째로 0 이 됐던
#: 그 키다), 값 셋이 품셈 구분과 그대로 맞물린다(2026-09-08 조율 창 확인).
GROUND_TYPE_LABEL = {
"soil": "토사",
"ripping_rock": "암절취",
"blasting_rock": "발파암",
}
def ground_types_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, str]:
"""저장된 횡단 설계 목록 → `{측점: 지반갈래}`. 값이 없는 측점은 담지 않는다."""
grounds: dict[float, str] = {}
for item in designs or ():
design = item.get("design") if isinstance(item, dict) else None
ground = str((design or {}).get("ground_type") or "").strip()
if not ground:
continue
grounds[float(_num(item.get("chainage_m")))] = ground
return grounds
def ground_type_at(
structure: dict[str, Any], ground_types: dict[float, str] | None
) -> tuple[str | None, str]:
"""(토질, 근거). 구조물이 **걸친 측점 전부**를 보고 갈래가 하나일 때만 값을 낸다.
⚠ **판정 규칙 — 섞이면 안 고른다.** 구조물은 구간(start~end)이고 지반은 측점 값이라
한 구조물이 토사 측점과 암 측점에 걸칠 수 있다. 그때 다수결로 한쪽을 고르면 **임의값이
금액으로 굳는다**(암 단가가 몇 배다). 섞였다는 사실과 갈래별 측점 수를 근거에 적고
값은 `None` 으로 둔다 — 성절토·용수에서 지킨 그대로다.
⚠ 걸친 측점이 하나도 없으면(구간이 측점 사이에 통째로 들어간 짧은 구조물) **가장 가까운
측점**을 쓴다 — 그 사실도 근거에 적는다.
"""
if not ground_types:
return None, "측점 지반 갈래가 저장에 없어 못 가름"
start, end = _num(structure.get("start_m")), _num(structure.get("end_m"))
if end < start:
start, end = end, start
inside = {
chainage: kind for chainage, kind in ground_types.items() if start <= float(chainage) <= end
}
if not inside:
center = (start + end) / 2.0
nearest = min(ground_types, key=lambda chainage: abs(float(chainage) - center))
kind = ground_types[nearest]
return (
kind,
f"걸친 측점이 없어 가장 가까운 측점({nearest:g}m)의 {GROUND_TYPE_LABEL.get(kind, kind)}",
)
counts: dict[str, int] = {}
for kind in inside.values():
counts[kind] = counts.get(kind, 0) + 1
if len(counts) == 1:
kind = next(iter(counts))
return kind, f"걸친 측점 {len(inside)}곳이 모두 {GROUND_TYPE_LABEL.get(kind, kind)}"
breakdown = " · ".join(
f"{GROUND_TYPE_LABEL.get(kind, kind)} {count}곳" for kind, count in sorted(counts.items())
)
return None, f"걸친 측점의 지반이 섞여 못 가름 — {breakdown}"
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 _rubble_base_component(
components: list[Component], thickness_m: float | None
) -> Component | None:
"""기초잡석 한 줄 — **버림 폭이 곧 잡석다짐 폭**이라 두께 비로 낸다(확정 3차 ②).
⚠ 폭을 다시 세지 않는다. 버림이 이미 그 폭으로 서 있으므로 두께 비만 곱하면
**관측 원단위로 오는 구조물(옹벽)에도 같은 식이 선다** — 두 벌로 짜지 않는 자리다.
"""
thickness = RUBBLE_BASE_THICKNESS_M if thickness_m is None else float(thickness_m)
if thickness <= 0:
return None
blinding = next((item for item in components if item.name == "버림콘크리트"), None)
if blinding is None or blinding.amount <= 0:
return None
ratio = thickness / BLINDING_THICKNESS_M
return Component(
RUBBLE_BASE_NAME,
"㎥",
blinding.amount * ratio,
DESTINATION[RUBBLE_BASE_NAME],
f"버림 {blinding.amount:.3f}× (잡석두께 {thickness:g} ÷ 버림두께"
f" {BLINDING_THICKNESS_M:g}) — 폭이 같음(KCS 34 50 05) · 두께는 사용자 확정 3차 ②"
" (품셈 12-25 는 ㎥당 품만 주고 두께를 정하지 않음)",
)
def _append_section_trench(
quantity: StructureQuantity,
structure: dict[str, Any],
observed: ObservedUnitTable,
thickness_m: float | None,
) -> None:
"""단면이 실린 관측 줄(옹벽)의 터파기·되메우기·잔토 — 기초잡석과 **같은 두께**로 깊이를 잡음."""
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import scale_for, section_trench_components
keys = OBSERVED_SPEC_KEYS.get(quantity.type_id)
if not keys or not quantity.components:
return
options = structure.get("options") or {}
entry = observed.find(
quantity.type_id, {key: options[key] for key in keys if options.get(key) is not None}
)
if entry is None:
return
scale, _note = scale_for(entry, structure)
thickness = RUBBLE_BASE_THICKNESS_M if thickness_m is None else float(thickness_m)
found = section_trench_components(entry, max(thickness, 0.0), scale)
if found is None:
return
rows, quantity.trench_depth_m = found
quantity.components.extend(Component(**row) for row in rows)
def _append_box_trench(
quantity: StructureQuantity, structure: dict[str, Any], thickness_m: float | None
) -> None:
"""BOX암거 구체 터파기(토공 축) — 기초잡석과 **같은 두께**로 든 것을 뺌(2026-09-15)."""
if quantity.type_id != "box_culvert" or not quantity.components:
return
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Box import box_trench
thickness = RUBBLE_BASE_THICKNESS_M if thickness_m is None else float(thickness_m)
found = box_trench(structure.get("options") or {}, max(thickness, 0.0))
if found is not None:
rows, quantity.trench_depth_m = found
quantity.components.extend(rows)
def build_table(
structures: Iterable[dict[str, Any]],
names: dict[str, str] | None = None,
section_modes: dict[float, str] | None = None,
ground_types: dict[float, str] | None = None,
rubble_base_thickness_m: float | None = None,
use_templates: bool = True,
structure_formulas: dict[str, Any] | None = None,
structure_templates: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.
⭐ 2026-09-13(PLAN 3장 ④-2) — **양식이 있는 종류는 양식 풀이 값으로 성분을 갈음**
(`B08_Quantity_Engine_StructureTemplate.replace_with_templates`). 원단위·자재총괄·인계가
구조물도와 같은 값을 보게 함. `use_templates=False` 는 대조 시험이 **전개만** 볼 때 씀.
⭐ `structure_formulas` — 사용자가 양식마다 고친 식(산출 조건 `structure_formula_overrides`,
PLAN 3장 ⑤). 부르는 쪽이 산출 조건에서 넘김 — 안 넘기면 구조물도와 값이 갈림.
⭐ `structure_templates` — 프로젝트에 박힌 양식(PLAN 4장 가져오기, `project_templates`).
없는 종류는 프로그램 기본. 같은 까닭으로 부르는 쪽이 넘김.
"""
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 = []
for item in expanded_inputs:
quantity = expand(item, names, observed, _section_mode_at(item, section_modes))
quantity.ground_type, quantity.ground_type_basis = ground_type_at(item, ground_types)
# 기초잡석 — 버림이 선 구조물에 함께 선다(전개식이든 관측 원단위든 같은 자리).
rubble = _rubble_base_component(quantity.components, rubble_base_thickness_m)
if rubble is not None:
quantity.components.append(rubble)
_append_section_trench(quantity, item, observed, rubble_base_thickness_m)
_append_box_trench(quantity, item, rubble_base_thickness_m)
quantity.unconfirmed = str(item.get("unconfirmed") or "") or missing_height_reason(item)
quantities.append(quantity)
if use_templates:
# 늦게 부름 — 양식 모듈이 이 모듈을 부르므로 맨 위에서 부르면 맞물림.
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import replace_with_templates
replace_with_templates(
quantities,
expanded_inputs,
section_modes,
rubble_base_thickness_m,
structure_formulas,
structure_templates,
)
violations = verify_no_mix_components(quantities)
totals: dict[str, dict[str, Any]] = {}
# 합은 **금액에 드는 구조물만** — 기본값으로 선 구조물(`unconfirmed`)은 줄에만 보임.
priced = [item for item in quantities if not item.unconfirmed]
for item in priced:
for component in item.components:
key = f"{component.name}|{component.spec}|{component.unit}"
entry = totals.setdefault(
key,
{
"name": component.name,
"spec": component.spec,
"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,
# 구조물이 놓인 자리의 지반 갈래 — **품셈 9-13 토질 3구분**이 이 값으로 갈린다.
# ⚠ 여기서 새로 만드는 값이 아니라 측점 설계값(`design.ground_type`)을 옮긴 것이다.
"ground_type": item.ground_type,
"ground_type_basis": item.ground_type_basis,
# 파는 깊이 — 단면으로 터파기를 세운 종류만(옹벽 기초분). 비면 직고 + 기초 깊이.
"trench_depth_m": item.trench_depth_m,
# 양식 있음/없음 — 화면이 가림(비면 지금 전개).
"library_item": item.library_item,
# 기본값으로 선 까닭 — 차 있으면 금액·자재·토공·운반 합에서 빠짐(`is_unconfirmed`).
"unconfirmed": item.unconfirmed,
"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,
"spec": component.spec,
}
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 이 아니라 「없음」이라고 말한다(BOX암거 동바리 줄이 서면 대상).
"shoring": _shoring_of(quantities),
# ⚠ 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다.
# 「무엇을 정해야 하는지」만으로는 부족하고 **「정하면 얼마나 달라지는지」**까지.
"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 priced
for component in item.components
if component.name == "채집석"
),
3,
),
"amount_spread": spread_by_unit(totals.values(), value_key="amount"),
}