Files
Aislo/B08_Quantity/B08_Quantity_Engine_Pipe.py
T
eomsangdonandClaude Opus 5 c7b9956bf3 feat(b06,b08): 관 기준벽도 벽 몸 ∩ 성토 폐회로 겹침을 줄 사유로 — 다단에만 뜨면 「다단만 문제」로 읽힘(㉡)
서버 Node 가 기준벽 역할별 넓이를 design.wall_fill_overlap 에 남기고 B08 이 다단 목록 통(base 표시 칸)으로 읽어 기준벽 줄 비고에 붙임 · 호출부(Router_Material) 안 늘림 · 936be972 빈 저장: 관 9 곳 유출 벽 258.12 1.417 · 720 1.403 · 804.18 1.166 · 982.64 0.807 · 620 0.136 · 440 0.046 · 533.98 0.004 · 85.05/900 0㎡ · 이 프로젝트 기준벽은 모두 미확정(금액 밖)이라 비고 안 뜸 · 벽 칸을 적으면 「1.42㎡ × 10m ≈ 14.2㎥」 · 금액 변화 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 15:02:43 +09:00

542 lines
28 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.
"""배수관 물량 — **정본 셋을 잇기만 한다** (2026-09-08 두 창 합의).
값이 어디서 오나
관 자체(있나·어디·관경·관종) → `pipe_points.json` (레지스트리 `pipe` 타입이
`managed_by: pipe_points`)
관 연장(m) → 측점 `design.pipe_length_m`
(B06 횡단이 **서버 Node 로** 계산해 m 단위 올림까지
끝낸 값을 정본에 남긴다 — 계산이 두 벌이 아니다)
관종 → 공종코드 → `work_item_mapping` 의 `pipe.kind_codes`
⚠ **여기서 길이를 짓지 않는다.** 앞서 「도로폭 = 관 길이」처럼 잡을 뻔했는데 그것이 곧
임의 수치다. 연장이 없는 관은 **줄을 세우되 막힌 사유와 함께** 보낸다.
⚠ **`facility` 가 `pipe` 인 점만 배관이다.**
`pipe_points.json` 은 **계곡 통과 시설 전부의 정본**이라 BOX암거·물넘이·세월교·독립
기슭막이가 같은 파일에 있다. `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**
(실측: `5601e828` 11점 중 2점이 `facility: ford_bridge`).
⚠ **유출·유입부 기슭막이는 관 줄에 넣지 않는다 — `facility_structures` 가 원단위 전개로 한 번 셈.**
관 옵션(`outlet_revet_*`)이 정본이다(2026-08-28 이관). 관 부설(품셈 12-11 m당: 관·기초콘크리트·
거푸집)에 기슭막이 몫이 없어 겹치지 않는다. 2026-09-14 까지는 **어느 쪽도 안 세고 있었다**(A1).
⚠ **터파기·되메우기를 관 줄에 붙이지 않는다.**
관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다(B09 ㉡ 가드와 같은 자리).
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
#: 배관으로 보는 `facility` 값. 그 밖(BOX암거·물넘이·세월교·독립 기슭막이)은 관이 아니다.
FACILITY_PIPE = "pipe"
#: 연장을 못 찾은 줄의 막힌 갈래 — 「입력하면 풀림」이 아니라 **앞 단계가 내야 하는 값**이다.
BLOCKED_LENGTH_MISSING = "input_missing"
#: ⚠ **둘을 갈라 말한다** — 「[저장]을 누르면 풀리는 것」과 「눌러도 안 풀리는 것」은
#: 사용자가 할 일이 다르다. 뒤엣것에 앞 문구를 쓰면 눌러 보고 안 되어 헤맨다
#: (2026-09-08 실측: 관 9개 중 3개가 **횡단 행 자체가 없는** 자리였다).
NOTE_LENGTH_MISSING = (
"관 연장이 아직 정본에 없습니다 — 횡단설계에서 [저장]을 한 번 누르면 "
"그 측점의 관 길이가 남고 값이 섭니다"
)
NOTE_SECTION_MISSING = (
"그 측점의 횡단 자체가 없습니다 — 관은 놓였는데 횡단이 안 만들어진 자리라 "
"[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다"
)
# ── 허용 오차 둘 — **묻는 것이 다르다.** 한 자리에 모아 둔다 ────────────────
# · `LENGTH_MATCH_TOLERANCE_M` — 「이 관의 **길이를 어디서 가져오나**」.
# 넉넉해도 된다. 값을 못 찾는 것보다 옆 측점 길이를 쓰는 편이 낫다.
# · `SECTION_MATCH_TOLERANCE_M` — 「이 측점에 **횡단 설계가 있나**」.
#
# ⚠⚠ **둘 다 0.5 다. 좁히지 말 것** — 앞서 「좁아야 한다」던 판단이 실측으로 뒤집혔다
# (2026-09-09 랩탑 메인). 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로
# 스냅**한다(횡단 파일명이 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드).
# 그래서 관 440.241 의 횡단은 **측점 440.0** 이고 구조물 이름표까지 달고 있다 —
# **없는 것이 아니다.** 좁게 보면 「그 측점의 횡단 자체가 없습니다」라는 **거짓 사유**가
# 뜬다(반대 방향의 거짓). 스냅 폭이 「정수 미터 반올림」이라 최대 어긋남이 0.5m 이고,
# 그래서 길이 찾기와 **같은 값**이 된다 — 우연이 아니라 같은 까닭이다.
# ⚠ 값이 같아졌다고 **하나로 합치지 말 것.** 묻는 것이 둘이라 근거도 둘이고, 한쪽 근거가
# 바뀌면 한쪽만 움직여야 한다.
# ⚠ 두 값은 **증상**이지 병이 아니다. 병은 **관 자리에 측점이 없는 것**이고
# (측점을 만드는 자리는 B05 노선 [확정] 한 곳뿐 — 관을 나중에 놓거나 옮기면 안 생긴다,
# 계획서 3-14), 그것이 고쳐지면 이 값들이 **아무 관도 안 건드린다.**
LENGTH_MATCH_TOLERANCE_M = 0.5
SECTION_MATCH_TOLERANCE_M = 0.5
NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다"
NOTE_KIND_UNKNOWN = "「{kind}」은(는) 아는 관종이 아니라 공종을 못 골랐습니다"
def _length_by_chainage(designs: list[dict[str, Any]], key: str) -> dict[float, float]:
"""측점별 관 길이. **없는 측점은 담지 않는다** — 0 으로 채우면 「없음」과 구별이 안 된다."""
found: dict[float, float] = {}
for row in designs or []:
design = row.get("design") if isinstance(row, dict) else None
if not isinstance(design, dict):
continue
value = design.get(key)
if value is None:
continue
try:
length = float(value)
except (TypeError, ValueError):
continue
if length > 0:
found[round(float(row.get("chainage_m") or 0.0), 3)] = length
return found
def _nearest(
lengths: dict[float, float],
chainage: float,
tolerance: float = LENGTH_MATCH_TOLERANCE_M,
) -> float | None:
"""관 측점과 단면 측점이 소수점에서 어긋날 수 있어 **가까운 것**을 본다.
⚠ 좁게 본다(기본 0.5m) — 넓히면 옆 측점의 길이를 물어 와 조용히 틀린다.
⚠ 「횡단이 있나」를 재는 `SECTION_MATCH_TOLERANCE_M`(0.5m)와 **다른 물음**이다 —
위 주석을 볼 것. 맞추면 둘 다 나빠진다.
"""
if not lengths:
return None
key = round(chainage, 3)
if key in lengths:
return lengths[key]
best = min(lengths, key=lambda x: abs(x - chainage))
return lengths[best] if abs(best - chainage) <= tolerance else None
def build_rows(
pipe_points: list[dict[str, Any]],
designs: list[dict[str, Any]],
mapping: dict[str, Any] | None = None,
section_chainages: list[float] | None = None,
) -> dict[str, Any]:
"""관 줄 목록. **값이 서는 줄도, 못 서는 줄도** 함께 낸다.
`pipe_points` 는 `PipePoint.model_dump()` 또는 같은 모양의 딕셔너리 목록이다.
"""
table = mapping or {}
kind_codes: dict[str, str] = table.get("kind_codes") or {}
kind_key = str(table.get("kind_option_key") or "pipe_kind")
default_kind = str(table.get("default_kind") or "")
length_key = str(table.get("length_key") or "pipe_length_m")
diameter_key = str(table.get("diameter_option_key") or "pipe_diameter_mm")
lengths = _length_by_chainage(designs, length_key)
# 횡단이 **있는데 길이가 없는 것**과 **관 자리에 횡단이 없는 것**을 가르기 위한 목록.
# 안 주면 종전대로 「[저장]하면 풀림」 하나로만 말한다.
sections = [float(x) for x in (section_chainages or [])]
rows: list[dict[str, Any]] = []
notes: list[str] = []
for point in pipe_points or []:
if str(point.get("facility") or FACILITY_PIPE) != FACILITY_PIPE:
continue # 배관이 아닌 시설 — 그쪽 줄은 그쪽이 센다
options = point.get("options") or {}
chainage = float(point.get("chainage_m") or 0.0)
stored_kind = str(options.get(kind_key) or "").strip()
kind = stored_kind or default_kind
code = kind_codes.get(kind)
kind_note = ""
if not stored_kind and default_kind:
kind_note = NOTE_KIND_DEFAULT.format(kind=default_kind)
elif stored_kind and code is None:
kind_note = NOTE_KIND_UNKNOWN.format(kind=stored_kind)
length = _nearest(lengths, chainage)
# ⚠ 관이 선 자리의 횡단이 있는지를 본다. **스냅을 셈에 넣는다**(2026-09-09) —
# 관 440.241 의 횡단은 측점 440.0 이고 그것이 정상이다. 옛 주석은 0.05m 로 좁게
# 보라고 했으나, 그때는 **스냅 때문에 관이 측점에 안 붙던 것**을 「측점이 없다」로
# 읽던 시절이라 판단이 뒤집혔다. 길이는 여전히 **주인 측점 하나에만** 실린다.
has_section = not sections or any(
abs(x - chainage) <= SECTION_MATCH_TOLERANCE_M for x in sections
)
blocked_kind = None if length else BLOCKED_LENGTH_MISSING
blocked_reason = ""
if not length:
blocked_reason = NOTE_LENGTH_MISSING if has_section else NOTE_SECTION_MISSING
if code is None:
blocked_kind = blocked_kind or BLOCKED_LENGTH_MISSING
blocked_reason = blocked_reason or kind_note
rows.append(
{
"chainage_m": chainage,
"work_item_code": code,
"kind": kind,
"kind_from_default": not stored_kind,
# 갈래는 **저장 원본값**만 보낸다 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫.
"variant_axis": diameter_key,
"variant_value": options.get(diameter_key),
"unit": str(table.get("unit") or "m"),
"quantity": float(length or 0.0),
"blocked_kind": blocked_kind,
"blocked_reason": blocked_reason or kind_note,
"in_bill": bool(length and code),
}
)
if kind_note and kind_note not in notes:
notes.append(kind_note)
missing = sum(1 for row in rows if not row["in_bill"])
if missing:
notes.append(f"관 {len(rows)}개 중 {missing}개가 아직 값이 안 섭니다")
return {
"rows": rows,
"notes": notes,
"pipe_count": len(rows),
"ready_count": sum(1 for row in rows if row["in_bill"]),
"length_total_m": round(sum(row["quantity"] for row in rows if row["in_bill"]), 3),
}
INLET_BASIN = "집수정"
#: 기슭막이 벽 칸 — 관·독립 기슭막이 모두 `{쪽}_revet_{칸}` · 독립의 옛 저장분은 `{칸}`(B06 `_revet_side`).
REVET_FIELDS = (
("form", "형태"),
("height_m", "높이"),
("length_m", "길이"),
("before_m", ""),
("after_m", ""),
)
#: (저장 채널, 관에 딸린 줄 이름 꼬리, 독립 기슭막이 줄 이름 꼬리)
REVET_ROLES = (
("inlet", "유입부 기슭막이", "유입 칸 벽"),
("outlet", "유출부 기슭막이", "유출 칸 벽"),
)
#: 관 벽 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(B06 `pipe_wall_min_height_m`,
#: TS 짝 `pipeWallMinPureHeight`)로 섬 — 등록부·구조물도·횡단도가 한 값(2026-09-14 브레인 판정).
#: ⚠ 「최소」라 실제 설계값이 더 클 수 있어 **미확정·금액 밖은 그대로**.
NOTE_DEFAULT_WALL = (
"⚠ 시설 지점에 {filled} 을 안 적어 기본값으로 섰음 — 미확정이라 금액에 안 들어감"
)
#: 꼬리 — 관 벽은 횡단도와 같은 높이.
NOTE_WALL_HEIGHT_PIPE = " · 높이는 횡단도와 같은 관경 기준 최소 높이(실제 설계값은 더 클 수 있음)"
#: 독립 기슭막이 치수 **한 벌**(`resources/data_masonry/revetment_sabang_*.json`) — B05 칸 밑 근거와
#: 이 사유가 같은 파일을 읽음(2026-09-14 브레인 판정). 높이는 **설계자 입력** — 등록부 기본 2.5 걷음.
REVET_SABANG_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
REVET_SABANG_PREFIX = "revetment_sabang_"
UNCONFIRMED_OWN_HEIGHT = "높이를 안 적음 — {rule}({source}) 설계자 입력 · 적으면 금액이 섬"
@lru_cache(maxsize=1)
def load_revetment_sabang() -> dict[str, Any]:
"""독립 기슭막이 치수 한 벌. 없으면 빈 표 — 사유가 근거 없이 짧아질 뿐 값은 안 바뀜."""
files = sorted(REVET_SABANG_DIR.glob(REVET_SABANG_PREFIX + "*.json"))
return json.loads(files[-1].read_text(encoding="utf-8")) if files else {}
def _span(values: list[float], unit: str = "") -> str:
return f"{values[0]:g}~{values[1]:g}{unit}"
def own_revetment_basis() -> str:
"""독립 기슭막이 근거 한 줄 — 높이·계획비탈·둑마루·뒷채움, 쪽·줄 번호 그대로."""
data = load_revetment_sabang()
items = data.get("items") or {}
if not items:
return ""
height, slope = items["height"], items["face_slope"]
crown, pebble = items["crown_thickness"], items["backfill_pebble"]
return (
f"ⓘ 독립 기슭막이 치수 근거 — 높이 {height['rule']}({height['source']})"
f" · 계획비탈 1:{_span(slope['standard_ratio'])}({slope['source']})"
f" · 둑마루 두께 {_span(crown['standard_m'], 'm')}({crown['source']} · 콘크리트 기준)"
f" · 뒷채움 조약돌 두께 {pebble['source']} · {data.get('scope', '')}"
)
def own_height_unconfirmed() -> str:
"""높이를 안 적은 독립 기슭막이의 미확정 까닭."""
height = (load_revetment_sabang().get("items") or {}).get("height") or {}
return UNCONFIRMED_OWN_HEIGHT.format(
rule=height.get("rule", "계획홍수위 + 0.5~0.7m"),
source=height.get("source", "사방기술교본"),
)
#: 금액에서 뺄 까닭(2026-09-14 브레인 판정) — 줄·표·그림은 서되 내역 합에는 안 듦.
UNCONFIRMED_WALL = "벽 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬"
NOTE_SIDE_UNKNOWN = (
"⚠ 설치 측 「{side}」인데 두 칸(유입·유출) 값이 달라 어느 칸이 그쪽인지 서버가 못 가림"
"(횡단 지형이 정함) — 값을 안 세움 · 두 칸을 같게 적으면 섬"
)
def _blank(value: Any) -> bool:
return value is None or value == ""
def _num_or_zero(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _revet_values(
options: dict[str, Any],
role: str,
defaults: dict[str, Any],
legacy: bool,
min_height_m: float | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""벽 한쪽 제원과 기본값으로 채운 칸 — 등록부 기본값 · 관 벽 높이는 관경 기준 최소 높이."""
values: dict[str, Any] = {}
filled: list[str] = []
for name, label in REVET_FIELDS:
value = options.get(f"{role}_revet_{name}")
if _blank(value) and legacy:
value = options.get(name)
if _blank(value):
value = defaults.get(name if legacy else f"{role}_revet_{name}")
if value is None and name == "height_m" and min_height_m is not None:
value = min_height_m
label = f"{label}(관경 기준)"
if label and value is not None:
filled.append(f"{label} {value}")
values[name] = value
return values, filled
def _pipe_diameter_m(options: dict[str, Any], defaults: dict[str, Any]) -> float:
"""관경(m) — 저장값, 없으면 등록부 기본(B06 세트 스펙과 같은 채움)."""
raw = options.get("pipe_diameter_mm")
if _blank(raw):
raw = defaults.get("pipe_diameter_mm")
return _num_or_zero(raw) / 1000.0 or 1.0
#: 다단 추가 기슭막이(④, 2026-09-14) — 횡단 설계가 정본이고 **실제로 선** 단 수·높이는 지형이 정함.
#: 서버 Node 재계산(`B06_Section_Structure_Layouts.extraWallRows`)이 소유 측점 `design.extra_walls` 에
#: 「선 다단 벽 목록」을 남기고 여기서 읽음(관 연장 `pipe_length_m` 과 같은 길).
EXTRA_WALLS_KEY = "extra_walls"
EXTRA_SIDE_LABELS = {"outlet": "유출측", "basin": "유입측"}
#: 다단이 딸린 기준벽 — 유출측 다단은 유출 벽, 유입측(집수정 계류측) 다단은 유입 벽.
EXTRA_SIDE_ROLES = {"outlet": "outlet", "basin": "inlet"}
UNCONFIRMED_EXTRA_HEIGHT = (
"다단 높이를 안 적음 — 기본 1.5m 를 지반에 맞춰 그린 {height}m 로 섰음 · 적으면 금액이 섬"
)
UNCONFIRMED_EXTRA_FORM = "다단 형태를 안 정해 기준벽의 기본 형태 「{form}」로 섰음"
#: 첫 미확정 단 아래는 횡단도 면적도 없는 것으로 봄(B06 `confirmedOnly`) — 같은 선에서 끊음.
UNCONFIRMED_EXTRA_ABOVE = "윗단({above}단)이 미확정이라 이 단도 미확정 — 윗단 높이를 적으면 섬"
#: 벽 몸이 성토 폐회로에도 듦(2026-09-06 사용자 확정 「구조물 면적 안 뺌」) — 고치지 않고 사유로(2026-09-14 브레인).
#: 넓이는 서버 Node 가 횡단도 벽 몸 ∩ 성토 폐회로로 잼(`wallBodyInFillM2` · `fill_overlap_m2`).
NOTE_FILL_OVERLAP = (
"⚠ 벽 몸 자리가 성토 면적에도 들어 있음(2026-09-06 확정 「구조물 면적 안 뺌」) — 이 측점 횡단도 "
"벽 몸 {area:.2f}× 연장 {length:g}m ≈ {volume:.1f}㎥ 를 벽과 성토에 두 번 셈 · "
"벽 뒤 막자갈 자리도 성토선 아래라 더 겹침"
)
#: 관 기준벽(유입·유출) 벽 몸 겹침 ㎡ — `design.wall_fill_overlap` {inlet, outlet}(서버 Node 가 잼, ㉡ 2026-09-14).
#: 호출부(`Router_Material`)를 안 늘리려고 다단 목록 통에 `base` 표시 칸으로 실어 옴 — 다단 줄은 거름.
WALL_OVERLAP_KEY = "wall_fill_overlap"
def extra_walls_from_designs(designs: list[dict[str, Any]] | None) -> dict[float, list[dict]]:
"""측점별 「선 다단 벽 목록」 — 목록이 있는 측점만(빈 목록도 담음 — 「다단 없음」).
기준벽 겹침 값이 있으면 `{"base": 역할, "fill_overlap_m2": ㎡}` 칸을 뒤에 붙임."""
found: dict[float, list[dict]] = {}
for row in designs or []:
design = row.get("design") if isinstance(row, dict) else None
if not isinstance(design, dict):
continue
walls = design.get(EXTRA_WALLS_KEY)
overlaps = design.get(WALL_OVERLAP_KEY)
if not isinstance(walls, list) and not isinstance(overlaps, dict):
continue
entries = list(walls) if isinstance(walls, list) else []
if isinstance(overlaps, dict):
entries += [{"base": role, "fill_overlap_m2": area} for role, area in overlaps.items()]
found[round(float(row.get("chainage_m") or 0.0), 3)] = entries
return found
def _extra_walls_at(extra_walls: dict[float, list[dict]] | None, chainage: float) -> list[dict]:
"""관 자리의 주인 측점 목록 — 스냅 때문에 최대 0.5m 어긋남(`SECTION_MATCH_TOLERANCE_M`)."""
if not extra_walls:
return []
best = min(extra_walls, key=lambda value: abs(value - chainage))
return extra_walls[best] if abs(best - chainage) <= SECTION_MATCH_TOLERANCE_M else []
def facility_structures(
points: list[dict[str, Any]],
extra_walls: dict[float, list[dict]] | None = None,
) -> list[dict[str, Any]]:
"""계곡 통과 시설(`pipe_points.json`) → 원단위 전개가 읽는 구조물 줄 (A1, 2026-09-14).
⚠ 관 자체는 안 냄 — `build_rows` 가 관 연장으로 셈.
⚠ 다단 추가 기슭막이는 `extra_walls`(측점 → 선 벽 목록, `extra_walls_from_designs`)로 줄을 세움.
⚠ 안 적힌 벽 칸은 **등록부 기본값** — 그 사실을 줄 사유로 붙이고 `unconfirmed` 로 금액 합에서 뺌
(2026-09-14 브레인 판정 「줄은 서되 금액은 실제 값이 있을 때만」).
⚠ 집수정·기슭막이 터파기·되메우기는 전개 성분(`destination: earthwork`)이라 토공집계로만 감.
"""
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m
from B08_Quantity.B08_Quantity_Engine_StructureSummary import pipe_row_id
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of, child_row
types = structure_type_map()
def defaults(type_id: str) -> dict[str, Any]:
found = types.get(type_id)
return {option.key: option.default for option in found.options} if found else {}
rows: list[dict[str, Any]] = []
for point in points or []:
facility = str(point.get("facility") or FACILITY_PIPE)
chainage = float(point.get("chainage_m") or 0.0)
options = dict(point.get("options") or {})
base = {
"structure_id": pipe_row_id(point),
"type_id": facility,
"chainage_m": chainage,
"start_m": point.get("start_m") if point.get("start_m") is not None else chainage,
"end_m": point.get("end_m") if point.get("end_m") is not None else chainage,
"options": options,
}
if facility not in (FACILITY_PIPE, "revetment"):
rows.append(base) # BOX암거·물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬
continue
legacy = facility == "revetment" # 독립 기슭막이 — 벽 칸 밖의 제원(뒷길이 등)도 벽이 씀
wall_defaults = defaults(facility)
roles = list(REVET_ROLES)
inlet_notes: list[str] = []
if not legacy:
parent = {**base, "type_id": "pipe"}
children = [r for r in attachments_of(parent) if r["type_id"] != "pipe_inlet_basin"]
inlet = str(options.get("inlet_type") or wall_defaults.get("inlet_type") or "")
if inlet == INLET_BASIN:
# 형식을 안 골랐어도 줄은 세움 — 관측표가 「형식을 골라야 섬」 사유를 냄.
children.append(child_row(parent, "pipe_inlet_basin", "유입부 집수정"))
roles = roles[1:]
elif options.get("inlet_basin_form"):
inlet_notes.append(
f"⚠ 집수정 형식({options['inlet_basin_form']})이 적혀 있으나 유입구 구조가 "
f"「{inlet}」라 집수정은 안 셈 — 유입구를 「집수정」으로 바꾸면 섬"
)
rows.extend(children)
walls = []
min_height = (
None if legacy else pipe_wall_min_height_m(_pipe_diameter_m(options, wall_defaults))
)
for role, pipe_label, own_label in roles:
values, filled = _revet_values(options, role, wall_defaults, legacy, min_height)
notes = list(inlet_notes) if role == "inlet" else []
if filled:
tail = "" if legacy else NOTE_WALL_HEIGHT_PIPE
notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled)) + tail)
if legacy:
# 독립 기슭막이 — 치수 한 벌 근거(사방 교본 기준 · 쪽·줄 번호)를 늘 붙임.
basis = own_revetment_basis()
if basis:
notes.append(basis)
walls.append([role, own_label if legacy else pipe_label, values, notes, False, filled])
side = str(options.get("side") or "") if legacy else ""
if side in ("좌", "우"):
# 좌·우가 유입/유출 어느 칸인지는 **횡단 지형**이 정함 — 두 칸이 같을 때만 한 벽으로 셈.
same = walls[0][2] == walls[1][2]
walls = [[walls[0][0], f"{side} 벽", walls[0][2], walls[0][3], not same, walls[0][5]]]
if not same:
walls[0][3].append(NOTE_SIDE_UNKNOWN.format(side=side))
foundation = options.get("foundation" if legacy else "revet_foundation")
kept = {k: v for k, v in options.items() if not k.startswith(("inlet_", "outlet_"))}
station_walls = _extra_walls_at(extra_walls, chainage)
base_overlap = {
str(entry["base"]): _num_or_zero(entry.get("fill_overlap_m2"))
for entry in station_walls
if entry.get("base")
}
for role, label, values, notes, withheld, filled in walls:
before, after = _num_or_zero(values["before_m"]), _num_or_zero(values["after_m"])
row = child_row(base, "revetment", label, f"{role}_revet")
reasons = [UNCONFIRMED_WALL.format(filled=" · ".join(filled))] if filled else []
if legacy and _blank(values["height_m"]):
# 높이는 설계자 입력(계획홍수위 + 여유고) — 없으면 줄만 서고 금액 밖.
reasons.append(own_height_unconfirmed())
# 좌·우 한 벽으로 합친 독립 기슭막이는 그 측점에 선 벽 몸 전부.
overlap = (
sum(base_overlap.values())
if legacy and len(walls) == 1
else base_overlap.get(role, 0.0)
)
if overlap > 0 and not reasons: # 미확정 벽은 금액에 안 듦 — 두 번 셀 금액이 없음
notes.append(
NOTE_FILL_OVERLAP.format(
area=overlap, length=before + after, volume=overlap * (before + after)
)
)
row.update(
start_m=chainage - before,
end_m=chainage + after,
options={**(kept if legacy else {}), **values, "foundation": foundation},
notes=notes,
withheld=withheld,
unconfirmed=" · ".join(reasons),
)
rows.append(row)
# 다단 — 기준벽의 기초·(독립이면) 제원 칸을 물려받고, 형태·높이·구간은 선 벽 목록 값.
parents = {role: (values, filled) for role, _label, values, _notes, _w, filled in walls}
counts = {"outlet": 0, "basin": 0}
first_unset: dict[str, int] = {}
for tier in station_walls:
if tier.get("base"):
continue
side = "basin" if str(tier.get("side")) == "basin" else "outlet"
counts[side] += 1
if not tier.get("height_set"):
first_unset.setdefault(side, counts[side])
parent_values, parent_filled = parents.get(
EXTRA_SIDE_ROLES[side], next(iter(parents.values()), ({}, []))
)
height = _num_or_zero(tier.get("height_m"))
form = str(tier.get("form") or parent_values.get("form") or "")
before, after = _num_or_zero(tier.get("before_m")), _num_or_zero(tier.get("after_m"))
reasons = []
if not tier.get("height_set"):
reasons.append(UNCONFIRMED_EXTRA_HEIGHT.format(height=f"{height:g}"))
elif first_unset.get(side, counts[side]) < counts[side]:
reasons.append(UNCONFIRMED_EXTRA_ABOVE.format(above=first_unset[side]))
if not tier.get("form_set") and any(str(f).startswith("형태") for f in parent_filled):
reasons.append(UNCONFIRMED_EXTRA_FORM.format(form=form))
label = f"{EXTRA_SIDE_LABELS[side]} 다단 기슭막이 {counts[side]}단"
row = child_row(base, "revetment", label, str(tier.get("key") or label))
tier_notes = [
f"ⓘ 횡단도에 선 다단 벽 — 높이 {height:g}m · 형태 {form} · 전 {before:g}/후 {after:g}m"
]
overlap = _num_or_zero(tier.get("fill_overlap_m2"))
if overlap > 0 and not reasons: # 미확정 단은 면적에도 금액에도 안 듦
tier_notes.append(
NOTE_FILL_OVERLAP.format(
area=overlap, length=before + after, volume=overlap * (before + after)
)
)
row.update(
start_m=chainage - before,
end_m=chainage + after,
options={
**(kept if legacy else {}),
"form": form,
"height_m": height,
"length_m": before + after,
"before_m": before,
"after_m": after,
"foundation": foundation,
},
notes=tier_notes,
withheld=False,
unconfirmed=" · ".join(reasons),
)
rows.append(row)
return rows