From f7bd8dbf0671882fdb17c70e24acdc34c3b246ab Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 3 Nov 2026 16:30:29 +0900 Subject: [PATCH] =?UTF-8?q?fix(769-1):=20=EC=B6=94=EC=B2=9C=204=20?= =?UTF-8?q?=EA=B0=88=EB=9E=98=20=3D=20=EA=B3=84=EC=82=B0=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B2=B0=EA=B3=BC=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20=EA=B0=99=EC=9D=80=20=EC=AA=BD=20=EA=B3=A1=EC=84=A0?= =?UTF-8?q?=20=ED=95=A9=EC=B9=98=EA=B8=B0=20=C2=B7=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=EB=BA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 요구 611: 추천노선 = 평면계획선을 긋는 가이드 선 · 합치기 · 스냅은 예정노선 수정 뒤 평면계획선에만 - _trace = 경로 탐색 → 곡선 넣기(curve_every_kink) → 큰 R(widen) 까지 · merge_same_side · settle 뺌 - 예상노선 추적(merged_follow) · 체인 · /route/plan · replan · follow 는 합치기 · 스냅 그대로 - Pool.snap · Pool.cross · build_pool snap · cross 인자 · 엔진 fit · route_earth · merge_delta 걷음(쓰는 곳 없음) - ALGO_VERSION 9 → 10 - 낡은 시험 고침: test_419_1 · test_351_2 추천 갈래 측점 위 기대 뺌(추적만 측점 위 · 갈래는 안 맞춤 확인 추가) · test_route_initial 영선 · test_351_2 비중 기울기 상한 기대 뺌(원지반 기울기 참고만 · 비중은 벌점만) · test_224_4 build_pool 엿봄 인자 Co-Authored-By: Claude Opus 5.5 --- .../B05_Profile_Engine_RouteInitial.py | 36 +++-------------- .../B05_Profile_Engine_RouteInitial_Pool.py | 40 +++++-------------- .../B05_Profile_Engine_RouteInitial_Store.py | 5 ++- .../tester/test_224_4_recommend_radius.py | 4 +- resources/tester/test_351_2_curve_stations.py | 15 ++++--- .../tester/test_419_1_recommend_engine.py | 5 +-- resources/tester/test_route_initial.py | 5 ++- 7 files changed, 35 insertions(+), 75 deletions(-) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index 5c43c2bef..e17814ced 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -28,7 +28,8 @@ 가파른 지형(옆경사 중앙값 67 %)에서는 제약 안의 길이 폭 10~20 m 한 띠뿐이라 갈래끼리 가까울 수 있다 — 응답 `similar`(10 m 안인 다른 갈래) · `band_width_m`(띠 폭 어림)로 알린다(PLAN 23-4). -공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 최소 곡선반지름 · 계류 이격 · 곡선 MC = 일반측점: +공통 제약(follow 밖 모든 갈래 · **넘지 못함** · 측점 맞춤 · 합치기 없음 = 가이드 선 · 요구 611) — +최소 곡선반지름 · 계류 이격: · 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌(비탈 곧장 오르는 길 막이). 곡선 맞춤 뒤 측점(20 m) 사이 **원지반 기울기는 참고만**(계획선 높이로 푸는 값 · 요구 253-1 · 419) — 못 지킨 곳 수(`over_limit.total`)에 안 듦 · 목록엔 남음. 시종점 높이 차가 상한을 넘으면 @@ -64,11 +65,8 @@ import numpy as np from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import cross_earthwork from B05_Profile.B05_Profile_Engine_RouteInitial_Follow import ( FollowLand, - cross_delta, - cross_total, merged_follow, ) -from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import RouteEarth from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, route_metrics from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import ( Pool, @@ -94,7 +92,6 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( ) from B05_Profile.B05_Profile_Engine_RouteInitial_Station import ( off_station_count, - snap_curves_to_stations, with_station_misses, ) @@ -352,12 +349,6 @@ def generate_initial_route( ) ) - def fit(outline: Any, guard: Any = None) -> Any: - # R = 일반측점(383-1) — 갈래 후보는 직접 잼(`guard` · 406 · 거리 한도 없음) · 추적은 제약을 - # 안 따짐(새 초기 노선 `snapped_initial` 과 같은 잣대 · 거리 한도 그대로). - moved = snap_curves_to_stations(outline, min_radius_m, guard=guard) - return outline if moved is None else moved - def follow_line(grid: Any = None) -> tuple[Any, dict[str, Any]]: # 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 함수(곡선 최적 · 측점 # 맞춤 · 758-1 `..._Follow.merged_follow`) · 같은 입력 → 같은 선. 넘은 곳만 목록에 뜸. @@ -392,13 +383,6 @@ def generate_initial_route( design_curves=[curve.as_dict() for curve in line.curves], ) - def route_earth(line: Any) -> float: - # 합치기 멈춤 잣대 = 계획고 지반고 횡단(1차 종단 규칙 · 종단 끔과 같음) - return cross_total(line, terrain, standard) - - def merge_delta(before: Any, after: Any, window: tuple[float, float]) -> float: - return cross_delta(before, after, window, terrain, standard) - def cross_score(route: Any, capped: bool) -> float: # 후보 줄 세움(419) — 켬 = 상한 누른 계획선(`_reference`) 횡단 · 끔 = 계획고 지반고 횡단 held = pool.scores if pool is not None else {} @@ -446,16 +430,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))) - # 합치기 멈춤 토공 = 노선 횡단 + 바뀐 측점 횡단 차이(420 `..._CrossEarth` · 419) - _POOLS[key] = build_pool( - graph, - follow_line(grid), - max_grade, - min_radius_m, - measure, - fit, - cross=RouteEarth(route_earth, merge_delta), - ) + _POOLS[key] = build_pool(graph, follow_line(grid), max_grade, min_radius_m, measure) pool = _POOLS[key] bound = pool.bound if target == "weighted": @@ -464,8 +439,7 @@ def generate_initial_route( # 토공 · 공사비 = 횡단 토공으로 줄 세움 · 종단 반영 켬이면 위 후보만 균형 종단(빠른 # 판) · 고른 하나만 아래에서 바로 셈(419) outline, metrics = choose(target, pool.candidates, with_profile, quick, cross_score) - # 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle` · 맞추기 전 선은 안 냄 · 419) - snapped = (outline, metrics) + # 추천 = 계산 로직 결과 그대로(합치기 · 측점 맞춤 없음 · 요구 611) · 추적만 평면계획선 길 def finish(route: tuple[Any, dict[str, Any]]) -> dict[str, Any]: # 정밀 종단은 낼 사본에만 — 묶음 캐시 후보는 빠른 판 그대로 둬 다음 고르기가 같은 잣대끼리 @@ -486,7 +460,7 @@ def generate_initial_route( "metrics": values, } - shown = finish(snapped) + shown = finish((outline, metrics)) ends = ends_grade(expected, terrain, shown["metrics"]["length_m"]) return { "mode": mode, diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index f4f322c98..6913f2c99 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -12,9 +12,9 @@ · 못 지킨 곳 수(R · 계류 · 측점 — 원지반 기울기는 참고만 · 419 `hard_limits`)가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. 탐색 링크 상한(비탈 곧장 오르는 길 막이)은 남김. - · R = 일반측점이 기본 제약(383-1) — 후보마다 곡선 MC 를 일반측점에 맞춘 선(`snap`)으로 재고 - 고르고 거름. 못 맞춘 곡선은 「못 지킨 구간」(station) · 고르기는 측점 밖 0 후보를 앞세움. - 꺾임만(R 없는 교점)은 안 남김(`..._Radius.curve_every_kink` · 측점 맞춤 · 419). + · 추천 = 평면계획선을 긋는 가이드 선(요구 611 · 769-1) — 계산 로직 결과 그대로(경로 탐색 → 곡선 + 넣기 → 큰 R) · 같은 쪽 곡선 합치기 · 측점 맞춤은 안 함(평면계획선 길 `..._Follow.merged_follow` + 몫). 꺾임만(R 없는 교점)은 안 남김(`..._Radius.curve_every_kink` · 419). 갈래는 **비교값으로** 고른다(`pick` · `GOALS`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면 평균) · 영선 노선 = 목표 기울기 편차 · 안전 우선 = 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) · @@ -38,7 +38,7 @@ from typing import Any import numpy as np from shapely.geometry import LineString -from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, RouteEarth, merge_same_side +from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( COST_CROSSING, COST_EARTH_M3, @@ -52,7 +52,6 @@ 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 @@ -87,8 +86,6 @@ TRACE_DENSIFY_M = 2.0 Measure = Callable[..., dict[str, Any]] Route = tuple[Any, dict[str, Any]] -# (선, 직접 잼) → 곡선 MC 를 일반측점에 맞춘 선(못 맞추면 그 선 그대로) -Snap = Callable[[Any, Any], Any] @dataclass @@ -96,7 +93,6 @@ class Pool: """같은 입력의 후보 묶음 — 갈래 고르기 · 비중 탐색이 같이 쓴다.""" graph: RouteGraph - snap: Snap candidates: list[Route] = field(default_factory=list) bound: float | None = None limit: int = 0 # 후보로 남길 못 지킨 곳 수 상한 @@ -111,10 +107,8 @@ class Pool: follow: Route | None = None # 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m) band_width_m: float = 0.0 - # 큰 R · 합치기 · 측점 맞춤이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`) + # 큰 R 이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`) guard: Guard | None = None - # 합치기 멈춤 토공 = 횡단(노선 + 바뀐 측점 차이 · 420 · 없으면 띠 가늠 · `merge_same_side`) - cross: RouteEarth | None = None # 후보 줄 세움 횡단 토공 기억(잰 값 id · 종단 반영) — 갈래 사이 다시 안 셈(419) scores: dict[tuple[int, bool], float] = field(default_factory=dict) @@ -127,15 +121,6 @@ def hard_limits(metrics: dict[str, Any]) -> dict[str, Any]: return {**metrics, "over_limit": {**over, "total": over["total"] - over.get("grade", 0)}} -def settle(route: Route, snap: Snap, measure: Measure, **kw: Any) -> Route: - """측점에 맞춘 선 + 잰 값(측점 밖 곡선 = 못 지킨 구간 · 383-1).""" - outline, metrics = route - moved = snap(outline) - if moved is not outline: - outline, metrics = moved, measure(moved, **kw) - return outline, with_station_misses(metrics, outline) - - def _trace( pool: Pool, nodes: list[tuple[float, float]], min_radius_m: float, measure: Measure, **kw: Any ) -> Route: @@ -144,9 +129,9 @@ def _trace( # 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반(R · 계류 · 원지반 기울기 뺌)이 # 최소 R 판(노드마다 곡선 · 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시 # 짜는 꼴. 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄). - # 고른 판의 꺾임만을 곡선 · 교점 지움으로(419) → 같은 쪽 이웃 곡선 합치기(406 · `..._Merge`) → - # 측점에 맞춤(`settle` · 383-1). 큰 R · 합치기 · 측점 맞춤 모두 거리 한도 대신 직접 - # 잼(`pool.guard`) · 비중 계산은 예상노선 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만). + # 고른 판의 꺾임만을 곡선 · 교점 지움으로(419) — 끝. 같은 쪽 곡선 합치기 · 측점 맞춤은 안 함 + # (추천 = 가이드 선 · 요구 611). 큰 R 은 거리 한도 대신 직접 잼(`pool.guard`) · 비중 계산은 + # 예상노선 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만). traced = pool.traced guard = pool.guard if guard is not None and kw.get("weighted"): @@ -176,10 +161,7 @@ def _trace( line = curve_every_kink(chosen[0], min_radius_m) if line is not chosen[0]: chosen = (line, measure(line, **kw)) - chosen = merge_same_side( - chosen, guard, min_radius_m, lambda line: measure(line, **kw), pool.cross - ) - traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, **kw) + traced[key] = chosen return traced[key] @@ -252,13 +234,11 @@ def build_pool( max_grade: float, min_radius_m: float, measure: Measure, - snap: Snap, - cross: RouteEarth | None = None, ) -> Pool: """후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함. `measure` 는 원지반 기울기를 뺀 못 지킨 곳 수를 내야 함(`hard_limits`).""" - pool = Pool(graph, snap, follow=follow, guard=Guard(graph.grid, max_grade), cross=cross) + pool = Pool(graph, follow=follow, guard=Guard(graph.grid, max_grade)) 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]] diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py index 32817ae4d..80cb582d8 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -41,8 +41,9 @@ from common_util.common_util_time import utc_now_text # 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1) · # 6 = 같은 쪽 곡선 합치기 · 거리 한도 대신 직접 잼(406) · 7 = 측점 밖이 늘면 옛 차례 맞춤(406-4) · # 8 = 횡단 토공 · 표준 횡단면 = 저장 패널값 · 엔진 소스 지문(420) · -# 9 = 측점 맞춤 덜 나쁜 자리에 원지반 기울기 늘어남 · 옮긴 거리(요구 606-2) -ALGO_VERSION = 9 +# 9 = 측점 맞춤 덜 나쁜 자리에 원지반 기울기 늘어남 · 옮긴 거리(요구 606-2) · +# 10 = 추천 4 갈래 합치기 · 측점 맞춤 뺌(가이드 선 · 요구 611) +ALGO_VERSION = 10 # 엔진 소스 지문에 드는 파일(저장소 뿌리 기준) — 추천 결과를 낳는 코드 SOURCE_GLOBS = ( "B05_Profile/B05_Profile_Engine_RouteInitial*.py", diff --git a/resources/tester/test_224_4_recommend_radius.py b/resources/tester/test_224_4_recommend_radius.py index 72a365801..5c363263a 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, snap, **kw): + def spy(graph, follow, max_grade, min_radius_m, measure, **kw): caught["measure"] = measure - caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure, snap, **kw) + caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure, **kw) return caught["pool"] engine._POOLS.clear() diff --git a/resources/tester/test_351_2_curve_stations.py b/resources/tester/test_351_2_curve_stations.py index ce6816a33..b60c2014a 100644 --- a/resources/tester/test_351_2_curve_stations.py +++ b/resources/tester/test_351_2_curve_stations.py @@ -122,8 +122,8 @@ def hill_runs() -> dict: @pytest.mark.parametrize("mode", ["follow", "gentle", "earthwork", "weighted"]) def test_recommend_result_on_stations(hill_runs: dict, mode: str) -> None: + """추적(평면계획선 길)만 측점 맞춤 · 추천 갈래는 계산 로직 결과 그대로(769-1 · 요구 611).""" result = hill_runs[mode] - assert result["off_station_curves"] == 0 outline = build_planned_polyline( [tuple(n[k] for k in ("x", "y")) for n in result["nodes"]], min_radius_m=12.0, @@ -133,16 +133,18 @@ def test_recommend_result_on_stations(hill_runs: dict, mode: str) -> None: ) # 화면이 펴서 다시 짠 선 = 서버 선 assert np.allclose(outline.vertices, result["planned"], atol=1e-3) - assert station_hits(outline) == len(outline.curves) > 0 - if mode != "follow": # 갈래는 기울기 상한 안(언덕은 비켜 갈 수 있음) + if mode == "follow": + assert result["off_station_curves"] == 0 + assert station_hits(outline) == len(outline.curves) > 0 + # 갈래는 기울기 상한 안(언덕은 비켜 갈 수 있음) · 비중은 제약을 벌점으로만(넘으면 목록) + if mode in ("gentle", "earthwork"): assert result["metrics"]["over_limit"]["total"] == 0 assert result["metrics"]["max_grade_pct"] <= 14.0 + 1e-6 - # 측점 맞춤 필수 — 맞추기 전 선은 안 냄(419) assert "unsnapped" not in result def test_합성_지형_modes_before_after(hill_runs: dict) -> None: - """합성 언덕(14 % · R12) — 갈래별 최대 기울기와 측점 밖 곡선을 함께 지킨다.""" + """합성 언덕(14 % · R12) — 추적만 측점 위 · 추천 갈래는 측점 맞춤 없음(769-1).""" on_total, curves_total = 0, 0 for mode, result in hill_runs.items(): after = result["metrics"] @@ -155,7 +157,8 @@ def test_합성_지형_modes_before_after(hill_runs: dict) -> None: f" · 곡선 {curves}(측점 밖 {result['off_station_curves']})" ) print(f"측점 위 곡선 {on_total}/{curves_total} = {100 * on_total / curves_total:.0f}%") - assert curves_total and on_total == curves_total + assert curves_total and hill_runs["follow"]["off_station_curves"] == 0 + assert on_total < curves_total # 추천 갈래는 맞추지 않음 def test_합성_예상노선_eight_outlines() -> None: diff --git a/resources/tester/test_419_1_recommend_engine.py b/resources/tester/test_419_1_recommend_engine.py index 89ca5bceb..3a0deee5d 100644 --- a/resources/tester/test_419_1_recommend_engine.py +++ b/resources/tester/test_419_1_recommend_engine.py @@ -203,13 +203,12 @@ def hill_runs() -> dict: @pytest.mark.parametrize("mode", ["gradeline", "earthwork", "gentle", "weighted"]) def test_hill_branches_rules(hill_runs: dict, mode: str) -> None: - """언덕 — 꺾임만 0 · 측점 밖 0 · 못 지킨 곳 수 = R · 계류 · 측점 · 맞추기 전 선 없음.""" + """언덕 — 꺾임만 0 · 못 지킨 곳 수 = R · 계류 · 맞추기 전 선 없음(측점 맞춤 없음 · 769-1).""" result = hill_runs[mode] interior = result["nodes"][1:-1] assert interior and all(node["radius_m"] is not None for node in interior) # 삼각 0 - assert result["off_station_curves"] == 0 over = result["metrics"]["over_limit"] - assert over["total"] == over["radius"] + over["stream"] + over.get("station", 0) + assert over["total"] == over["radius"] + over["stream"] assert "unsnapped" not in result assert result["ends_over_limit"] is False and result["ends_grade_pct"] < 14.0 diff --git a/resources/tester/test_route_initial.py b/resources/tester/test_route_initial.py index 316e6c80a..b9456dbd4 100644 --- a/resources/tester/test_route_initial.py +++ b/resources/tester/test_route_initial.py @@ -104,7 +104,10 @@ def test_every_mode_keeps_grade_limit_on_hill(hill: Terrain, mode: str) -> None: _kept(result) metrics = result["metrics"] assert metrics["over_limit"]["total"] == 0 - assert metrics["max_grade_pct"] <= GRADE * 100.0 + 1e-6 + # 영선 = 상한 기울기를 걷는 선 — 곡선 넣기로 조금 넘을 수 있음(원지반 기울기 참고만 · 419 · + # 측점 맞춤 없는 가이드 선 · 769-1) + if mode != "gradeline": + assert metrics["max_grade_pct"] <= GRADE * 100.0 + 1e-6 # 기점 · 종점은 옮기지 않고 복도(100 m) 밖으로 안 나감 assert result["planned"][0] == [50.0, 200.0] assert result["planned"][-1] == [750.0, 200.0]