Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Sheet.py
T
eomsangdonandClaude Opus 5 918e7c12da fix(B07): 표준도가 단면유형을 안 넘겨 기울기 판정이 통째로 꺼져 있던 것
표준도가 `build_table` 을 직접 부르면서 `section_modes` 를 안 넘겨, 성절토를 가를 근거가
없다며 **전 구조물이 종전값 1:0.3** 으로 섰음. 값이 없는 게 아니라 **안 넘긴 것**이었음.

`section_modes_from_designs` 한 벌을 그대로 씀(부르는 쪽마다 다시 짜면 B08 과 갈림).
노선은 `get_workflow_route_context` 로 찾고, 못 찾으면 빈 표 — 성토로 눅이지 않음.

⚠ 고치고 나니 **제목·그림이 표와 갈렸음** — 표는 판정된 1:0.35, 제목은 다시 판정하며
근거를 못 받아 1:0.3. 실측으로 잡았음. 그래서 **표를 만든 그 판정을 시트에 실어**
제목·그림이 같은 값을 쓰게 함(`face`·`face_reason`). 자리를 고르는 것도 B08 이 쓰는
`_section_mode_at` 과 판정 한 벌 `structure_face_role` 을 그대로 부름.

실측 (프로젝트 936be972) — 제목과 표가 **같은 값**
```
큰돌쌓기  H=2.5  제목 1:0.3   표 0.3    면적 2.6101
돌쌓기(메) H=2.0  제목 1:0.35  표 0.35   면적 2.1190   ← 메쌓기가 갈림
돌쌓기(찰) H=2.5  제목 1:0.3   표 0.3    면적 2.6101
옹벽      H=2.0  기울기 없음(판정 대상 아님)
```
표 산출근거에 판정 경로가 그대로 뜸 — 「품셈 13-4-4 [주]⑪ 표준경사 · 메쌓기 성토 ·
직고 2m ≤3m → 1:0.35 · right_cut · 자동(성토 쪽) → 성토면」.

앞서 `face_slope_ratio({}, wet=…)` 로 확인한 「근거 없음」은 `face` 를 안 준 호출이라
**정상 동작**이었음 — 함수가 성토로 안 눅이게 짠 결과.

자체검증 — 회귀 580 통과 · 0 실패. 세 라우터 모두 700줄 안(588 · 190 · 692).

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

211 lines
9.7 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).
"""
from __future__ import annotations
import json
from typing import Any
#: 장을 가르지 **않는** 제원 칸 — 개소마다 다를 뿐 그림·단위수량을 안 바꾼다.
#: 여기 없는 칸은 전부 장 나눔에 들어간다(모르는 칸을 빠뜨려 두 장이 한 장으로 합쳐지는 것보다,
#: 장이 하나 더 서는 쪽이 안전하다 — 합쳐지면 값이 조용히 틀린다).
PER_PLACE_OPTION_KEYS: frozenset[str] = frozenset(
{
"start_m",
"end_m",
"station",
"station_m",
"side",
"length_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)
def _slope_or_none(structure: dict[str, Any]) -> float | None:
"""돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`. 판정은 그림 모듈이 가진 한 벌."""
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import (
FIGURE_TYPE_IDS,
slope_of,
)
if str(structure.get("type_id") or "") not in FIGURE_TYPE_IDS:
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 = options.get("back_len_cm") or options.get("stone_back_length_cm")
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 "",
# 실무 시트의 「산출근거」 칸 — B08 이 이미 사람이 읽는 문구로 낸다.
"basis": component.get("basis") or "",
"unit_amount": _unit_amount(amount, quantity),
"amount": amount,
"unit": component.get("unit") 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 []),
}