From ad855b35d7dabbf33ef9b3d5b9b06fc5f90ca1c6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 12:45:38 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EF=BB=BFfix(B05=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EC=97=94=EC=A7=84):=20=EC=9B=90=EC=A7=80?= =?UTF-8?q?=EB=B0=98=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20=EC=B0=B8=EA=B3=A0?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20=EC=82=BC=EA=B0=81=20=EC=97=86=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EC=B8=A1=EC=A0=90=20=ED=95=84=EC=88=98=20=C2=B7=20?= =?UTF-8?q?=ED=95=A9=EC=B9=98=EA=B8=B0=20=EB=A9=88=EC=B6=A4=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=86=A0=EA=B3=B5=20+10=20%=20=C2=B7=20=ED=86=A0?= =?UTF-8?q?=EA=B3=B5=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=9C=84=203=20?= =?UTF-8?q?=EA=B0=9C=EB=A7=8C=20=EA=B7=A0=ED=98=95=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?(419-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 못 지킨 곳 수 = R · 계류 · 측점(원지반 기울기는 목록 · grade 수에 참고) · 후보 거름 · 큰 R 판 · 합치기 · 측점 맞춤의 기울기 막기 걷음 · 탐색 링크 상한 남김 - 꺾임만 → 곡선(155° 이상도) 또는 교점 지움 · 측점 맞춤 곡선 끔 · 늦춤 단계 걷음 - 맞추기 전 선(unsnapped) 걷음 · 시종점 기울기 ends_grade_pct · ends_over_limit - 검증_sub4 첫 갈래 122.6 s → 23.8 s · 곡선 33 → 16~18 · 세 갈래 다른 선 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../B05_Profile_Engine_RouteInitial.py | 83 ++++--- .../B05_Profile_Engine_RouteInitial_Merge.py | 75 +++---- .../B05_Profile_Engine_RouteInitial_Pool.py | 121 +++++++---- .../B05_Profile_Engine_RouteInitial_Radius.py | 26 +++ ...B05_Profile_Engine_RouteInitial_Station.py | 57 ++--- resources/tester/test_351_2_curve_stations.py | 21 +- .../test_383_1_route_station_default.py | 2 +- resources/tester/test_406_1_route_merge.py | 25 +-- .../tester/test_419_1_recommend_engine.py | 203 ++++++++++++++++++ resources/tester/test_route_initial.py | 24 ++- 10 files changed, 472 insertions(+), 165 deletions(-) create mode 100644 resources/tester/test_419_1_recommend_engine.py diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index ba623c52b..b9623bdd4 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -28,9 +28,11 @@ 가파른 지형(옆경사 중앙값 67 %)에서는 제약 안의 길이 폭 10~20 m 한 띠뿐이라 갈래끼리 가까울 수 있다 — 응답 `similar`(10 m 안인 다른 갈래) · `band_width_m`(띠 폭 어림)로 알린다(PLAN 23-4). -공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격: - · 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌. 곡선 맞춤 뒤 측점(20 m) 사이 - 지반이 상한을 넘으면 탐색 상한을 조금씩 낮춰 다시 찾음(`GRADE_CAP_STEPS`). +공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 최소 곡선반지름 · 계류 이격 · 곡선 MC = 일반측점: + · 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌(비탈 곧장 오르는 길 막이). 곡선 + 맞춤 뒤 측점(20 m) 사이 **원지반 기울기는 참고만**(계획선 높이로 푸는 값 · 요구 253-1 · 419) — + 못 지킨 곳 수(`over_limit.total`)에 안 듦 · 목록엔 남음. 시종점 높이 차가 상한을 넘으면 + `ends_over_limit`(종단으로도 못 풂 · 화면 알림). · 최소 곡선반지름 — 한 번에 이웃 방향(약 22.5°)으로만 꺾고, 꺾은 뒤 원호 두 개의 접선 길이만큼 곧게 가야 다시 꺾음 → 노드마다 최소 반지름 원호가 들어감. · 계류 이격 — 버퍼 안에서는 계류 쪽 · 반대쪽 ±45° 로만 움직임(건너기 · 빠져나가기). @@ -61,7 +63,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import ( Pool, build_pool, - pick, + choose, + hard_limits, settle, similar, weighted, @@ -303,21 +306,24 @@ def generate_initial_route( def measure( outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False ) -> dict[str, Any]: - return route_metrics( - outline.vertices, - expected, - terrain, - streams, - outline.curves, - max_grade=max_grade, - min_radius_m=min_radius_m, - stream_offset_m=stream_offset, - road_width_m=ROAD_WIDTH_M, - bound_pct=None if bound is None else bound * 100.0, - grade_smooth_m=grade_smooth, - follow=follow, - weighted=weighted, - target_grade=goal_grade, + # 못 지킨 곳 수 = R · 계류 · 측점 — 원지반 기울기는 참고만(목록엔 남음 · 419) + return hard_limits( + route_metrics( + outline.vertices, + expected, + terrain, + streams, + outline.curves, + max_grade=max_grade, + min_radius_m=min_radius_m, + stream_offset_m=stream_offset, + road_width_m=ROAD_WIDTH_M, + bound_pct=None if bound is None else bound * 100.0, + grade_smooth_m=grade_smooth, + follow=follow, + weighted=weighted, + target_grade=goal_grade, + ) ) def fit(outline: Any, guard: Any = None) -> Any: @@ -326,14 +332,12 @@ def generate_initial_route( moved = snap_curves_to_stations(outline, min_radius_m, guard=guard) return outline if moved is None else moved - unsnapped: dict[int, Any] = {} - def follow_line() -> tuple[Any, dict[str, Any]]: # 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv` · 383-1 측점 맞춤)과 같은 # 규칙 · 같은 입력 → 같은 선. 제약은 따지지 않고 넘은 곳만 목록에 뜸. outline = build_planned_polyline(expected, min_radius_m=min_radius_m) route = (outline, measure(outline, bound=None, follow=True)) - return settle(route, fit, measure, unsnapped, bound=None, follow=True) + return settle(route, fit, measure, bound=None, follow=True) def profiled(route: Any, exact: bool = True) -> None: if route is not None: @@ -347,6 +351,9 @@ def generate_initial_route( exact=exact, ) + def quick(route: Any) -> None: + profiled(route, exact=False) + bound = None pool: Pool | None = None target = _weighted_end(used) if mode == "weighted" else mode @@ -369,19 +376,19 @@ 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 `cross_earthwork_delta`(바뀐 측점 횡단 차이)가 오면 `earth_delta` + # 로 이음 · 그 전엔 노선 잣대 토공(띠 가늠 · `..._Merge.merge_same_side`) _POOLS[key] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure, fit) pool = _POOLS[key] bound = pool.bound if target == "weighted": outline, metrics = weighted(pool, used, min_radius_m, measure) else: - if with_profile: - for route in [*pool.candidates, pool.follow]: - profiled(route, exact=False) # 줄 세우기 — 고른 하나만 아래에서 바로 셈 - outline, metrics = pick(target, pool.candidates, with_profile) - # 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle`) — 모달 「측점 맞춤」 끔 = 맞추기 전 선 + # 종단 반영 켬 토공 · 공사비 = 줄 세운 위 후보만 균형 종단(빠른 판) · 고른 하나만 + # 아래에서 바로 셈(419) + outline, metrics = choose(target, pool.candidates, with_profile, quick) + # 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle` · 맞추기 전 선은 안 냄 · 419) 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 @@ -401,6 +408,7 @@ def generate_initial_route( } shown = finish(snapped) + ends = ends_grade(expected, terrain, shown["metrics"]["length_m"]) return { "mode": mode, "logic": mode, @@ -409,10 +417,11 @@ def generate_initial_route( # 지형상 불가능할 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%) · 그 위는 막았음 "terrain_bound_pct": None if bound is None else round(bound * 100.0, 2), **shown, - # 측점 맞춤 끔 = 고른 선 그대로(맞출 곡선이 없거나 못 맞췄으면 None — 위와 같음) - "unsnapped": None if raw is None else finish(raw), + # 시종점 높이 차 ÷ 노선 연장(%) — 상한 넘으면 종단으로도 못 풂(노선을 늘려야 함 · 화면 알림) + "ends_grade_pct": ends, + "ends_over_limit": ends > max_grade * 100.0 + 1e-6, # 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만 - "similar": None if pool is None else similar(pool, target, outline, with_profile), + "similar": None if pool is None else similar(pool, target, outline, with_profile, quick), "with_profile": with_profile, "band_width_m": None if pool is None else round(pool.band_width_m, 1), "criteria": { @@ -429,6 +438,18 @@ def generate_initial_route( } +def ends_grade(expected: list[tuple[float, float]], terrain: Terrain, length_m: float) -> float: + """시종점 지반 높이 차 ÷ 노선 연장(%) — 균형 종단이 끝점을 지반고에 두므로 이 값이 상한을 넘으면 + 어떤 계획선도 상한 안에 못 듦(419 · 원지반 기울기를 참고로 돌린 뒤 남는 알림).""" + from scipy.interpolate import RegularGridInterpolator + + sample = RegularGridInterpolator( + (terrain.y, terrain.x), terrain.z, bounds_error=False, fill_value=None + ) + z = sample([expected[0][::-1], expected[-1][::-1]]) + return round(abs(float(z[1] - z[0])) / max(length_m, 1e-9) * 100.0, 2) + + def _weighted_end(weights: dict[str, float]) -> str: """비중이 한쪽 100 이면 그 갈래(끝값 = 갈래와 같은 선 · PLAN 23-3e) · 아니면 비중 탐색.""" for name, mode in (("expected", "follow"), ("earthwork", "earthwork"), ("grade", "gentle")): diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py index b7a39eb59..eff942037 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py @@ -9,12 +9,13 @@ 거리 한도(이탈 m) 없음 — 「원래 선」이 없어 한도가 대신하던 것을 직접 잰다(`Guard`): · 탐색 복도(`CORRIDOR_M`) 안 · 계류 버퍼 안 나란히(`along`) 안 늘어남 · 최소 R(별표2) 이상. - · 옮긴 조각이 같은 자리 옛 조각보다 지반 기울기 상한을 더 넘지 않음(`GRADE_NOISE`) · 토공 - 가늠이 `EARTH_TOL` 넘게 늘지 않음 · 비중 계산이면 예상노선 이격도(`keeping_offset`). + · 옮긴 조각 토공 가늠(띠 · 빠른 거름)이 `EARTH_TOL` 넘게 늘지 않음 · 비중 계산이면 예상노선 + 이격도(`keeping_offset`). 원지반 기울기는 참고만(419 · 막지 않음 · 자리 잣대에도 안 씀). · 합친 곡선은 MC 가 측점 사이 어디에 떨어져도 IP 를 밀어 측점에 놓은 꼴이 잼을 지나야 함 (`PLACE_OFFSETS_M` · 측점 맞춤이 합친 곡선을 늘 측점에 · 합치기 풀기 없음). - · 합치기는 노선 전체 잣대(`route_metrics`)도 — 넘은 곳 수 · 기울기 넘은 곳 안 늘어남 · 최대 - 기울기 `GRADE_TOL_PCT` · 절성토 `TOTAL_EARTH_TOL` 안(합치기 전 선 기준 · 되풀이해도 누적 안 됨). + · 합치기 멈춤 = 노선 전체 잣대 — 못 지킨 곳 수(R · 계류) 안 늘어남 · 노선 토공이 합치기 전 선보다 + `TOTAL_EARTH_TOL` 넘게 늘지 않음(엔진 상수 · 사용자 옵션 아님 · 되풀이해도 누적 안 됨). 토공 = + 바뀐 측점 횡단 차이(`cross_earthwork_delta` · 420) · 없으면 노선 잣대 토공(띠 가늠). 큰 R(`..._Radius.widen`) · 측점 맞춤(`..._Station`)도 같은 `Guard` 로 잰다(거리 한도 대신). """ @@ -27,7 +28,7 @@ from typing import Any import numpy as np import shapely -from shapely.geometry import LineString +from shapely.geometry import LineString, Point from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import SAMPLE_M, STATION_M, _resample from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat @@ -52,11 +53,8 @@ Route = tuple[Any, dict[str, Any]] # 옮긴 조각 토공 가늠(탐색 몫 · 옆경사 단면)이 옛 조각보다 늘어도 되는 몫 · 바닥(㎥ · 짧은 조각) EARTH_TOL = 0.05 EARTH_FLOOR_M3 = 5.0 -# 옮긴 조각 지반 기울기가 상한을 더 넘어도 「안 늘어남」으로 볼 잴 잡음(비율 · 0.2 %p) -GRADE_NOISE = 0.002 -# 합치기 노선 전체 잣대 — 최대 기울기(%p) · 절성토 늘어도 되는 몫 -GRADE_TOL_PCT = 0.5 -TOTAL_EARTH_TOL = 0.02 +# 합치기 멈춤 — 노선 토공(횡단)이 합치기 전 선보다 늘어도 되는 몫(곡선 수 ↔ 토공 · 요구 254) +TOTAL_EARTH_TOL = 0.10 # R 후보 수(자리 R ~ 최소 R 사이 · 큰 것부터) RADIUS_STEPS = 12 # 합친 곡선 MC 가 앞 측점에서 이만큼(m) 뒤일 때 — 앞 측점으로 당기거나(−t) 뒤 측점으로 미는(20 − t) @@ -150,27 +148,19 @@ class Guard: self.seen[key] = found return found - def judge( - self, local: list[Xy], reference: list[Xy] - ) -> tuple[bool, tuple[bool, float, float, float]]: - """(지나나, 자리 고르기 잣대) — 지남 = 복도 안 · 계류 나란히 · 기울기 넘음 안 늘어남 · 토공 - `EARTH_TOL` 안(`offset` 이면 예상노선 이격도 같은 폭). 잣대(작을수록 좋음) = 복도 밖 · - 늘어난 계류 나란히(m) · 더 넘은 기울기 · 늘어난 토공 가늠(㎥).""" - excess, earth, along, inside, offset = self._look(local) - old_excess, old_earth, old_along, _, old_offset = self._look(reference) + def judge(self, local: list[Xy], reference: list[Xy]) -> tuple[bool, tuple[bool, float, float]]: + """(지나나, 자리 고르기 잣대) — 지남 = 복도 안 · 계류 나란히 · 토공 `EARTH_TOL` 안(`offset` + 이면 예상노선 이격도 같은 폭) · 원지반 기울기는 안 봄(419). 잣대(작을수록 좋음) = 복도 밖 · + 늘어난 계류 나란히(m) · 늘어난 토공 가늠(㎥).""" + _, earth, along, inside, offset = self._look(local) + _, old_earth, old_along, _, old_offset = self._look(reference) passed = ( inside - and excess <= old_excess + GRADE_NOISE and along <= old_along + 1e-6 and earth <= old_earth * (1 + EARTH_TOL) + EARTH_FLOOR_M3 and (not self.offset or offset <= old_offset * (1 + EARTH_TOL) + EARTH_FLOOR_M3) ) - cost = ( - not inside, - round(max(0.0, along - old_along), 3), - round(max(0.0, excess - old_excess), 4), - earth - old_earth, - ) + cost = (not inside, round(max(0.0, along - old_along), 3), earth - old_earth) return passed, cost def ok(self, local: list[Xy], reference: list[Xy]) -> bool: @@ -243,15 +233,8 @@ def _earth(metrics: dict[str, Any]) -> float: return metrics["cut_m3"] + metrics["fill_m3"] -def _worse(new: dict[str, Any], base: dict[str, Any]) -> bool: - """노선 전체 잣대가 합치기 전보다 나빠짐(허용 폭 밖).""" - over, was = new["over_limit"], base["over_limit"] - return ( - over["total"] > was["total"] - or over["grade"] > was["grade"] - or new["max_grade_pct"] > base["max_grade_pct"] + GRADE_TOL_PCT - or _earth(new) > _earth(base) * (1 + TOTAL_EARTH_TOL) - ) +# (합치기 전 선, 합친 선, 바뀐 자리 누가거리(앞 선 기준 s0, s1)) → 노선 토공 차(㎥ · 횡단) +EarthDelta = Callable[[PlannedPolyline, PlannedPolyline, tuple[float, float]], float] def merge_same_side( @@ -259,14 +242,21 @@ def merge_same_side( guard: Guard, min_radius_m: float, measure: Callable[[PlannedPolyline], dict[str, Any]], + cross_earthwork_delta: EarthDelta | None = None, ) -> Route: - """같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로.""" + """같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로. + + 멈춤 = 못 지킨 곳 수(R · 계류 · 원지반 기울기 뺌) 늘어남 · 노선 토공이 합치기 전 선의 + 1 + `TOTAL_EARTH_TOL` 넘음. `cross_earthwork_delta` 주면 토공 = 바뀐 측점 횡단 차이 누적 · + 없으면 노선 잣대 토공(`measure` 절 + 성).""" outline, base = route if len(outline.curves) < 2: return route points, flags, radii = _flat(outline) points = [(round(x, 4), round(y, 4)) for x, y in points] current = route + limit = _earth(base) * (1 + TOTAL_EARTH_TOL) + earth = _earth(base) # 지금 선 토공 — 차이를 누적 blocked: set[tuple[Xy, ...]] = set() while True: reference = _Reference(current[0].vertices) @@ -287,9 +277,9 @@ def merge_same_side( LineString(local), LineString(reference.window(local)), densify=0.1 ) ) - found.append((gap, k, cross, radius)) + found.append((gap, k, cross, radius, local)) merged = None - for _, k, cross, radius in sorted(found): + for _, k, cross, radius, local in sorted(found, key=lambda item: item[:2]): trial_points = points[:k] + [cross] + points[k + 2 :] trial_flags = flags[:k] + [True] + flags[k + 2 :] trial_radii = radii[:k] + [radius] + radii[k + 2 :] @@ -301,11 +291,16 @@ def merge_same_side( radii=trial_radii, ) metrics = measure(line) - if _worse(metrics, base): + if cross_earthwork_delta is None: + total = _earth(metrics) + else: + window = sorted(reference.line.project(Point(xy)) for xy in (local[0], local[-1])) + total = earth + cross_earthwork_delta(current[0], line, (window[0], window[1])) + if metrics["over_limit"]["total"] > base["over_limit"]["total"] or total > limit: blocked.add(tuple(points[k - 1 : k + 3])) continue - merged = (trial_points, trial_flags, trial_radii, (line, metrics)) + merged = (trial_points, trial_flags, trial_radii, (line, metrics), total) break if merged is None: return current - points, flags, radii, current = merged + points, flags, radii, current, earth = merged diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index 6943b4f48..63c8a199b 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -4,18 +4,23 @@ 후보 묶음(`build_pool`) = 같은 입력(지형 · 예상노선 · 계류 · 제약)이면 한 번만 짠다: · 제약 탐색 후보 — 목표 하나씩(`TARGETS` · 토공 · 기울기 · 이격 · 목표 기울기 편차 · 위험 · - 계류 횡단 · 공사비) × 탐색 상한 단계(지킬 수 있을 - 때) 또는 벌점 크기(지형상 불가능할 때 · 막은 기울기 안) — PLAN 23-1 그대로. + 계류 횡단 · 공사비) × 탐색 링크 상한(지킬 수 있을 때 · 한 번) 또는 벌점 크기(지형상 불가능할 + 때 · 막은 기울기 안) — PLAN 23-1. · 토공 ↔ 기울기 쓸기 — 비중 계산(예상 0)을 토공 100 → 기울기 100 사이 `SWEEP_STEPS` 벌. 갈래가 그 사이 어느 비중보다 제 목표에서 좋게(토공 최소 ≤ 쓸기 절성토 · 기울기 순한 ≤ 쓸기 최대 기울기). - · 못 지킨 곳 수가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. + · 못 지킨 곳 수(R · 계류 · 측점 — 원지반 기울기는 참고만 · 419 `hard_limits`)가 기준(엄격: 후보 + 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. 탐색 링크 상한(비탈 곧장 오르는 길 막이)은 + 남김. · R = 일반측점이 기본 제약(383-1) — 후보마다 곡선 MC 를 일반측점에 맞춘 선(`snap`)으로 재고 고르고 거름. 못 맞춘 곡선은 「못 지킨 구간」(station) · 고르기는 측점 밖 0 후보를 앞세움. + 꺾임만(R 없는 교점)은 안 남김(`..._Radius.curve_every_kink` · 측점 맞춤 · 419). 갈래는 **비교값으로** 고른다(`pick` · `GOALS`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면 평균) · 영선 노선 = 목표 기울기 편차 · 안전 우선 = 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) · 계류 횡단 최소 = 횡단 수(같으면 계류에서 먼 쪽) · 공사비 어림 최소 = 상대 공사비(PLAN 23-5). +종단 반영 켬 토공 · 공사비(`choose`) = 상한 누른 계획선 토공으로 줄 세운 위 `SHORTLIST` 개만 균형 +종단(빠른 판)을 붙여 고름(419). 비중 계산(`weighted`)은 고르기가 아니라 **탐색 비용의 가중합** 한 번(PLAN 23-4a): · 몫 = 예상노선에서 벗어난 거리 · 토공 가늠 · 기울기 — 각 몫을 끝 노선 셋(추적 · 토공 최소 · @@ -33,14 +38,14 @@ from typing import Any import numpy as np from shapely.geometry import LineString -from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, merge_same_side +from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import EarthDelta, Guard, merge_same_side from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( COST_CROSSING, COST_EARTH_M3, COST_LENGTH_M, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth -from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import widen +from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import curve_every_kink, widen from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CROSSING_BAND, OBJECTIVES, @@ -62,8 +67,8 @@ TARGETS: dict[str, dict[str, float]] = { "crossings": {"crossing": 100.0}, "cost": {}, } -# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음 -GRADE_CAP_STEPS = (1.0, 0.93, 0.85) +# 종단 반영 켬 토공 · 공사비 고르기 — 균형 종단(빠른 판)을 붙일 위 후보 수(419) +SHORTLIST = 3 # 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게 # 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨) SOFT_GRADE_COST = 500.0 @@ -102,20 +107,28 @@ 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 # 큰 R · 합치기 · 측점 맞춤이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`) guard: Guard | None = None + # 합치기 멈춤 토공 = 바뀐 측점 횡단 차이(420 · 없으면 노선 잣대 토공 · `merge_same_side`) + earth_delta: EarthDelta | None = None -def settle(route: Route, snap: Snap, measure: Measure, raw: dict[int, Route], **kw: Any) -> Route: - """측점에 맞춘 선 + 잰 값(측점 밖 곡선 = 못 지킨 구간 · 383-1) · 맞추기 전 선은 `raw` 에.""" +def hard_limits(metrics: dict[str, Any]) -> dict[str, Any]: + """원지반 기울기 = 참고만(419) — 못 지킨 곳 수(`over_limit.total`)에서 뺀 잰 값. + + 기울기 넘은 곳은 목록(`violations` · kind grade)과 `over_limit.grade` 에 참고로 남는다.""" + over = metrics["over_limit"] + 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: - raw[id(moved)] = (outline, with_station_misses(metrics, outline)) outline, metrics = moved, measure(moved, **kw) return outline, with_station_misses(metrics, outline) @@ -125,12 +138,12 @@ def _trace( ) -> Route: # 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 2단을 최소 R 판 # 노드에 바로 건 판(단순화가 기울기 위반을 늘리는 급한 지형 · 검증_sub8 실측) → 1단 판(탐색 - # 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 · - # 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시 짜는 꼴. - # 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로). - # 고른 판의 같은 쪽 이웃 곡선을 합치고(406 · `..._Merge`) 측점에 맞춤(`settle` · 383-1). - # 큰 R · 합치기 · 측점 맞춤 모두 거리 한도 대신 직접 잼(`pool.guard`) · 비중 계산은 예상노선 - # 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만). + # 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반(R · 계류 · 원지반 기울기 뺌)이 + # 최소 R 판(노드마다 곡선 · 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시 + # 짜는 꼴. 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄). + # 고른 판의 꺾임만을 곡선 · 교점 지움으로(419) → 같은 쪽 이웃 곡선 합치기(406 · `..._Merge`) → + # 측점에 맞춤(`settle` · 383-1). 큰 R · 합치기 · 측점 맞춤 모두 거리 한도 대신 직접 + # 잼(`pool.guard`) · 비중 계산은 예상노선 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만). traced = pool.traced guard = pool.guard if guard is not None and kw.get("weighted"): @@ -157,8 +170,13 @@ def _trace( least, ) if guard is not None: - chosen = merge_same_side(chosen, guard, min_radius_m, lambda line: measure(line, **kw)) - traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, pool.raw, **kw) + 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.earth_delta + ) + traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, **kw) return traced[key] @@ -184,22 +202,18 @@ def _candidates( ) -> tuple[list[Route], float | None, int]: """제약 탐색 후보들 · 지형상 불가능 때 막은 기울기(아니면 None) · 후보로 남길 못 지킨 곳 상한. - 제약을 지키는 길이 있으면 목표마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 · - 없으면 막은 기울기 안에서 벌점 크기 · 목표를 바꿔 찾은 노선들. + 제약을 지키는 길이 있으면 목표마다 탐색 링크 상한으로 찾은 노선들(곡선 맞춤 뒤 원지반 + 기울기는 참고만 — 상한 낮춰 다시 찾지 않음 · 419) · 없으면 막은 기울기 안에서 벌점 크기 · + 목표를 바꿔 찾은 노선들. """ graph = pool.graph found: list[Route] = [] for target in TARGETS: - for step in GRADE_CAP_STEPS: - try: - nodes = _solve(graph, target, cap=max_grade * step, strict_stream=True) - except NoRoute: - break - found.append(_trace(pool, nodes, min_radius_m, measure, bound=None)) - if found[-1][1]["over_limit"]["total"] == 0: - break - if not found: # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음 - break + try: + nodes = _solve(graph, target, cap=max_grade, strict_stream=True) + except NoRoute: + break # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음 + found.append(_trace(pool, nodes, min_radius_m, measure, bound=None)) if found: least = min(item[1]["over_limit"]["total"] for item in found) return [item for item in found if item[1]["over_limit"]["total"] == least], None, least @@ -236,9 +250,14 @@ def build_pool( min_radius_m: float, measure: Measure, snap: Snap, + earth_delta: EarthDelta | None = None, ) -> Pool: - """후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함.""" - pool = Pool(graph, snap, follow=follow, guard=Guard(graph.grid, max_grade)) + """후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함. + + `measure` 는 원지반 기울기를 뺀 못 지킨 곳 수를 내야 함(`hard_limits`).""" + pool = Pool( + graph, snap, follow=follow, guard=Guard(graph.grid, max_grade), earth_delta=earth_delta + ) 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]] @@ -259,14 +278,21 @@ def build_pool( return pool -def similar(pool: Pool, mode: str, outline: Any, with_profile: bool = True) -> list[dict[str, Any]]: +def similar( + pool: Pool, + mode: str, + outline: Any, + with_profile: bool = True, + profile: Callable[[Route], None] | None = None, +) -> list[dict[str, Any]]: """이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m). - 화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다.""" + 화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다. + 다른 갈래도 그 갈래와 같은 고르기(`choose` · `profile` = 줄 세운 위 후보에 균형 종단).""" here = LineString(outline.vertices) others = { "follow": pool.follow, - **{name: pick(name, pool.candidates, with_profile) for name in GOALS}, + **{name: choose(name, pool.candidates, with_profile, profile) for name in GOALS}, } found = [] for name, (other, _) in others.items(): @@ -333,6 +359,29 @@ GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = { } +def choose( + mode: str, + candidates: list[Route], + with_profile: bool, + profile: Callable[[Route], None] | None, +) -> Route: + """갈래 고르기 — 종단 반영 켬 토공 · 공사비는 상한 누른 계획선 토공(종단 전 비교값)으로 줄 세운 + 위 `SHORTLIST` 개만 `profile`(균형 종단 빠른 판)을 붙여 그 사이에서 고름(419 · 후보마다 종단을 + 세우지 않음). 나머지 갈래 · 종단 끔은 `pick` 그대로.""" + if with_profile and profile is not None and mode in ("earthwork", "cost"): + goal = GOALS[mode] + candidates = sorted( + {id(item[1]): item for item in candidates}.values(), # 같은 노선 겹침 뺌 + key=lambda item: ( + item[1]["over_limit"].get("station", 0) > 0, + *goal(plain_metrics(item[1])), + ), + )[:SHORTLIST] + for route in candidates: + profile(route) + return pick(mode, candidates, with_profile) + + def pick(mode: str, candidates: list[Route], with_profile: bool = True) -> Route: """갈래 목표 비교값이 가장 좋은 후보 — 측점 밖 곡선 없는 후보 먼저(383-1). 같으면 앞 후보. `with_profile` 끔 = 원지반 가늠 점수.""" diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py index baac35b68..d2e173310 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py @@ -10,6 +10,8 @@ 복도 밖 · 기울기 · 계류 · 토공이 나빠지지 않는 가장 큰 R(`..._Merge.Guard` · 자리 R ~ 맞춘 R 사이 `GUARD_STEPS` 벌 · 1 m 내림). +꺾임만(`curve_every_kink` · 419) — 추천 후보에 R 없는 교점을 안 남김(곡선 또는 교점 지움). + 고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로 펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다. """ @@ -51,6 +53,30 @@ def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float | return points, flags, radii +def curve_every_kink(outline: PlannedPolyline, min_radius_m: float) -> PlannedPolyline: + """꺾임만(R 없는 교점 · 삼각) 없앤 선(419) — 최소 R 원호가 들어가면 곡선(내각 155° 이상도) · + 아니면 교점 지움(곧게). 꺾임만이 없으면 받은 그대로.""" + points, flags, radii = _flat(outline) + if all(flags[1:-1]): + return outline + index = 1 + while index < len(points) - 1: + if flags[index]: + index += 1 + continue + before, apex, after = points[index - 1 : index + 2] + half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2) + room = min(math.dist(before, apex), math.dist(apex, after)) / 2 + if half_tan > 1e-9 and room / half_tan >= min_radius_m: + flags[index] = True + index += 1 + else: + del points[index], flags[index], radii[index] + return build_planned_polyline( + points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii + ) + + def _cross(u: np.ndarray, v: np.ndarray) -> np.ndarray: return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0] diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py index 94a4a7cc8..5a6f7622a 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py @@ -21,11 +21,13 @@ R = 일반측점이 기본 제약(383-1 · 요구 241-13) — 추천 후보마 · 그것도 못 하면 None — 부르는 쪽이 원래 선을 씀. 추천 후보(`guard` 줌 · PLAN 406 · 요구 251-2)는 이탈 한도 없음 — 옮긴 조각을 직접 잼 -(`..._Merge.Guard` · 복도 · 기울기 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐)으로 거름: +(`..._Merge.Guard` · 복도 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐 · 원지반 기울기는 +참고만 · 419)으로 거름. MC = 일반측점 필수 · 꺾임만(R 없는 교점 · 삼각) 없음(419): · 같은 쪽으로 가장 많이 합친 꼴부터 놓아 봄(S자는 안 합침) · 자리는 그 잣대가 가장 나은 곳부터. - · 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안 · 더 넘는 기울기 `SOFT_SLACKS` 단계까지) — - MC 를 다 측점에 놓는 첫 단계 판. - · 그래도 못 놓으면 더 안 넘기는 놓기만 쓰고 그 곡선은 그 자리(측점 밖 · 기울기 먼저). + · 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안) — MC 는 늘 측점에. + · 곡선 끔 없음 — 내각 155° 이상도 곡선 · 꺾임만으로 들어온 자리도 곡선으로 놓아 봄 · 곡선을 못 + 끼우는 자리(나란함)는 교점 지움 · 이웃과 한 측점을 다투면 교점째 뺌. + · 그래도 못 놓으면 그 곡선은 그 자리(측점 밖 · 마지막 수). · 합친 꼴 먼저 판에 측점 밖이 생기면 옛 차례(그대로 → 합침) 판도 맞춰 측점 밖이 적은 쪽(406-4). 예상노선 추적 · 새 초기 노선은 안 줌(이탈 한도 그대로). @@ -78,9 +80,6 @@ RADIUS_STEPS = 8 BACKTRACK_LIMIT = 30 # 한 꺾임 꼴(그대로 · 합친 꼴마다)에서 볼 놓기 수 — 막히면 R · 측점만 바꾸기보다 합치기로 PER_VARIANT = 3 -# 직접 잼(406) — 잼을 못 지나는 놓기가 더 넘겨도 되는 기울기(비율) 단계 · 앞 단계로 다 놓으면 -# 그 판 · 다 못 놓으면 더 안 넘기는 놓기만 쓰고 못 놓는 곡선은 그 자리(부분 맞춤) -SOFT_SLACKS = (0.0, 0.01, 0.03) @dataclass @@ -317,7 +316,6 @@ class _Snapper: self.excess = excess self.guard = guard self.keep = False - self.slack = math.inf # 못 지나는 놓기가 더 넘겨도 되는 기울기(`SOFT_SLACKS`) self.merged_first = guard is not None # 같은 쪽으로 합친 꼴부터 놓아 봄(406) def _steeper(self, local: list[Xy]) -> bool: @@ -362,7 +360,7 @@ class _Snapper: max(lowest, STATION_M * math.floor(mc / STATION_M)), max(lowest, STATION_M * math.ceil(mc / STATION_M)), } - found: list[tuple[tuple[bool, float, float], Xy, _Corner, float, float]] = [] + found: list[tuple[tuple, list[Xy], Xy, _Corner, float, float]] = [] for station in stations: for radius in radii: solved = _solve(state, plan, k, station, radius) @@ -376,12 +374,12 @@ class _Snapper: rank: tuple = (tight, abs(station - mc), abs(radius - corner.radius)) if self.guard is not None: # 직접 잼(406) — 잣대 가장 나은 자리부터 · 복도 밖은 버림 - passed, (out, along, steeper, earth) = self.guard.judge( + passed, (out, along, earth) = self.guard.judge( local, self.reference.window(local) ) - if out or passed == soft or steeper > self.slack: + if out or passed == soft: continue - rank = (along, steeper, tight, rank[1], earth, rank[2]) + rank = (along, tight, rank[1], earth, rank[2]) found.append((rank, local, new_at, new_corner, new_mc, station)) given = 0 for _, local, new_at, new_corner, new_mc, station in sorted( @@ -408,7 +406,19 @@ class _Snapper: """꺾임점 k 의 갈래 — 측점에 놓기 → 뒤 꺾임과 합쳐 놓기 → (완만하면) 곡선 끔.""" plan, k = state.plan, state.k before, at, after = plan.points[k - 1 : k + 2] - if not plan.flags[k] or _corner(before, at, after, plan.radii[k] or 1.0) is None: + if self.guard is not None: + # 추천 후보(419) — 꺾임만 없음: 곡선 못 끼우는 자리(나란함)는 교점 지움 · 꺾임만으로 + # 들어온 자리도 곡선으로 놓아 봄 + if _corner(before, at, after, plan.radii[k] or 1.0) is None: + gone = plan.copy() + del gone.points[k], gone.flags[k], gone.radii[k] + yield replace(state, plan=gone) + return + if not plan.flags[k]: + plan = plan.copy() + plan.flags[k] = True + state = replace(state, plan=plan) + elif not plan.flags[k] or _corner(before, at, after, plan.radii[k] or 1.0) is None: kink = plan.copy() kink.flags[k] = False # 꺾임만(나란한 직선 등 곡선 못 끼움 포함) yield self._kink(state, kink) @@ -433,10 +443,13 @@ class _Snapper: for soft in (False, True) if self.guard is not None else (False,): for trial in variants: yield from self._placements(state, trial, soft) - # 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만 + # 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만(추천 후보는 + # 안 함 · 155° 이상도 곡선 · 419) middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2) - if _inner_angle_deg(before, at, after) >= STRAIGHT_INNER_ANGLE_DEG and not self._far( - [state.end_xy, at, middle] + if ( + self.guard is None + and _inner_angle_deg(before, at, after) >= STRAIGHT_INNER_ANGLE_DEG + and not self._far([state.end_xy, at, middle]) ): kink = plan.copy() kink.flags[k], kink.radii[k] = False, None @@ -486,7 +499,8 @@ class _Snapper: at, ((at[0] + b[0]) / 2, (at[1] + b[1]) / 2), ] - if self._far(piece) or self._steeper(piece): + # 꺾임만 남김 — 추천 후보는 안 함(삼각 없음 · 419) + if self.guard is not None or self._far(piece) or self._steeper(piece): continue off = plan.copy() off.flags[small], off.radii[small] = False, None @@ -584,13 +598,8 @@ def snap_curves_to_stations( def _snap(snapper: _Snapper, min_radius_m: float) -> PlannedPolyline | None: - """다 놓는 길(직접 잼이면 `SOFT_SLACKS` 단계) → 부분 맞춤 차례로 맞춘 선 · 못 하면 None.""" - state = None - for slack in SOFT_SLACKS if snapper.guard is not None else (math.inf,): - snapper.slack = slack - state = state or snapper.run(keep=False) - snapper.slack = 0.0 - state = state or snapper.run(keep=True) + """다 놓는 길 → 부분 맞춤 차례로 맞춘 선 · 못 하면 None.""" + state = snapper.run(keep=False) or snapper.run(keep=True) if state is None: return None snapped = build_planned_polyline( diff --git a/resources/tester/test_351_2_curve_stations.py b/resources/tester/test_351_2_curve_stations.py index a725dfe86..bc961a0f1 100644 --- a/resources/tester/test_351_2_curve_stations.py +++ b/resources/tester/test_351_2_curve_stations.py @@ -3,8 +3,8 @@ ./venv/Scripts/python.exe -m pytest resources/tester/test_351_2_curve_stations.py -q 모든 곡선 MC % 20 ≈ 0 · 이웃 MC 20 m 이상 · 곡선 수 ≤ 내부 일반측점 수 · 맞추기 전 선에서 -`ROUTE_STATION_SNAP_MAX_GAP_M`(6 m) 안 · 화면이 펴서 다시 짜도(저장 반올림) 그대로 · 추천 결과의 -기울기 넘은 곳이 맞추기 전보다 늘지 않음 · 검증_sub1 예상노선 8벌 수치. +`ROUTE_STATION_SNAP_MAX_GAP_M`(6 m) 안 · 화면이 펴서 다시 짜도(저장 반올림) 그대로 · 추천 결과는 +측점에 맞춘 선만(맞추기 전 선 안 냄 · 419) · 검증_sub1 예상노선 8벌 수치. """ import math @@ -142,11 +142,8 @@ def test_recommend_result_on_stations(hill_runs: dict, mode: str) -> None: if mode != "follow": # 갈래는 기울기 상한 안(언덕은 비켜 갈 수 있음) assert result["metrics"]["over_limit"]["total"] == 0 assert result["metrics"]["max_grade_pct"] <= 14.0 + 1e-6 - # 측점 맞춤 끔 = 맞추기 전 선(같으면 None) — 비교값 따로 - raw = result["unsnapped"] - if raw is not None: - assert raw["planned"] != result["planned"] and raw["metrics"]["length_m"] > 0 - assert set(raw) == {"planned", "nodes", "curves", "off_station_curves", "metrics"} + # 측점 맞춤 필수 — 맞추기 전 선은 안 냄(419) + assert "unsnapped" not in result def _project_terrain(): @@ -189,17 +186,15 @@ def test_project_modes_before_after() -> None: min_radius_m=12.0, with_profile=False, ) - raw = result["unsnapped"] or result - after, before = result["metrics"], raw["metrics"] + after = result["metrics"] curves = len(result["curves"]) on_total += curves - result["off_station_curves"] curves_total += curves print( - f"{mode}: 넘은 곳 {before['over_limit']['total']} → {after['over_limit']['total']}" - f" · 최대 기울기 {before['max_grade_pct']:.1f} → {after['max_grade_pct']:.1f} %" - f" · 곡선 {len(raw['curves'])} → {curves}(측점 밖 {result['off_station_curves']})" + f"{mode}: 넘은 곳 {after['over_limit']['total']}" + f" · 최대 기울기 {after['max_grade_pct']:.1f} %" + f" · 곡선 {curves}(측점 밖 {result['off_station_curves']})" ) - assert raw["off_station_curves"] >= result["off_station_curves"] print(f"측점 위 곡선 {on_total}/{curves_total} = {100 * on_total / curves_total:.0f}%") assert on_total / curves_total >= 0.75 diff --git a/resources/tester/test_383_1_route_station_default.py b/resources/tester/test_383_1_route_station_default.py index 889991380..95d5d45db 100644 --- a/resources/tester/test_383_1_route_station_default.py +++ b/resources/tester/test_383_1_route_station_default.py @@ -100,7 +100,7 @@ def test_follow_nodes_equal_initial(tmp_path: Path) -> None: 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 # 「측점 맞춤」 끔 = 맞추기 전 선 + assert "unsnapped" not in result # 측점 맞춤 필수 — 맞추기 전 선 안 냄(419) def test_old_copy_flag_and_line_unchanged(tmp_path: Path) -> None: diff --git a/resources/tester/test_406_1_route_merge.py b/resources/tester/test_406_1_route_merge.py index 35a1145b9..102ab4298 100644 --- a/resources/tester/test_406_1_route_merge.py +++ b/resources/tester/test_406_1_route_merge.py @@ -12,11 +12,7 @@ import numpy as np from shapely.geometry import LineString, Point from B05_Profile.B05_Profile_Engine_RouteInitial import generate_initial_route -from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import ( - GRADE_NOISE, - Guard, - merge_same_side, -) +from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, merge_same_side from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, widen from B05_Profile.B05_Profile_Engine_RouteInitial_Search import SearchGrid, Terrain from B05_Profile.B05_Profile_Engine_RouteInitial_Station import _Reference @@ -73,16 +69,18 @@ def test_s_curve_keeps_straight() -> None: assert _flat(merged)[0] == _flat(_outline(S_CURVE))[0] -def test_valley_grade_blocks() -> None: - """옛 선 = 골짜기 바닥(평평) · 합친 곡선은 옆 비탈을 올라 기울기 상한을 넘음.""" +def test_valley_earth_blocks() -> None: + """옛 선 = 골짜기 바닥 · 합친 곡선은 옆 비탈을 올라 토공 가늠이 늘어 막힘(419 — 원지반 기울기는 + 참고만이라 기울기로는 안 막음 · 넘는 만큼은 잼에 남음).""" distance = _distance(_outline(SAME)) guard = _guard(SAME, z=lambda xx, yy: 3.0 * distance(xx, yy)) assert len(_merge(SAME, guard).curves) == 2 - # 막은 까닭 = 기울기 — 합친 꼴 조각이 옛 자리보다 상한을 더 넘음 cross = _line_intersection(*SAME) piece = guard.piece(SAME[0], cross, SAME[-1], 40.0) window = _Reference(_outline(SAME).vertices).window(piece) - assert guard._look(piece)[0] > guard._look(window)[0] + GRADE_NOISE + passed, (out, along, earth) = guard.judge(piece, window) + assert not passed and not out and along == 0.0 and earth > 0.0 + assert guard._look(piece)[0] > guard._look(window)[0] # 기울기는 참고로 잼 def test_outside_corridor_blocks() -> None: @@ -111,9 +109,12 @@ def test_stream_buffer_blocks() -> None: def test_route_yardstick_blocks() -> None: - """노선 전체 잣대(넘은 곳 · 최대 기울기 · 절성토)가 합치기 전보다 나빠지면 안 합침.""" - worse = {**FLAT, "max_grade_pct": 5.0} - assert len(_merge(SAME, _guard(SAME), measure=lambda line: worse).curves) == 2 + """노선 전체 잣대(못 지킨 곳 · 절성토 +10 %)가 합치기 전보다 나빠지면 안 합침 · 최대 기울기는 + 참고만(419).""" + steeper = {**FLAT, "max_grade_pct": 5.0} + assert len(_merge(SAME, _guard(SAME), measure=lambda line: steeper).curves) == 1 + over = {**FLAT, "over_limit": {"total": 1, "grade": 0}} + assert len(_merge(SAME, _guard(SAME), measure=lambda line: over).curves) == 2 def test_widen_guard_larger_than_gap_limit() -> None: diff --git a/resources/tester/test_419_1_recommend_engine.py b/resources/tester/test_419_1_recommend_engine.py new file mode 100644 index 000000000..4c4f91c5a --- /dev/null +++ b/resources/tester/test_419_1_recommend_engine.py @@ -0,0 +1,203 @@ +"""419 노선 추천 엔진 — 원지반 기울기 참고만 · 삼각 없음 · 측점 필수 · 합치기 멈춤 = 노선 토공. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_419_1_recommend_engine.py -q + +· 못 지킨 곳 수 = R · 계류 · 측점(원지반 기울기는 목록 · `grade` 수에 참고로만). +· 꺾임만(R 없는 교점) → 곡선(155° 이상도) 또는 교점 지움 · 측점 맞춤(직접 잼)도 꺾임만을 안 냄. +· 합치기 멈춤 = 노선 토공 +10 % — 바뀐 측점 횡단 차이(`cross_earthwork_delta`)를 주면 그 값으로. +· 종단 반영 켬 토공 고르기 = 줄 세운 위 3 개만 균형 종단 · 시종점 기울기 알림 · 맞추기 전 선 안 냄. +""" + +import math + +import numpy as np +import pytest + +from B05_Profile.B05_Profile_Engine_RouteInitial import ends_grade, generate_initial_route +from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import ( + TOTAL_EARTH_TOL, + Guard, + merge_same_side, +) +from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import SHORTLIST, choose, hard_limits +from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, curve_every_kink +from B05_Profile.B05_Profile_Engine_RouteInitial_Search import SearchGrid, Terrain +from B05_Profile.B05_Profile_Engine_RouteInitial_Station import ( + off_station_count, + snap_curves_to_stations, +) +from common_util.common_util_route_polyline import build_planned_polyline + +RADIUS = 12.0 +SAME = [(20.0, 20.0), (150.0, 20.0), (230.0, 70.0), (260.0, 180.0)] + + +def _kinks(outline) -> int: + return sum(1 for flag in _flat(outline)[1][1:-1] if not flag) + + +def _flat_terrain(width=802.0, height=402.0) -> Terrain: + x = np.arange(0.0, width, 2.0) + y = np.arange(0.0, height, 2.0) + xx, _ = np.meshgrid(x, y) + return Terrain(x, y, np.zeros(xx.shape), np.ones(xx.shape, dtype=bool)) + + +def test_hard_limits_leave_grade_as_reference() -> None: + metrics = { + "over_limit": {"grade": 3, "radius": 1, "stream": 1, "total": 5}, + "violations": [{"kind": "grade"}] * 3 + [{"kind": "radius"}, {"kind": "stream"}], + } + hard = hard_limits(metrics) + assert hard["over_limit"] == {"grade": 3, "radius": 1, "stream": 1, "total": 2} + assert hard["violations"] == metrics["violations"] # 목록엔 참고로 남음 + + +def test_kink_becomes_curve_even_gentle() -> None: + """내각 160° 꺾임만(곡선 끔) → 곡선(최소 R) · 155° 이상도 곡선 둠.""" + points = [(0.0, 0.0), (100.0, 0.0), (200.0, 100.0 * math.tan(math.radians(20.0)))] + kinked = build_planned_polyline( + points, min_radius_m=RADIUS, simplify=False, curve_flags=[True, False, True] + ) + assert _kinks(kinked) == 1 + fixed = curve_every_kink(kinked, RADIUS) + assert _kinks(fixed) == 0 and len(fixed.curves) == 1 + assert fixed.curves[0].radius_m >= RADIUS - 1e-6 and not fixed.curves[0].violations + + +def test_kink_without_room_is_removed() -> None: + """앞뒤 직선이 짧아 최소 R 이 안 드는 꺾임만 → 교점 지움(곧게).""" + points = [(0.0, 0.0), (100.0, 0.0), (103.0, 2.0), (200.0, 2.0)] + kinked = build_planned_polyline( + points, min_radius_m=RADIUS, simplify=False, curve_flags=[True, True, False, True] + ) + fixed = curve_every_kink(kinked, RADIUS) + assert _kinks(fixed) == 0 + assert len(_flat(fixed)[0]) == 3 # 짧은 쪽 교점 하나 빠짐 + + +def _zigzag(seed: int) -> list[tuple[float, float]]: + rng = np.random.default_rng(seed) + points, heading = [(60.0, 200.0)], 0.0 + for _ in range(10): + heading += float(rng.uniform(-0.6, 0.6)) + step = float(rng.uniform(40.0, 80.0)) + x, y = points[-1] + points.append((x + step * math.cos(heading), y + step * math.sin(heading))) + return points + + +@pytest.mark.parametrize("seed", range(6)) +def test_guard_snap_no_kinks_all_on_station(seed: int) -> None: + """직접 잼 측점 맞춤(추천 후보) — 평지 · 꺾임만 섞인 선 → 꺾임만 0 · 측점 밖 0.""" + points = _zigzag(seed) + flags = [True] + [i % 3 != 1 for i in range(1, len(points) - 1)] + [True] + before = build_planned_polyline(points, min_radius_m=RADIUS, simplify=False, curve_flags=flags) + lo = np.min(points, axis=0) - 150.0 + hi = np.max(points, axis=0) + 150.0 + x = np.arange(lo[0], hi[0], 2.0) + y = np.arange(lo[1], hi[1], 2.0) + terrain = Terrain(x, y, np.zeros((len(y), len(x))), np.ones((len(y), len(x)), dtype=bool)) + guard = Guard(SearchGrid(points, terrain, [], stream_offset_m=100.0), 0.14) + after = snap_curves_to_stations(before, RADIUS, guard=guard) + assert after is not None + assert _kinks(after) == 0 + assert off_station_count(after) == 0 + + +FLAT = {"over_limit": {"total": 0, "grade": 0}, "max_grade_pct": 0.0} + + +def _merge(earth_base: float, delta: float): + """평지 같은 쪽 두 꺾임 — 노선 토공 `earth_base` · 합친 선 차이 `delta`(가짜 횡단 함수).""" + outline = build_planned_polyline( + SAME, min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(SAME) + ) + base = {**FLAT, "cut_m3": earth_base, "fill_m3": 0.0} + calls = [] + + def cross(before, after, window): + calls.append(window) + return delta + + guard = Guard(SearchGrid(SAME, _flat_terrain(402.0, 262.0), [], stream_offset_m=100.0), 0.14) + merged = merge_same_side((outline, base), guard, RADIUS, lambda line: base, cross)[0] + return merged, calls + + +def test_merge_stops_at_route_earth_tolerance() -> None: + """합치기 판정 = 바뀐 측점 횡단 차이 — +10 % 안이면 합치고 넘으면 안 합침(엔진 상수).""" + assert TOTAL_EARTH_TOL == pytest.approx(0.10) + merged, calls = _merge(1000.0, 90.0) + assert len(merged.curves) == 1 and calls + s0, s1 = calls[0] + assert 0.0 <= s0 < s1 # 바뀐 자리(앞 선 누가거리)만 넘김 + blocked, calls = _merge(1000.0, 110.0) + assert len(blocked.curves) == 2 and calls + + +def _route(name: str, earth: float, station: int = 0): + metrics = { + "name": name, + "cut_m3": earth, + "fill_m3": 0.0, + "cost_index": earth, + "over_limit": {"total": station, "station": station}, + } + return (object(), metrics) + + +def test_choose_profiles_only_shortlist() -> None: + """종단 반영 켬 토공 = 상한 누른 계획선 토공 위 3 개만 균형 종단 · 그 사이에서 고름.""" + routes = [_route(f"r{i}", 100.0 + 10 * i) for i in range(8)] + [_route("off", 1.0, 1)] + profiled = [] + + def profile(route): + profiled.append(route[1]["name"]) + # 균형 종단 토공은 순서가 바뀜 — 셋째가 가장 작음 + earth = {"r0": 900.0, "r1": 800.0, "r2": 300.0}.get(route[1]["name"], 50.0) + route[1]["profile"] = {"cut_m3": earth, "fill_m3": 0, "borrow_m3": 0, "spoil_m3": 0} + + chosen = choose("earthwork", routes, True, profile) + assert profiled == ["r0", "r1", "r2"] and len(profiled) == SHORTLIST + assert chosen[1]["name"] == "r2" + # 종단 끔 = 줄 세우기 값 그대로 · 종단 안 붙임 + profiled.clear() + assert choose("earthwork", routes, False, profile)[1]["name"] == "r0" and not profiled + + +def test_ends_grade() -> None: + x = np.arange(0.0, 402.0, 2.0) + y = np.arange(0.0, 102.0, 2.0) + xx, _ = np.meshgrid(x, y) + slope = Terrain(x, y, 0.3 * xx, np.ones(xx.shape, dtype=bool)) + assert ends_grade([(50.0, 50.0), (250.0, 50.0)], slope, 200.0) == pytest.approx(30.0) + assert ends_grade([(50.0, 50.0), (250.0, 50.0)], slope, 600.0) == pytest.approx(10.0) + + +@pytest.fixture(scope="module") +def hill_runs() -> dict: + x = np.arange(0.0, 802.0, 2.0) + y = np.arange(0.0, 402.0, 2.0) + xx, yy = np.meshgrid(x, y) + hill = Terrain(x, y, 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2), xx > -1) + expected = [(float(v), 200.0 + 40.0 * math.sin(v / 60.0)) for v in np.arange(50.0, 752.0, 4.0)] + return { + mode: generate_initial_route( + expected, hill, [], mode=mode, weights=None, max_grade=0.14, min_radius_m=RADIUS + ) + for mode in ("gradeline", "earthwork", "gentle", "weighted") + } + + +@pytest.mark.parametrize("mode", ["gradeline", "earthwork", "gentle", "weighted"]) +def test_hill_branches_rules(hill_runs: dict, mode: str) -> None: + """언덕 — 꺾임만 0 · 측점 밖 0 · 못 지킨 곳 수 = R · 계류 · 측점 · 맞추기 전 선 없음.""" + 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 "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 c29b0d5eb..ead394515 100644 --- a/resources/tester/test_route_initial.py +++ b/resources/tester/test_route_initial.py @@ -2,8 +2,9 @@ ./venv/Scripts/python.exe -m pytest resources/tester/test_route_initial.py -q -공통 제약(종단기울기 상한 · 최소 곡선반지름 · 계류 이격)은 넘지 못함 — 지형상 불가능할 때만 -`metrics.violations` 에 측점 · 값 · 사유로 뜨고 `over_limit.total` 은 그 수와 같다. +공통 제약(최소 곡선반지름 · 계류 이격)은 넘지 못함 — 지형상 불가능할 때만 `metrics.violations` 에 +측점 · 값 · 사유로 뜨고 `over_limit.total` 은 그 수와 같다. 원지반 기울기(탐색 링크 상한은 막음)는 +참고만 — 목록엔 뜨되 `total` 에 안 듦(419). 예상노선 추적(follow)은 전처리 초기값과 같은 선 — 제약 탐색 없이 넘은 곳만 목록(PLAN 23-1). 갈래는 제 목표 비교값이 가장 좋은 후보 — 실프로젝트(랩탑_보조 · 읽기만)에서 갈래가 갈림. """ @@ -32,8 +33,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( grade_line, station_label, ) -from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS -from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth +from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS, SHORTLIST +from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth from common_util.common_util_route_polyline import build_planned_polyline SEARCHED = [mode for mode in MODES if mode != "follow"] @@ -68,9 +69,10 @@ def _run(terrain: Terrain, mode: str, streams=(), weights=None) -> dict: def _kept(result: dict) -> None: - """제약을 지켰거나, 못 지킨 곳이 목록에 있고 수가 같음.""" + """제약을 지켰거나, 못 지킨 곳이 목록에 있고 수가 같음(원지반 기울기는 참고 · 수에 안 듦).""" metrics = result["metrics"] - assert metrics["over_limit"]["total"] == len(metrics["violations"]) + hard = [v for v in metrics["violations"] if v["kind"] != "grade"] + assert metrics["over_limit"]["total"] == len(hard) if not metrics["violations"]: assert metrics["max_grade_pct"] <= GRADE * 100.0 + 1e-6 assert result["relaxed"] is False @@ -109,7 +111,7 @@ def test_follow_is_initial_polyline_and_lists_violations(hill: Terrain) -> None: assert result["relaxed"] is False and result["terrain_bound_pct"] is None grades = [v for v in result["metrics"]["violations"] if v["kind"] == "grade"] assert grades and all("예상노선 추적" in v["reason"] for v in grades) - assert result["metrics"]["over_limit"]["total"] == len(result["metrics"]["violations"]) + _kept(result) def _earth(result: dict) -> float: @@ -123,13 +125,19 @@ def _earth(result: dict) -> float: def _check_branch_goals(runs: dict) -> None: """갈래마다 제 목표 비교값(`GOALS` — 절성토 · 최대 기울기 · 목표 기울기 편차 · 위험 길이 · 계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5). - 고르기는 측점에 맞춘 선 · 측점 밖 곡선 없는 후보 먼저(383-1).""" + 고르기는 측점에 맞춘 선 · 측점 밖 곡선 없는 후보 먼저(383-1). 토공 · 공사비는 상한 누른 계획선 + 토공(종단 전 값) 위 `SHORTLIST` 안에서 균형 종단으로 고름(419) — 종단 전 값으로 위 3 안.""" 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(): + if mode in ("earthwork", "cost"): + plain = sorted(rank(goal, plain_metrics(m)) for m in branches) + own = rank(goal, plain_metrics(runs[mode]["metrics"])) + assert own <= plain[min(SHORTLIST, len(plain)) - 1], mode + continue assert all(rank(goal, runs[mode]["metrics"]) <= rank(goal, m) for m in branches), mode for result in runs.values(): m = result["metrics"] From 4ae4fa49ea7f5a8864ccabd109eac48255af8ee7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 12:56:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B05=20=EB=85=B8=EC=84=A0=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=97=94=EC=A7=84):=20=ED=95=A9=EC=B9=98=EA=B8=B0?= =?UTF-8?q?=20=ED=8C=90=EC=A0=95=20=3D=20=EB=B0=94=EB=80=90=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=ED=9A=A1=EB=8B=A8=20=EC=B0=A8=EC=9D=B4=20=C2=B7=20?= =?UTF-8?q?=ED=9B=84=EB=B3=B4=20=EC=A4=84=20=EC=84=B8=EC=9B=80=20=3D=20?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=ED=9A=A1=EB=8B=A8=20=ED=86=A0=EA=B3=B5=20?= =?UTF-8?q?=C2=B7=20=EA=B7=A0=ED=98=95=20=EC=A2=85=EB=8B=A8=20=EC=9C=84=20?= =?UTF-8?q?5=20=EA=B0=9C=20(419-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 420 cross_earthwork · cross_earthwork_delta 이음 — 합치기 멈춤 = 계획고 지반고 노선 횡단 +10 % - 토공 · 공사비 줄 세움 = 상한 누른 계획선 횡단(종단 켬) · 지반고 횡단(종단 끔) - 위 후보 수 3 → 5(검증_sub4 균형 종단 토공 최소 = 줄 세움 5 위) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../B05_Profile_Engine_RouteInitial.py | 91 +++++++++++++++++-- .../B05_Profile_Engine_RouteInitial_Merge.py | 26 ++++-- .../B05_Profile_Engine_RouteInitial_Pool.py | 67 ++++++++------ .../tester/test_224_4_recommend_radius.py | 4 +- .../tester/test_419_1_recommend_engine.py | 27 ++++-- 5 files changed, 159 insertions(+), 56 deletions(-) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index 4535c59fd..538fbd3f1 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -54,11 +54,18 @@ from __future__ import annotations import hashlib import json +import math from pathlib import Path from typing import Any import numpy as np +from shapely.geometry import LineString +from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import ( + cross_earthwork, + cross_earthwork_delta, +) +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, @@ -70,6 +77,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import ( weighted, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import ( + STATION_M, + _reference, attach_profile, cover_shortfall, plain_metrics, @@ -300,9 +309,10 @@ def generate_initial_route( 후보 묶음(`..._Pool`)에서 제 목표 비교값이 가장 좋은 것. `stream_offset_m`·`grade_smooth_m` 을 안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서 프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정). - 후보마다 균형 종단(`..._Profile` · PLAN 63-4)을 붙여 토공 점수를 그것으로 고른다 — 편집 창 - 추천 점수 · 미리 보기에만 쓰고 확정 종단과 무관. `pipes`(x, y, 덮개 여유)는 덮개 부족 수만 센다. - `with_profile` 끄면(종단 반영 끔) 종단 없이 63-4 전 점수(원지반 가늠)로 고른다. + 토공 · 공사비 갈래는 후보를 노선 횡단 토공(`..._CrossEarth` · 상한 누른 계획선)으로 줄 세워 위 + 몇 개에만 균형 종단(`..._Profile` · PLAN 63-4)을 붙여 고른다(419) — 편집 창 추천 점수 · 미리 + 보기에만 쓰고 확정 종단과 무관. `pipes`(x, y, 덮개 여유)는 덮개 부족 수만 센다. + `with_profile` 끄면(종단 반영 끔) 종단 없이 계획고 지반고 횡단 토공으로 고른다. """ if mode not in MODES: raise ValueError(f"모르는 갈래: {mode}") @@ -365,6 +375,58 @@ def generate_initial_route( def quick(route: Any) -> None: profiled(route, exact=False) + def cross(line: Any, at: Any = None, design_z: Any = None) -> dict[str, Any]: + return cross_earthwork( + line.vertices, + at, + design_z, + terrain, + standard, + design_curves=[curve.as_dict() for curve in line.curves], + ) + + def route_earth(line: Any) -> float: + # 합치기 멈춤 잣대 = 계획고 지반고 횡단(1차 종단 규칙 · 종단 끔과 같음) + try: + found = cross(line) + except ValueError: + return math.inf + return found["cut"] + found["fill"] + + def merge_delta(before: Any, after: Any, window: tuple[float, float]) -> float: + # 바뀐 자리 측점만 — 뒤 측점은 같은 선을 따라 밀린 것뿐(옛 선 끝 s1 → 새 선 s1 + 연장 차) + shift = LineString(after.vertices).length - LineString(before.vertices).length + first = math.ceil(window[0] / STATION_M) * STATION_M + try: + return cross_earthwork_delta( + before.vertices, + after.vertices, + np.arange(first, window[1] + shift + 1e-9, STATION_M), + terrain, + standard, + before_chainages=np.arange(first, window[1] + 1e-9, STATION_M), + design_curves_before=[curve.as_dict() for curve in before.curves], + design_curves_after=[curve.as_dict() for curve in after.curves], + )["earth"] + except ValueError: + return math.inf + + def cross_score(route: Any, capped: bool) -> float: + # 후보 줄 세움(419) — 켬 = 상한 누른 계획선(`_reference`) 횡단 · 끔 = 계획고 지반고 횡단 + held = pool.scores if pool is not None else {} + key = (id(route[1]), capped) + if key not in held: + try: + found = cross(route[0]) + if capped: + at = np.asarray([row["chainage_m"] for row in found["per_station"]]) + ground = np.asarray([row["ground_m"] for row in found["per_station"]]) + found = cross(route[0], at, _reference(ground, np.diff(at), max_grade)) + held[key] = found["cut"] + found["fill"] + except ValueError: + held[key] = math.inf + return held[key] + bound = None pool: Pool | None = None target = _weighted_end(used) if mode == "weighted" else mode @@ -387,17 +449,24 @@ 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 `cross_earthwork_delta`(바뀐 측점 횡단 차이)가 오면 `earth_delta` - # 로 이음 · 그 전엔 노선 잣대 토공(띠 가늠 · `..._Merge.merge_same_side`) - _POOLS[key] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure, fit) + # 합치기 멈춤 토공 = 노선 횡단 + 바뀐 측점 횡단 차이(420 `..._CrossEarth` · 419) + _POOLS[key] = build_pool( + graph, + follow_line(), + max_grade, + min_radius_m, + measure, + fit, + cross=RouteEarth(route_earth, merge_delta), + ) pool = _POOLS[key] bound = pool.bound if target == "weighted": outline, metrics = weighted(pool, used, min_radius_m, measure) else: - # 종단 반영 켬 토공 · 공사비 = 줄 세운 위 후보만 균형 종단(빠른 판) · 고른 하나만 - # 아래에서 바로 셈(419) - outline, metrics = choose(target, pool.candidates, with_profile, quick) + # 토공 · 공사비 = 횡단 토공으로 줄 세움 · 종단 반영 켬이면 위 후보만 균형 종단(빠른 + # 판) · 고른 하나만 아래에서 바로 셈(419) + outline, metrics = choose(target, pool.candidates, with_profile, quick, cross_score) # 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle` · 맞추기 전 선은 안 냄 · 419) snapped = (outline, metrics) @@ -432,7 +501,9 @@ def generate_initial_route( "ends_grade_pct": ends, "ends_over_limit": ends > max_grade * 100.0 + 1e-6, # 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만 - "similar": None if pool is None else similar(pool, target, outline, with_profile, quick), + "similar": None + if pool is None + else similar(pool, target, outline, with_profile, quick, cross_score), "with_profile": with_profile, "band_width_m": None if pool is None else round(pool.band_width_m, 1), "criteria": { diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py index eff942037..82cf6e214 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py @@ -24,6 +24,7 @@ from __future__ import annotations import copy import math from collections.abc import Callable +from dataclasses import dataclass from typing import Any import numpy as np @@ -233,8 +234,13 @@ def _earth(metrics: dict[str, Any]) -> float: return metrics["cut_m3"] + metrics["fill_m3"] -# (합치기 전 선, 합친 선, 바뀐 자리 누가거리(앞 선 기준 s0, s1)) → 노선 토공 차(㎥ · 횡단) -EarthDelta = Callable[[PlannedPolyline, PlannedPolyline, tuple[float, float]], float] +@dataclass +class RouteEarth: + """합치기 멈춤 잣대 — 노선 토공(횡단 ㎥ · `..._CrossEarth`)과 바뀐 측점 횡단 차이.""" + + total: Callable[[PlannedPolyline], float] + # (합치기 전 선, 합친 선, 바뀐 자리 누가거리(앞 선 기준 s0, s1)) → 노선 토공 차(㎥) + delta: Callable[[PlannedPolyline, PlannedPolyline, tuple[float, float]], float] def merge_same_side( @@ -242,21 +248,23 @@ def merge_same_side( guard: Guard, min_radius_m: float, measure: Callable[[PlannedPolyline], dict[str, Any]], - cross_earthwork_delta: EarthDelta | None = None, + route_earth: RouteEarth | None = None, ) -> Route: """같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로. 멈춤 = 못 지킨 곳 수(R · 계류 · 원지반 기울기 뺌) 늘어남 · 노선 토공이 합치기 전 선의 - 1 + `TOTAL_EARTH_TOL` 넘음. `cross_earthwork_delta` 주면 토공 = 바뀐 측점 횡단 차이 누적 · - 없으면 노선 잣대 토공(`measure` 절 + 성).""" + 1 + `TOTAL_EARTH_TOL` 넘음. `route_earth` 주면 토공 = 횡단(합치기 전 선 전체 + 바뀐 측점 차이 + 누적) · 없으면 노선 잣대 토공(`measure` 절 + 성 · 띠 가늠).""" outline, base = route if len(outline.curves) < 2: return route points, flags, radii = _flat(outline) points = [(round(x, 4), round(y, 4)) for x, y in points] current = route - limit = _earth(base) * (1 + TOTAL_EARTH_TOL) - earth = _earth(base) # 지금 선 토공 — 차이를 누적 + earth = ( + _earth(base) if route_earth is None else route_earth.total(outline) + ) # 지금 선 토공 — 차이를 누적 + limit = earth * (1 + TOTAL_EARTH_TOL) blocked: set[tuple[Xy, ...]] = set() while True: reference = _Reference(current[0].vertices) @@ -291,11 +299,11 @@ def merge_same_side( radii=trial_radii, ) metrics = measure(line) - if cross_earthwork_delta is None: + if route_earth is None: total = _earth(metrics) else: window = sorted(reference.line.project(Point(xy)) for xy in (local[0], local[-1])) - total = earth + cross_earthwork_delta(current[0], line, (window[0], window[1])) + total = earth + route_earth.delta(current[0], line, (window[0], window[1])) if metrics["over_limit"]["total"] > base["over_limit"]["total"] or total > limit: blocked.add(tuple(points[k - 1 : k + 3])) continue diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index 63c8a199b..f4f322c98 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -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 EarthDelta, Guard, merge_same_side +from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, RouteEarth, merge_same_side from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( COST_CROSSING, COST_EARTH_M3, @@ -67,8 +67,9 @@ TARGETS: dict[str, dict[str, float]] = { "crossings": {"crossing": 100.0}, "cost": {}, } -# 종단 반영 켬 토공 · 공사비 고르기 — 균형 종단(빠른 판)을 붙일 위 후보 수(419) -SHORTLIST = 3 +# 종단 반영 켬 토공 · 공사비 고르기 — 균형 종단(빠른 판)을 붙일 위 후보 수(419 · 검증_sub4 = 상한 누른 +# 계획선 횡단 5 위가 균형 종단 토공 최소 → 5) +SHORTLIST = 5 # 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게 # 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨) SOFT_GRADE_COST = 500.0 @@ -112,8 +113,10 @@ class Pool: band_width_m: float = 0.0 # 큰 R · 합치기 · 측점 맞춤이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`) guard: Guard | None = None - # 합치기 멈춤 토공 = 바뀐 측점 횡단 차이(420 · 없으면 노선 잣대 토공 · `merge_same_side`) - earth_delta: EarthDelta | None = None + # 합치기 멈춤 토공 = 횡단(노선 + 바뀐 측점 차이 · 420 · 없으면 띠 가늠 · `merge_same_side`) + cross: RouteEarth | None = None + # 후보 줄 세움 횡단 토공 기억(잰 값 id · 종단 반영) — 갈래 사이 다시 안 셈(419) + scores: dict[tuple[int, bool], float] = field(default_factory=dict) def hard_limits(metrics: dict[str, Any]) -> dict[str, Any]: @@ -174,7 +177,7 @@ def _trace( 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.earth_delta + chosen, guard, min_radius_m, lambda line: measure(line, **kw), pool.cross ) traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, **kw) return traced[key] @@ -250,14 +253,12 @@ def build_pool( min_radius_m: float, measure: Measure, snap: Snap, - earth_delta: EarthDelta | None = None, + cross: RouteEarth | None = None, ) -> Pool: """후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함. `measure` 는 원지반 기울기를 뺀 못 지킨 곳 수를 내야 함(`hard_limits`).""" - pool = Pool( - graph, snap, follow=follow, guard=Guard(graph.grid, max_grade), earth_delta=earth_delta - ) + pool = Pool(graph, snap, follow=follow, guard=Guard(graph.grid, max_grade), cross=cross) 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]] @@ -284,15 +285,16 @@ def similar( outline: Any, with_profile: bool = True, profile: Callable[[Route], None] | None = None, + earth: CrossScore | None = None, ) -> list[dict[str, Any]]: """이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m). 화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다. - 다른 갈래도 그 갈래와 같은 고르기(`choose` · `profile` = 줄 세운 위 후보에 균형 종단).""" + 다른 갈래도 그 갈래와 같은 고르기(`choose` · `profile` · `earth`).""" here = LineString(outline.vertices) others = { "follow": pool.follow, - **{name: choose(name, pool.candidates, with_profile, profile) for name in GOALS}, + **{name: choose(name, pool.candidates, with_profile, profile, earth) for name in GOALS}, } found = [] for name, (other, _) in others.items(): @@ -359,27 +361,38 @@ GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = { } +# (후보, 종단 반영) → 노선 횡단 토공 ㎥ — 켬 = 상한 누른 계획선 · 끔 = 계획고 지반고(420) +CrossScore = Callable[[Route, bool], float] + + def choose( mode: str, candidates: list[Route], with_profile: bool, profile: Callable[[Route], None] | None, + earth: CrossScore | None = None, ) -> Route: - """갈래 고르기 — 종단 반영 켬 토공 · 공사비는 상한 누른 계획선 토공(종단 전 비교값)으로 줄 세운 - 위 `SHORTLIST` 개만 `profile`(균형 종단 빠른 판)을 붙여 그 사이에서 고름(419 · 후보마다 종단을 - 세우지 않음). 나머지 갈래 · 종단 끔은 `pick` 그대로.""" - if with_profile and profile is not None and mode in ("earthwork", "cost"): - goal = GOALS[mode] - candidates = sorted( - {id(item[1]): item for item in candidates}.values(), # 같은 노선 겹침 뺌 - key=lambda item: ( - item[1]["over_limit"].get("station", 0) > 0, - *goal(plain_metrics(item[1])), - ), - )[:SHORTLIST] - for route in candidates: - profile(route) - return pick(mode, candidates, with_profile) + """갈래 고르기 — 토공 · 공사비는 노선 횡단 토공(`earth` · 없으면 띠 가늠)으로 줄 세움(419): + 종단 반영 켬 = 상한 누른 계획선 횡단 위 `SHORTLIST` 개만 `profile`(균형 종단 빠른 판)을 붙여 + 그 사이에서 고름(후보마다 종단을 세우지 않음) · 끔 = 계획고 지반고 횡단으로 바로 고름. + 나머지 갈래는 `pick` 그대로.""" + if mode not in ("earthwork", "cost"): + return pick(mode, candidates, with_profile) + unique = list({id(item[1]): item for item in candidates}.values()) # 같은 노선 겹침 뺌 + + def score(item: Route, profiled: bool) -> tuple: + plain = plain_metrics(item[1]) + value = _earth(plain) if earth is None else earth(item, profiled) + if mode == "cost": # 공사비 어림의 토공 몫을 횡단 값으로 갈음 + value += plain["cost_index"] - _earth(plain) + return (item[1]["over_limit"].get("station", 0) > 0, value) + + if not (with_profile and profile is not None): + return min(unique, key=lambda item: score(item, False)) + shortlist = sorted(unique, key=lambda item: score(item, True))[:SHORTLIST] + for route in shortlist: + profile(route) + return pick(mode, shortlist, with_profile) def pick(mode: str, candidates: list[Route], with_profile: bool = True) -> Route: diff --git a/resources/tester/test_224_4_recommend_radius.py b/resources/tester/test_224_4_recommend_radius.py index 07f742958..72a365801 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): + def spy(graph, follow, max_grade, min_radius_m, measure, snap, **kw): caught["measure"] = measure - caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure, snap) + caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure, snap, **kw) return caught["pool"] engine._POOLS.clear() diff --git a/resources/tester/test_419_1_recommend_engine.py b/resources/tester/test_419_1_recommend_engine.py index 4c4f91c5a..613b22309 100644 --- a/resources/tester/test_419_1_recommend_engine.py +++ b/resources/tester/test_419_1_recommend_engine.py @@ -17,6 +17,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import ends_grade, generate_ini from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import ( TOTAL_EARTH_TOL, Guard, + RouteEarth, merge_same_side, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import SHORTLIST, choose, hard_limits @@ -113,7 +114,7 @@ def _merge(earth_base: float, delta: float): outline = build_planned_polyline( SAME, min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(SAME) ) - base = {**FLAT, "cut_m3": earth_base, "fill_m3": 0.0} + base = {**FLAT, "cut_m3": 0.0, "fill_m3": 0.0} # 띠 가늠은 안 씀 calls = [] def cross(before, after, window): @@ -121,7 +122,8 @@ def _merge(earth_base: float, delta: float): return delta guard = Guard(SearchGrid(SAME, _flat_terrain(402.0, 262.0), [], stream_offset_m=100.0), 0.14) - merged = merge_same_side((outline, base), guard, RADIUS, lambda line: base, cross)[0] + earth = RouteEarth(lambda line: earth_base, cross) + merged = merge_same_side((outline, base), guard, RADIUS, lambda line: base, earth)[0] return merged, calls @@ -148,22 +150,31 @@ def _route(name: str, earth: float, station: int = 0): def test_choose_profiles_only_shortlist() -> None: - """종단 반영 켬 토공 = 상한 누른 계획선 토공 위 3 개만 균형 종단 · 그 사이에서 고름.""" - routes = [_route(f"r{i}", 100.0 + 10 * i) for i in range(8)] + [_route("off", 1.0, 1)] + """종단 반영 켬 토공 = 노선 횡단 토공(줄 세움) 위 `SHORTLIST` 개만 균형 종단 · 그 사이에서 고름 · + 줄 세움 = `earth`(켬 = 상한 누른 계획선 · 끔 = 지반고) · 없으면 띠 가늠.""" + routes = [_route(f"r{i}", 100.0 + 10 * i) for i in range(12)] + [_route("off", 1.0, 1)] + last = f"r{SHORTLIST - 1}" profiled = [] def profile(route): profiled.append(route[1]["name"]) - # 균형 종단 토공은 순서가 바뀜 — 셋째가 가장 작음 - earth = {"r0": 900.0, "r1": 800.0, "r2": 300.0}.get(route[1]["name"], 50.0) + # 균형 종단 토공은 순서가 바뀜 — 줄 세운 마지막이 가장 작음 · 밖은 더 작아도 안 봄 + earth = 300.0 if route[1]["name"] == last else 900.0 route[1]["profile"] = {"cut_m3": earth, "fill_m3": 0, "borrow_m3": 0, "spoil_m3": 0} chosen = choose("earthwork", routes, True, profile) - assert profiled == ["r0", "r1", "r2"] and len(profiled) == SHORTLIST - assert chosen[1]["name"] == "r2" + assert profiled == [f"r{i}" for i in range(SHORTLIST)] + assert chosen[1]["name"] == last # 종단 끔 = 줄 세우기 값 그대로 · 종단 안 붙임 profiled.clear() assert choose("earthwork", routes, False, profile)[1]["name"] == "r0" and not profiled + # 횡단 줄 세움이 띠 가늠과 다르면 그것을 따름(끔 = 지반고 횡단 하나로 고름) + cross = {f"r{i}": 1000.0 - i for i in range(12)} + + def earth(route, capped): + return cross.get(route[1]["name"], 0.0) + (0.0 if capped else 5.0) + + assert choose("earthwork", routes, False, None, earth)[1]["name"] == "r11" def test_ends_grade() -> None: