From 4ae4fa49ea7f5a8864ccabd109eac48255af8ee7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 12:56:34 +0900 Subject: [PATCH] =?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: