Files
Aislo/B08_Quantity/B08_Quantity_Engine_StructureSheet.py
T

276 lines
13 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.
"""표준도(구조물도) 하단표 — **장 나눔**과 **표 조판**.
실무 원본은 울진소광 `07-구조도-소광리.xlsx`(보이는 22탭 + 숨긴 31탭). **탭 하나 = 장 하나**이고
탭 안은 「위 치수조서 + 아래 수량산출서」다. 이 모듈이 만드는 것은 **아래쪽 표**다.
실무 표의 짜임(돌쌓기 계열 네 탭 실측, 2026-09-09)::
(단위 표시) m당
공종 | 산 출 근 거 | 수량 | 단위
1 | 면적 2 × 1 × 1.044 2.088 ㎡
...
11 | 잔토정리 터파기 − 되메우기 0.165 ㎥
찰쌓기는 11줄, 메쌓기는 8줄(채움콘크리트·모르터·물구멍이 빠짐)이다. 우리 B08 전개가 이미
줄마다 `name · unit · amount · basis` 를 내므로 **여기서 새로 계산하지 않는다** — 접어서 낼 뿐이다.
⚠ **장 나눔 축은 「제원 조합」이다**(2026-09-09 사용자 확정 ⑨). 같은 종류라도 높이·기울기·
뒷길이·돌 종류가 다르면 그림도 수량도 달라지므로 **장이 갈린다**. 기울기가 제원의 한 칸이라
「기울기가 바뀌면 장이 갈린다」는 확정이 저절로 지켜진다.
⚠ **단위당 값**은 성분 수량을 `billing_quantity` 로 나눈 것이다. 실무 시트 머리의 `m당` ·
`개소당` · `㎡당` 이 그 단위이고, **구조물마다 다르다**(통일하지 않음 — 4-1).
⚠ **2026-09-13 B07 에서 이관**(PLAN 3장 · 사용자 확정 ③ 「구조물도는 B08 로」).
옛 자리 `B07_DesignDetail_Engine_Standard_Sheet.py`.
B07 그림 파일을 부르지 않게 기울기 판정 대상도 함께 옮김.
"""
from __future__ import annotations
import json
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import BACK_LENGTH_KEYS, face_slope_ratio
#: 장을 가르지 **않는** 제원 칸 — 개소마다 다를 뿐 그림·단위수량을 안 바꾼다.
#: 여기 없는 칸은 전부 장 나눔에 들어간다(모르는 칸을 빠뜨려 두 장이 한 장으로 합쳐지는 것보다,
#: 장이 하나 더 서는 쪽이 안전하다 — 합쳐지면 값이 조용히 틀린다).
PER_PLACE_OPTION_KEYS: frozenset[str] = frozenset(
{
"start_m",
"end_m",
"station",
"station_m",
"side",
"length_m",
# 구간 규약(C군) — 기준 측점에서 앞뒤로 얼마나 걸치나. **자리**이지 제원이 아니다
# (2026-09-13 실측: 제원이 같은 찰쌓기 두 기가 이 칸 때문에 두 장으로 갈렸음).
"before_m",
"after_m",
"inlet_revet_before_m",
"inlet_revet_after_m",
"outlet_revet_before_m",
"outlet_revet_after_m",
"inlet_basin_before_m",
"inlet_basin_after_m",
"note",
"memo",
"label",
}
)
def billing_of(structure: dict[str, Any]) -> tuple[str, float]:
"""이 장이 설 **단위와 그 수량**.
⚠ `billing_unit` 이 비어 있으면 「단위가 없다」가 아니라 **「m · 연장」**이라는 뜻이다
(`StructureQuantity` 계약). 관측 원단위가 「개소당」·「㎡당」인 종류만 자기 단위를 채운다.
이것을 「미정」으로 읽으면 **돌쌓기처럼 흔한 종류가 전부 단위 없는 장**이 된다.
"""
unit = str(structure.get("billing_unit") or "")
if unit:
return unit, float(structure.get("billing_quantity") or 0.0)
return "m", float(structure.get("length_m") or 0.0)
def _sheet_options(structure: dict[str, Any]) -> dict[str, Any]:
"""장 나눔에 쓰는 제원만 남긴다."""
options = structure.get("options") or {}
return {
key: value for key, value in sorted(options.items()) if key not in PER_PLACE_OPTION_KEYS
}
def sheet_key(structure: dict[str, Any]) -> str:
"""제원 조합 하나를 가리키는 이름. 같은 값이면 같은 장이다."""
payload = {
"type_id": structure.get("type_id") or "",
"height_m": round(float(structure.get("height_m") or 0.0), 3),
"options": _sheet_options(structure),
}
return json.dumps(payload, ensure_ascii=False, sort_keys=True)
#: **전면 기울기 판정** 대상 — 그림 대상(`FIGURE_TYPE_IDS`)과 **다르다**.
#: 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다(품셈 13-6 「1:0.3 이상」).
#: 한 목록으로 묶어 두었더니 큰돌쌓기를 그림에서 뺄 때 **장 제목의 「1:0.3」까지
#: 사라졌다**(2026-09-09 화면 실측에서 잡음).
SLOPE_TYPE_IDS: frozenset[str] = frozenset(
{"masonry_wet", "masonry_dry", "boulder_masonry", "revetment"}
)
#: 기슭막이는 **형태가 돌쌓기일 때만** 돌쌓기 식이 섬(`_UnitQuantity_Revetment.STONE_FORMS`).
REVETMENT_STONE_FORMS: dict[str, bool] = {"돌쌓기(찰)": True, "돌쌓기(메)": False}
def sheet_is_wet(sheet: dict[str, Any]) -> bool:
"""장의 메/찰 — 표준경사·뒷길이 표준이 같은 갈래로 읽음.
큰돌쌓기는 `bond` 칸(메쌓기/찰쌓기)이 그것이고, 안 고르면 찰쌓기로 본다 — 품셈 13-6 은
둘 다 「1:0.3 **이상**」이라 그림 기울기가 갈리지 않는다.
"""
options = sheet.get("options") or {}
type_id = str(sheet.get("type_id") or "")
if type_id == "masonry_dry":
return False
if type_id == "boulder_masonry":
return options.get("bond") != "메쌓기"
if type_id == "revetment":
# 표(기슭막이 전개)가 넘긴 그 갈래 — 형태 「돌쌓기(메)」만 메.
return REVETMENT_STONE_FORMS.get(str(options.get("form") or "").strip(), True)
return True
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
"""장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다."""
options = sheet.get("options") or {}
wet = sheet_is_wet(sheet)
return face_slope_ratio(
options,
wet=wet,
height_m=float(sheet.get("height_m") or 0.0),
# ⚠ 표를 만든 그 판정을 그대로 넘긴다 — 여기서 다시 가르면 근거를 못 받아
# 종전값으로 떨어지고 **표와 갈린다**(2026-09-09 실측).
face=sheet.get("face"),
face_reason=str(sheet.get("face_reason") or ""),
)
def _slope_or_none(structure: dict[str, Any]) -> float | None:
"""돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`."""
# ⚠ **그림 대상 목록을 쓰지 않는다** — 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다.
type_id = str(structure.get("type_id") or "")
if type_id not in SLOPE_TYPE_IDS:
return None
form = str((structure.get("options") or {}).get("form") or "").strip()
if type_id == "revetment" and form not in REVETMENT_STONE_FORMS:
return None # 돌쌓기형이 아닌 기슭막이는 기울기 판정이 없음(표도 안 섬)
return slope_of(structure)[0]
def sheet_title(structure: dict[str, Any]) -> str:
"""장 제목 — 「이름 H=2.0 1:0.3 뒷길이 45㎝」처럼 **무엇이 갈랐는지**가 보이게."""
parts = [str(structure.get("name") or structure.get("type_id") or "구조물")]
height = float(structure.get("height_m") or 0.0)
if height > 0:
parts.append(f"H={height:g}")
options = structure.get("options") or {}
# 기울기는 **판정 결과**를 적는다 — 사용자가 안 정해도 품셈 표준경사로 갈린다(확정 ⑨).
# 판정이 안 되는 종류(옹벽 등)는 안 적는다.
slope = _slope_or_none(structure)
if slope is not None:
parts.append(f"1:{slope:g}")
# 옛 저장분 키까지 읽는 차례는 전개의 한 벌(`BACK_LENGTH_KEYS`) — 키 이름을 다시 적지 않음.
back = next((options[key] for key in BACK_LENGTH_KEYS if options.get(key)), None)
if back:
parts.append(f"뒷길이 {int(back)}㎝")
kind = options.get("stone_kind")
if kind:
parts.append(str(kind))
return " ".join(parts)
def _unit_amount(amount: float, quantity: float) -> float | None:
"""단위당 값. 셀 단위를 못 정했으면 **0 으로 나누지 않고 `None`** 을 낸다."""
if quantity <= 0:
return None
return amount / quantity
def _rows_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
"""구조물 하나의 성분을 표 줄로 접는다 — 실무 시트의 `공종 | 산출근거 | 수량 | 단위`."""
_unit, quantity = billing_of(structure)
rows: list[dict[str, Any]] = []
for index, component in enumerate(structure.get("components") or [], start=1):
amount = float(component.get("amount") or 0.0)
rows.append(
{
"no": index,
"name": component.get("name") or "",
# 실무 시트 머리의 「규격」 칸(210 · Ø50 등) — 전개가 이미 싣고 옴(2026-09-13).
"spec": component.get("spec") or "",
# 실무 시트의 「산출근거」 칸 — B08 이 이미 사람이 읽는 문구로 낸다.
"basis": component.get("basis") or "",
"unit_amount": _unit_amount(amount, quantity),
"amount": amount,
"unit": component.get("unit") or "",
# 갈 곳 — 이중계상 경계를 줄마다 보임(명세 13장 Ⓑ). 전개가 이미 싣고 옴.
"destination": component.get("destination") or "",
# 값이 식에서 나왔나(derived) 관측 원단위표에서 왔나(observed) — 되짚기용.
"basis_kind": component.get("basis_kind") or "",
"source": component.get("source") or "",
}
)
return rows
def build_standard_sheets(
unit_table: dict[str, Any], section_modes: dict[float, str] | None = None
) -> dict[str, Any]:
"""B08 원단위 전개(`build_unit_table` 결과)를 **표준도 장 목록**으로 접는다.
⚠ 계산을 다시 하지 않는다 — 들어온 전개를 제원 조합으로 묶고 단위당으로 나눌 뿐이다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _section_mode_at
from common_util.common_util_structure_face_role import structure_face_role
groups: dict[str, dict[str, Any]] = {}
for structure in unit_table.get("structures") or []:
# ⚠ 성절토는 **표를 만든 그 판정**을 그대로 물고 온다 — 제목·그림이 다시 판정하면
# 근거를 못 받아 종전값으로 떨어져 **표와 갈린다**(2026-09-09 실측: 표 1:0.35 ·
# 제목 1:0.3). 자리를 고르는 것도 B08 이 쓰는 그 함수를 쓴다.
mode = _section_mode_at(structure, section_modes)
face, face_reason = structure_face_role(mode, (structure.get("options") or {}).get("side"))
structure = {**structure, "face": face, "face_reason": face_reason}
key = sheet_key(structure)
sheet = groups.get(key)
if sheet is None:
unit, _quantity = billing_of(structure)
sheet = {
"key": key,
"title": sheet_title(structure),
"type_id": structure.get("type_id") or "",
"height_m": float(structure.get("height_m") or 0.0),
"options": _sheet_options(structure),
# 판정 근거 — 제목·그림이 표와 **같은 기울기**를 쓰게 하는 열쇠.
"face": structure.get("face"),
"face_reason": structure.get("face_reason") or "",
# 실무 시트 머리의 「m당」·「개소당」·「㎡당」. 종류마다 다르다 — 통일하지 않는다.
"unit_label": f"{unit}당",
"billing_unit": unit,
"rows": _rows_of(structure),
"members": [],
"notes": list(structure.get("notes") or []),
}
groups[key] = sheet
sheet["members"].append(
{
"structure_id": structure.get("structure_id"),
"name": structure.get("name") or "",
"start_m": structure.get("start_m"),
"end_m": structure.get("end_m"),
"length_m": float(structure.get("length_m") or 0.0),
"billing_quantity": billing_of(structure)[1],
}
)
for note in structure.get("notes") or []:
if note not in sheet["notes"]:
sheet["notes"].append(note)
sheets = sorted(groups.values(), key=lambda sheet: (sheet["type_id"], sheet["height_m"]))
for sheet in sheets:
sheet["member_count"] = len(sheet["members"])
sheet["billing_total"] = sum(member["billing_quantity"] for member in sheet["members"])
# ⚠ 단위당을 못 낸 줄 — 「값이 없다」를 숫자 0 으로 때우지 않고 이름으로 드러낸다.
sheet["unpriced_rows"] = [
row["name"] for row in sheet["rows"] if row["unit_amount"] is None
]
return {
"sheets": sheets,
"sheet_count": len(sheets),
# 왜 안 실렸는지 — 화면이 「구조물이 없다」와 「걸러졌다」를 가릴 수 있어야 한다.
"structure_count": int(unit_table.get("structure_count") or 0),
"pending_choices": list(unit_table.get("pending_choices") or []),
}