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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user