㉕ 타설 줄 실증 중에 드러남. 관측 원단위가 「개소당」인 종류(집수정 셋)를 인계 줄이 늘 「m · 연장」으로 내보내고 있었음. - 연장 2m 짜리 집수정이 「2.000 m」로 나가 값이 두 배로 실림 - 성분은 개소 기준으로 맞게 서는데 줄의 축만 어긋나 아무 시험도 안 잡았음 - billing_of() 로 관측표가 정한 단위·개수를 꺼내 전개 결과에 실음 - 인계 줄이 그 단위를 따름. 비어 있으면 종전대로 「m · 연장」 시험 셋 — 개소로 서는 것 · 연장이 길어도 한 개소 · ⚠ 정상 「m」 줄은 그대로일 것(좁게 고치는 짝 시험). 곁들여 타설 줄 실증: 옹벽 H=2.0(20m) + 집수정 Ø800 으로 돌리면 FP-12-01-01#철근구조물 32.840㎥ 가 인계에 섬(옹벽 27+3 · 집수정 2.84). 시험: 677 passed · 24 skipped (B05 코리도 1건 기존 깨짐, 무관). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
193 lines
8.8 KiB
Python
193 lines
8.8 KiB
Python
"""콘크리트 구조물 — **관측 원단위표** 조회 (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" / "data_structure_unit"
|
||
DATASET_PREFIX = "structure_unit_observed_"
|
||
|
||
#: 값이 어디서 왔나 — 한 표에 섞이므로 줄마다 단다.
|
||
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
|
||
|
||
|
||
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
|
||
|
||
return [], [f"{type_label(type_id)}의 연장·면적이 0 이라 물량을 내지 않았습니다"]
|
||
|
||
source_key = str(found.get("source") or "")
|
||
components: list[dict[str, Any]] = []
|
||
for item in found.get("components") or []:
|
||
note = str(item.get("basis_note") or "")
|
||
components.append(
|
||
{
|
||
"name": item["name"],
|
||
"unit": item["unit"],
|
||
"amount": float(item["amount"]) * scale,
|
||
"destination": item.get("destination") or "material",
|
||
# 근거를 값 옆에 붙인다 — 관측값임을 화면·인계에서 바로 알아야 한다.
|
||
"basis": f"관측 원단위 {item['amount']:g}/{found.get('unit')} × {scale_note}"
|
||
+ (f" ({note})" if note else ""),
|
||
"basis_kind": BASIS_OBSERVED,
|
||
"source": source_key,
|
||
}
|
||
)
|
||
return components, [f"관측 원단위 적용 — {found.get('source_note') or source_key}"]
|