feat(B05): 구조물 컨테이너 병합 1단계 — 계곡 통과 시설·구간 기준점·phase 분리

PLAN 2026-08-17 「B05 구조물 컨테이너 병합」 백엔드. 배관 정본은 유역 계산의
입력이라 저장소를 옮기지 않고 제자리 확장한다.

- PipePoint: facility(배관/BOX암거/물넘이/세월교)·start_m/end_m(기준점 앞뒤
  구간)·options(세월교 관 종류/크기/수량) 추가. 구 파일은 배관·폭 미지정으로
  읽히고 기본값은 저장 시 생략된다(하위 호환). 교량은 임도용이 아니라 없다.
- carry_facility_attributes: 세부유역 계산기는 chainage만 다뤄 재구성 목록이
  전부 기본 배관이 된다 — 원본에서 종류·구간·옵션을 되붙인다. B04 basins
  라우터의 재구성 지점에 적용하고 응답·GeoJSON에도 시설 정보를 싣는다.
- StructureInstance: 구간형 chainage_m = 기준점(마킹 위치) 허용, 시작≤기준≤종료
  검증, 미지정 시 시점으로 채움(기존 저장분 호환). anchor_m = 기준점.
- 레지스트리: 옵션 phase 필드(b05/detail) 신설. required 32건을 detail로 이동
  — 필수 원칙은 유지하고 강제 시점만 B06/B07로 미룬다(B05 = 유무·종류·위치
  단계, 2026-08-17 사용자 확정). BOX암거·물넘이·세월교(표시용, managed_by=
  pipe_points)와 A6 노출형 횡단수로·A7 개거(수동) 5종 추가, 총 37종.
- Repository: phase=detail 옵션은 B05 저장에서 필수 강제 제외. b05 필수는
  기존대로 강제.

tmp/tests 88건 통과 (신규: pipe facility 16·span anchor 10·registry 정책 재편).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 08:54:47 +09:00
co-authored by Claude Opus 5
parent 9a5496c47f
commit 1d07633260
5 changed files with 299 additions and 49 deletions
+11 -9
View File
@@ -28,6 +28,7 @@ from common_util.common_util_drainage_detail import DrainageDetail, build_detail
from common_util.common_util_drainage_pipes import (
PIPE_SOURCE_USER,
PipePoint,
carry_facility_attributes,
clear_pipe_points,
load_pipe_points,
parse_pipe_points,
@@ -60,9 +61,10 @@ def _build(
return None, []
if points:
_retag(detail.pipes, points)
return detail, [
PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes
]
rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes]
# 계산기는 chainage만 다뤄 시설 종류·구간·옵션이 사라진다 — 원본에서 되붙인다
# (2026-08-17 계곡 통과 시설: 배관/BOX암거/물넘이/세월교).
return detail, carry_facility_attributes(rebuilt, points or [])
def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
@@ -107,13 +109,15 @@ def _payload(
"flow_arrows": detail.flow_arrows,
"arrow_spacing_m": detail.arrow_spacing_m,
"upstream_lonlat": detail.upstream_lonlat,
# 시설 종류·구간·옵션까지 함께 싣는다(as_dict — 기본 배관·폭 0은 필드 생략).
"pipe_points": [
{
**point.as_dict(),
"chainage_m": round(pipe.chainage_m, 2),
"lonlat": list(to_lonlat(pipe.x, pipe.y)),
"source": pipe.reason,
}
for pipe in detail.pipes
for pipe, point in zip(detail.pipes, points)
],
"basins": [
{
@@ -172,11 +176,9 @@ def _basin_features(
features.append(
{
"type": "Feature",
"properties": {
"kind": "pipe",
"chainage_m": round(point.chainage_m, 2),
"source": point.source,
},
# 시설 종류·구간·옵션도 함께 남긴다(as_dict — 기본값 생략) — GeoJSON만
# 보고도 세월교인지 배관인지 알 수 있어야 한다.
"properties": {"kind": "pipe", **point.as_dict()},
"geometry": {"type": "Point", "coordinates": list(to_lonlat(pipe.x, pipe.y))},
}
)
+191 -33
View File
@@ -21,6 +21,132 @@
],
"options": []
},
{
"type_id": "box_culvert",
"group": "A",
"name": "BOX암거",
"placement": "point",
"managed_by": "pipe_points",
"style": {
"color": "#3b6fd4",
"abbr": "암"
},
"drawing_views": [
"plan",
"profile",
"cross_section",
"detail",
"quantity"
],
"options": []
},
{
"type_id": "ford_pavement",
"group": "A",
"name": "물넘이포장",
"placement": "point",
"managed_by": "pipe_points",
"style": {
"color": "#2f9e8f",
"abbr": "물"
},
"drawing_views": [
"plan",
"profile",
"cross_section",
"detail",
"quantity"
],
"options": []
},
{
"type_id": "ford_bridge",
"group": "A",
"name": "세월교",
"placement": "point",
"managed_by": "pipe_points",
"style": {
"color": "#28857a",
"abbr": "세"
},
"drawing_views": [
"plan",
"profile",
"cross_section",
"detail",
"quantity"
],
"options": [
{
"key": "pipe_kind",
"label": "구체 내 배관 종류",
"input": "select",
"choices": [
"흄관",
"VR관",
"파형강관"
],
"default": null,
"required": false
},
{
"key": "pipe_diameter_mm",
"label": "배관 크기",
"input": "select",
"choices": [
"800",
"1000",
"1200",
"1500"
],
"unit": "mm",
"default": null,
"required": false
},
{
"key": "pipe_count",
"label": "배관 수량",
"input": "number",
"unit": "련",
"default": null,
"required": false
}
]
},
{
"type_id": "cross_drain_exposed",
"group": "A",
"name": "노출형 횡단수로",
"placement": "point",
"style": {
"color": "#5aa7e8",
"abbr": "횡"
},
"drawing_views": [
"plan",
"profile",
"cross_section",
"quantity"
],
"options": []
},
{
"type_id": "open_ditch",
"group": "A",
"name": "개거(겉도랑)",
"placement": "point",
"style": {
"color": "#6d9bd1",
"abbr": "개"
},
"drawing_views": [
"plan",
"profile",
"cross_section",
"quantity"
],
"options": []
},
{
"type_id": "ditch_side",
"group": "B",
@@ -47,7 +173,8 @@
"J형"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
},
{
"key": "depth_cm",
@@ -83,7 +210,8 @@
"떼"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -130,7 +258,8 @@
"반원관"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
},
{
"key": "section",
@@ -142,7 +271,8 @@
"사다리꼴"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -169,7 +299,8 @@
"돌"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -223,7 +354,8 @@
"부벽식"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
},
{
"key": "height_m",
@@ -231,7 +363,8 @@
"input": "number",
"unit": "m",
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -256,7 +389,8 @@
"input": "number",
"unit": "m",
"default": null,
"required": true
"required": true,
"phase": "detail"
},
{
"key": "back_len_cm",
@@ -272,7 +406,8 @@
"75"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -297,7 +432,8 @@
"input": "number",
"unit": "m",
"default": null,
"required": true
"required": true,
"phase": "detail"
},
{
"key": "back_len_cm",
@@ -313,7 +449,8 @@
"75"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -346,7 +483,8 @@
"통나무쌓기"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
},
{
"key": "height_m",
@@ -354,7 +492,8 @@
"input": "number",
"unit": "m",
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -379,7 +518,8 @@
"input": "number",
"unit": "m",
"default": null,
"required": true
"required": true,
"phase": "detail"
},
{
"key": "stone_cm",
@@ -391,7 +531,8 @@
"80~100"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -424,7 +565,8 @@
"흙"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
},
{
"key": "length_m",
@@ -464,7 +606,8 @@
"input": "number",
"unit": "m",
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -489,7 +632,8 @@
"input": "number",
"unit": "m",
"default": null,
"required": true
"required": true,
"phase": "detail"
},
{
"key": "apron",
@@ -500,7 +644,8 @@
"없음"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -533,7 +678,8 @@
"바자"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
},
{
"key": "height_m",
@@ -541,7 +687,8 @@
"input": "number",
"unit": "m",
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -566,7 +713,8 @@
"input": "number",
"unit": "m",
"default": null,
"required": true
"required": true,
"phase": "detail"
},
{
"key": "length_m",
@@ -654,7 +802,8 @@
"양면"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -683,7 +832,8 @@
"위험표지"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -751,7 +901,8 @@
"input": "number",
"unit": "㎡",
"default": null,
"required": true
"required": true,
"phase": "detail"
},
{
"key": "capacity_m3",
@@ -759,7 +910,8 @@
"input": "number",
"unit": "㎥",
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -783,7 +935,8 @@
"input": "number",
"unit": "㎡",
"default": null,
"required": true
"required": true,
"phase": "detail"
},
{
"key": "capacity_m3",
@@ -791,7 +944,8 @@
"input": "number",
"unit": "㎥",
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -818,7 +972,8 @@
"자연형계단"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -878,7 +1033,8 @@
"나무심기"
],
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -903,7 +1059,8 @@
"input": "number",
"unit": "cm",
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -927,7 +1084,8 @@
"input": "number",
"unit": "cm",
"default": null,
"required": true
"required": true,
"phase": "detail"
}
]
},
@@ -953,4 +1111,4 @@
]
}
]
}
}
@@ -162,6 +162,10 @@ def _validate_options(item: StructureInstance, definition) -> None:
elif option.input == "select" and option.choices and str(value) not in option.choices:
raise ValueError(f"{definition.name}{option.label} 값이 선택지에 없습니다: {value}")
for option in definition.options:
# phase=detail은 B06/B07 상세 단계 입력 — B05 저장(유무·종류·위치)에서는
# 강제하지 않는다. 필수 원칙은 유지되고 시점만 미뤄진다(2026-08-17 사용자 확정).
if option.phase == "detail":
continue
if option.required and item.options.get(option.key) in (None, ""):
raise ValueError(
f"{definition.name}{option.label}은(는) 필수 입력입니다 — "
+13 -4
View File
@@ -40,6 +40,10 @@ class StructureOptionField(BaseModel):
# 미확정 수치(기본값 없음)는 사용자가 직접 넣어야 저장된다 — 지식DB 원칙:
# 기본값 선정은 사용자 협의 영역, 임의값 자동 저장 금지(2026-08-16 크로스체크 반영).
required: bool = False
# 입력 시점 — B05는 유무·종류·위치만 고르고 상세 치수는 B06/B07에서 받는다
# (2026-08-17 사용자 확정). `detail`이면 required여도 B05 저장에서 강제하지 않는다
# — 필수 원칙은 유지되고 강제 시점만 B06/B07로 미뤄진다.
phase: Literal["b05", "detail"] = "b05"
class StructureType(BaseModel):
@@ -111,8 +115,12 @@ class StructureInstance(BaseModel):
raise ValueError("구간형 구조물은 start_m과 end_m이 모두 필요합니다.")
if self.end_m <= self.start_m:
raise ValueError("구간형 구조물의 end_m은 start_m보다 커야 합니다.")
if self.chainage_m is not None:
raise ValueError("구간형 구조물에는 chainage_m을 함께 보낼 수 없습니다.")
# 구간형 chainage_m = 기준점(마킹 위치). 기존 저장분에는 없으므로 시점으로
# 채운다 (2026-08-17 사용자 확정: 기준점에 마킹 + 시작·종료 측점).
if self.chainage_m is None:
self.chainage_m = self.start_m
elif not (self.start_m <= self.chainage_m <= self.end_m):
raise ValueError("구간형 구조물의 기준점은 시작~종료 측점 안에 있어야 합니다.")
else:
if self.chainage_m is None:
raise ValueError("점형·부지형 구조물은 chainage_m이 필요합니다.")
@@ -121,8 +129,9 @@ class StructureInstance(BaseModel):
return self
def anchor_m(self) -> float:
"""종단도 서클마크 위치 — 구간형은 기점(시점)에 찍는다(2026-08-16 사용자 확정)."""
return self.start_m if self.placement == "interval" else self.chainage_m # type: ignore[return-value]
"""종단도 서클마크 위치 = 기준점. 구간형도 chainage_m이 기준점이다
(2026-08-17 사용자 확정 — validator가 미지정 시 시점으로 채워 둔다)."""
return self.chainage_m # type: ignore[return-value]
class StructureTypesResponse(BaseModel):
+80 -3
View File
@@ -36,16 +36,53 @@ PIPE_SOURCE_SPACING = "spacing" # 자동 보충 — 관 최대 간격 규칙
PIPE_SOURCE_USER = "user" # 수동 — 사용자가 우클릭으로 추가하거나 옮긴 관
_KNOWN_SOURCES = (PIPE_SOURCE_STREAM, PIPE_SOURCE_SPACING, PIPE_SOURCE_USER)
# 계곡 통과 시설 종류 (2026-08-17 컨테이너 병합). 같은 계곡 교차 지점에서 유량·지형에
# 따라 택일하는 관계라 별도 정본을 만들지 않고 관 지점에 종류만 얹는다 — 유역 계산은
# 기준점(chainage_m)만 읽으므로 어느 종류든 계산이 같다. 교량은 임도용이 아니라 없다
# (2026-08-17 사용자 확정).
PIPE_FACILITY_PIPE = "pipe" # 배관(횡단배수관) — 기본
PIPE_FACILITY_BOX = "box_culvert" # BOX암거
PIPE_FACILITY_FORD_PAVEMENT = "ford_pavement" # 물넘이포장
PIPE_FACILITY_FORD_BRIDGE = "ford_bridge" # 세월교
_KNOWN_FACILITIES = (
PIPE_FACILITY_PIPE,
PIPE_FACILITY_BOX,
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_FORD_BRIDGE,
)
@dataclass
class PipePoint:
"""계획선 위 관 매설 지점 한 개."""
"""계획선 위 계곡 통과 시설 한 개 (배관·BOX암거·물넘이·세월교).
`chainage_m`가 기준점(계곡 교차, 종단 마킹 위치)이고, `start_m`/`end_m`는 유입·유출
부속이 차지하는 앞뒤 구간이다. 구간 미지정(None)은 폭 0 — 자동 배치분의 기본값이며
사용자가 필요할 때 벌린다. 상세 치수는 B06/B07 몫이라 여기에는 유무·종류 수준의
`options`(예: 세월교 관 종류/크기/수량)만 둔다 (2026-08-17 사용자 확정).
"""
chainage_m: float
source: str = PIPE_SOURCE_USER
facility: str = PIPE_FACILITY_PIPE
start_m: float | None = None
end_m: float | None = None
options: dict[str, Any] | None = None
def as_dict(self) -> dict[str, Any]:
return {"chainage_m": round(float(self.chainage_m), 2), "source": self.source}
# 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다.
payload: dict[str, Any] = {
"chainage_m": round(float(self.chainage_m), 2),
"source": self.source,
}
if self.facility != PIPE_FACILITY_PIPE:
payload["facility"] = self.facility
if self.start_m is not None and self.end_m is not None:
payload["start_m"] = round(float(self.start_m), 2)
payload["end_m"] = round(float(self.end_m), 2)
if self.options:
payload["options"] = self.options
return payload
def edits_dir(stored_path: str) -> Path:
@@ -90,8 +127,19 @@ def load_pipe_points(stored_path: str, signature: str) -> list[PipePoint] | None
return parse_pipe_points(document.get("points"))
def _parse_span(item: dict[str, Any], chainage: float) -> tuple[float | None, float | None]:
"""시작·종료 구간을 정규화한다 — 기준점을 항상 품고, 뒤집힘은 바로잡는다."""
raw_start, raw_end = item.get("start_m"), item.get("end_m")
start = float(raw_start) if isinstance(raw_start, (int, float)) else None
end = float(raw_end) if isinstance(raw_end, (int, float)) else None
if start is None and end is None:
return None, None
values = [value for value in (start, end) if value is not None] + [chainage]
return min(values), max(values)
def parse_pipe_points(values: Any) -> list[PipePoint]:
"""외부에서 들어온 목록(파일·요청 본문)을 정리한다. 누가거리 오름차순."""
"""외부에서 들어온 시설 목록(파일·요청 본문)을 정리한다. 누가거리 오름차순."""
if not isinstance(values, list):
return []
points: list[PipePoint] = []
@@ -105,16 +153,45 @@ def parse_pipe_points(values: Any) -> list[PipePoint]:
if not isinstance(chainage, (int, float)):
continue
source = str(item.get("source") or PIPE_SOURCE_USER)
facility = str(item.get("facility") or PIPE_FACILITY_PIPE)
start, end = _parse_span(item, float(chainage))
options = item.get("options")
points.append(
PipePoint(
chainage_m=float(chainage),
source=source if source in _KNOWN_SOURCES else PIPE_SOURCE_USER,
facility=facility if facility in _KNOWN_FACILITIES else PIPE_FACILITY_PIPE,
start_m=start,
end_m=end,
options=dict(options) if isinstance(options, dict) and options else None,
)
)
points.sort(key=lambda point: point.chainage_m)
return points
def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint]) -> list[PipePoint]:
"""계산기를 거쳐 재구성된 목록에 시설 종류·구간·옵션을 되붙인다.
세부유역 계산기는 chainage만 다루므로 계산에서 돌아온 목록은 전부 기본 배관이 된다.
그대로 저장하면 사용자가 고른 세월교·BOX암거가 배관으로 되돌아간다. 좌표가 미세
조정(스냅)될 수 있어 정확 일치가 없으면 가장 가까운 원본에서 승계한다 — 생성 사유를
되붙이는 `_retag`(B04 라우터)과 같은 기준이다.
"""
if not reference:
return base
by_key = {round(ref.chainage_m, 2): ref for ref in reference}
for point in base:
ref = by_key.get(round(point.chainage_m, 2))
if ref is None:
ref = min(reference, key=lambda item: abs(item.chainage_m - point.chainage_m))
point.facility = ref.facility
point.start_m = ref.start_m
point.end_m = ref.end_m
point.options = ref.options
return base
def save_pipe_points(stored_path: str, signature: str, points: list[PipePoint]) -> int:
"""관 지점을 정본 파일에 쓴다. 저장된 개수를 돌려준다."""
path = pipe_points_path(stored_path)