From c7d63c1b7879b953a08865ec5dbe0e0a75318f8a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 3 Nov 2026 20:42:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(775-1=C2=B7775-2):=20=ED=8F=89=EB=A9=B4?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EC=84=A0=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=A7=9E=EC=B6=A4=20=E2=80=94=20=EA=B3=A1=EC=84=A0?= =?UTF-8?q?=20=EC=88=98=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EA=B5=90=EA=B0=81?= =?UTF-8?q?=EC=A0=90=20=C2=B7=20R=20=EC=B5=9C=EC=86=8C=EC=A0=9C=EA=B3=B1?= =?UTF-8?q?=20+=20=EC=B8=A1=EC=A0=90=20=C2=B7=20=EC=B5=9C=EC=86=8C=20R=20?= =?UTF-8?q?=EB=B2=8C=EC=A0=90=20=C2=B7=20=EC=88=98=EC=A0=95=20=EB=81=9D=20?= =?UTF-8?q?=3D=20=EC=88=98=EC=A0=95=20=EC=A0=84=20=EA=B3=A1=EC=84=A0=20?= =?UTF-8?q?=EC=88=98=20=ED=8B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 B05_Profile_Engine_RouteInitial_Fit: 성긴 최소제곱 맞춤 · 측점 벌점 1 → 1000 · 끝 확인(곡선 수 · MC 측점 어김 0 · R ≥ 최소 R) · 수정 끝 틀 고르기(예정노선 거리 덜 늘어나는 합침) - merged_follow: 합치기 뒤 측점 맞춤 대신 맞춤 · 끝 확인 못 지나면 지금 측점 맞춤으로 물러남 · keep_curves - /route/follow: 수정 전 곡선 수 = 본문 keep_curves 또는 저장 노선 곡선표 · 응답 keep_curves · curve_note - 773 여덟 꼴 평균 최대 26.5 → 6.9 m · 평균 3.80 → 1.24 m · N 유지 8/8 · 측점 어김 0 · R 미달 0 · 물러남 0 - 지킴 시험 test_775_route_fit 새로 · test_758_1 그대로 통과(입력 덧점 정리로 체인 · 추적 같은 선) Co-Authored-By: Claude Opus 5.5 --- .../B05_Profile_Engine_RouteInitial_Fit.py | 196 ++++++++++++++++++ .../B05_Profile_Engine_RouteInitial_Follow.py | 24 ++- .../B05_Profile_Route_Polyline_Write.py | 8 +- B05_Profile/B05_Profile_Router_RouteFollow.py | 50 ++++- resources/tester/test_775_route_fit.py | 92 ++++++++ 5 files changed, 355 insertions(+), 15 deletions(-) create mode 100644 B05_Profile/B05_Profile_Engine_RouteInitial_Fit.py create mode 100644 resources/tester/test_775_route_fit.py diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Fit.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Fit.py new file mode 100644 index 000000000..912434633 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Fit.py @@ -0,0 +1,196 @@ +"""평면계획선 예정노선 맞춤(775 · 요구 615 · 617 · 분석 773 갈래 M) — 곡선 수 그대로 교각점 · R. + +`..._Follow.merged_follow` 가 같은 쪽 합치기 뒤 부름(체인 처음 · `/route/plan` · `/route/replan` · +`/route/follow` · 추천 「예상노선 추적」). +- `fit_to_expected` = 틀(곡선 수) 그대로 교각점 · R 를 예정노선 1 m 점 거리 제곱합 최소로(성긴 + 자코비안 · 이웃 곡선만) → 가까운 일반측점 고름 → 「이웃 MC 사이 = 측점 사이」 · 「R ≥ 최소 R」 + 벌점을 1 → 1000 으로 키우며 다시 맞춤 → 4 자리 · R 2 자리 반올림 → 끝 확인(MC 측점 어김 0 · + R ≥ 최소 R · 곡선 수 그대로) · 못 지나면 None(부르는 쪽이 지금 측점 맞춤으로 물러남 · 스냅 절대). +- `frame_for_count` = 수정 끝 틀 고르기 — 꺾임마다 곡선인 선에서 예정노선 거리가 가장 덜 늘어나는 + 합침(교각점 지움 · 같은 쪽 둘 → 바깥 직선 교점)을 차례로 해 곡선 수 N · 굽이가 N 보다 적으면 + 그대로. +""" + +from __future__ import annotations + +import numpy as np +from scipy.optimize import least_squares +from scipy.spatial import cKDTree +from shapely.geometry import LineString, Point + +from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat +from B05_Profile.B05_Profile_Engine_RouteInitial_Station import ( + STATION_M, + curve_mc_chainages, + off_station_count, +) +from common_util.common_util_route_polyline import PlannedPolyline, build_planned_polyline + +Xy = tuple[float, float] +PENALTY_STEPS = (1.0, 10.0, 100.0, 1000.0) + + +def _build(points: list[Xy], radii: list[float], min_radius_m: float) -> PlannedPolyline: + n = len(points) + return build_planned_polyline( + points, + min_radius_m=min_radius_m, + simplify=False, + curve_flags=[False] + [True] * (n - 2) + [False], + radii=[None, *radii, None], + ) + + +def _along(coords: np.ndarray) -> np.ndarray: + return np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(coords, axis=0).T))]) + + +def _samples(vertices, step: float) -> np.ndarray: + c = np.asarray(vertices, float) + ch = _along(c) + s = np.arange(0.0, ch[-1] + 1e-9, step) + return np.c_[np.interp(s, ch, c[:, 0]), np.interp(s, ch, c[:, 1])] + + +def _plain(expected: list[Xy]) -> list[Xy]: + """곧은 줄 위 덧점 지움 — 조밀화한 예정노선 · 원본이 같은 맞춤(체인 · `/route/follow`).""" + return list(LineString(expected).simplify(1e-6).coords) + + +def _frame(line: PlannedPolyline, min_radius_m: float) -> tuple[list[Xy], list[float]]: + points, _, radii = _flat(line) + return points, [r if r else min_radius_m for r in radii[1:-1]] + + +def _fit(points0, radii0, expected, min_radius_m, targets=None, w_mc=0.0, r_pen=5.0, nfev=100): + """교각점(끝 둘 고정) · R 최소제곱 — targets 주면 이웃 MC 사이 길이 = 측점 사이 벌점.""" + P = np.asarray(points0, float) + n = len(P) - 2 + E = _samples(expected, 1.0) + sE = np.arange(len(E), dtype=float) + eline = LineString(expected) + s_ip = np.array([eline.project(Point(p)) for p in P]) + m, n_t = len(E), (n if targets is not None else 0) + sparsity = np.zeros((m + n + n_t, 3 * n), dtype=bool) + for k in range(1, n + 1): + lo, hi = s_ip[max(0, k - 2)] - 30, s_ip[min(n + 1, k + 2)] + 30 + rows = np.where((sE >= lo) & (sE <= hi))[0] + for c in (2 * (k - 1), 2 * (k - 1) + 1, 2 * n + k - 1): + sparsity[rows, c] = True + sparsity[m + max(1, k - 1) - 1 : m + min(n, k + 1), c] = True + if n_t: + sparsity[m + n + max(1, k - 2) - 1 : m + n + min(n, k + 2), c] = True + gaps = np.diff(np.concatenate([[0.0], targets])) if n_t else None + + def unpack(x): + pts = [tuple(P[0]), *(tuple(p) for p in x[: 2 * n].reshape(n, 2)), tuple(P[-1])] + return pts, list(x[2 * n :]) + + def resid(x): + pts, radii = unpack(x) + line = _build(pts, radii, min_radius_m) + d = cKDTree(_samples(line.vertices, 0.25)).query(E)[0] + ok = len(line.curves) == n + built = np.array([c.radius_m for c in line.curves]) if ok else np.zeros(n) + out = [d, r_pen * np.maximum(0.0, min_radius_m + 0.01 - built)] + if n_t: + mc = np.array(curve_mc_chainages(line)) if ok else np.full(n, 1e4) + out.append(w_mc * (np.diff(np.concatenate([[0.0], mc])) - gaps)) + return np.concatenate(out) + + x0 = np.concatenate([P[1:-1].ravel(), np.maximum(np.asarray(radii0, float), min_radius_m)]) + lower = np.concatenate([np.full(2 * n, -np.inf), np.full(n, min_radius_m)]) + scale = np.concatenate([np.full(2 * n, 5.0), np.full(n, 10.0)]) + found = least_squares( + resid, x0, jac_sparsity=sparsity, bounds=(lower, np.inf), x_scale=scale, max_nfev=nfev + ) + return unpack(found.x) + + +def _targets(line: PlannedPolyline) -> np.ndarray: + """MC 마다 가까운 일반측점(No.1 이상 · 앞 측점보다 뒤).""" + out, last = [], 0.0 + for mc in curve_mc_chainages(line): + last = max(last + STATION_M, round(mc / STATION_M) * STATION_M) + out.append(last) + return np.array(out) + + +def passes(line: PlannedPolyline, count: int, min_radius_m: float) -> bool: + """끝 확인 — 곡선 수 그대로 · MC 측점 어김 0 · R ≥ 최소 R.""" + return ( + len(line.curves) == count + and off_station_count(line) == 0 + and all(c.radius_m >= min_radius_m - 1e-6 for c in line.curves) + ) + + +def fit_to_expected( + frame: PlannedPolyline, expected: list[Xy], min_radius_m: float +) -> PlannedPolyline | None: + """틀 곡선 수 그대로 예정노선에 맞추고 MC 를 일반측점에 놓은 선 · 끝 확인 못 지나면 None.""" + count = len(frame.curves) + if count == 0: + return None + expected = _plain(expected) + try: + points, radii = _fit(*_frame(frame, min_radius_m), expected, min_radius_m) + targets = _targets(_build(points, radii, min_radius_m)) + for w in PENALTY_STEPS: + points, radii = _fit( + points, radii, expected, min_radius_m, targets, w, max(5.0, w), nfev=60 + ) + except (ValueError, np.linalg.LinAlgError): + return None + points = [(round(x, 4), round(y, 4)) for x, y in points] + line = _build(points, [round(r, 2) for r in radii], min_radius_m) + return line if passes(line, count, min_radius_m) else None + + +def _gap(line: PlannedPolyline, tree: cKDTree) -> float: + return float(np.square(tree.query(_samples(line.vertices, 1.0))[0]).sum()) + + +def _crossing(a: Xy, b: Xy, c: Xy, d: Xy) -> Xy | None: + """a→b 반직선 · d→c 반직선(b · c 넘어) 교점 — 둘 다 앞쪽일 때만.""" + u, v = np.subtract(b, a), np.subtract(c, d) + den = u[0] * v[1] - u[1] * v[0] + if abs(den) < 1e-9: + return None + w = np.subtract(d, a) + t, s = (w[0] * v[1] - w[1] * v[0]) / den, (w[0] * u[1] - w[1] * u[0]) / den + if t <= 1.0 or s <= 1.0: + return None + return (float(a[0] + u[0] * t), float(a[1] + u[1] * t)) + + +def frame_for_count( + kinked: PlannedPolyline, expected: list[Xy], min_radius_m: float, count: int +) -> PlannedPolyline: + """꺾임마다 곡선인 선 → 예정노선 거리가 가장 덜 늘어나는 합침을 차례로 해 곡선 수 `count`.""" + tree = cKDTree(_samples(_plain(expected), 1.0)) + points, radii = _frame(kinked, min_radius_m) + line = _build(points, radii, min_radius_m) + # ponytail: 한 걸음마다 후보 전부 다시 짬(곡선 30 개 · 수 초) · 느리면 바뀐 곡선 둘레만 잼 + while len(points) - 2 > max(count, 0): + best = None + for k in range(1, len(points) - 1): + trials = [(points[:k] + points[k + 1 :], radii[: k - 1] + radii[k:])] + if k + 1 < len(points) - 1: + at = _crossing(points[k - 1], points[k], points[k + 1], points[k + 2]) + if at is not None: + r = max(radii[k - 1], radii[k]) + trials.append( + (points[:k] + [at] + points[k + 2 :], radii[: k - 1] + [r] + radii[k + 1 :]) + ) + for pts, rr in trials: + trial = _build(pts, rr, min_radius_m) + if len(trial.curves) != len(pts) - 2: + continue + cost = _gap(trial, tree) + if best is None or cost < best[0]: + best = (cost, pts, rr, trial) + if best is None: + break + _, points, radii, line = best + return line if len(line.curves) == len(points) - 2 else kinked diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Follow.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Follow.py index 8dcfed727..10305ef7d 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Follow.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Follow.py @@ -5,7 +5,9 @@ 차례(`merged_follow`) = ① 예정노선 폴리라인(꺾임점 · 묶기 · R 맞춤 · 기본 규칙) → 큰 R (`..._Radius.widen`) → 꺾임만 곡선으로(`curve_every_kink`) → 같은 쪽 이웃 곡선 합치기 -(`..._Merge.merge_same_side`) → ② 곡선 MC 일반측점 맞춤. 큰 R · 합치기 · 측점 맞춤 모두 직접 잼 +(`..._Merge.merge_same_side`) → ② 예정노선 맞춤(775 · `..._Fit` — 곡선 수 그대로 교각점 · R 를 +예정노선에 맞추며 MC = 일반측점 · 끝 확인 못 지나면 지금 측점 맞춤 · 수정 끝 `keep_curves` = 수정 전 +곡선 수 틀). 큰 R · 합치기 · 측점 맞춤(물러남) 모두 직접 잼 (`Guard` · 지형 · 계류 격자 · 토공 최소 갈래와 같은 판 — 이격도 판 `keeping_offset` 은 합치기 0 · 측점 맞춤이 더 벗어나 안 씀) · 합치기 멈춤 = 못 지킨 곳 수 · 노선 횡단 토공 +10 %. 지형이 없으면(`FollowLand` None) 부르는 쪽이 옛 길(순수 기하 `snapped_initial`)을 씀. @@ -25,6 +27,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import ( cross_earthwork, cross_earthwork_delta, ) +from B05_Profile.B05_Profile_Engine_RouteInitial_Fit import fit_to_expected, frame_for_count from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, RouteEarth, merge_same_side from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import STATION_M, route_metrics from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import hard_limits @@ -100,10 +103,15 @@ def cross_delta( def merged_follow( - points: list[Xy], min_radius_m: float, land: FollowLand, grid: SearchGrid | None = None + points: list[Xy], + min_radius_m: float, + land: FollowLand, + grid: SearchGrid | None = None, + keep_curves: int | None = None, ) -> PlannedPolyline: """예정노선 점 묶음 → 곡선 최적 · 측점 맞춘 폴리라인(못 맞추면 맞추기 전 선). `grid` = 이미 짠 - 탐색 격자(같은 예정노선 · 추천 묶음) · 없으면 짬.""" + 탐색 격자(같은 예정노선 · 추천 묶음) · 없으면 짬. `keep_curves` = 지킬 곡선 수(수정 끝 = 수정 전 + 평면계획선 곡선 수) · 없으면 합친 뒤 곡선 수.""" key = ( tuple((float(x), float(y)) for x, y in points), round(min_radius_m, 6), @@ -115,6 +123,7 @@ def merged_follow( land.corridor_m, repr(land.standard), len(land.streams), + keep_curves, ) if key in _MEMO: _MEMO.move_to_end(key) @@ -173,8 +182,13 @@ def merged_follow( ), ), ) - snapped = snap_curves_to_stations(route[0], min_radius_m, guard=guard) - found = route[0] if snapped is None else snapped + frame = route[0] + if keep_curves is not None and keep_curves != len(frame.curves): + frame = frame_for_count(kinked, points, min_radius_m, keep_curves) + found = fit_to_expected(frame, points, min_radius_m) + if found is None: # 끝 확인 못 지남 — 지금 측점 맞춤(스냅 절대) + snapped = snap_curves_to_stations(route[0], min_radius_m, guard=guard) + found = route[0] if snapped is None else snapped _MEMO[key] = found while len(_MEMO) > MEMO_SIZE: _MEMO.popitem(last=False) diff --git a/B05_Profile/B05_Profile_Route_Polyline_Write.py b/B05_Profile/B05_Profile_Route_Polyline_Write.py index c87a27fbf..0abfa4caa 100644 --- a/B05_Profile/B05_Profile_Route_Polyline_Write.py +++ b/B05_Profile/B05_Profile_Route_Polyline_Write.py @@ -204,13 +204,17 @@ def polyline_table( def followed_table( - points: list[tuple[float, float]], radius_m: float, land: Any = None + points: list[tuple[float, float]], + radius_m: float, + land: Any = None, + keep_curves: int | None = None, ) -> tuple[PlannedPolyline, dict[str, Any]]: """예정노선 추적(383-1) — 점 묶음 → 곡선 MC 를 일반측점에 맞춘 폴리라인 + 곡선표(스냅 켬). 체인 초기 폴리라인(`_ensure_planned_initial`) · `/route/follow`(626-2)가 같이 씀 · 못 맞추면 지금 선. `land`(`..._RouteInitial_Follow.FollowLand`) 주면 곡선 최적(큰 R · 같은 쪽 합치기 · 758-1 · 추천 「예상노선 추적」과 같은 함수) · 없으면 순수 기하. + `keep_curves` = 지킬 곡선 수(775 · 수정 끝 = 수정 전 곡선 수). """ from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial @@ -218,7 +222,7 @@ def followed_table( from B05_Profile.B05_Profile_Engine_RouteInitial_Follow import merged_follow from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat - fit = _flat(merged_follow(points, radius_m, land)) + fit = _flat(merged_follow(points, radius_m, land, keep_curves=keep_curves)) else: fit = snapped_initial(points, radius_m) shape = {"simplify": False, "curve_flags": fit[1], "radii": fit[2]} if fit else {} diff --git a/B05_Profile/B05_Profile_Router_RouteFollow.py b/B05_Profile/B05_Profile_Router_RouteFollow.py index df2cab5b2..6d41ab471 100644 --- a/B05_Profile/B05_Profile_Router_RouteFollow.py +++ b/B05_Profile/B05_Profile_Router_RouteFollow.py @@ -9,15 +9,20 @@ 추천 저장본은 안 건드림. POST /api/projects/{id}/route/follow - 본문 {points: [[x, y], …], crs: "tm" | "lonlat"} ("lonlat" = [경도, 위도]) + 본문 {points: [[x, y], …], crs: "tm" | "lonlat", keep_curves?: int} ("lonlat" = [경도, 위도]) → {status, planned, nodes, curves, node_edits, station_snap, min_radius_m, - curve_count, violation_count} (GET /route/plan 칸 이름 · 사업지 TM) + curve_count, violation_count, keep_curves, curve_note} + (GET /route/plan 칸 이름 · 사업지 TM) + `keep_curves`(775-2) = 수정 전 평면계획선 곡선 수 — 안 주면 저장 노선(수정본 → 초기본) + 곡선표에서 읽음 · 곡선 수가 그와 다르면 `curve_note` 안내 글 + (굽이가 모자람 · 맞춤 못 해 지금 측점 맞춤). 400 점 2 미만 · 404 프로젝트 없음 · 409 좌표계 모름(lonlat) """ from __future__ import annotations import asyncio +from pathlib import Path from typing import Any, Literal from uuid import UUID @@ -25,7 +30,7 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg -from B05_Profile.B05_Profile_Route_Polyline_Write import followed_table +from B05_Profile.B05_Profile_Route_Polyline_Write import _read_curves, followed_table from B05_Profile.B05_Profile_Router_FollowLand import follow_land from B05_Profile.B05_Profile_Router_Replan import ( _PROJECT_PATH_MISSING, @@ -34,7 +39,11 @@ from B05_Profile.B05_Profile_Router_Replan import ( router, ) from common_util.common_util_crs import lonlat_to_tm -from common_util.common_util_route_geometry import densify_route +from common_util.common_util_route_geometry import ( + densify_route, + planned_route_initial_path, + planned_route_working_path, +) from config.config_db import get_db_pool from config.config_system import ( ROUTE_DIRECT_LINK_CELL_FACTOR, @@ -46,14 +55,36 @@ from config.config_system import ( class RouteFollowRequest(BaseModel): points: list[tuple[float, float]] crs: Literal["tm", "lonlat"] = "tm" + keep_curves: int | None = None + + +def saved_curve_count(project_root: Path) -> int | None: + """저장 노선 곡선 수(수정본 → 초기본 · 775-2) · 곡선표 없으면 None.""" + for path in ( + planned_route_working_path(project_root), + planned_route_initial_path(project_root), + ): + if path.is_file(): + return len(_read_curves(path)) or None + return None def follow_route_line( - points: list[tuple[float, float]], radius_m: float, land: Any = None + points: list[tuple[float, float]], + radius_m: float, + land: Any = None, + keep_curves: int | None = None, ) -> dict[str, Any]: - """점 묶음(TM) → 예정노선 추적 평면선 — 파일 꼴(csv 4자리)과 같은 값 · `land` = 곡선 최적.""" + """점 묶음(TM) → 예정노선 추적 평면선 — 파일 꼴(csv 4자리)과 같은 값 · `land` = 곡선 최적 · + `keep_curves` = 지킬 곡선 수(지형 있을 때만).""" spacing = ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY - result, table = followed_table(densify_route(points, spacing), radius_m, land) + result, table = followed_table(densify_route(points, spacing), radius_m, land, keep_curves) + note = None + if land is not None and keep_curves and result.curve_count != keep_curves: + note = ( + f"수정 전 곡선 {keep_curves}개를 지키지 못해 {result.curve_count}개로 그었습니다" + " — 굽이가 모자라거나 측점 맞춤으로 물러남." + ) return { "planned": [[round(x, 4), round(y, 4)] for x, y in result.vertices], "nodes": [node.as_dict() for node in result.nodes], @@ -63,6 +94,8 @@ def follow_route_line( "min_radius_m": round(radius_m, 2), "curve_count": result.curve_count, "violation_count": result.violation_count, + "keep_curves": keep_curves if land is not None else None, + "curve_note": note, } @@ -92,5 +125,6 @@ async def follow_route(project_id: UUID, body: RouteFollowRequest) -> dict[str, points = [lonlat_to_tm(lon, lat, epsg) for lon, lat in points] radius_m = (await _plan_criteria(project_id))[0] land = await follow_land(project_id, paths[0]) - line = await asyncio.to_thread(follow_route_line, points, radius_m, land) + keep = body.keep_curves or await asyncio.to_thread(saved_curve_count, paths[0]) + line = await asyncio.to_thread(follow_route_line, points, radius_m, land, keep) return {"status": "success", "project_id": str(project_id), **line} diff --git a/resources/tester/test_775_route_fit.py b/resources/tester/test_775_route_fit.py new file mode 100644 index 000000000..0186d96f5 --- /dev/null +++ b/resources/tester/test_775_route_fit.py @@ -0,0 +1,92 @@ +"""775 평면계획선 예정노선 맞춤(요구 615 · 617 · 갈래 M) — 곡선 수 그대로 · 스냅 절대 · 안전판. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_775_route_fit.py -q + +- 맞춤 = 지금 측점 맞춤보다 예정노선에 가까움 · MC 모두 일반측점 · R ≥ 최소 R · 곡선 수 = 합친 뒤. +- 수정 끝 `keep_curves` = 그 곡선 수(덜 합침 · 더 합침) · 굽이보다 많으면 있는 만큼 + 안내 글. +- 끝 확인 못 지나면 지금 측점 맞춤으로 물러남. +- 저장 노선 곡선 수 = 수정본 → 초기본 곡선표. +""" + +from __future__ import annotations + +import json + +from shapely.geometry import LineString +from test_758_1_follow_merge import EXPECTED, RADIUS, _land + +from B05_Profile import B05_Profile_Engine_RouteInitial_Follow as follow_mod +from B05_Profile.B05_Profile_Engine_RouteInitial_Fit import passes +from B05_Profile.B05_Profile_Engine_RouteInitial_Station import off_station_count +from B05_Profile.B05_Profile_Router_RouteFollow import follow_route_line, saved_curve_count +from common_util.common_util_route_geometry import ( + planned_route_initial_path, + planned_route_working_path, +) + + +def _gap(line) -> float: + return LineString(EXPECTED).hausdorff_distance(LineString(line.vertices)) + + +def _ok(line) -> None: + assert off_station_count(line) == 0 + assert min(c.radius_m for c in line.curves) >= RADIUS - 1e-6 + + +def _old(monkeypatch): + follow_mod._MEMO.clear() + monkeypatch.setattr(follow_mod, "fit_to_expected", lambda *a: None) + try: + return follow_mod.merged_follow(EXPECTED, RADIUS, _land()) + finally: + monkeypatch.undo() + follow_mod._MEMO.clear() + + +def test_맞춤_가까움_측점_최소_R(monkeypatch): + old = _old(monkeypatch) + new = follow_mod.merged_follow(EXPECTED, RADIUS, _land()) + _ok(new) + assert passes(new, len(new.curves), RADIUS) + assert _gap(new) < _gap(old) + + +def test_곡선_수_지킴(): + base = len(follow_mod.merged_follow(EXPECTED, RADIUS, _land()).curves) + for keep in (base - 1, base + 1): + line = follow_mod.merged_follow(EXPECTED, RADIUS, _land(), keep_curves=keep) + assert len(line.curves) == keep + _ok(line) + + +def test_굽이보다_많으면_있는_만큼_안내(): + found = follow_route_line(EXPECTED, RADIUS, _land(), keep_curves=500) + assert found["curve_count"] < 500 + assert "500" in found["curve_note"] + plain = follow_route_line(EXPECTED, RADIUS, None, keep_curves=500) + assert plain["curve_note"] is None and plain["keep_curves"] is None + + +def test_끝_확인_못_지나면_지금_측점_맞춤(monkeypatch): + old = _old(monkeypatch) + _ok(old) # 물러난 선도 측점 · 최소 R 지킴 + assert not passes(old, len(old.curves) + 1, RADIUS) # 곡선 수 다르면 끝 확인 못 지남 + + +def test_저장_노선_곡선_수(tmp_path): + assert saved_curve_count(tmp_path) is None + initial = planned_route_initial_path(tmp_path) + initial.parent.mkdir(parents=True, exist_ok=True) + initial.write_text("x,y\n0,0\n", encoding="utf-8") + initial.with_name(f"{initial.stem}_curves.json").write_text( + json.dumps({"curves": [{}, {}, {}]}), encoding="utf-8" + ) + assert saved_curve_count(tmp_path) == 3 + working = planned_route_working_path(tmp_path) + working.parent.mkdir(parents=True, exist_ok=True) + working.write_text("x,y\n0,0\n", encoding="utf-8") + working.with_name(f"{working.stem}_curves.json").write_text( + json.dumps({"curves": [{}, {}]}), encoding="utf-8" + ) + assert saved_curve_count(tmp_path) == 2