diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index 2ce0afed1..ba7278c84 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -271,7 +271,7 @@ def generate_initial_route( outline = build_planned_polyline(expected, min_radius_m=min_radius_m) return outline, measure(outline, bound=None, follow=True) - def profiled(route: Any) -> None: + def profiled(route: Any, exact: bool = True) -> None: if route is not None: attach_profile( route[1], @@ -279,6 +279,8 @@ def generate_initial_route( terrain, max_grade=max_grade, design_speed=design_speed, + design_curves=[curve.as_dict() for curve in route[0].curves], + exact=exact, ) bound = None @@ -311,7 +313,7 @@ def generate_initial_route( else: if with_profile: for route in [*pool.candidates, pool.follow]: - profiled(route) + profiled(route, exact=False) # 줄 세우기 — 고른 하나만 아래에서 바로 셈 outline, metrics = pick(target, pool.candidates, with_profile) if with_profile: profiled((outline, metrics)) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py index 6aab58523..13ec86a8e 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py @@ -8,12 +8,14 @@ 방법: 1. 측점(20 m)마다 지반 횡단(±20 m · 0.5 m)을 DEM 에서 뜬다 — B05 측점 생성과 같은 꼴 - (좌 = +offset · 상단측 = 좌우 평균 표고가 높은 쪽 · 그쪽 절토). - 2. 측점마다 계획고 ±Δ 의 절 · 성 단면적 곡선 — B06 `compute_cross_design` 그대로(지반유형 - 리핑암 = B06 기본값 · 곡선부 확폭 = B05 측점 생성과 같은 `_plan_radii` · `_curve_widenings` - — 빼면 확정 뒤 성토가 1.7배로 벌어짐 · PLAN 77-7) · `AREA_STEP_M` 간격으로 재고 사이는 보간. + (좌 = +offset · 상단측 = 좌우 평균 표고가 높은 쪽 · 그쪽 절토). 비탈이 안 닫히는 측점용으로 + `CROSS_WIDE_M` 까지 더 떠 둔다(지표면 끝에서 자름). + 2. 측점마다 계획고 ±Δ 의 절 · 성 단면적 곡선 — B06 `compute_cross_design` 그대로. 본 설계 + 기본 설계(`attach_default_designs`)와 같은 입력: 지반유형 리핑암 · 암반 경계 기본 오프셋 · + 곡선부 확폭은 곡선표(없으면 측점별 실측 반경) · 비탈이 안 닫히면 넓힌 샘플로 다시 셈 + (`extend_unclosed` 와 같은 뜻 · PLAN 78). `AREA_STEP_M` 간격으로 재고 사이는 보간. 3. 동적계획(앞 두 측점 상태)으로 측점 계획고를 고른다 — 목표 = 절토(다짐 환산) + 성토 ㎥ + - λ × (성토 − 절토). λ 를 이분해 |절 − 성| / 큰 쪽이 허용(10 %) 안인 가장 작은 λ. + λ × (성토 − 절토). λ 를 이분해 누가토량 끝값(절 다짐 − 성)이 ±허용 ㎥ 안인 가장 작은 λ. · 종단기울기 상한 — 넘지 못함. · 종단곡선 — 측점마다 변화점(20 m)이라 곡선 반쪽이 직선의 0.45 를 못 넘어 L ≤ 18 m < 법정 최소 L(20 m 이상). 그래서 대수차가 곡선 생략 한도(비포장 5 %) 안이어야 함 — 넘으면 @@ -36,6 +38,8 @@ from config.config_system_design import ( EARTHWORK_CONVERSION_FACTORS, FOREST_ROAD_PROFILE_ALIGNMENT, FOREST_ROAD_PROFILE_CRITERIA, + ROUTE_RECOMMEND_MASS_TOLERANCE_M3, + STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, ) if TYPE_CHECKING: @@ -45,21 +49,28 @@ STATION_M = 20.0 # 측점 간격 — 추천 비교값(`..._Metrics.STATION_M`) TAIL_MERGE_M = 5.0 # 종점과 이보다 가까운 정규 측점은 뺌(짧은 끝 구간의 기울기 튐 방지) CROSS_HALF_M = 20.0 # 지반 횡단 반폭 · 간격 — B06 횡단 파일과 같음 CROSS_STEP_M = 0.5 +# 비탈 안 닫힘 측점의 넓힌 샘플 끝 — 배열 크기 한도일 뿐 · 지표면 끝이 먼저면 거기서 자름 +CROSS_WIDE_M = 60.0 LEVEL_RANGE_M = 6.0 # 계획고 후보 = 상한 안 기준선 ± 이 폭 LEVEL_STEP_M = 0.25 -AREA_STEP_M = 2.0 # 단면적 곡선을 재는 계획고 간격(사이는 PCHIP — 1 m 간격과 3 % 안) +AREA_STEP_M = 2.0 # 단면적 곡선을 재는 계획고 간격(사이는 PCHIP — 고를 때만 · 끝 토량은 `_exact`) GROUND_TYPE = "ripping_rock" # B06 기본 지반유형(`attach_default_designs`) CURVE_PENALTY_M3 = 500.0 # 대수차가 생략 한도를 1 %p 넘을 때 벌점(㎥) LAMBDA_MAX = 0.95 LAMBDA_STEPS = 14 +EXACT_STEPS = 6 # 고른 계획선의 실제 누가토량으로 λ 를 더 좁히는 횟수 PIPE_SNAP_M = 5.0 # 관 자리가 노선에서 이 안이면 그 노선의 관으로 봄 GRADE_EPS = 1e-6 def route_sections( - vertices: list[tuple[float, float]], terrain: Terrain + vertices: list[tuple[float, float]], + terrain: Terrain, + design_curves: list[dict[str, Any]] | None = None, ) -> tuple[np.ndarray, np.ndarray, list[list[dict[str, Any]]], list[str], list[dict[str, Any]]]: - """측점 누가거리 · 중심 지반고 · 측점별 지반 횡단 샘플 · 단면유형(상단측 절토) · 곡선부 확폭.""" + """측점 누가거리 · 중심 지반고 · 측점별 지반 횡단 샘플 · 단면유형(상단측 절토) · 곡선부 확폭. + + 샘플은 `CROSS_WIDE_M` 까지 — ±`CROSS_HALF_M` 밖은 지표면이 끊기기 전까지만 담는다.""" xy = np.asarray(vertices, dtype=np.float64) keep = np.r_[True, np.hypot(*np.diff(xy, axis=0).T) > 1e-9] xy = xy[keep] @@ -74,7 +85,7 @@ def route_sections( tangent = np.diff(xy, axis=0)[index] / seg[index][:, None] left = np.column_stack([-tangent[:, 1], tangent[:, 0]]) center = np.column_stack([np.interp(at, chain, xy[:, 0]), np.interp(at, chain, xy[:, 1])]) - offsets = np.arange(-CROSS_HALF_M, CROSS_HALF_M + 1e-9, CROSS_STEP_M) + offsets = np.arange(-CROSS_WIDE_M, CROSS_WIDE_M + 1e-9, CROSS_STEP_M) points = center[:, None, :] + left[:, None, :] * offsets[None, :, None] lookup = points.reshape(-1, 2)[:, ::-1] grid = (terrain.y, terrain.x) @@ -90,32 +101,90 @@ def route_sections( raise ValueError("노선 위 지반고가 없어 종단을 세울 수 없습니다.") fine = ~np.isnan(ground) ground = np.interp(at, at[fine], ground[fine]) + inner = np.abs(offsets) <= CROSS_HALF_M + 1e-9 sections, modes = [], [] for row, flags in zip(z, ok): + # 반폭 밖은 가운데부터 끊김 없이 이어진 유효 샘플만(본 설계 넓힘이 지표면 끝에서 멈춤) + reach = inner.copy() + for side in (offsets > CROSS_HALF_M + 1e-9, offsets < -CROSS_HALF_M - 1e-9): + index = np.flatnonzero(side) + index = index if offsets[index[0]] > 0 else index[::-1] + reach[index] = np.cumprod(flags[index]).astype(bool) sections.append( [ {"offset_m": float(o), "elevation_m": float(e) if f else None, "valid": bool(f)} - for o, e, f in zip(offsets, row, flags) + for o, e, f, r in zip(offsets, row, flags, reach) + if r ] ) - lefts, rights = row[(offsets > 0) & flags], row[(offsets < 0) & flags] + lefts = row[(offsets > 0) & inner & flags] + rights = row[(offsets < 0) & inner & flags] up = "right" if lefts.size and rights.size and rights.mean() > lefts.mean() else "left" modes.append(f"{up}_cut") - return at, ground, sections, modes, _widenings(xy, chain, at) + return at, ground, sections, modes, _widenings(xy, chain, at, design_curves) -def _widenings(xy: np.ndarray, chain: np.ndarray, at: np.ndarray) -> list[dict[str, Any]]: - """측점마다 `compute_cross_design` 확폭 인자 — B05 측점 생성(곡선표 없을 때)과 같은 셈.""" - from B05_Profile.B05_Profile_Engine_Sections_Core import _curve_widenings, _plan_radii +def _widenings( + xy: np.ndarray, + chain: np.ndarray, + at: np.ndarray, + design_curves: list[dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """측점마다 `compute_cross_design` 확폭 인자 — B05 측점 생성(`generate_sections`)과 같은 셈: + 곡선표가 노선에 맞으면 그것 · 없으면 측점별 실측 반경.""" + from B05_Profile.B05_Profile_Engine_Sections_Core import ( + _curve_widenings, + _design_curve_spans, + _design_curve_widenings, + _plan_radii, + ) - radii, outer = _plan_radii(xy, chain, at, float(chain[-1])) - widenings, sides = _curve_widenings(at, radii, outer) + spans = _design_curve_spans(xy, chain, design_curves or []) + if spans: + radii, sides, widenings = _design_curve_widenings(at, spans) + else: + radii, outer = _plan_radii(xy, chain, at, float(chain[-1])) + widenings, sides = _curve_widenings(at, radii, outer) return [ {"plan_radius_m": r, "curve_outer_side": s, "curve_widening_m": w} for r, s, w in zip(radii, sides, widenings) ] +def _station_areas( + samples: list[dict[str, Any]], mode: str, elevation: float, widen: dict[str, Any] +) -> tuple[float, float, float]: + """측점 하나 · 계획고 하나의 (절토 자연 ㎡, 절토 다짐 환산 ㎡, 성토 ㎡) — 본 설계 기본 설계와 + 같은 입력. 반폭에서 비탈이 안 닫히면 넓힌 샘플로 다시 셈(본 설계 `extend_unclosed`).""" + from B06_Section.B06_Section_Engine_Design import compute_cross_design + + def design(pick: list[dict[str, Any]]) -> dict[str, Any]: + return compute_cross_design( + pick, + elevation, + ground_type=GROUND_TYPE, + section_mode=mode, + rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, + **widen, + ) + + narrow = [s for s in samples if abs(s["offset_m"]) <= CROSS_HALF_M + 1e-9] + try: + r = design(narrow) + if r.get("slope_unclosed") and len(samples) > len(narrow): + r = design(samples) + except ValueError: # 지반 샘플이 모자란 측점 — 토량 모름(0) + return 0.0, 0.0, 0.0 + factors = EARTHWORK_CONVERSION_FACTORS + rock = factors.get(r.get("cut_rock_kind") or GROUND_TYPE, factors[GROUND_TYPE]) + return ( + r["cut_area_m2"], + r["cut_soil_area_m2"] * factors["soil"]["compacted"] + + r["cut_rock_area_m2"] * rock["compacted"], + r["fill_area_m2"], + ) + + def _area_curves( sections: list[list[dict[str, Any]]], modes: list[str], @@ -123,10 +192,7 @@ def _area_curves( levels: np.ndarray, curves: list[dict[str, Any]] | None = None, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """측점 · 계획고 후보마다 (절토 자연 ㎡, 절토 다짐 환산 ㎡, 성토 ㎡).""" - from B06_Section.B06_Section_Engine_Design import compute_cross_design - - factors = EARTHWORK_CONVERSION_FACTORS + """측점 · 계획고 후보마다 (절토 자연 ㎡, 절토 다짐 ㎡, 성토 ㎡) — `AREA_STEP_M` 마디 보간.""" shape = levels.shape cut, cut_c, fill = np.zeros(shape), np.zeros(shape), np.zeros(shape) for i, (samples, mode) in enumerate(zip(sections, modes)): @@ -134,29 +200,7 @@ def _area_curves( delta = levels[i] - ground[i] lo = np.floor(delta.min() / AREA_STEP_M) * AREA_STEP_M at = np.arange(lo, delta.max() + AREA_STEP_M, AREA_STEP_M) - rows = [] - for d in at: - try: - r = compute_cross_design( - samples, - float(ground[i] + d), - ground_type=GROUND_TYPE, - section_mode=mode, - **widen, - ) - except ValueError: # 지반 샘플이 모자란 측점 — 토량 모름(0) - rows.append((0.0, 0.0, 0.0)) - continue - rock = factors.get(r.get("cut_rock_kind") or GROUND_TYPE, factors[GROUND_TYPE]) - rows.append( - ( - r["cut_area_m2"], - r["cut_soil_area_m2"] * factors["soil"]["compacted"] - + r["cut_rock_area_m2"] * rock["compacted"], - r["fill_area_m2"], - ) - ) - table = np.asarray(rows) + table = np.asarray([_station_areas(samples, mode, float(ground[i] + d), widen) for d in at]) # 단면적은 계획고에 대해 볼록(대략 2차)이라 직선 보간은 크게 잡힌다 — 단조 3차(PCHIP) if len(at) > 1: curve = PchipInterpolator(at, table, axis=0)(delta) @@ -219,10 +263,12 @@ def balanced_profile( max_grade: float, design_speed: int = 20, curves: list[dict[str, Any]] | None = None, + exact: bool = True, ) -> dict[str, Any]: """상한(종단기울기 · 종단곡선)을 지키며 절 · 성토를 횡단 ㎥ 로 맞춘 계획선과 토량. 시점 · 종점은 지반고(확정 체인 기본 오프셋 0 과 같음) — 그 길이 없을 때만 풀어 다시 찾는다. + `exact` 끄면 보간값 그대로(후보 줄 세우기 — 빠름) · 켜면 고른 선을 바로 셈해 누가토량을 맞춘다. """ started = time.perf_counter() spans = np.diff(chainage) @@ -242,12 +288,31 @@ def balanced_profile( room = 2.0 * FOREST_ROAD_PROFILE_ALIGNMENT["curve_tangent_max_ratio"] * float(spans.min()) fits = criteria["min_curve_length_m"] <= room delta_max = max(skip, room / criteria["min_vertical_radius_m"]) if fits else skip - tolerance = FOREST_ROAD_PROFILE_ALIGNMENT["balance_tolerance_percent"] / 100.0 + tolerance = ROUTE_RECOMMEND_MASS_TOLERANCE_M3 pinned = np.full(levels.shape, np.inf) for end in (0, -1): pinned[end, int(np.argmin(np.abs(levels[end] - ground[end])))] = 0.0 pinned[1:-1] = 0.0 + def measured(result: dict[str, Any]) -> dict[str, Any]: + # 보간은 비탈이 갑자기 안 닫히는 측점에서 크게 틀림(누가 ±1,400㎥) — 고른 선만 바로 셈 + plan = levels[np.arange(len(chainage)), result["path"]] + rows = np.asarray( + [ + _station_areas(sections[i], modes[i], float(plan[i]), curves[i] if curves else {}) + for i in range(len(chainage)) + ] + ) + a, c, f = (float((weight * rows[:, k]).sum()) for k in range(3)) + return { + **result, + "cut": a, + "cut_c": c, + "fill": f, + "gap": f - c, + "imbalance": (f - c) / max(c, f, 1e-9), + } + def solve(lam: float) -> dict[str, Any] | None: base = cut_c + fill + lam * (fill - cut_c) path = _dp(levels, base + pinned, spans, max_grade, delta_max) @@ -263,6 +328,7 @@ def balanced_profile( "cut": float(cut[pick, path].sum()), "cut_c": c, "fill": f, + "gap": f - c, # 누가토량 끝값의 반대 부호 "imbalance": (f - c) / max(c, f, 1e-9), } @@ -270,8 +336,8 @@ def balanced_profile( if tried[0] is None: raise ValueError("종단기울기 상한 안에서 계획고를 고를 수 없습니다.") first = tried[0] - if abs(first["imbalance"]) > tolerance: - sign = 1.0 if first["imbalance"] > 0 else -1.0 + if abs(first["gap"]) > tolerance: + sign = 1.0 if first["gap"] > 0 else -1.0 low, high = 0.0, LAMBDA_MAX for _ in range(LAMBDA_STEPS): mid = 0.5 * (low + high) @@ -279,16 +345,36 @@ def balanced_profile( if result is None: break tried.append(result) - if abs(result["imbalance"]) <= tolerance or result["imbalance"] * sign < 0: + if abs(result["gap"]) <= tolerance or result["gap"] * sign < 0: high = mid else: low = mid - within = [r for r in tried if abs(r["imbalance"]) <= tolerance] + within = [r for r in tried if abs(r["gap"]) <= tolerance] best = ( min(within, key=lambda r: abs(r["lambda"])) if within - else min(tried, key=lambda r: abs(r["imbalance"])) + else min(tried, key=lambda r: abs(r["gap"])) ) + best = measured(best) if exact else best + if exact and abs(best["gap"]) > tolerance: # 실제 누가토량으로 λ 를 더 좁힘 + start, sign = best["lambda"], 1.0 if best["gap"] > 0 else -1.0 + checked, low, high = [best], start, sign * LAMBDA_MAX + for _ in range(EXACT_STEPS): + result = solve(0.5 * (low + high)) + if result is None: + break + result = measured(result) + checked.append(result) + if abs(result["gap"]) <= tolerance or result["gap"] * sign < 0: + high = result["lambda"] + else: + low = result["lambda"] + within = [r for r in checked if abs(r["gap"]) <= tolerance] + best = ( + min(within, key=lambda r: abs(r["lambda"] - start)) + if within + else min(checked, key=lambda r: abs(r["gap"])) + ) plan = levels[np.arange(len(chainage)), best["path"]] grades = np.diff(plan) / spans changes = np.abs(np.diff(grades)) @@ -302,8 +388,10 @@ def balanced_profile( "borrow_m3": round(max(best["fill"] - best["cut_c"], 0.0), 1), "spoil_m3": round(max(best["cut_c"] - best["fill"], 0.0), 1), "imbalance_pct": round(best["imbalance"] * 100.0, 1), - "balanced": abs(best["imbalance"]) <= tolerance, - "tolerance_pct": round(tolerance * 100.0, 1), + "mass_end_m3": round(-best["gap"], 1), # 누가토량 끝값 = 절토(다짐) − 성토 + "balanced": abs(best["gap"]) <= tolerance, + "mass_tolerance_m3": round(tolerance, 1), + "exact": exact, "max_grade_pct": round(float(np.abs(grades).max()) * 100.0 if grades.size else 0.0, 2), "grade_limit_pct": round(max_grade * 100.0, 2), "curve_over": int((changes > delta_max + GRADE_EPS).sum()), @@ -315,10 +403,18 @@ def balanced_profile( def route_profile( - vertices: list[tuple[float, float]], terrain: Terrain, *, max_grade: float, design_speed: int + vertices: list[tuple[float, float]], + terrain: Terrain, + *, + max_grade: float, + design_speed: int, + design_curves: list[dict[str, Any]] | None = None, + exact: bool = True, ) -> dict[str, Any]: - """후보 노선 하나의 균형 종단 — 측점 횡단을 뜨고 `balanced_profile`.""" - chainage, ground, sections, modes, curves = route_sections(vertices, terrain) + """후보 노선 하나의 균형 종단 — 측점 횡단을 뜨고 `balanced_profile`. + + `design_curves` = 그 노선의 곡선표(`RouteCurve.as_dict` 꼴) — 확폭을 본 설계와 같게 매긴다.""" + chainage, ground, sections, modes, curves = route_sections(vertices, terrain, design_curves) if len(chainage) < 2: raise ValueError("노선이 짧아 종단을 세울 수 없습니다.") return balanced_profile( @@ -329,6 +425,7 @@ def route_profile( max_grade=max_grade, design_speed=design_speed, curves=curves, + exact=exact, ) @@ -384,24 +481,35 @@ def attach_profile( *, max_grade: float, design_speed: int, + design_curves: list[dict[str, Any]] | None = None, + exact: bool = True, ) -> None: - """후보 비교값에 균형 종단을 붙이고 토공 점수를 그것으로 바꾼다(한 번만 · 묶음 캐시에 남음). + """후보 비교값에 균형 종단을 붙이고 토공 점수를 그것으로 바꾼다(묶음 캐시에 남음). 점수 = 절토(자연) + 성토 + 반입 + 사토 ㎥ — 가늠값(폭 4 m 띠 · 비탈면 뺌)은 - `earth_estimate_m3` 로 남기고, 공사비 어림의 토공 몫도 이 점수로 바꾼다.""" - if "profile" in metrics: + `earth_estimate_m3` 로 남기고, 공사비 어림의 토공 몫도 이 점수로 바꾼다. 줄 세우기용 + (`exact` 끔)으로 붙은 것은 화면에 낼 때(`exact` 켬) 한 번 더 바로 셈해 갈아 끼운다.""" + held = metrics.get("profile") + if held and ("error" in held or held.get("exact") or not exact): return try: - profile = route_profile(vertices, terrain, max_grade=max_grade, design_speed=design_speed) + profile = route_profile( + vertices, + terrain, + max_grade=max_grade, + design_speed=design_speed, + design_curves=design_curves, + exact=exact, + ) except ValueError as error: metrics["profile"] = {"error": str(error)} return estimate = metrics["cut_m3"] + metrics["fill_m3"] - metrics["cost_index_base"] = metrics["cost_index"] # 「종단 반영」 끔이 되돌아갈 값 + metrics.setdefault("cost_index_base", metrics["cost_index"]) # 「종단 반영」 끔이 되돌아갈 값 metrics["profile"] = profile metrics["earth_estimate_m3"] = round(estimate, 1) metrics["cost_index"] = round( - metrics["cost_index"] + COST_EARTH_M3 * (profile_earth(profile) - estimate) + metrics["cost_index_base"] + COST_EARTH_M3 * (profile_earth(profile) - estimate) ) diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 1178f9284..120510fb1 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -192,8 +192,12 @@ async def balanced_target( """확정할 노선의 균형 종단(누가거리 · 계획고) — 추천 창과 같은 코드 · 같은 상한(PLAN 74-4). 화면 값을 받지 않고 노선 정점으로 다시 셈한다(CLAUDE.md 5). 못 세우면 None(지반고 종단). + 확폭은 측점 생성과 같은 곡선표(`_load_design_curves`)로. """ - profile = await balanced_profile_of(project_id, project_root, points, criteria) + from B05_Profile.B05_Profile_Engine_Sections import _load_design_curves + + curves = await asyncio.to_thread(_load_design_curves, project_root) + profile = await balanced_profile_of(project_id, project_root, points, criteria, curves) return None if profile is None else (profile["stations_m"], profile["plan_m"]) @@ -202,6 +206,7 @@ async def balanced_profile_of( project_root: Path, points: list[dict[str, float]], criteria: Any, + design_curves: list[dict[str, Any]] | None = None, ) -> dict[str, Any] | None: """노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 · 편집 창 종단 띠가 함께 씀.""" from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct @@ -227,17 +232,19 @@ async def balanced_profile_of( terrain, max_grade=max_grade, design_speed=design_speed or 20, + design_curves=design_curves, ) ) except (FileNotFoundError, ValueError): logger.exception("균형 종단 셈 실패(지반고 종단으로): project_id=%s", project_id) return None logger.info( - "균형 종단 셈: project_id=%s 측점 %d 절 %.0f 성 %.0f %.1fs", + "균형 종단 셈: project_id=%s 측점 %d 절 %.0f 성 %.0f 누가 %.0f %.1fs", project_id, len(profile["stations_m"]), profile["cut_m3"], profile["fill_m3"], + profile["mass_end_m3"], profile["seconds"], ) return profile @@ -300,7 +307,8 @@ async def profile_preview( ) # [확인]은 CSV(소수 4자리)로 적은 뒤 읽는다 — 같은 점을 셈하게 같은 자릿수로. points = [{"x": round(float(x), 4), "y": round(float(y), 4)} for x, y in built.vertices] - profile = await balanced_profile_of(project_id, project_root, points, request.criteria) + curves = [curve.as_dict() for curve in built.curves] + profile = await balanced_profile_of(project_id, project_root, points, request.criteria, curves) if profile is None: return JSONResponse( status_code=409, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts index 0020daf7e..83bc2f10e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts @@ -18,9 +18,9 @@ export interface BalanceProfile { fillM3: number; borrowM3: number; spoilM3: number; - /** (성 − 절 다짐 환산) / 큰 쪽 % · 허용 %. */ - imbalancePct: number; - tolerancePct: number; + /** 누가토량 끝값(절토 다짐 − 성토) · 허용 ±㎥. */ + massEndM3: number; + massToleranceM3: number; maxGradePct: number; gradeLimitPct: number; /** 종단곡선 넣을 자리가 모자란 변화점 수(대수차가 생략 한도를 넘음). */ @@ -54,8 +54,8 @@ export function parseBalance(raw: unknown): BalanceProfile | null { fillM3: num(p.fill_m3)!, borrowM3: num(p.borrow_m3)!, spoilM3: num(p.spoil_m3)!, - imbalancePct: num(p.imbalance_pct) ?? 0, - tolerancePct: num(p.tolerance_pct) ?? 10, + massEndM3: num(p.mass_end_m3) ?? 0, + massToleranceM3: num(p.mass_tolerance_m3) ?? 500, maxGradePct: num(p.max_grade_pct)!, gradeLimitPct: num(p.grade_limit_pct) ?? 0, curveOver: num(p.curve_over) ?? 0, @@ -66,14 +66,15 @@ export function parseBalance(raw: unknown): BalanceProfile | null { const m3 = (value: number): string => `${Math.round(value).toLocaleString("ko-KR")}㎥`; const pct = (value: number): string => `${value.toFixed(1)}%`; +const signedM3 = (value: number): string => `${value > 0 ? "+" : ""}${m3(value)}`; -/** 한 줄 — 「균형 종단: 절 7,646 · 성 7,944 · 반입 0 · 사토 848㎥ · 불균형 −9.6%(허용 10%) · …」. */ +/** 한 줄 — 「균형 종단: 절 7,646 · 성 7,944 · 반입 0 · 사토 848㎥ · 누가토량 +212㎥(허용 ±500㎥) · …」. */ export function balanceLine(balance: BalanceProfile | null): string { if (!balance) return ""; const parts = [ `균형 종단: 절 ${m3(balance.cutM3)} · 성 ${m3(balance.fillM3)}`, `반입 ${m3(balance.borrowM3)} · 사토 ${m3(balance.spoilM3)}`, - `불균형 ${pct(balance.imbalancePct)}(허용 ${pct(balance.tolerancePct)})`, + `누가토량 ${signedM3(balance.massEndM3)}(허용 ±${m3(balance.massToleranceM3)})`, `최대 종단경사 ${pct(balance.maxGradePct)}(상한 ${pct(balance.gradeLimitPct)})`, ]; if (balance.curveOver > 0) parts.push(`종단곡선 자리 모자람 ${balance.curveOver}곳`); diff --git a/config/config_system_design.py b/config/config_system_design.py index 3342217f7..13fb522ef 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -631,3 +631,5 @@ FOREST_ROAD_PROFILE_ALIGNMENT = { # 법정 종단기울기 상한 초과 시 동작: "warn"(경고만) | "block"(편집 차단) "grade_violation_policy": "warn", } +# 노선 추천 균형 종단의 허용 — 누가토량 끝값(절토 다짐 − 성토) ±㎥ (사용자 확정). +ROUTE_RECOMMEND_MASS_TOLERANCE_M3 = 500.0 diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs index b1e02dff1..a9762095c 100644 --- a/resources/tester/helper_b05_routeedit_initial.cjs +++ b/resources/tester/helper_b05_routeedit_initial.cjs @@ -490,7 +490,8 @@ const withProfile = model.normalizeRouteInitial( borrow_m3: 0, spoil_m3: 39.9, imbalance_pct: -28.6, - tolerance_pct: 10, + mass_end_m3: -39.9, + mass_tolerance_m3: 500, max_grade_pct: 7.5, grade_limit_pct: 14, curve_over: 1, diff --git a/resources/tester/test_63_4_route_profile.py b/resources/tester/test_63_4_route_profile.py index 3f4b8a06f..7cffd1f61 100644 --- a/resources/tester/test_63_4_route_profile.py +++ b/resources/tester/test_63_4_route_profile.py @@ -3,7 +3,7 @@ ./venv/Scripts/python.exe -m pytest resources/tester/test_63_4_route_profile.py -q 합성 지형(노선 따라 ±20 % 물결 · 옆경사 50 %)에서 균형 계획선이 종단기울기 상한을 지키고, -절(다짐 환산) · 성토가 허용(10 %) 안에서 맞고, 시 · 종점은 지반고에 붙는다. 토공 최소 갈래는 +누가토량 끝값(절 다짐 − 성)이 ±500㎥ 안에서 맞고, 시 · 종점은 지반고에 붙는다. 토공 최소 갈래는 균형 종단 토공(절 + 성 + 반입 + 사토)으로 고른다. 균형 종단은 화면 캐시에 안 적힌다. """ @@ -56,7 +56,11 @@ def test_상한을_지키고_지반_기울기는_넘는다(profile: dict) -> Non def test_절성토_균형은_허용_안(profile: dict) -> None: assert profile["balanced"] is True - assert abs(profile["imbalance_pct"]) <= profile["tolerance_pct"] + # 누가토량 끝값(절토 다짐 − 성토)이 ±500㎥ 안(PLAN 78) + assert profile["mass_tolerance_m3"] == 500.0 + assert abs(profile["mass_end_m3"]) <= profile["mass_tolerance_m3"] + net = profile["cut_compacted_m3"] - profile["fill_m3"] + assert profile["mass_end_m3"] == pytest.approx(net, abs=0.2) # 반입 − 사토 = 성토 − 절토(다짐 환산) net = profile["fill_m3"] - profile["cut_compacted_m3"] assert abs((profile["borrow_m3"] - profile["spoil_m3"]) - net) <= 0.2 @@ -114,5 +118,6 @@ def test_균형_종단은_캐시에_안_남는다() -> None: assert result["cached"]["metrics"]["cutM3"] is not None # 비교값은 그대로 캐시 assert result["missing"] is None and result["broken"] is None assert "반입 0㎥" in result["line"] and "덮개 모자란 관 1/2" in result["line"] + assert "누가토량 -40㎥(허용 ±500㎥)" in result["line"] assert result["svg"].count("토공 최소' in result["table"] diff --git a/resources/tester/test_75_profile_strip.py b/resources/tester/test_75_profile_strip.py index 325034fad..206ea40c7 100644 --- a/resources/tester/test_75_profile_strip.py +++ b/resources/tester/test_75_profile_strip.py @@ -24,8 +24,8 @@ def _strip_points(monkeypatch, tmp_path: Path, criteria=None) -> tuple[list, obj async def plan_criteria(_pid): return RADIUS_M, 0.0, 0.0 - async def profile_of(_pid, _root, points, crit): - seen["points"], seen["criteria"] = points, crit + async def profile_of(_pid, _root, points, crit, curves=None): + seen["points"], seen["criteria"], seen["curves"] = points, crit, curves return {"stations_m": [0.0, 20.0], "plan_m": [1.0, 2.0]} monkeypatch.setattr(replan, "_project_paths", paths) @@ -60,6 +60,33 @@ def test_띠_노선점은_확인이_읽는_CSV와_같다(monkeypatch, tmp_path: assert len(points) > len(NODES) # 원호가 펴져 있다 +def test_띠는_그_노선의_곡선표로_확폭을_맨다(monkeypatch, tmp_path: Path) -> None: + seen: dict = {} + + async def paths(_pid): + return tmp_path, "x" + + async def plan_criteria(_pid): + return RADIUS_M, 0.0, 0.0 + + async def profile_of(_pid, _root, _points, _crit, curves=None): + seen["curves"] = curves + return {"stations_m": [0.0, 20.0], "plan_m": [1.0, 2.0]} + + monkeypatch.setattr(replan, "_project_paths", paths) + monkeypatch.setattr(replan, "_plan_criteria", plan_criteria) + monkeypatch.setattr(initial, "balanced_profile_of", profile_of) + request = initial.ProfilePreviewRequest( + vertices=[ + replan.RouteVertexInput(x=x, y=y, curve=i != 2, radius_m=40.0 if i == 1 else None) + for i, (x, y) in enumerate(NODES) + ] + ) + asyncio.run(initial.profile_preview(uuid4(), request)) + assert seen["curves"] and 40.0 in [c["radius_m"] for c in seen["curves"]] + assert all({"start", "apex", "end", "radius_m"} <= set(c) for c in seen["curves"]) + + def test_띠는_지금_추천_제약을_싣는다(monkeypatch, tmp_path: Path) -> None: criteria = replan.RouteInitialCriteria(max_grade_pct=12.0) _, got = _strip_points(monkeypatch, tmp_path, criteria) diff --git a/resources/tester/test_78_recommend_mass.py b/resources/tester/test_78_recommend_mass.py new file mode 100644 index 000000000..c148e9ae5 --- /dev/null +++ b/resources/tester/test_78_recommend_mass.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +"""노선 추천 균형 종단 — 본 설계와 같은 횡단 · 누가토량 ±500㎥(PLAN 78). + +랩탑 메인 노선 271: 추천은 곡선표 확폭 · 비탈 안 닫힘 넓힘을 빼 성토를 4,200㎥ 적게 봄. +""" + +import numpy as np + +from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import ( + CROSS_HALF_M, + _station_areas, + _widenings, + route_profile, + route_sections, +) +from B05_Profile.B05_Profile_Engine_RouteInitial_Search import Terrain +from config.config_system_design import curve_widening_m + + +def _steep() -> Terrain: + # 노선(y = 200) 왼쪽(+y)으로 70 % 내리막 — 성토 비탈(1:1.5)이 ±20 m 안에서 안 닫힘 + x = np.arange(0.0, 202.0, 2.0) + y = np.arange(120.0, 282.0, 2.0) + xx, yy = np.meshgrid(x, y) + z = 100.0 - 0.7 * (yy - 200.0) + 0.02 * xx + return Terrain(x, y, z, np.ones(xx.shape, dtype=bool)) + + +def test_곡선표가_있으면_그_반경으로_확폭(): + xy = np.array([(0.0, 0.0), (40.0, 0.0), (80.0, 0.0)]) + chain = np.array([0.0, 40.0, 80.0]) + at = np.array([0.0, 20.0, 40.0, 60.0, 80.0]) + table = [{"start": [30.0, 0.0], "apex": [40.0, 1.0], "end": [50.0, 0.0], "radius_m": 12.0}] + rows = _widenings(xy, chain, at, table) + assert rows[2]["curve_widening_m"] == curve_widening_m(12.0) + assert rows[2]["plan_radius_m"] == 12.0 + # 곡선표 없으면 직선(반경 실측 없음) — 확폭 0 + assert all(row["curve_widening_m"] == 0 for row in _widenings(xy, chain, at)) + + +def test_비탈_안_닫히면_넓힌_샘플로_셈(): + at, ground, sections, modes, curves = route_sections([(20.0, 200.0), (180.0, 200.0)], _steep()) + i = len(at) // 2 + samples = sections[i] + assert max(s["offset_m"] for s in samples) > CROSS_HALF_M # 반폭 밖까지 떠 둠 + narrow = [s for s in samples if abs(s["offset_m"]) <= CROSS_HALF_M] + high = float(ground[i] + 3.0) # 쌓기 — 내리막 쪽 성토 비탈이 길어짐 + _, _, wide_fill = _station_areas(samples, modes[i], high, curves[i]) + _, _, narrow_fill = _station_areas(narrow, modes[i], high, curves[i]) + assert wide_fill > narrow_fill + 1.0 + + +def test_누가토량_끝값은_허용_안(): + profile = route_profile( + [(20.0, 200.0), (180.0, 200.0)], _steep(), max_grade=0.14, design_speed=20 + ) + assert profile["exact"] is True + assert abs(profile["mass_end_m3"]) <= profile["mass_tolerance_m3"] == 500.0 + assert profile["balanced"] is True