- _Base(110줄) 성분 계약·갈 곳(DESTINATION)·배합 성분(MIX_COMPONENTS) — 이중계상 경계 - _StoneSpec(525줄) 돌쌓기 제원·계수표·표준경사 판정·벽 두께·버림/기초잡석 상수 - _Masonry(523줄) 돌쌓기·큰돌쌓기 전개식(버림·터파기 줄) - UnitQuantity(581줄) 종류→전개식 표·관측 원단위·딸린 줄·build_table — 옮긴 이름은 다시 내보내 부르는 쪽 44곳 그대로 - 코드 줄은 옮기기만 함 · 검증 프로젝트 응답 여섯(원단위·구조물 집계·구조물도·운반·인계·내역) 지문이 쪼개기 전과 같음 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
582 lines
28 KiB
Python
582 lines
28 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
|
||
|
||
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,
|
||
)
|
||
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
|
||
|
||
# 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다).
|
||
# ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다.
|
||
# 값의 키(규격)를 저장 제원의 어느 칸에서 읽는지 여기 적는다. 표에 규격이 없으면
|
||
# 「원단위 미확보」로 드러난다 — 가까운 값을 갖다 쓰지 않는다.
|
||
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),
|
||
# ⚠⚠ **큰돌쌓기(`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 _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] = {}
|
||
|
||
|
||
#: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다
|
||
#: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다.
|
||
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 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)]
|
||
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 = 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
|
||
|
||
|
||
#: 저장된 지반 갈래 ↔ 품셈 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 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)
|
||
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]] = {}
|
||
for item in quantities:
|
||
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,
|
||
# 양식 있음/없음 — 화면이 가림(비면 지금 전개).
|
||
"library_item": item.library_item,
|
||
"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 이 아니라 「없음」이라고 말한다.
|
||
"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"),
|
||
}
|