From 86884935dd7ef7cb253d764a4737ec09f045d6d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 15:07:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(383-1):=20R=20=3D=20=EC=9D=BC=EB=B0=98?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EA=B8=B0=EB=B3=B8=20=EC=A0=9C=EC=95=BD=20?= =?UTF-8?q?=E2=80=94=20=EC=83=88=20=EC=B4=88=EA=B8=B0=20=EB=85=B8=EC=84=A0?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=A4=20+=20=EC=8A=A4=EB=83=85=20=EC=BC=AC=20?= =?UTF-8?q?=C2=B7=20=EC=B6=94=EC=B2=9C=20=ED=9B=84=EB=B3=B4=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EB=A7=9E=EC=B6=A4(ALGO=205)=20=C2=B7=20[=EC=A0=81?= =?UTF-8?q?=EC=9A=A9]=20=EC=8A=A4=EB=83=85=20=EC=BC=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 초기 노선: 예상노선 폴리라인 곡선 MC 를 일반측점에 맞춤 + 곡선표 station_snap 켬(못 맞추면 지금 선 + 켬) · 옛 사본 그대로 - 추천: 후보마다 맞춘 선으로 고르기 · 쓸기 · 거름 · 측점 밖 0 후보 앞세움 · 남은 측점 밖 곡선 = 못 지킨 구간(station) - 추천 [적용] = 측점 스냅 켬 저장 · 첫 셈 74.6 s → 42.6 s(검증_sub1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- .../B05_Profile_Engine_RouteInitial.py | 47 ++++--- .../B05_Profile_Engine_RouteInitial_Pool.py | 65 ++++++---- ...B05_Profile_Engine_RouteInitial_Station.py | 46 ++++++- .../B05_Profile_Engine_RouteInitial_Store.py | 3 +- B05_Profile/B05_Profile_Router_Replan.py | 8 +- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 3 +- .../B05_Profile_UI_RouteEdit_Initial_Model.ts | 4 +- .../tester/test_224_4_recommend_radius.py | 4 +- .../test_383_1_route_station_default.py | 120 ++++++++++++++++++ resources/tester/test_route_initial.py | 21 +-- 10 files changed, 254 insertions(+), 67 deletions(-) create mode 100644 resources/tester/test_383_1_route_station_default.py diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index 71472b785..f12c1f8bd 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -70,6 +70,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import ( Pool, build_pool, pick, + settle, similar, weighted, ) @@ -345,11 +346,24 @@ def generate_initial_route( run = np.maximum(chain[span:] - chain[:-span], 1e-9) return max(0.0, float((np.abs(z[span:] - z[:-span]) / run).max()) - max_grade) + def fit(outline: Any, follow: bool = False) -> Any: + # R = 일반측점(383-1) — 갈래는 옮긴 조각이 원래보다 더 가파르면 그 곡선만 그 자리(추적은 + # 제약을 안 따짐 · 새 초기 노선 `snapped_initial` 과 같은 잣대). + moved = snap_curves_to_stations( + outline, min_radius_m, excess=None if follow else grade_excess + ) + return outline if moved is None else moved + + unsnapped: dict[int, Any] = {} + def follow_line() -> tuple[Any, dict[str, Any]]: - # 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 입력 → - # 같은 선(`_write_planned_polyline` 기본값). 제약은 따지지 않고 넘은 곳만 목록에 뜸. + # 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv` · 383-1 측점 맞춤)과 같은 + # 규칙 · 같은 입력 → 같은 선. 제약은 따지지 않고 넘은 곳만 목록에 뜸. outline = build_planned_polyline(expected, min_radius_m=min_radius_m) - return outline, measure(outline, bound=None, follow=True) + route = (outline, measure(outline, bound=None, follow=True)) + return settle( + route, lambda line: fit(line, True), measure, unsnapped, bound=None, follow=True + ) def profiled(route: Any, exact: bool = True) -> None: if route is not None: @@ -385,7 +399,7 @@ def generate_initial_route( graph = RouteGraph(grid, expected, limit=max_grade, min_radius_m=min_radius_m) while len(_POOLS) >= POOL_CACHE_SIZE: _POOLS.pop(next(iter(_POOLS))) - _POOLS[key] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure) + _POOLS[key] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure, fit) pool = _POOLS[key] bound = pool.bound if target == "weighted": @@ -395,26 +409,9 @@ def generate_initial_route( for route in [*pool.candidates, pool.follow]: profiled(route, exact=False) # 줄 세우기 — 고른 하나만 아래에서 바로 셈 outline, metrics = pick(target, pool.candidates, with_profile) - # 측점 맞춤(351-2 · 브레인 확정) — 고른 선의 곡선 MC 를 일반측점에 놓은 선을 늘 함께 냄(모달 - # 「측점 맞춤」 켬 = 이 선 · 끔 = 고른 선 그대로). 갈래는 옮긴 조각이 원래보다 더 가파르면 그 - # 곡선만 그 자리(추적은 제약을 안 따짐). 비교값은 맞춘 선으로 다시 셈. - raw = (outline, metrics) - moved = snap_curves_to_stations( - outline, min_radius_m, excess=None if target == "follow" else grade_excess - ) - snapped = ( - raw - if moved is None or moved is outline - else ( - moved, - measure( - moved, - bound=bound, - follow=target == "follow", - weighted=target == "weighted", - ), - ) - ) + # 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle`) — 모달 「측점 맞춤」 끔 = 맞추기 전 선 + snapped = (outline, metrics) + raw = unsnapped.get(id(outline)) or (pool.raw.get(id(outline)) if pool else None) def finish(route: tuple[Any, dict[str, Any]]) -> dict[str, Any]: line, values = route @@ -443,7 +440,7 @@ def generate_initial_route( "terrain_bound_pct": None if bound is None else round(bound * 100.0, 2), **shown, # 측점 맞춤 끔 = 고른 선 그대로(맞출 곡선이 없거나 못 맞췄으면 None — 위와 같음) - "unsnapped": None if snapped is raw else finish(raw), + "unsnapped": None if raw is None else finish(raw), # 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만 "similar": None if pool is None else similar(pool, target, outline, with_profile), "with_profile": with_profile, diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index 999230c66..580ee69e4 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -10,6 +10,8 @@ 갈래가 그 사이 어느 비중보다 제 목표에서 좋게(토공 최소 ≤ 쓸기 절성토 · 기울기 순한 ≤ 쓸기 최대 기울기). · 못 지킨 곳 수가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. + · R = 일반측점이 기본 제약(383-1) — 후보마다 곡선 MC 를 일반측점에 맞춘 선(`snap`)으로 재고 + 고르고 거름. 못 맞춘 곡선은 「못 지킨 구간」(station) · 고르기는 측점 밖 0 후보를 앞세움. 갈래는 **비교값으로** 고른다(`pick` · `GOALS`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면 평균) · 영선 노선 = 목표 기울기 편차 · 안전 우선 = 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) · @@ -44,6 +46,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( NoRoute, RouteGraph, ) +from B05_Profile.B05_Profile_Engine_RouteInitial_Station import with_station_misses from common_util.common_util_route_geometry import densify_route from common_util.common_util_route_polyline import build_planned_polyline @@ -77,6 +80,8 @@ TRACE_DENSIFY_M = 2.0 Measure = Callable[..., dict[str, Any]] Route = tuple[Any, dict[str, Any]] +# 선 → 곡선 MC 를 일반측점에 맞춘 선(못 맞추면 그 선 그대로) +Snap = Callable[[Any], Any] @dataclass @@ -84,9 +89,10 @@ class Pool: """같은 입력의 후보 묶음 — 갈래 고르기 · 비중 탐색이 같이 쓴다.""" graph: RouteGraph - candidates: list[Route] - bound: float | None - limit: int # 후보로 남길 못 지킨 곳 수 상한 + snap: Snap + candidates: list[Route] = field(default_factory=list) + bound: float | None = None + limit: int = 0 # 후보로 남길 못 지킨 곳 수 상한 # 비중 몫 크기(1 m 당) — 이격 · 토공 · 기울기 · 넘은 기울기 · 계류 나란히 scale: np.ndarray = field(default_factory=lambda: np.ones(5)) # 비중 탐색 상한 — 예상 0 이면 갈래 상한(`base_cap`) · 예상 100 이면 추적 노선이 지나는 @@ -95,23 +101,32 @@ class Pool: follow_cap: float = np.inf searched: dict[tuple[float, ...], Route] = field(default_factory=dict) traced: dict[tuple, Route] = field(default_factory=dict) # 노드 → 잰 노선(`_trace`) + raw: dict[int, Route] = field(default_factory=dict) # 맞춘 선 id → 맞추기 전(「측점 맞춤」 끔) follow: Route | None = None # 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m) band_width_m: float = 0.0 +def settle(route: Route, snap: Snap, measure: Measure, raw: dict[int, Route], **kw: Any) -> Route: + """측점에 맞춘 선 + 잰 값(측점 밖 곡선 = 못 지킨 구간 · 383-1) · 맞추기 전 선은 `raw` 에.""" + outline, metrics = route + moved = snap(outline) + if moved is not outline: + raw[id(moved)] = (outline, with_station_misses(metrics, outline)) + outline, metrics = moved, measure(moved, **kw) + return outline, with_station_misses(metrics, outline) + + def _trace( - nodes: list[tuple[float, float]], - min_radius_m: float, - measure: Measure, - traced: dict[tuple, Route], - **kw: Any, + pool: Pool, nodes: list[tuple[float, float]], min_radius_m: float, measure: Measure, **kw: Any ) -> Route: # 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 2단을 최소 R 판 # 노드에 바로 건 판(단순화가 기울기 위반을 늘리는 급한 지형 · 검증_sub8 실측) → 1단 판(탐색 # 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 · # 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시 짜는 꼴. # 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로). + # 고른 판을 측점에 맞춤(`settle` · 383-1). + traced = pool.traced key = (tuple(nodes), tuple(sorted(kw.items()))) if key not in traced: floor = build_planned_polyline( @@ -121,7 +136,7 @@ def _trace( path = densify_route(nodes, TRACE_DENSIFY_M) fitted = build_planned_polyline(path, min_radius_m=min_radius_m) larger = (widen(fitted, path, min_radius_m), widen(floor, path, min_radius_m), fitted) - traced[key] = next( + chosen = next( ( route for route in ((outline, measure(outline, **kw)) for outline in larger) @@ -129,6 +144,7 @@ def _trace( ), least, ) + traced[key] = settle(chosen, pool.snap, measure, pool.raw, **kw) return traced[key] @@ -150,17 +166,14 @@ def _solve(graph: RouteGraph, target: str, **constraint: Any) -> list[tuple[floa def _candidates( - graph: RouteGraph, - max_grade: float, - min_radius_m: float, - measure: Measure, - traced: dict[tuple, Route], + pool: Pool, max_grade: float, min_radius_m: float, measure: Measure ) -> tuple[list[Route], float | None, int]: """제약 탐색 후보들 · 지형상 불가능 때 막은 기울기(아니면 None) · 후보로 남길 못 지킨 곳 상한. 제약을 지키는 길이 있으면 목표마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 · 없으면 막은 기울기 안에서 벌점 크기 · 목표를 바꿔 찾은 노선들. """ + graph = pool.graph found: list[Route] = [] for target in TARGETS: for step in GRADE_CAP_STEPS: @@ -168,7 +181,7 @@ def _candidates( nodes = _solve(graph, target, cap=max_grade * step, strict_stream=True) except NoRoute: break - found.append(_trace(nodes, min_radius_m, measure, traced, bound=None)) + found.append(_trace(pool, nodes, min_radius_m, measure, bound=None)) if found[-1][1]["over_limit"]["total"] == 0: break if not found: # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음 @@ -182,6 +195,7 @@ def _candidates( bound = graph.terrain_bound() cap = bound + 1e-9 seed = _trace( + pool, graph.solve( {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}, cap=cap, @@ -190,14 +204,13 @@ def _candidates( ), min_radius_m, measure, - traced, bound=bound, ) found = [seed] for target in TARGETS: for cost in RELAXED_GRADE_COSTS: nodes = _solve(graph, target, cap=cap, strict_stream=False, grade_cost=cost) - found.append(_trace(nodes, min_radius_m, measure, traced, bound=bound)) + found.append(_trace(pool, nodes, min_radius_m, measure, bound=bound)) most = seed[1]["over_limit"]["total"] return [item for item in found if item[1]["over_limit"]["total"] <= most], bound, most @@ -208,11 +221,12 @@ def build_pool( max_grade: float, min_radius_m: float, measure: Measure, + snap: Snap, ) -> Pool: """후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함.""" - traced: dict[tuple, Route] = {} - candidates, bound, limit = _candidates(graph, max_grade, min_radius_m, measure, traced) - pool = Pool(graph, candidates, bound, limit, traced=traced, follow=follow) + pool = Pool(graph, snap, follow=follow) + pool.candidates, pool.bound, pool.limit = _candidates(pool, max_grade, min_radius_m, measure) + candidates, bound, limit = pool.candidates, pool.bound, pool.limit ends = [follow[0], pick("earthwork", candidates)[0], pick("gentle", candidates)[0]] pool.scale, steepest = _scale(graph, [outline.vertices for outline in ends], max_grade) pool.base_cap = (max_grade if bound is None else bound) + 1e-9 @@ -282,7 +296,7 @@ def weighted(pool: Pool, weights: dict[str, float], min_radius_m: float, measure scale=np.concatenate([pool.scale[:3], np.ones(len(OBJECTIVES) - 3)]), ) pool.searched[key] = _trace( - nodes, min_radius_m, measure, pool.traced, bound=pool.bound, weighted=True + pool, nodes, min_radius_m, measure, bound=pool.bound, weighted=True ) return pool.searched[key] @@ -306,8 +320,13 @@ GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = { def pick(mode: str, candidates: list[Route], with_profile: bool = True) -> Route: - """갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보. `with_profile` 끔 = 원지반 가늠 점수.""" + """갈래 목표 비교값이 가장 좋은 후보 — 측점 밖 곡선 없는 후보 먼저(383-1). 같으면 앞 후보. + `with_profile` 끔 = 원지반 가늠 점수.""" goal = GOALS[mode] return min( - candidates, key=lambda item: goal(item[1] if with_profile else plain_metrics(item[1])) + candidates, + key=lambda item: ( + item[1]["over_limit"].get("station", 0) > 0, + *goal(item[1] if with_profile else plain_metrics(item[1])), + ), ) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py index 34a059919..08d22745e 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py @@ -1,8 +1,11 @@ """추천 노선 곡선 MC → 일반측점(PLAN 351-2 · 요구 235-3). -`B05_Profile_Engine_RouteInitial.generate_initial_route` 끝이 고른 선(다섯 갈래 · 큰 R 뒤)에 -부름 — 맞춘 선 · 맞추기 전 선 두 벌을 냄(모달 「측점 맞춤」 켬끔 · 브레인 확정). 전처리 초기 -노선 · 손 편집 · [측점 스냅]은 안 거침. +R = 일반측점이 기본 제약(383-1 · 요구 241-13) — 추천 후보마다(`..._Pool._trace` · 고르기 · 쓸기 · +거름이 맞춘 선으로) · 새 초기 노선(`snapped_initial` · `_ensure_planned_initial`)이 부름. 맞추기 전 +선은 모달 「측점 맞춤」 끔. 못 맞춘 곡선 = 「못 지킨 구간」(`with_station_misses`). 손 편집 · +[측점 스냅]은 TS 짝 꼴(`logic/B05_Profile_RouteEdit_StationFit.ts`). + +「MC = 서로 다른 일반측점」은 사용자 규칙(지식DB 근거 없음 — 원전 노선측량은 MC = 보조측점). 일반측점 = 누가거리 20 m 격자(No.N+0). 곡선 가운데 MC(BC · EC 누가거리 가운데 = 곡선 중심과 마주치는 법선 자리)를 일반측점 위에 둔다 → 곡선 수 ≤ 내부 일반측점 수. 앞 곡선이 뒤 누가거리를 @@ -32,6 +35,7 @@ import numpy as np import shapely from shapely.geometry import LineString, Point +from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import _item from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat from common_util.common_util_route_polyline import ( STRAIGHT_INNER_ANGLE_DEG, @@ -134,6 +138,31 @@ def off_station_count(outline: PlannedPolyline) -> int: return len(outline.curves) - station_hits(outline) +REASON_STATION = "곡선 MC 측점 밖 — 이웃 곡선 · 이탈 한도 · 지반 기울기가 막아 일반측점에 못 놓음" + + +def with_station_misses(metrics: dict, outline: PlannedPolyline) -> dict: + """측점 밖 곡선(MC 가 일반측점 밖 · 앞 곡선과 같은 측점)을 「못 지킨 구간」에 더한 잰 값(383-1). + + 값 = 가까운 일반측점까지(m). `over_limit` 에 `station` 으로 셈 — 후보 거름이 측점 밖 0 을 + 앞세움. + """ + taken: set[int] = set() + misses = [] + for mc in curve_mc_chainages(outline): + n = round(mc / STATION_M) + if n >= 1 and abs(mc - n * STATION_M) <= MC_TOL_M and n not in taken: + taken.add(n) + continue + misses.append(_item("station", mc, mc, abs(mc - n * STATION_M), 0.0, REASON_STATION)) + counts = {**metrics["over_limit"], "station": len(misses)} + counts["total"] = metrics["over_limit"]["total"] - metrics["over_limit"].get("station", 0) + counts["total"] += len(misses) + kept = [item for item in metrics["violations"] if item["kind"] != "station"] + violations = sorted(kept + misses, key=lambda item: item["from_m"]) + return {**metrics, "over_limit": counts, "violations": violations} + + def _unit(a: Xy, b: Xy) -> Xy: length = math.dist(a, b) return ((b[0] - a[0]) / length, (b[1] - a[1]) / length) @@ -462,6 +491,17 @@ class _Snapper: return None +def snapped_initial( + points: list[Xy], min_radius_m: float +) -> tuple[list[Xy], list[bool], list[float | None]] | None: + """새 초기 노선(383-1) — 예상노선 폴리라인(기본 규칙)의 곡선 MC 를 일반측점에 맞춘 노드 · 곡선 + 켬 · R(기울기 잣대 없음 = 추천 「예상노선 추적」 켬과 같은 선). 못 맞추면 None(지금 선).""" + moved = snap_curves_to_stations( + build_planned_polyline(points, min_radius_m=min_radius_m), min_radius_m + ) + return None if moved is None else _flat(moved) + + def snap_curves_to_stations( outline: PlannedPolyline, min_radius_m: float, diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py index 8b89d8468..7add30046 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -32,7 +32,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import ( ) from common_util.common_util_json import atomic_write_json -ALGO_VERSION = 4 # 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) +# 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1) +ALGO_VERSION = 5 STORE_DIR = "B05_Profile/route_recommend" # 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음 KEEP_PER_KIND = 2 diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 85c4fe5ba..a103411ca 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -40,6 +40,7 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice +from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial from B05_Profile.B05_Profile_Engine_Sections import load_station_snap from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀 @@ -299,7 +300,12 @@ def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None: points = [(x, y) for x, y in _vertices_of(source)] if len(points) < 2: return None - summary = _write_planned_polyline(target, points, radius_m) + # 383-1 — 곡선 MC = 일반측점(기본 제약) + [측점 스냅] 켬 · 못 맞추면 지금 선 + 켬 + fit = snapped_initial(points, radius_m) + shape = {"simplify": False, "curve_flags": fit[1], "radii": fit[2]} if fit else {} + summary = _write_planned_polyline( + target, fit[0] if fit else points, radius_m, station_snap=True, **shape + ) logger.info( "계획노선 초기 폴리라인 생성: %s (노드 %d · 곡선 %d · 위반 %d · 정점 %d)", target, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 796b9022a..b18034fed 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -175,7 +175,8 @@ export async function saveRecommendedRoute( showLoadingOverlay(); try { await saveRouteDraft(projectId, { - draft: routeDraftOf(nodes, settings), + // 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬으로 저장 + draft: routeDraftOf(nodes, { ...settings, stationSnap: true }), initial, balanced_profile: balancedProfile, as_initial: true, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index eee451aff..49d0bda3a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -549,7 +549,9 @@ export function violationText(item: InitialViolation): string { ? `R ${fixed(item.value, 1, "m")}` : item.kind === "stream" ? `계류 ${fixed(item.value, 1, "m")}` - : fixed(item.value, 1, ""); + : item.kind === "station" + ? `측점 밖 ${fixed(item.value, 1, "m")}` + : fixed(item.value, 1, ""); const head = item.reason.split(" — ")[0].trim(); return `${item.station} ${value}${head ? `(${head})` : ""}`; } diff --git a/resources/tester/test_224_4_recommend_radius.py b/resources/tester/test_224_4_recommend_radius.py index befa3dae8..07f742958 100644 --- a/resources/tester/test_224_4_recommend_radius.py +++ b/resources/tester/test_224_4_recommend_radius.py @@ -40,9 +40,9 @@ def runs() -> dict: caught: dict = {} real = engine.build_pool - def spy(graph, follow, max_grade, min_radius_m, measure): + def spy(graph, follow, max_grade, min_radius_m, measure, snap): caught["measure"] = measure - caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure) + caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure, snap) return caught["pool"] engine._POOLS.clear() diff --git a/resources/tester/test_383_1_route_station_default.py b/resources/tester/test_383_1_route_station_default.py new file mode 100644 index 000000000..11fc6f0bc --- /dev/null +++ b/resources/tester/test_383_1_route_station_default.py @@ -0,0 +1,120 @@ +"""383-1 R = 일반측점 기본 제약 — 새 초기 노선 맞춤 + [측점 스냅] 켬 · 추천 후보 맞춤 · [적용] 켬. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_383_1_route_station_default.py -q + +새 사본(예상노선만) → 초기 노선 곡선 MC 가 일반측점 위(≈ 0 밖) · 곡선표 `station_snap` 켬 · +추천 「예상노선 추적」(켬) 노드 = 초기 노선 노드 · 측점 밖 곡선 = 「못 지킨 구간」(station) · +옛 사본(초기 노선 이미 있음)은 파일 · 깃발 그대로(말없이 측점 안 바뀜) · 추천 [적용] 은 스냅 켬. +첫 셈(검증_sub1 · 14 % · R12 · 묶음 + 영선) 74.6 s → 42.6 s(10-17 실측 · 같은 기계). +""" + +import json +import shutil +from pathlib import Path + +import pytest +from shapely.geometry import LineString, Point + +from B05_Profile.B05_Profile_Engine_RouteInitial import generate_initial_route +from B05_Profile.B05_Profile_Engine_RouteInitial_Station import MC_TOL_M, STATION_M +from B05_Profile.B05_Profile_Engine_Sections import load_station_snap +from B05_Profile.B05_Profile_Router_Replan import ( + _curves_path, + _ensure_planned_initial, + _nodes_path, + _write_planned_polyline, +) +from common_util.common_util_route_geometry import ( + expected_route_csv_path, + planned_route_initial_path, + read_planned_route_csv, +) + +ROOT = Path(__file__).resolve().parents[2] +PROJECT = ROOT / "storage/1/3/7e420ed6-f940-4d12-a1a5-39cdcf70a391" # 검증_sub1 +RADIUS = 12.0 + + +def _copy_expected(tmp_path: Path) -> Path: + source = expected_route_csv_path(PROJECT) + if not source.is_file(): + pytest.skip("검증_sub1 storage 없음") + target = expected_route_csv_path(tmp_path) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + return tmp_path + + +def _off_station(path: Path) -> tuple[int, int]: + """(측점 밖 곡선 수, 곡선 수) — 저장 파일에서 다시 잼(MC = BC · EC 누가거리 가운데).""" + line = LineString([(v.x, v.y) for v in read_planned_route_csv(path).vertices]) + curves = json.loads(_curves_path(path).read_text(encoding="utf-8"))["curves"] + taken, off = set(), 0 + for curve in curves: + mc = (line.project(Point(curve["start"])) + line.project(Point(curve["end"]))) / 2 + n = round(mc / STATION_M) + if n >= 1 and abs(mc - n * STATION_M) <= MC_TOL_M and n not in taken: + taken.add(n) + else: + off += 1 + return off, len(curves) + + +def test_new_copy_initial_on_stations_and_flag_on(tmp_path: Path) -> None: + root = _copy_expected(tmp_path) + assert _ensure_planned_initial(root, RADIUS) is not None + initial = planned_route_initial_path(root) + off, total = _off_station(initial) + print(f"새 초기 노선 곡선 {total} · 측점 밖 {off}") + assert total and off <= 1 + assert load_station_snap(root) is True + + +def test_follow_nodes_equal_initial(tmp_path: Path) -> None: + """추천 「예상노선 추적」(측점 맞춤 켬) = 새 초기 노선 · 측점 밖 곡선 수 = station 목록 수.""" + surface = PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz" + if not surface.is_file(): + pytest.skip("검증_sub1 storage 없음") + import test_351_2_curve_stations as t351 + + root = _copy_expected(tmp_path) + _ensure_planned_initial(root, RADIUS) + terrain, streams = t351._project_terrain() + expected = [(v.x, v.y) for v in read_planned_route_csv(expected_route_csv_path(root)).vertices] + result = generate_initial_route( + expected, + terrain, + streams, + mode="follow", + weights=None, + max_grade=0.14, + min_radius_m=RADIUS, + with_profile=False, + ) + saved = [ + (v.x, v.y) + for v in read_planned_route_csv(_nodes_path(planned_route_initial_path(root))).vertices + ] + nodes = [(node["x"], node["y"]) for node in result["nodes"]] + assert len(nodes) == len(saved) + assert all(abs(a[0] - b[0]) <= 1e-3 and abs(a[1] - b[1]) <= 1e-3 for a, b in zip(nodes, saved)) + misses = [v for v in result["metrics"]["violations"] if v["kind"] == "station"] + assert len(misses) == result["metrics"]["over_limit"]["station"] == result["off_station_curves"] + assert result["unsnapped"] is not None # 「측점 맞춤」 끔 = 맞추기 전 선 + + +def test_old_copy_flag_and_line_unchanged(tmp_path: Path) -> None: + """옛 사본 — 초기 노선(깃발 칸 없음)이 이미 있으면 다시 안 씀 · 스냅 끔 그대로.""" + root = _copy_expected(tmp_path) + initial = planned_route_initial_path(root) + points = [(v.x, v.y) for v in read_planned_route_csv(expected_route_csv_path(root)).vertices] + _write_planned_polyline(initial, points, RADIUS) # 옛 규칙(맞춤 없음 · 칸 없음) + before = {p: p.read_bytes() for p in (initial, _curves_path(initial), _nodes_path(initial))} + assert _ensure_planned_initial(root, RADIUS) is None + assert {p: p.read_bytes() for p in before} == before + assert load_station_snap(root) is False + + +def test_apply_saves_station_snap_on() -> None: + text = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8") + assert "routeDraftOf(nodes, { ...settings, stationSnap: true })" in text diff --git a/resources/tester/test_route_initial.py b/resources/tester/test_route_initial.py index d657a8828..c29b0d5eb 100644 --- a/resources/tester/test_route_initial.py +++ b/resources/tester/test_route_initial.py @@ -120,19 +120,17 @@ def _earth(result: dict) -> float: return result["metrics"]["cut_m3"] + result["metrics"]["fill_m3"] -def _raw(result: dict) -> dict: - """측점 맞추기 전 선(351-2) — 갈래 고르기 · `similar` 는 이 선으로 함.""" - return {**result, **(result.get("unsnapped") or {})} - - def _check_branch_goals(runs: dict) -> None: """갈래마다 제 목표 비교값(`GOALS` — 절성토 · 최대 기울기 · 목표 기울기 편차 · 위험 길이 · 계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5). - 고르기는 측점 맞추기 전 선으로 함(351-2).""" - runs = {mode: _raw(result) for mode, result in runs.items()} + 고르기는 측점에 맞춘 선 · 측점 밖 곡선 없는 후보 먼저(383-1).""" branches = [runs[mode]["metrics"] for mode in BRANCHES] + + def rank(goal, m: dict) -> tuple: + return (m["over_limit"].get("station", 0) > 0, *goal(m)) + for mode, goal in GOALS.items(): - assert all(goal(runs[mode]["metrics"]) <= goal(m) for m in branches), mode + assert all(rank(goal, runs[mode]["metrics"]) <= rank(goal, m) for m in branches), mode for result in runs.values(): m = result["metrics"] assert abs(m["surplus_m3"] - (m["cut_m3"] - m["fill_m3"])) <= 0.11 @@ -242,7 +240,10 @@ def test_terrain_impossible_is_listed() -> None: assert result["relaxed"] is True assert result["terrain_bound_pct"] > GRADE * 100.0 grades = [v for v in result["metrics"]["violations"] if v["kind"] == "grade"] - assert grades and all("지형상 불가능" in v["reason"] for v in grades) + # 측점 밖 0 후보 먼저(383-1) — 쓸기 후보(비중 계산 사유)가 뽑힐 수 있음 + assert grades and all( + ("지형상 불가능" in v["reason"]) or ("비중 계산" in v["reason"]) for v in grades + ) assert all(v["station"].startswith("No.") for v in grades) @@ -454,7 +455,7 @@ def test_project_similar_and_band(project_runs: dict) -> None: earthwork = project_runs["earthwork"] assert earthwork["band_width_m"] < 20.0 near = {item["logic"]: item["max_gap_m"] for item in earthwork["similar"]} - assert near["gentle"] == round(_gap(_raw(earthwork), _raw(project_runs["gentle"])), 1) + assert near["gentle"] == round(_gap(earthwork, project_runs["gentle"]), 1) assert all(gap < 10.0 for gap in near.values()) and "earthwork" not in near assert "follow" not in near # 추적은 40 m 넘게 떨어짐