feat(b08): 구조물도 그림 2조각 — 집수정(□형 Ø800) · 물넘이포장 · 날개벽·돌집수정은 까닭

- 집수정 □형 Ø800 — 관측 줄에 원문 치수(소광리 「집수정구조도(800)」 설계조건 칸·산출식)를 싣고 그 치수로 표 수량이 원문 식대로 다시 나오는지 시험
  원문 값으로: 콘크리트 2.84 → 2.83752 · 합판거푸집 21.28 → 21.2843 · 철근 D13 4.78 → 4.776 · 면목 12.67 → 12.6728(터파기·되메우기·잔토 그대로)
  그림: 긴 벽 정면(바닥기초·관·방수로·터파기) + 평면 · 관·방수로 자리는 원문 입체도 모식이라 적음
- 물넘이포장 — 슬래브 T(규격) · 와이어메쉬 2/3T(소광리 「포장」 그림) · 터파기 · 새 사유 「원문 둘이 다름: 소광리 포장 와이어매쉬 1.0·비닐 2㎡ ↔ 이 표(울진 1공구) 1.16·비닐 없음」을 표·그림이 같이 씀
- 날개벽 5종 — 원문 탭에 구조도 그림이 없고 날개 모양이 손으로 적은 수뿐이라 까닭 · 돌집수정 ㄷ·ㄴ형 — 집계표 관측값이라 치수 원문 없음 까닭
- 사유 문구(브레인 판정): 개거 150×200 「원문 둘이 다름(산출식 0.3×0.1 ↔ 그림 홈 150×200 + 밑 보 400×200)」 · 바닥막이 「옹벽은 버림·잡석까지 팜 — 바닥막이는 정본이 돌 두께만이라 다름」

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-14 07:09:53 +09:00
co-authored by Claude Opus 5
parent a98689c966
commit 7b6fe3873b
7 changed files with 285 additions and 14 deletions
@@ -302,6 +302,8 @@ def expand_observed(
}
)
notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}"]
# 줄에 붙은 사유(원문끼리 다름 등) — 구조물도 그림도 같은 글자를 씀.
notes.extend(str(note) for note in found.get("notes") or [])
earthwork_note = earthwork_missing_note(found)
if earthwork_note:
notes.append(earthwork_note)
@@ -53,13 +53,18 @@ SCALE_MM_PER_M = 40.0
MASONRY_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "revetment"})
#: 정본 개소당·㎡당 구조물 — `_StructureFigure_Small`(치수는 표가 쓴 한 벌).
SMALL_TYPE_IDS: frozenset[str] = frozenset({"erosion_check", "soil_guard", "bed_sill"})
FIGURE_TYPE_IDS: frozenset[str] = MASONRY_TYPE_IDS | SMALL_TYPE_IDS | {"retaining_wall"}
#: 관측 원단위 줄의 `section`(표 수량이 나온 그 단면)으로 그리는 종류 — 단면 없는 규격은 까닭.
OBSERVED_TYPE_IDS: frozenset[str] = frozenset(
{"retaining_wall", "pipe_inlet_basin", "pipe_wing_wall", "ford_pavement"}
)
FIGURE_TYPE_IDS: frozenset[str] = MASONRY_TYPE_IDS | SMALL_TYPE_IDS | OBSERVED_TYPE_IDS
#: 개거 — 표는 서는데 그림을 못 정한 까닭(2026-09-14 원문 대조).
_OPEN_DITCH_REASONS: dict[str, str] = {
"콘크리트 개거 150×200": (
"그림 없음 — 원문끼리 치수가 다릅니다: 산출식 터파기 0.3 × 0.1 ↔ 같은 탭 그림 "
"포장 슬래브 사이 홈 150×200 + 밑 보 400×200 · 어느 쪽으로 그릴지 판정 대기"
"그림 없음 — 원문 둘이 다름(산출식 0.3×0.1 ↔ 그림 홈 150×200 + 밑 보 400×200)"
" · 같은 탭 「개거(150-200)」 안에서 갈림"
" · 어느 쪽으로 그릴지 확인 대기(임의로 고르지 않음)"
),
"L형수로 H=0.2": (
"그림 없음 — 원문 「L형수로-(201)」 탭에 단면 그림이 없어"
@@ -107,7 +112,7 @@ def figure_reason(sheet: dict[str, Any]) -> str | None:
return _NO_FIGURE_REASONS.get(type_id, _NO_FIGURE_DEFAULT)
if type_id in SMALL_TYPE_IDS:
return None # 치수가 없으면 표가 안 서고, 그때는 위 「표가 안 선 장」이 사유를 냄
if type_id == "retaining_wall":
if type_id in OBSERVED_TYPE_IDS:
return _observed_section(sheet)[1]
if float(sheet.get("height_m") or 0.0) <= 0:
return "그림 없음 — 높이가 없습니다(제원에서 높이를 넣으면 그림이 섭니다)"
@@ -134,10 +139,26 @@ def _observed_section(sheet: dict[str, Any]) -> tuple[dict[str, Any] | None, str
if entry is None:
return None, f"그림 없음 — {spec_missing(type_id, table.specs_for(type_id))}"
if not entry.get("section"):
return None, "그림 없음 — 이 규격은 자료에 수량만 있고 단면 치수가 없습니다"
why = _NO_SECTION_REASONS.get(type_id) or _NO_SECTION_BY_SOURCE.get(
str(entry.get("source") or ""), "이 규격은 자료에 수량만 있고 단면 치수가 없습니다"
)
return None, f"그림 없음 — {why}"
return entry, None
#: 관측 줄에 단면이 없는 까닭 — 종류별(원문을 봤는데 못 정한 자리).
_NO_SECTION_REASONS: dict[str, str] = {
"pipe_wing_wall": (
"원문 「날개벽(…)산출근거」 탭에 구조도 그림이 없고(「구조도 참조」만) 날개 모양이"
" 산출식에 손으로 적은 수로만 있어 모양을 정하지 않음 — 구조도 원본 확보 대기"
),
}
#: 출처별 — 수량 집계표 관측값은 치수 원문 자체가 없음.
_NO_SECTION_BY_SOURCE: dict[str, str] = {
"uljin_compare": "수량 집계표 관측값(울진 1공구)이라 구조도·치수 원문이 없습니다",
}
def _path(points: list[tuple[float, float]], role: str, *, dash: bool = False) -> dict[str, Any]:
return {"kind": "path", "points": [list(p) for p in points], "role": role, "dash": dash}
@@ -161,6 +182,13 @@ def build_figure(
if type_id == "retaining_wall":
entry, _reason = _observed_section(sheet)
return _wall_figure(sheet, entry or {}, rubble_thickness_m)
if type_id in ("pipe_inlet_basin", "ford_pavement"):
from B08_Quantity import B08_Quantity_Engine_StructureFigure_Small as small
entry, _reason = _observed_section(sheet)
if type_id == "pipe_inlet_basin":
return small.inlet_basin_figure(sheet, entry or {})
return small.pavement_figure(sheet, entry or {})
if type_id in SMALL_TYPE_IDS:
from B08_Quantity import B08_Quantity_Engine_StructureFigure_Small as small
@@ -59,6 +59,114 @@ def _circle(cx: float, cy: float, r: float) -> list[tuple[float, float]]:
]
def _rect(x0: float, y0: float, x1: float, y1: float) -> list[tuple[float, float]]:
return [(x0, y0), (x0, y1), (x1, y1), (x1, y0), (x0, y0)]
def inlet_basin_figure(sheet: dict[str, Any], entry: dict[str, Any]) -> list[dict[str, Any]]:
"""집수정(□형) — 긴 벽 정면(바닥기초 · 관 · 방수로 · 터파기) + 평면(바깥·안 벽).
치수는 관측 줄 `section`(표 수량이 나온 그 치수). 관·방수로 **자리**는 원문 입체도 모식.
"""
section = entry["section"]
inner, wall, base = section["inner"], section["wall_m"], section["base_m"]
spill, dig = section["spillway"], section["dig"]
length, width = inner["length_m"] + 2 * wall, inner["width_m"] + 2 * wall
height = inner["height_m"]
top = base + height
radius = section["pipe_d_m"] / 2
mid = length / 2
notch_half_top, notch_half_bottom = spill["top_m"] / 2, spill["bottom_m"] / 2
# 긴 벽 정면 — 윗변 가운데에 방수로 한 곳(모두 `count` 곳).
outline = [
(0.0, 0.0),
(0.0, top),
(mid - notch_half_top, top),
(mid - notch_half_bottom, top - spill["depth_m"]),
(mid + notch_half_bottom, top - spill["depth_m"]),
(mid + notch_half_top, top),
(length, top),
(length, 0.0),
(0.0, 0.0),
]
dig_len = length + dig["extra_length_m"]
shapes = [
_path(outline, "wall"),
_path([(0.0, base), (length, base)], "guide"),
_path(_circle(mid, base + radius, radius), "guide"),
_path(
_rect(-dig["extra_length_m"] / 2, 0.0, length + dig["extra_length_m"] / 2, top),
"guide",
dash=True,
),
]
# 평면 — 바깥 벽 · 안 벽 · 터파기 평면(점선).
px = length + 1.2
shapes += [
_path(_rect(px, 0.0, px + length, width), "wall"),
_path(_rect(px + wall, wall, px + length - wall, width - wall), "guide"),
_path(
_rect(
px - dig["extra_length_m"] / 2,
(width - dig["width_m"]) / 2,
px + length + dig["extra_length_m"] / 2,
(width + dig["width_m"]) / 2,
),
"guide",
dash=True,
),
]
right = px + length + 0.5
shapes += [
_title(sheet, max(top, dig["width_m"]) + 0.55),
_text("정면(긴 벽)", (mid, top + 0.15), "center"),
_text("평면", (px + length / 2, width + 0.15 + (dig["width_m"] - width) / 2), "center"),
_text(
f"{inner['length_m']:g}×{inner['width_m']:g}×{height:g} · 벽 {wall:g} · 바닥기초"
f" {length:g}×{width:g}×{base:g} m",
(right, top * 0.9),
"left",
),
_text(f"관 Ø{section['pipe_d_m']:g}", (right, top * 0.65), "left"),
_text(
f"방수로 {spill['count']}곳 (상장 {spill['top_m']:g} · 하장 {spill['bottom_m']:g}"
f" · 고 {spill['depth_m']:g})",
(right, top * 0.4),
"left",
),
_text(
f"터파기 {dig_len:g} × {dig['width_m']:g} × 깊이 {top:g} m",
(right, top * 0.15),
"left",
),
_text("관·방수로 자리는 원문 입체도 모식 — 치수는 산출식", (0.0, -0.3), "left"),
]
return shapes
def pavement_figure(sheet: dict[str, Any], entry: dict[str, Any]) -> list[dict[str, Any]]:
"""물넘이포장 — ㎡당 한 칸 단면: 슬래브 두께(규격) · 와이어메쉬(2/3T) · 터파기(두께만큼)."""
thickness = float(entry["spec"]["thickness_cm"]) / 100
mesh = thickness * entry["section"]["mesh_from_bottom_ratio"]
width = 1.0
shapes = [
_path(_rect(0.0, 0.0, width, thickness), "wall"),
_path([(0.05, mesh), (width - 0.05, mesh)], "guide", dash=True),
_path(_rect(-0.12, 0.0, width + 0.12, thickness), "guide", dash=True),
]
right = width + 0.4
shapes += [
_title(sheet, thickness + 0.45),
_text("폭 1 m 당(표는 ㎡당)", (width / 2, thickness + 0.12), "center"),
_text(f"콘크리트 T={thickness:g} m", (right, thickness * 0.8), "left"),
_text(f"와이어메쉬 — 밑에서 {mesh:.3f} m (2/3T)", (right, mesh - 0.02), "left"),
_text(f"터파기 깊이 {thickness:g} m (= 두께)", (right, 0.0), "left"),
]
for index, note in enumerate(entry.get("notes") or []):
shapes.append(_text(str(note), (0.0, -0.25 - index * 0.14), "left"))
return shapes
def erosion_check_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]:
"""골막이 — 정면(사다리꼴 · 방수로 관 · 바닥파기 띠) + 옆 단면(상부·하부 두께)."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import erosion_check_section
@@ -622,4 +622,5 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
#: 바닥막이 터파기 사유 — 표 사유·구조물도 그림이 **같은 말**(2026-09-14 그림에서 드러남).
BED_SILL_TRENCH_NOTE = (
"⚠ 터파기는 정본대로 면적 × 돌 두께만 — 그 밑 버림·기초잡석 두께는 판 깊이에 안 들어감"
" · 옹벽은 버림·잡석까지 팜 — 바닥막이는 정본이 돌 두께만이라 다름"
)
@@ -193,31 +193,43 @@
"unit": "개소",
"source": "uljin_library",
"source_note": "§2 집수정 Ø800 — 내부 3.0×1.0×1.2, 벽 0.2, 바닥기초 3.4×1.4×0.2",
"section": {
"note": "치수(m) — 소광리 07-구조도 「집수정구조도(800)」 설계조건 칸(AB4~AE17)과 산출식 Y21~Y30 · 같은 탭 입체도. 관·방수로 자리는 원문 입체도 모식(산출식에 자리 치수 없음)",
"inner": { "length_m": 3.0, "width_m": 1.0, "height_m": 1.2 },
"wall_m": 0.2,
"base_m": 0.2,
"pipe_d_m": 0.8,
"spillway": { "count": 3, "top_m": 1.0, "bottom_m": 0.4, "depth_m": 0.3 },
"dig": { "extra_length_m": 0.4, "width_m": 2.0 }
},
"components": [
{
"name": "콘크리트",
"unit": "㎥",
"amount": 2.84,
"destination": "unit_price"
"amount": 2.83752,
"destination": "unit_price",
"basis_note": "원문 값(라이브러리 표기 2.84)"
},
{
"name": "합판거푸집",
"unit": "㎡",
"amount": 21.28,
"destination": "unit_price"
"amount": 21.2843,
"destination": "unit_price",
"basis_note": "원문 값(라이브러리 표기 21.28)"
},
{
"name": "이형철근 D13",
"unit": "kg",
"amount": 4.78,
"destination": "material"
"amount": 4.776,
"destination": "material",
"basis_note": "원문 값(라이브러리 표기 4.78)"
},
{
"name": "면목",
"unit": "m",
"amount": 12.67,
"amount": 12.6728,
"destination": "material",
"basis_note": "A25"
"basis_note": "A25 · 원문 값(라이브러리 표기 12.67)"
},
{
"name": "터파기",
@@ -247,6 +259,13 @@
"unit": "㎡",
"source": "uljin_compare",
"source_note": "콘크리트포장 T=20cm /㎡",
"section": {
"note": "단면 — 두께는 규격(thickness_cm) · 와이어메쉬 자리는 소광리 「포장」 그림 「WIRE MASH (콘크리트 2/3T 지점에 설치)」",
"mesh_from_bottom_ratio": 0.6667
},
"notes": [
"⚠ 원문 둘이 다름 — 소광리 「포장」 ㎡당: 와이어매쉬 1.0 · 비닐 2㎡(깔기+덮기) ↔ 이 표(울진 1공구 집계): 와이어메쉬 1.16 · 비닐 없음 · 고르지 않음(확인 대기)"
],
"components": [
{
"name": "레미콘",
@@ -0,0 +1,113 @@
"""구조물도 그림 — 집수정 · 날개벽 · 물넘이포장 (관측 원단위, 2026-09-14 · 표 = 그림).
집수정 Ø800 그림 치수(`section`) ** 수량이 원문 그대로** 다시 나와야 .
원문 값으로 바꿈: 콘크리트 2.84 2.83752 · 합판거푸집 21.28 21.2843 ·
철근 4.78 4.776 · 면목 12.67 12.6728.
"""
from __future__ import annotations
import math
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table # noqa: E402
from B08_Quantity.B08_Quantity_Engine_StructureFigure import attach_figures # noqa: E402
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
BASIN_SPEC = {
"inlet_basin_form": "□형(기본형)",
"inlet_basin_material": "콘크리트",
"pipe_diameter_mm": "800",
}
def _sheets(**options) -> list[dict]:
pipe = {
"structure_id": "p1",
"type_id": "pipe",
"start_m": 0.0,
"end_m": 0.0,
"options": {"pipe_kind": "흄관", **options},
}
payload = build_standard_sheets(build_table([pipe], None))
attach_figures(payload)
return payload["sheets"]
def _sheet(sheets: list[dict], type_id: str) -> dict:
return next(sheet for sheet in sheets if sheet["type_id"] == type_id)
def test_집수정_치수로_표_수량이_원문_식대로_다시_나온다() -> None:
entry = load_observed_table().find("pipe_inlet_basin", BASIN_SPEC)
s = entry["section"]
amounts = {item["name"]: item["amount"] for item in entry["components"]}
inner, wall, base = s["inner"], s["wall_m"], s["base_m"]
ol, ow, h = inner["length_m"] + 2 * wall, inner["width_m"] + 2 * wall, inner["height_m"]
il, iw = inner["length_m"], inner["width_m"]
sp = s["spillway"]
notch = (sp["top_m"] + sp["bottom_m"]) / 2 * sp["depth_m"]
hole = 3.14 * (s["pipe_d_m"] / 2) ** 2 # 원문이 3.14 로 셈
concrete = ol * ow * h - il * iw * h - sp["count"] * notch * wall - hole * wall + ol * ow * base
slant = math.hypot((sp["top_m"] - sp["bottom_m"]) / 2, sp["depth_m"])
form = (
(ol * h - hole)
+ (ol * h - notch)
+ (ow * h - notch) * 2
+ (il * h - hole)
+ (il * h - notch)
+ (iw * h - notch) * 2
+ (ol + ow) * base * 2
+ slant * wall * sp["count"] * 2
)
chamfer = ol + il * 2 + slant * sp["count"] + sp["bottom_m"] * sp["count"] + wall * 4
trench = (ol + s["dig"]["extra_length_m"]) * s["dig"]["width_m"] * (h + base)
backfill = trench - il * iw * (h + base)
assert abs(concrete - amounts["콘크리트"]) < 1e-9 # 2.83752
assert abs(form - amounts["합판거푸집"]) < 5e-5 # 21.28432
assert abs(chamfer - amounts["면목"]) < 5e-5 # 12.67279
assert abs(trench - amounts["터파기"]) < 1e-9
assert abs(backfill - amounts["되메우기"]) < 1e-9
assert abs(trench - backfill - amounts["잔토처리"]) < 1e-9
def test_집수정_그림이_서고_날개벽은_까닭() -> None:
sheets = _sheets(wing_wall_type="A-TYPE", **BASIN_SPEC)
basin = _sheet(sheets, "pipe_inlet_basin")
texts = [s["text"] for s in basin["figure"] if s["kind"] == "text"]
assert "안 3×1×1.2 · 벽 0.2 · 바닥기초 3.4×1.4×0.2 m" in texts
assert "터파기 3.8 × 2 × 깊이 1.4 m" in texts
wing = _sheet(sheets, "pipe_wing_wall")
assert (
wing["rows"] and wing["figure"] is None and "구조도 원본 확보 대기" in wing["figure_reason"]
)
def test_돌집수정은_치수_원문이_없어_까닭() -> None:
basin = _sheet(
_sheets(pipe_diameter_mm="800", inlet_basin_form="돌집수정 ㄷ형"), "pipe_inlet_basin"
)
assert basin["rows"] and basin["figure"] is None and "집계표 관측값" in basin["figure_reason"]
def test_물넘이포장_그림과_표에_원문_차이_사유가_같다() -> None:
structure = {
"structure_id": "f1",
"type_id": "ford_pavement",
"start_m": None,
"end_m": None,
"options": {"ford_width_m": 5, "length_m": 10, "thickness_cm": 20},
}
payload = build_standard_sheets(build_table([structure], None))
attach_figures(payload)
sheet = payload["sheets"][0]
texts = [s["text"] for s in sheet["figure"] if s["kind"] == "text"]
diff = [note for note in sheet["notes"] if "원문 둘이 다름" in note]
assert diff and diff[0] in texts
assert "와이어메쉬 — 밑에서 0.133 m (2/3T)" in texts
@@ -106,6 +106,6 @@ def test_바닥막이_그림_사유는_표와_같은_말이고_기초잡석은_
def test_개거는_표가_서도_치수를_못_정한_까닭() -> None:
sheet = _sheet("open_ditch", ditch_spec="콘크리트 개거 150×200", length_m=5)
assert sheet["rows"] and sheet["figure"] is None and "판정 대기" in sheet["figure_reason"]
assert sheet["rows"] and sheet["figure"] is None and "확인 대기" in sheet["figure_reason"]
l_type = _sheet("open_ditch", ditch_spec="L형수로 H=0.2", length_m=5)
assert l_type["rows"] and "단면 그림이 없어" in l_type["figure_reason"]