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>
This commit is contained in:
2026-09-07 11:54:23 +09:00
co-authored by Claude Opus 5
parent a622bcb964
commit e2d329d35f
+52 -2
View File
@@ -160,6 +160,38 @@ def fill_pipe_coordinates(points: list[PipePoint], vertices: list[RouteVertex])
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]:
"""저장된 좌표를 주어진 노선에 투영해 누가거리를 다시 매긴다.
@@ -214,10 +246,28 @@ def load_pipe_points_file(
stored_signature = str(document.get("route_signature") or "")
if stored_signature == signature:
return points
if vertices and points and all(p.x is not None and p.y is not None for p in 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건을 좌표로 이월합니다 (%s).",
"배수유역: 지문은 다르나 같은 노선입니다 — 관 %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)