refactor(B08,B09): 폴더째 old_code 로 옮기고 빈 화면 둘만 남김 (PLAN 7-3)
B08_Quantity 86 · B09_Estimation 111 파일을 old_code/ 로 옮김(지우지 않음). 화면은 메뉴·주소·단계 막대만 남은 빈 틀 둘 — main.py 라우터 13 개는 끊음. B07 이 빌려 쓰던 비탈 길이·면적은 필요한 함수만 B07_DesignDetail_Engine_SlopeGeometry 로 옮겨 적음(면적 적분·노면 면적·측점 묶음은 안 옮김) · 시험 하나를 새로 둠. B07 구조물도 조립(Cad_StandardSheet)은 2026-09-13 에 이미 도면 목록에서 빠져 부르는 곳이 없어 old_code 로 같이 보냄 — 구조물 그림은 되살리지 않음. B06 구조물 몫 조회는 빈 값으로 두어 화면이 그대로 서게 함. B08·B09 를 부르던 시험 25 개도 old_code/resources/tester 로 옮김. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
"""콘크리트 구조물 — **관측 원단위표** 조회 (B08 일감 ⑩ · PLAN 8-6·8-8).
|
||||
|
||||
왜 전개식이 아니라 관측값인가
|
||||
지식DB 가 못 박아 둔 사실이다 — **구조물별 표준 물량표는 품셈에 없다**
|
||||
(`구조물_수량.md` 마지막 줄 · `배수공_수량.md` §2). 콘크리트 구조물의 물량은
|
||||
**설계 표준도**에서 나오는데 그 표준도가 원문(법·품셈)에 없다. 그래서 옹벽·집수정처럼
|
||||
치수가 표준화된 것은 **실무 설계원본에서 뽑은 관측값**이 유일한 원천이다.
|
||||
|
||||
두 근거가 한 표에 섞인다 — 그래서 줄마다 `basis` 를 단다
|
||||
· `derived` — 저장된 치수에서 **식으로** 나온 값(돌쌓기 계열, `..._Engine_UnitQuantity`).
|
||||
· `observed` — 실무 관측 원단위표에서 **규격을 맞춰 꺼낸** 값(이 모듈).
|
||||
섞어 두고 근거를 안 적으면, 나중에 「이 값이 왜 이런가」를 아무도 못 되짚는다.
|
||||
|
||||
⚠⚠ **보간하지 않는다**
|
||||
관측값은 **그 규격에서만** 맞다. `반중력식 H=2.0` 의 콘크리트 1.35 ㎥/m 를 H=1.6 으로
|
||||
줄여 쓰면 틀린다 — 기초·벽 두께는 높이에 비례하지 않는다. 규격이 표에 없으면
|
||||
**「원단위 미확보」로 드러낸다.** 가까운 값을 갖다 쓰는 길을 두지 않는다.
|
||||
|
||||
⚠ 치수를 지어내지 않는다
|
||||
BOX암거는 `structures.json` 에 **벽·저판·상판 두께가 없어** 전개식조차 못 세운다.
|
||||
두께를 가정하면 그 값이 콘크리트·거푸집·철근으로 **번져 나간다**. 미확보로 낸다.
|
||||
|
||||
⚠ 이중계상 규칙은 그대로다
|
||||
㉢ 배합을 분해하지 않는다(콘크리트 ㎥·모르터 ㎥ 까지). ㉠ 할증은 자재총괄 한 곳뿐.
|
||||
터파기·되메우기·잔토는 `destination: earthwork` 로 토공에 합산된다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "master_data" / "ref"
|
||||
DATASET_PREFIX = "수량_구조물원단위_울진_"
|
||||
|
||||
#: 값이 어디서 왔나 — 한 표에 섞이므로 줄마다 단다.
|
||||
BASIS_DERIVED = "derived" # 저장된 치수에서 식으로
|
||||
BASIS_OBSERVED = "observed" # 실무 관측 원단위표에서
|
||||
|
||||
NOTE_UNIT_MISSING = "원단위 미확보"
|
||||
|
||||
|
||||
def _latest_dataset_path(directory: Path | None = None) -> Path | None:
|
||||
folder = directory or DATASET_DIR
|
||||
if not folder.is_dir():
|
||||
return None
|
||||
files = sorted(folder.glob(DATASET_PREFIX + "*.json"))
|
||||
return files[-1] if files else None
|
||||
|
||||
|
||||
def _same(left: Any, right: Any) -> bool:
|
||||
"""규격 한 칸 비교. 숫자는 값으로, 나머지는 글자로 **정확히** 본다.
|
||||
|
||||
`"800"` 과 `800` 은 같게 보되(입력 폼이 문자열을 준다), `2.0` 과 `1.6` 은 다르다 —
|
||||
가까운 값을 같다고 보는 길은 두지 않는다.
|
||||
"""
|
||||
if isinstance(left, (int, float)) and not isinstance(left, bool):
|
||||
try:
|
||||
return abs(float(left) - float(right)) < 1e-9
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return str(left).strip() == str(right).strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObservedUnitTable:
|
||||
"""관측 원단위표 한 판."""
|
||||
|
||||
effective_date: str = ""
|
||||
entries: list[dict[str, Any]] = field(default_factory=list)
|
||||
sources: dict[str, Any] = field(default_factory=dict)
|
||||
not_found: dict[str, Any] = field(default_factory=dict)
|
||||
#: 값을 바꾸는 **설계 조건**인데 우리 제원에 칸이 없는 것 — 화면이 보이게 한다.
|
||||
pending_choices: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def find(self, type_id: str, spec: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""규격이 **모두** 맞는 줄만 돌려준다. 하나라도 어긋나면 없는 것으로 본다."""
|
||||
for entry in self.entries:
|
||||
if entry.get("type_id") != type_id:
|
||||
continue
|
||||
wanted = entry.get("spec") or {}
|
||||
if all(key in spec and _same(value, spec[key]) for key, value in wanted.items()):
|
||||
return entry
|
||||
return None
|
||||
|
||||
def specs_for(self, type_id: str) -> list[dict[str, Any]]:
|
||||
"""그 종류로 표에 있는 규격 목록 — 「무엇이 있는지」를 화면이 보이게."""
|
||||
return [
|
||||
entry.get("spec") or {} for entry in self.entries if entry.get("type_id") == type_id
|
||||
]
|
||||
|
||||
|
||||
def load_observed_table(path: Path | None = None) -> ObservedUnitTable:
|
||||
"""관측 원단위표를 읽는다. 파일이 없으면 **빈 표** — 전부 「미확보」로 드러난다."""
|
||||
target = path or _latest_dataset_path()
|
||||
if target is None or not target.is_file():
|
||||
return ObservedUnitTable()
|
||||
payload = json.loads(target.read_text(encoding="utf-8"))
|
||||
return ObservedUnitTable(
|
||||
effective_date=str(payload.get("effective_date") or ""),
|
||||
entries=list(payload.get("entries") or []),
|
||||
sources=payload.get("sources") or {},
|
||||
not_found=payload.get("not_found") or {},
|
||||
pending_choices=payload.get("pending_choices") or {},
|
||||
)
|
||||
|
||||
|
||||
def scale_for(entry: dict[str, Any], structure: dict[str, Any]) -> tuple[float, str]:
|
||||
"""관측값에 곱할 수 — 단위가 `m` 면 연장, `㎡` 면 면적, `개소` 면 1.
|
||||
|
||||
⚠ **규격을 늘리는 것이 아니라 개수를 세는 것**이다. `H=2.0 옹벽 10m` 는 같은 단면이
|
||||
10m 이어진 것이라 곱해도 되지만, `H=1.6` 으로 바꾸는 것은 단면이 달라지므로 안 된다.
|
||||
"""
|
||||
options = structure.get("options") or {}
|
||||
unit = str(entry.get("unit") or "개소")
|
||||
if unit == "m":
|
||||
length = options.get("length_m")
|
||||
if length is None:
|
||||
start, end = structure.get("start_m"), structure.get("end_m")
|
||||
length = (
|
||||
abs(float(end) - float(start)) if start is not None and end is not None else 0.0
|
||||
)
|
||||
return float(length or 0.0), f"연장 {float(length or 0.0):g} m"
|
||||
if unit == "㎡":
|
||||
width = options.get("ford_width_m")
|
||||
length = options.get("length_m") or 0.0
|
||||
area = float(width or 0.0) * float(length or 0.0)
|
||||
return area, f"면적 {area:g} ㎡"
|
||||
return 1.0, "1 개소"
|
||||
|
||||
|
||||
def billing_of(
|
||||
type_id: str,
|
||||
spec: dict[str, Any],
|
||||
structure: dict[str, Any],
|
||||
table: ObservedUnitTable | None = None,
|
||||
) -> tuple[str, float] | None:
|
||||
"""(내역 단위, 그 단위로 센 수량). 표에 없으면 `None`.
|
||||
|
||||
⚠ **왜 있나** — 관측 원단위는 「개소당」·「㎡당」으로도 온다. 그런데 인계 줄이 늘
|
||||
「m · 연장」으로 나가고 있어, **집수정 한 개소가 「연장 2m」면 값이 두 배로 실렸다**
|
||||
(2026-09-08 ㉕ 실증에서 드러남). 성분은 개소 기준으로 맞게 서는데 **줄의 축만
|
||||
어긋나** 있어서 아무 시험도 안 잡았다.
|
||||
"""
|
||||
found = (table or load_observed_table()).find(type_id, spec)
|
||||
if found is None:
|
||||
return None
|
||||
scale, _note = scale_for(found, structure)
|
||||
return str(found.get("unit") or "개소"), scale
|
||||
|
||||
|
||||
#: 단면으로 터파기를 세운 줄의 사유 — 표 사유·구조물도 그림이 **같은 말**(브레인 판정 2026-09-14).
|
||||
SECTION_TRENCH_NOTE = (
|
||||
"터파기는 기초 폭 + 양쪽 여유 · 옆면 1:0.5(판 깊이 기준)"
|
||||
" — 벽 높이 몫은 지반선이 제원에 없어 제외"
|
||||
)
|
||||
|
||||
|
||||
def earthwork_missing_note(entry: dict[str, Any]) -> str | None:
|
||||
"""관측 줄에 토공 줄이 없고 단면으로도 못 세우면 한 줄 — 표 사유·구조물도 그림이 같은 말."""
|
||||
if any(item.get("destination") == "earthwork" for item in entry.get("components") or []):
|
||||
return None
|
||||
if (entry.get("section") or {}).get("trench"):
|
||||
return SECTION_TRENCH_NOTE
|
||||
return "⚠ 터파기·되메우기·잔토가 안 섬 — 관측 원단위 표에 그 줄이 없음(값을 지어내지 않음)"
|
||||
|
||||
|
||||
def weep_component(
|
||||
entry: dict[str, Any], options: dict[str, Any], scale: float, scale_note: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""물구멍관 — 단면이 있는 관측 줄은 **실무 식에 제원 칸을 넣어** 다시 셈(붙박이 값 대신).
|
||||
|
||||
식 `벽 높이 ÷ 개소당 벽면적 × 관 길이`(소광리 「옹벽2.0」 T31). 관 길이는 실무 관측값이라
|
||||
`basis_kind` 는 그대로 `observed`. 규격 `Ø지름` 을 달아 돌쌓기 물구멍관과 (이름+규격)으로 합침.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import weep_hole_spec
|
||||
|
||||
section = entry.get("section") or {}
|
||||
weep = section.get("weep")
|
||||
if not weep:
|
||||
return None
|
||||
area, diameter, spec_basis = weep_hole_spec(options)
|
||||
height, length = section["wall"]["height_m"], weep["pipe_length_m"]
|
||||
per_unit = height / area * length
|
||||
return {
|
||||
"name": "물구멍관",
|
||||
"unit": "m",
|
||||
"amount": per_unit * scale,
|
||||
"destination": "material",
|
||||
"basis": (
|
||||
f"관측 원단위 식(소광리 「옹벽2.0」 T31) 벽 높이 {height:g} ÷ {area:g}㎡/개소"
|
||||
f" × 관 {length:g} m = {per_unit:.3f}/{entry.get('unit')} × {scale_note}"
|
||||
f" · 관 길이는 관측값 · {spec_basis}"
|
||||
),
|
||||
"basis_kind": BASIS_OBSERVED,
|
||||
"source": str(entry.get("source") or ""),
|
||||
"spec": f"Ø{diameter}",
|
||||
}
|
||||
|
||||
|
||||
def section_trench_components(
|
||||
entry: dict[str, Any], rubble_thickness_m: float, scale: float
|
||||
) -> tuple[list[dict[str, Any]], float] | None:
|
||||
"""단면이 있는 관측 줄의 터파기·되메우기·잔토(`destination: earthwork`)와 파는 깊이(m).
|
||||
|
||||
⚠ 같은 파일 식생옹벽블럭 기초 터파기 식 — 밑폭 = 기초 폭 + 양쪽 여유 0.3 · 옆면 1:0.5
|
||||
(윗폭 = 밑폭 + 0.5 × 판 깊이 × 2) · 깊이 = 기초 + 버림 + 기초잡석 · 벽 높이 몫은 안 셈
|
||||
(지반선이 제원에 없음). 브레인 판정 2026-09-14 — 처음 수직에서 1:0.5 로 고침.
|
||||
⚠ 되메우기 = 터파기 − 그 안에 든 것(기초·전단키 콘크리트 + 버림 + 기초잡석) — 잔토 = 든 것.
|
||||
"""
|
||||
section = entry.get("section") or {}
|
||||
trench = section.get("trench")
|
||||
if not trench:
|
||||
return None
|
||||
footing, key, blinding = section["footing"], section["key"], section["blinding"]
|
||||
clearance, slope = trench["clearance_m"], trench.get("side_slope", 0.0)
|
||||
width = footing["width_m"] + 2 * clearance
|
||||
depth = footing["thickness_m"] + blinding["thickness_m"] + rubble_thickness_m
|
||||
top_width = width + 2 * slope * depth
|
||||
key_below = max(key["depth_m"] - blinding["thickness_m"] - rubble_thickness_m, 0.0)
|
||||
excavation = (width + top_width) / 2 * depth + key["width_m"] * key_below
|
||||
layer_width = footing["width_m"] + 2 * blinding["overhang_m"] - key["width_m"]
|
||||
filled = (
|
||||
footing["width_m"] * footing["thickness_m"]
|
||||
+ key["width_m"] * key["depth_m"]
|
||||
+ layer_width * (blinding["thickness_m"] + rubble_thickness_m)
|
||||
)
|
||||
backfill = excavation - filled
|
||||
basis = (
|
||||
f"(밑폭 {width:.2f}(기초 {footing['width_m']:g} + 여유 {clearance:g}×2)"
|
||||
f" + 윗폭 {top_width:.2f}(1:{slope:g})) ÷ 2 × 깊이 {depth:.2f}"
|
||||
f"(기초 {footing['thickness_m']:g} + 버림 {blinding['thickness_m']:g}"
|
||||
f" + 기초잡석 {rubble_thickness_m:g})"
|
||||
f" · {SECTION_TRENCH_NOTE} · 여유·기울기는 소광리 식생옹벽블럭 기초 터파기"
|
||||
)
|
||||
rows = [
|
||||
("터파기", excavation, basis),
|
||||
("되메우기", backfill, f"터파기 − 든 것 {filled:.3f}(기초·전단키 + 버림 + 기초잡석)"),
|
||||
("잔토처리", filled, "터파기 − 되메우기"),
|
||||
]
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"unit": "㎥",
|
||||
"amount": amount * scale,
|
||||
"destination": "earthwork",
|
||||
"basis": f"{text} = {amount:.3f}/{entry.get('unit')}",
|
||||
"basis_kind": BASIS_DERIVED,
|
||||
"source": str(entry.get("source") or ""),
|
||||
}
|
||||
for name, amount, text in rows
|
||||
], depth
|
||||
|
||||
|
||||
def expand_observed(
|
||||
type_id: str,
|
||||
spec: dict[str, Any],
|
||||
structure: dict[str, Any],
|
||||
table: ObservedUnitTable | None = None,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""(성분 목록, 알림). 규격이 표에 없으면 **빈 목록 + 미확보 알림**을 낸다."""
|
||||
from B08_Quantity.B08_Quantity_Wording import spec_missing
|
||||
|
||||
found = (table or load_observed_table()).find(type_id, spec)
|
||||
if found is None:
|
||||
known = (table or load_observed_table()).specs_for(type_id)
|
||||
# ⚠ 키 이름을 화면에 내보내지 않는다 — 사용자는 `retaining_wall` 을 모른다.
|
||||
return [], [spec_missing(type_id, known)]
|
||||
|
||||
scale, scale_note = scale_for(found, structure)
|
||||
if scale <= 0:
|
||||
from B08_Quantity.B08_Quantity_Wording import type_label
|
||||
|
||||
where = (
|
||||
"면적(월류 폭 × 포장 길이)이 0 — 두 칸을 적으면 섬"
|
||||
if found.get("unit") == "㎡"
|
||||
else "연장이 0 — 연장을 적으면 섬"
|
||||
)
|
||||
return [], [f"{type_label(type_id)}의 {where}"]
|
||||
|
||||
source_key = str(found.get("source") or "")
|
||||
options = structure.get("options") or {}
|
||||
components: list[dict[str, Any]] = []
|
||||
blocked: list[str] = []
|
||||
for item in found.get("components") or []:
|
||||
note = str(item.get("basis_note") or "")
|
||||
if not item.get("destination"):
|
||||
# ⛔ 갈 곳 기본값 금지(명세 13장) — 종전 `or "material"` 은 줄이 늘 때 조용히 자재로 샘.
|
||||
blocked.append(
|
||||
f"「{item['name']}」 관측 원단위 줄에 갈 곳이 없어 물량을 어느 축에도 안 보냄"
|
||||
" — 표(`structure_unit_observed`)에 적을 것(명세 13장 갈 곳 기본값 금지)"
|
||||
)
|
||||
continue
|
||||
weep = (
|
||||
weep_component(found, options, scale, scale_note)
|
||||
if item["name"] == "물구멍관"
|
||||
else None
|
||||
)
|
||||
if weep is not None:
|
||||
components.append(weep)
|
||||
continue
|
||||
components.append(
|
||||
{
|
||||
"name": item["name"],
|
||||
"unit": item["unit"],
|
||||
"amount": float(item["amount"]) * scale,
|
||||
"destination": item["destination"],
|
||||
# 근거를 값 옆에 붙인다 — 관측값임을 화면·인계에서 바로 알아야 한다.
|
||||
"basis": f"관측 원단위 {item['amount']:g}/{found.get('unit')} × {scale_note}"
|
||||
+ (f" ({note})" if note else ""),
|
||||
"basis_kind": BASIS_OBSERVED,
|
||||
"source": source_key,
|
||||
# 규격은 이름과 따로(명세 13장 Ⓒ) — 「이형철근」 + 「D13」(2026-09-14 가르기).
|
||||
"spec": str(item.get("spec") or ""),
|
||||
}
|
||||
)
|
||||
notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}", *blocked]
|
||||
# 줄에 붙은 사유(원문끼리 다름 등) — 구조물도 그림도 같은 글자를 씀.
|
||||
notes.extend(str(note) for note in found.get("notes") or [])
|
||||
earthwork_note = earthwork_missing_note(found)
|
||||
if earthwork_note:
|
||||
notes.append(earthwork_note)
|
||||
return components, notes
|
||||
Reference in New Issue
Block a user