Files
Aislo/common_util/common_util_drainage_pipes.py
T
eomsangdonandClaude Opus 5 e2d329d35f fix(배수유역): 지문이 달라도 같은 노선이면 관 지점을 그대로 씀 (계획서 0-7 곁가지 ③)
같은 노선인데 지문이 늘 어긋나 관 지점이 매번 투영 이월을 탔음.

까닭 — 같은 노선을 두 파일이 다른 자릿수로 담고 있음.
`planned_route.csv` 는 소수 4자리(`208403.2001`), `route_main.geojson` 은 소수 3자리
(`208403.2`)로 csv 를 mm 반올림한 사본임. 좌표 차는 최대 0.5mm 뿐인데, 지문이
`f"{x:.2f}"` 로 0.01m 자리에서 끊는 탓에 그 0.5mm 가 `.xx5` 경계를 넘는 정점마다
글자가 바뀜 — 169개 중 16개가 그랬음.

경계에서 자르는 방식은 저장 자릿수가 또 바뀌면 다시 흔들리므로, 글자 일치 대신
**허용오차**로 가름. 저장된 관을 지금 노선에 투영해 **재 보기만** 하고
(`max_projection_shift`, 값은 안 고침), 최대 어긋남이 0.05m 이하면 같은 노선으로 보고
저장분을 그대로 돌려줌.

허용오차 0.05m 근거 — 실측 어긋남이 최대 0.0053m 이라 다섯 배 이상 여유이고,
사람이 노선을 실제로 고치면 관은 m 단위로 밀리므로 「같다」로 볼 위험이 없음.

투영 이월 가지는 그대로 둠 — 대신 그 가지가 실제로 돌면 WARNING 을 찍게 함.
한동안 0 인 것을 확인한 뒤에야 지울 수 있음(먼저 지우면 관이 통째로 사라짐).

자체검증
- 새 시험 3건 `tmp/tests/test_pipe_route_tolerance.py` — mm 반올림 사본은 같은 노선으로
  판정되고 누가거리가 한 값도 안 움직임 / 중간을 3m 민 노선은 안 걸림 / 허용오차 범위.
- 기존 `test_pipe_point_projection.py` 2건 그대로 통과(±0.7m 잔물결 노선은 여전히 투영).
- 저장된 실제 3개 프로젝트 전후 대조 — 셋 다 「다름 → 투영」이 「같음 → 저장분 그대로」로
  바뀜. 화면 값 변화는 최대 0.0053m · 0.0009m · 0.0005m.
- 전체 514 passed · 18 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 11:54:23 +09:00

529 lines
25 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.
"""관 매설 지점 정본 저장소 (B04 관리자 화면 · B05 사용자 화면 공용).
해석 산출물(`01~03`)은 다시 돌리면 덮어써도 되지만, 사용자가 찍고 옮긴 관은 그러면 안 된다.
그래서 편집분만 `{배수유역 폴더}/edits/pipe_points.json`에 따로 남기고 두 화면이 같은 파일을
읽고 쓴다 — 관리자 화면에서 옮긴 관이 사용자 화면에서 다르게 보이면 안 되기 때문이다
(2026-08-01 사용자 지시).
위치는 **누가거리(chainage_m) + 좌표(x, y)** 로 저장한다. 지면 필터나 지표면 모델을 바꾸면
종단 Z가 달라지지만 관이 놓인 자리는 그대로여야 하고, 그때는 세부유역만 다시 나누면 된다.
좌표를 같이 남기는 이유(2026-08-30 사용자 지적 — "결국 노선 위에 위치해야 한다"): 관 자리를
정한 선(계획노선 정본)과 화면에 그려지는 선(B05 최적 경로)은 **같은 자리를 지나면서 연장이
다르다**(실측 350.11m vs 354.83m). 누가거리만 남기면 읽는 쪽이 쥔 선에 따라 같은 값이 3~4m
미끄러져 관이 선 옆에 떨어진 것처럼 보인다. 좌표를 남겨 두면 어느 선으로 읽든 그 좌표를
투영해 **항상 선 위에** 앉힐 수 있다.
노선이 바뀌면(`route_signature` 불일치) 좌표가 있는 저장분은 새 노선에 투영해 이월하고,
좌표가 없는 구 저장분만 버린다.
"""
from __future__ import annotations
import hashlib
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from shapely.geometry import LineString, Point
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
from common_util.common_util_json import atomic_write_json
from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex
from config.config_system import (
DRAINAGE_CACHE_DIRNAME,
DRAINAGE_DETAIL_FILENAME,
DRAINAGE_EDITS_DIRNAME,
DRAINAGE_PIPE_POINTS_FILENAME,
)
logger = logging.getLogger(__name__)
# 관이 그 자리에 있는 이유. 화면 마커 모양과 "자동/수동" 구분이 여기에 달려 있다.
PIPE_SOURCE_STREAM = "stream" # 기본 관 — 도로 × 상류 세류선 교차점
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" # 세월교
# 독립 기슭막이(2026-08-28 사용자) — 배관 없이 성토 사면에 세우는 벽. 배관 세트 경로를
# 그대로 태우되 관을 숨긴다(hidden_pipe). 수량은 관 정보를 빼고 벽만 센다.
PIPE_FACILITY_REVET = "revetment" # 독립 기슭막이(관 숨김)
_KNOWN_FACILITIES = (
PIPE_FACILITY_PIPE,
PIPE_FACILITY_BOX,
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_FORD_BRIDGE,
PIPE_FACILITY_REVET,
)
@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
# 관이 실제로 놓인 자리(사업지 CRS, m). 노선이 바뀌어도 이 자리는 그대로다.
x: float | None = None
y: float | None = None
def as_dict(self) -> dict[str, Any]:
# 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다.
payload: dict[str, Any] = {
# 누가거리는 좌표(x·y)와 **같은 밀리미터 기준**으로 남긴다. cm 로 반올림하던
# 옛 규칙은 종단 정본 변화점(3자리)과 최대 5mm 어긋났고, 그 값으로 계획고를
# 편집하면 정본 옆 mm 자리에 가짜 변화점이 하나 더 서서 종단곡선이 mm 로
# 쭈그러들었다 — 그 구간 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고).
"chainage_m": round(float(self.chainage_m), 3),
"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
if self.x is not None and self.y is not None:
payload["x"] = round(float(self.x), 3)
payload["y"] = round(float(self.y), 3)
return payload
def edits_dir(stored_path: str) -> Path:
return drainage_dir(stored_path) / DRAINAGE_EDITS_DIRNAME
def pipe_points_path(stored_path: str) -> Path:
return edits_dir(stored_path) / DRAINAGE_PIPE_POINTS_FILENAME
def pipe_points_path_in(project_root: Path) -> Path:
"""`pipe_points_path`와 같은 자리를 **프로젝트 실경로**로 가리킨다.
B05는 스토리지 상대경로가 아니라 실경로를 쥐고 있어 `resolve_stored_project_path`를
다시 태울 수 없다(절대경로를 거절한다).
"""
return (
project_root
/ "B04_PreProcess"
/ DRAINAGE_CACHE_DIRNAME
/ DRAINAGE_EDITS_DIRNAME
/ DRAINAGE_PIPE_POINTS_FILENAME
)
def detail_basins_path(stored_path: str) -> Path:
return drainage_dir(stored_path) / DRAINAGE_DETAIL_FILENAME
def route_signature(vertices: list[RouteVertex]) -> str:
"""노선이 바뀌었는지 판별할 지문. 정점 좌표를 0.01m로 끊어 해시한다.
연장만 보면 노선이 통째로 옮겨져도 같은 값이 나온다. 좌표를 다 넣되 소수점을 끊어
부동소수 잡음으로 지문이 흔들리지 않게 한다.
"""
digest = hashlib.sha1(usedforsecurity=False)
for vertex in vertices:
digest.update(f"{vertex.x:.2f},{vertex.y:.2f};".encode())
return f"{len(vertices)}-{digest.hexdigest()[:16]}"
def fill_pipe_coordinates(points: list[PipePoint], vertices: list[RouteVertex]) -> list[PipePoint]:
"""좌표가 비어 있는 관에 그 누가거리의 노선 좌표를 채운다(제자리 수정)."""
if not vertices:
return points
for point in points:
if point.x is None or point.y is None:
x, y, _ = interpolate_vertex(vertices, float(point.chainage_m))
point.x, point.y = float(x), float(y)
return points
# 같은 노선으로 볼 누가거리 어긋남의 한계(m).
#
# 지문(`route_signature`)은 좌표를 **0.01m 자리에서 끊어** 해시한다. 그런데 같은 노선이
# `planned_route.csv`(소수 4자리)와 `route_main.geojson`(소수 3자리, csv 를 mm 로 반올림한
# 사본)로 **0.5mm 다르게** 저장돼 있어, 그 0.5mm 가 `.xx5` 경계를 넘는 정점마다 글자가
# 바뀐다(2026-09-07 실측: 169개 중 **16개**). 노선을 손댄 적이 없는데도 지문이 늘 달랐다.
#
# 경계에서 자르는 방식은 저장 자릿수가 또 바뀌면 다시 흔들리므로 **글자 일치 대신
# 허용오차**로 가른다. 값은 0.05m — 위 어긋남이 관 누가거리에 미치는 양이 실측
# **최대 0.01m** 이라 다섯 배 여유를 두었고, 사람이 노선을 실제로 고치면 관은 **m 단위**로
# 밀리므로 그것을 「같다」로 볼 위험은 없다.
ROUTE_MATCH_TOLERANCE_M = 0.05
def max_projection_shift(points: list[PipePoint], vertices: list[RouteVertex]) -> float | None:
"""저장된 관을 이 노선에 투영하면 누가거리가 최대 얼마나 움직이나 (**고치지 않고 잰다**).
좌표가 없는 관이 하나라도 있으면 잴 수 없어 None.
"""
if not vertices or not points:
return None
if any(point.x is None or point.y is None for point in points):
return None
line = LineString([(vertex.x, vertex.y) for vertex in vertices])
if line.length <= 0:
return None
return max(
abs(float(line.project(Point(point.x, point.y))) - float(point.chainage_m))
for point in points
)
def project_pipe_points(points: list[PipePoint], vertices: list[RouteVertex]) -> list[PipePoint]:
"""저장된 좌표를 주어진 노선에 투영해 누가거리를 다시 매긴다.
관이 놓인 **자리**는 좌표가 정본이고 누가거리는 그 자리를 읽는 선에 종속된 값이다.
앞뒤 구간(start_m·end_m)은 기준점이 옮겨간 만큼 같이 민다 — 구간 길이는 시설 치수라
노선이 바뀌어도 변하지 않는다.
"""
if not vertices:
return points
line = LineString([(vertex.x, vertex.y) for vertex in vertices])
if line.length <= 0:
return points
for point in points:
if point.x is None or point.y is None:
continue
moved = float(line.project(Point(point.x, point.y)))
shift = moved - float(point.chainage_m)
point.chainage_m = moved
if point.start_m is not None:
point.start_m = float(point.start_m) + shift
if point.end_m is not None:
point.end_m = float(point.end_m) + shift
points.sort(key=lambda item: item.chainage_m)
return points
def load_pipe_points(
stored_path: str, signature: str, vertices: list[RouteVertex] | None = None
) -> list[PipePoint] | None:
"""저장된 관 지점을 읽는다. 파일이 없거나 이월할 수 없으면 None(= 다시 만들어야 함).
노선 지문이 다르면 예전에는 전량 버렸다. 저장분에 좌표가 있으면 `vertices`(읽는 쪽이
쓰는 노선)에 투영해 이월한다 — 같은 자리를 지나면서 연장만 다른 선끼리 관이 통째로
사라지던 것을 막는다(2026-08-30 사용자 지적).
"""
return load_pipe_points_file(pipe_points_path(stored_path), signature, vertices)
def load_pipe_points_file(
path: Path, signature: str, vertices: list[RouteVertex] | None = None
) -> list[PipePoint] | None:
"""`load_pipe_points`와 같되 파일 경로로 직접 읽는다 (B05는 프로젝트 루트를 쥔다)."""
if not path.exists():
return None
try:
with path.open("r", encoding="utf-8") as file:
document = json.load(file)
except (OSError, json.JSONDecodeError):
logger.warning("배수유역: 관 지점 파일을 읽지 못했습니다 (%s).", path)
return None
points = parse_pipe_points(document.get("points"))
stored_signature = str(document.get("route_signature") or "")
if stored_signature == signature:
return points
# 지문이 다르다고 노선이 바뀐 것은 아니다 — 같은 노선을 두 파일이 0.5mm 다르게 담고
# 있어 글자가 늘 어긋난다(위 `ROUTE_MATCH_TOLERANCE_M` 주석). 관이 실제로 얼마나
# 밀리는지 **재 보고** 한계 안이면 저장분을 그대로 쓴다 — 건드리지 않는 것이 정답이다.
shift = max_projection_shift(points, vertices) if vertices else None
if shift is not None and shift <= ROUTE_MATCH_TOLERANCE_M:
logger.info(
"배수유역: 지문은 다르나 같은 노선입니다 — 관 %d건 그대로 씁니다 "
"(최대 어긋남 %.4fm ≤ %.2fm, %s).",
len(points),
shift,
ROUTE_MATCH_TOLERANCE_M,
path.name,
)
return points
if vertices and points and all(p.x is not None and p.y is not None for p in points):
# ⚠ 이 줄이 찍히면 **투영 이월이 실제로 돈 것**이다. 한동안 0 인 것을 확인한 뒤에야
# 이 가지를 지울 수 있다(계획서 0-7 — 먼저 지우면 관이 통째로 사라진다).
logger.warning(
"배수유역: 투영 이월 실행 — 노선이 바뀌어 관 지점 %d건을 좌표로 옮깁니다 "
"(최대 어긋남 %s, %s).",
len(points),
f"{shift:.3f}m" if shift is not None else "잴 수 없음",
path.name,
)
return project_pipe_points(points, vertices)
logger.info("배수유역: 노선이 바뀌어 저장된 관 지점을 버립니다 (%s).", path.name)
return None
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)
# 보호공 부위별 기본값 (2026-08-17 사용자 확정) — 구 "없음" 저장분을 끌어올릴 때 쓴다.
_PROTECTION_FALLBACK = {"inlet": "돌붙임(찰)", "outlet": "돌붙임(메)"}
def _migrate_protection(options: dict[str, Any]) -> dict[str, Any]:
"""구 저장분의 보호공 키를 새 한 축으로 옮긴다 (2026-08-17 보호공 개편).
개편 전에는 `*_pitching`(있음/없음)과 `*_pitching_finish`(찰/메) 두 축이었다.
읽는 순간 `*_protection`(돌붙임(찰)/돌붙임(메)/도수로) 한 축으로 바꿔 두면 화면도
수량도 옛 키를 알 필요가 없다. "없음"은 선택지가 사라졌으므로 부위 기본값으로
올린다 — 물이 흐르는 자리라 보호공은 반드시 있다(사용자 확정).
"""
for side, fallback in _PROTECTION_FALLBACK.items():
legacy = options.pop(f"{side}_pitching", None)
finish = options.pop(f"{side}_pitching_finish", None)
area = options.pop(f"{side}_pitching_area_m2", None)
if legacy is None and finish is None and area is None:
continue
if f"{side}_protection" not in options:
options[f"{side}_protection"] = (
f"돌붙임({finish})" if legacy == "있음" and finish in ("찰", "메") else fallback
)
if area is not None and f"{side}_protection_area_m2" not in options:
options[f"{side}_protection_area_m2"] = area
return options
def parse_pipe_points(values: Any) -> list[PipePoint]:
"""외부에서 들어온 시설 목록(파일·요청 본문)을 정리한다. 누가거리 오름차순."""
if not isinstance(values, list):
return []
points: list[PipePoint] = []
for item in values:
if isinstance(item, (int, float)):
points.append(PipePoint(chainage_m=float(item)))
continue
if not isinstance(item, dict):
continue
chainage = item.get("chainage_m")
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")
raw_x, raw_y = item.get("x"), item.get("y")
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,
x=float(raw_x) if isinstance(raw_x, (int, float)) else None,
y=float(raw_y) if isinstance(raw_y, (int, float)) else None,
options=(
_migrate_protection(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],
vertices: list[RouteVertex] | None = None,
) -> int:
"""관 지점을 정본 파일에 쓴다. 저장된 개수를 돌려준다.
`vertices`를 주면 좌표가 빈 관을 그 노선 위 좌표로 채워 둔다 — 다음에 다른 선으로
읽어도 그 자리에 되놓을 수 있다.
"""
if vertices:
fill_pipe_coordinates(points, vertices)
path = pipe_points_path(stored_path)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(
path,
{
"route_signature": signature,
"points": [point.as_dict() for point in points],
},
)
logger.info("배수유역: 관 지점 %d개를 저장했습니다 (%s).", len(points), path.name)
return len(points)
def read_pipe_points_file(path: Path) -> list[PipePoint]:
"""저장분을 **노선 지문과 무관하게** 그대로 읽는다. 파일이 없거나 깨졌으면 빈 목록.
지문 대조는 "이 노선에 그려도 되는가"를 가리는 것이고, 여기서 알고 싶은 것은
"그 자리에 이미 시설이 있는가"다(이관 중복 방지). 두 물음이 달라 읽기도 다르다.
"""
if not path.is_file():
return []
try:
with path.open("r", encoding="utf-8") as file:
document = json.load(file)
except (OSError, json.JSONDecodeError):
logger.warning("배수유역: 관 지점 파일을 읽지 못했습니다 (%s).", path)
return []
return parse_pipe_points(document.get("points"))
def append_pipe_points_file(path: Path, points: list[PipePoint]) -> int | None:
"""관 지점 정본에 시설을 덧붙인다. 덧붙인 개수, 파일이 없으면 None.
저장 당시 노선 지문과 이미 있는 관은 그대로 둔다 — 여기서 지문을 새로 만들면
읽는 쪽이 노선이 바뀐 것으로 보고 저장분을 통째로 버린다(`load_pipe_points_file`).
지문을 알 수 없는 상황(파일 없음 = B04 배수유역 산출물 없음)에서는 쓰지 않고
None으로 알린다 — 지어낸 지문으로 쓰면 다음 읽기에서 사라진다.
"""
if not path.is_file():
return None
try:
with path.open("r", encoding="utf-8") as file:
document = json.load(file)
except (OSError, json.JSONDecodeError):
logger.warning("배수유역: 관 지점 파일을 읽지 못해 덧붙이지 못했습니다 (%s).", path)
return None
stored = list(document.get("points") or [])
atomic_write_json(
path,
{
"route_signature": str(document.get("route_signature") or ""),
"points": [*stored, *(point.as_dict() for point in points)],
},
)
logger.info("배수유역: 관 지점 %d개를 덧붙였습니다 (%s).", len(points), path.name)
return len(points)
def clear_pipe_points(stored_path: str) -> bool:
"""저장된 관 지점과 그 파생물을 지운다. 하나라도 지웠으면 True.
"초기화"는 화면만 되돌리는 것이 아니라 **저장분까지** 되돌린다 — 화면만 되돌리면 다시
들어왔을 때 옛 관이 살아나 사용자가 초기화한 적 없는 상태를 보게 된다
(2026-08-02 사용자 보고).
"""
removed = False
for path in (pipe_points_path(stored_path), detail_basins_path(stored_path)):
try:
path.unlink()
removed = True
except FileNotFoundError:
continue
except OSError:
logger.warning("배수유역: 저장분을 지우지 못했습니다 (%s).", path)
if removed:
logger.info("배수유역: 관 지점 저장분을 초기화했습니다 (%s).", stored_path)
return removed
def save_detail_basins(stored_path: str, features: list[dict[str, Any]], crs: str) -> Path:
"""세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다).
`crs`는 좌표를 WGS84로 바꿀 때 쓴 **사업지 좌표계**다. 되읽는 쪽(B07 유역도)이 같은
좌표계로 되돌려야 하는데, 예전에는 이 값을 안 남겨 노선 정본의 EPSG 라벨로 되돌렸다
(2026-09-01: 라벨과 실좌표계가 갈린 프로젝트에서 유역이 딴 자리로 갔다).
"""
path = detail_basins_path(stored_path)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"type": "FeatureCollection", "crs_input": crs, "features": features})
logger.info("배수유역: 세부유역 %d개를 저장했습니다 (%s).", len(features), path.name)
return path
# ── 횡단배수 최소 계획고 (2026-08-23 사용자 확정) ─────────────────────────────
# 계획 종단선의 변화점(PVI)은 배수 시설 자리다. 그 자리에서 계획고를 지반고와 같게
# 두면 시설이 들어갈 자리가 없다 — 시설 제원만큼 계획고를 들어 올려야 한다.
# 배수관 Ø1000 → 지반고 + 1.0(관경) + 0.5(토피) = +1.5
# BOX암거 2×2 → 지반고 + 2.0(구체 높이) + 0.5(토피) = +2.5
# 세월교 Ø1000 → 지반고 + 1.0(관경) + 0.5(토피) + 0.5(물넘이 몫) = +2.0
# 물넘이포장 → 도로에 그대로 앉히는 시설이라 요구 여유 없음 (2026-08-23 사용자)
# 세월교는 배관을 여러 개 묶어 다리 형태로 만든 것이라 배수관과 같은 산식을 쓰되,
# 그 위에 물넘이가 얹히므로 0.5m를 더 얹는다. 토피 0.5m는 B06 배수관
# 엔진(`MIN_PIPE_COVER_M`)과 같은 값이다.
MIN_PIPE_COVER_M = 0.5
FORD_BRIDGE_EXTRA_M = 0.5
DEFAULT_PIPE_DIAMETER_MM = 1000.0
DEFAULT_BOX_HEIGHT_M = 2.0
def _positive(value: Any, fallback: float) -> float:
try:
parsed = float(value)
except (TypeError, ValueError):
return fallback
return parsed if parsed > 0 else fallback
def facility_clearance_m(facility: str, options: dict[str, Any] | None) -> float:
"""시설이 요구하는 지반고 대비 최소 여유(m). 계획선·경고가 같이 쓰는 정본 산식."""
values = options or {}
if facility == PIPE_FACILITY_BOX:
return _positive(values.get("body_height_m"), DEFAULT_BOX_HEIGHT_M) + MIN_PIPE_COVER_M
if facility == PIPE_FACILITY_FORD_PAVEMENT:
# 물넘이포장은 도로 위에 그대로 만든다 — 들어 올릴 이유가 없다.
return 0.0
if facility == PIPE_FACILITY_REVET:
# 독립 기슭막이는 성토 사면에 세우는 벽 — 관이 없어 들어 올릴 여유가 필요 없다.
return 0.0
diameter_m = _positive(values.get("pipe_diameter_mm"), DEFAULT_PIPE_DIAMETER_MM) / 1000.0
extra = FORD_BRIDGE_EXTRA_M if facility == PIPE_FACILITY_FORD_BRIDGE else 0.0
return diameter_m + MIN_PIPE_COVER_M + extra
def pipe_anchor_clearances(points: list[PipePoint]) -> list[tuple[float, float]]:
"""계획선 변화점으로 쓸 (누가거리, 최소 여유) 목록. 누가거리 오름차순."""
return sorted(
(float(point.chainage_m), facility_clearance_m(point.facility, point.options))
for point in points
)