feat(B05): 노선 추천 후보마다 균형 종단 — 횡단 ㎥ 절성토 균형 점수 · 편집 창 미리 보기
- 균형 계획선 함수(서버 단독) — 종단기울기 상한 · 종단곡선(대수차) 지키며 B06 횡단 단면적으로 절 · 성토 균형 - 토공 최소 · 공사비 어림 점수를 균형 종단 토공(절 + 성 + 반입 + 사토)으로 - 추천 창에 균형 종단 한 줄 · 종단 그림 · 셈한 후보 표 · 지난 초 표시 - 균형 종단은 캐시에 안 적음 · 확정 체인 종단 규칙은 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
갈래(`mode`):
|
||||
· follow — 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 선.
|
||||
제약 탐색은 하지 않고, 넘은 곳은 `metrics.violations` 에 뜬다.
|
||||
· earthwork — 후보 중 절 · 성토 합(`cut_m3 + fill_m3`) 최소.
|
||||
· earthwork — 후보 중 균형 종단 토공(절 + 성 + 반입 + 사토 ㎥ · `..._Profile` · PLAN 63-4) 최소.
|
||||
· gentle — 후보 중 최대 종단기울기 최소(같으면 평균 최소).
|
||||
· gradeline — 영선 노선: 목표 기울기(`target_grade` · 없으면 상한)로 등고선을 건너가는
|
||||
등경사선(PEGGER 식) · 후보 중 목표 기울기 편차(`grade_dev_pct`) 최소(PLAN 23-5).
|
||||
@@ -64,6 +64,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import (
|
||||
similar,
|
||||
weighted,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import attach_profile, cover_shortfall
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
|
||||
CORRIDOR_M,
|
||||
ROAD_WIDTH_M,
|
||||
@@ -216,12 +217,17 @@ def generate_initial_route(
|
||||
stream_offset_m: float | None = None,
|
||||
grade_smooth_m: float | None = None,
|
||||
target_grade: float | None = None,
|
||||
design_speed: int = 20,
|
||||
pipes: list[tuple[float, float, float]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).
|
||||
|
||||
follow 는 전처리 초기값 규칙 그대로 · weighted 는 비중 탐색 한 번(끝값은 그 갈래) · 나머지는
|
||||
후보 묶음(`..._Pool`)에서 제 목표 비교값이 가장 좋은 것. `stream_offset_m`·`grade_smooth_m` 을
|
||||
안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서 프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정).
|
||||
|
||||
후보마다 균형 종단(`..._Profile` · PLAN 63-4)을 붙여 토공 점수를 그것으로 고른다 — 편집 창
|
||||
추천 점수 · 미리 보기에만 쓰고 확정 종단과 무관. `pipes`(x, y, 덮개 여유)는 덮개 부족 수만 센다.
|
||||
"""
|
||||
if mode not in MODES:
|
||||
raise ValueError(f"모르는 갈래: {mode}")
|
||||
@@ -259,6 +265,16 @@ 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:
|
||||
if route is not None:
|
||||
attach_profile(
|
||||
route[1],
|
||||
route[0].vertices,
|
||||
terrain,
|
||||
max_grade=max_grade,
|
||||
design_speed=design_speed,
|
||||
)
|
||||
|
||||
bound = None
|
||||
pool: Pool | None = None
|
||||
target = _weighted_end(used) if mode == "weighted" else mode
|
||||
@@ -287,7 +303,12 @@ def generate_initial_route(
|
||||
if target == "weighted":
|
||||
outline, metrics = weighted(pool, used, min_radius_m, measure)
|
||||
else:
|
||||
for route in [*pool.candidates, pool.follow]:
|
||||
profiled(route)
|
||||
outline, metrics = pick(target, pool.candidates)
|
||||
profiled((outline, metrics))
|
||||
if pipes and "error" not in metrics["profile"]:
|
||||
metrics["profile"].update(cover_shortfall(metrics["profile"], outline.vertices, pipes))
|
||||
return {
|
||||
"mode": mode,
|
||||
"logic": mode,
|
||||
|
||||
@@ -36,6 +36,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
|
||||
COST_EARTH_M3,
|
||||
COST_LENGTH_M,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
|
||||
CROSSING_BAND,
|
||||
OBJECTIVES,
|
||||
@@ -266,6 +267,10 @@ def weighted(pool: Pool, weights: dict[str, float], min_radius_m: float, measure
|
||||
|
||||
|
||||
def _earth(metrics: dict[str, Any]) -> float:
|
||||
# 균형 종단을 붙인 후보(PLAN 63-4)는 절 + 성 + 반입 + 사토 · 못 붙였으면 가늠값
|
||||
profile = metrics.get("profile")
|
||||
if profile and "error" not in profile:
|
||||
return profile_earth(profile)
|
||||
return metrics["cut_m3"] + metrics["fill_m3"]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""노선 추천 후보의 균형 종단(PLAN 63-4 · 63-2 안 A).
|
||||
|
||||
후보 노선 하나에 종단을 세워 절 · 성토를 **횡단 ㎥** 로 맞춘 계획선과 그 토량을 낸다.
|
||||
편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 파일 · 캐시 · initial_snapshot · 확정 종단을
|
||||
바꾸지 않는다. 확정 체인의 종단 규칙(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다.
|
||||
|
||||
계산 자리 = 서버 단독(③) — 화면은 받은 측점 · 지반고 · 계획고를 그리기만 한다.
|
||||
|
||||
방법:
|
||||
1. 측점(20 m)마다 지반 횡단(±20 m · 0.5 m)을 DEM 에서 뜬다 — B05 측점 생성과 같은 꼴
|
||||
(좌 = +offset · 상단측 = 좌우 평균 표고가 높은 쪽 · 그쪽 절토).
|
||||
2. 측점마다 계획고 ±Δ 의 절 · 성 단면적 곡선 — B06 `compute_cross_design` 그대로(지반유형
|
||||
리핑암 = B06 기본값) · `AREA_STEP_M` 간격으로 재고 사이는 직선 보간.
|
||||
3. 동적계획(앞 두 측점 상태)으로 측점 계획고를 고른다 — 목표 = 절토(다짐 환산) + 성토 ㎥ +
|
||||
λ × (성토 − 절토). λ 를 이분해 |절 − 성| / 큰 쪽이 허용(10 %) 안인 가장 작은 λ.
|
||||
· 종단기울기 상한 — 넘지 못함.
|
||||
· 종단곡선 — 측점마다 변화점(20 m)이라 곡선 반쪽이 직선의 0.45 를 못 넘어 L ≤ 18 m <
|
||||
법정 최소 L(20 m 이상). 그래서 대수차가 곡선 생략 한도(비포장 5 %) 안이어야 함 — 넘으면
|
||||
벌점(지형상 못 지킬 때만 넘음) · 넘은 변화점 수를 낸다.
|
||||
4. 배관 덮개 — 제약은 끈 채(확정 체인과 같음) 관 자리 계획고 − 지반고 < 덮개 여유인 관 수만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
from scipy.interpolate import PchipInterpolator, RegularGridInterpolator
|
||||
from shapely.geometry import LineString, Point
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import COST_EARTH_M3
|
||||
from config.config_system_design import (
|
||||
EARTHWORK_CONVERSION_FACTORS,
|
||||
FOREST_ROAD_PROFILE_ALIGNMENT,
|
||||
FOREST_ROAD_PROFILE_CRITERIA,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import Terrain
|
||||
|
||||
STATION_M = 20.0 # 측점 간격 — 추천 비교값(`..._Metrics.STATION_M`)과 같음
|
||||
TAIL_MERGE_M = 5.0 # 종점과 이보다 가까운 정규 측점은 뺌(짧은 끝 구간의 기울기 튐 방지)
|
||||
CROSS_HALF_M = 20.0 # 지반 횡단 반폭 · 간격 — B06 횡단 파일과 같음
|
||||
CROSS_STEP_M = 0.5
|
||||
LEVEL_RANGE_M = 6.0 # 계획고 후보 = 상한 안 기준선 ± 이 폭
|
||||
LEVEL_STEP_M = 0.25
|
||||
AREA_STEP_M = 2.0 # 단면적 곡선을 재는 계획고 간격(사이는 PCHIP — 1 m 간격과 3 % 안)
|
||||
GROUND_TYPE = "ripping_rock" # B06 기본 지반유형(`attach_default_designs`)
|
||||
CURVE_PENALTY_M3 = 500.0 # 대수차가 생략 한도를 1 %p 넘을 때 벌점(㎥)
|
||||
LAMBDA_MAX = 0.95
|
||||
LAMBDA_STEPS = 14
|
||||
PIPE_SNAP_M = 5.0 # 관 자리가 노선에서 이 안이면 그 노선의 관으로 봄
|
||||
GRADE_EPS = 1e-6
|
||||
|
||||
|
||||
def route_sections(
|
||||
vertices: list[tuple[float, float]], terrain: Terrain
|
||||
) -> tuple[np.ndarray, np.ndarray, list[list[dict[str, Any]]], list[str]]:
|
||||
"""측점 누가거리 · 중심 지반고 · 측점별 지반 횡단 샘플 · 단면유형(상단측 절토)."""
|
||||
xy = np.asarray(vertices, dtype=np.float64)
|
||||
keep = np.r_[True, np.hypot(*np.diff(xy, axis=0).T) > 1e-9]
|
||||
xy = xy[keep]
|
||||
seg = np.hypot(*np.diff(xy, axis=0).T)
|
||||
chain = np.concatenate([[0.0], np.cumsum(seg)])
|
||||
total = float(chain[-1])
|
||||
at = np.arange(0.0, total, STATION_M)
|
||||
if len(at) > 1 and total - at[-1] < TAIL_MERGE_M:
|
||||
at = at[:-1]
|
||||
at = np.append(at, total)
|
||||
index = np.clip(np.searchsorted(chain, at, side="right") - 1, 0, len(seg) - 1)
|
||||
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)
|
||||
points = center[:, None, :] + left[:, None, :] * offsets[None, :, None]
|
||||
lookup = points.reshape(-1, 2)[:, ::-1]
|
||||
grid = (terrain.y, terrain.x)
|
||||
z = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=np.nan)(lookup)
|
||||
valid = RegularGridInterpolator(
|
||||
grid, terrain.valid.astype(np.float64), method="nearest", bounds_error=False, fill_value=0
|
||||
)(lookup)
|
||||
z = z.reshape(len(at), len(offsets))
|
||||
ok = (valid.reshape(z.shape) > 0.5) & np.isfinite(z)
|
||||
middle = int(np.argmin(np.abs(offsets)))
|
||||
ground = np.where(ok[:, middle], z[:, middle], np.nan)
|
||||
if np.isnan(ground).all():
|
||||
raise ValueError("노선 위 지반고가 없어 종단을 세울 수 없습니다.")
|
||||
fine = ~np.isnan(ground)
|
||||
ground = np.interp(at, at[fine], ground[fine])
|
||||
sections, modes = [], []
|
||||
for row, flags in zip(z, ok):
|
||||
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)
|
||||
]
|
||||
)
|
||||
lefts, rights = row[(offsets > 0) & flags], row[(offsets < 0) & 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
|
||||
|
||||
|
||||
def _area_curves(
|
||||
sections: list[list[dict[str, Any]]], modes: list[str], ground: np.ndarray, levels: np.ndarray
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""측점 · 계획고 후보마다 (절토 자연 ㎡, 절토 다짐 환산 ㎡, 성토 ㎡)."""
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
|
||||
factors = EARTHWORK_CONVERSION_FACTORS
|
||||
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)):
|
||||
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
|
||||
)
|
||||
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)
|
||||
# 단면적은 계획고에 대해 볼록(대략 2차)이라 직선 보간은 크게 잡힌다 — 단조 3차(PCHIP)
|
||||
if len(at) > 1:
|
||||
curve = PchipInterpolator(at, table, axis=0)(delta)
|
||||
else:
|
||||
curve = np.repeat(table, len(delta), axis=0)
|
||||
cut[i], cut_c[i], fill[i] = curve[:, 0], curve[:, 1], curve[:, 2]
|
||||
return cut, cut_c, fill
|
||||
|
||||
|
||||
def _reference(ground: np.ndarray, spans: np.ndarray, max_grade: float) -> np.ndarray:
|
||||
"""상한 안 기준선 — 앞 · 뒤로 기울기를 누른 두 선의 평균(`..._Metrics.grade_line` 꼴)."""
|
||||
forward, backward = ground.copy(), ground.copy()
|
||||
for i in range(1, len(ground)):
|
||||
rise = max_grade * spans[i - 1]
|
||||
forward[i] = min(max(ground[i], forward[i - 1] - rise), forward[i - 1] + rise)
|
||||
for i in range(len(ground) - 2, -1, -1):
|
||||
rise = max_grade * spans[i]
|
||||
backward[i] = min(max(ground[i], backward[i + 1] - rise), backward[i + 1] + rise)
|
||||
return 0.5 * (forward + backward)
|
||||
|
||||
|
||||
def _dp(
|
||||
levels: np.ndarray, cost: np.ndarray, spans: np.ndarray, max_grade: float, delta_max: float
|
||||
) -> np.ndarray | None:
|
||||
"""측점별 계획고 후보 번호 — 기울기 상한은 막고 대수차 넘침은 벌점. 길이 없으면 None."""
|
||||
n = len(levels)
|
||||
if n == 2:
|
||||
steep = np.abs(levels[1][None, :] - levels[0][:, None]) / spans[0] > max_grade + GRADE_EPS
|
||||
table = cost[0][:, None] + cost[1][None, :] + np.where(steep, np.inf, 0.0)
|
||||
if not np.isfinite(table.min()):
|
||||
return None
|
||||
return np.array(np.unravel_index(int(table.argmin()), table.shape))
|
||||
steep = np.abs(levels[1][None, :] - levels[0][:, None]) / spans[0] > max_grade + GRADE_EPS
|
||||
table = cost[0][:, None] + cost[1][None, :] + np.where(steep, np.inf, 0.0) # [앞앞, 앞]
|
||||
back = []
|
||||
for i in range(1, n - 1):
|
||||
g_in = (levels[i][None, :] - levels[i - 1][:, None]) / spans[i - 1] # [a, b]
|
||||
g_out = (levels[i + 1][None, :] - levels[i][:, None]) / spans[i] # [b, c]
|
||||
over = np.maximum(np.abs(g_out[None, :, :] - g_in[:, :, None]) - delta_max, 0.0)
|
||||
total = table[:, :, None] + CURVE_PENALTY_M3 * 100.0 * over # [a, b, c]
|
||||
arg = total.argmin(axis=0)
|
||||
table = np.take_along_axis(total, arg[None], axis=0)[0]
|
||||
table += np.where(np.abs(g_out) > max_grade + GRADE_EPS, np.inf, 0.0) + cost[i + 1][None, :]
|
||||
back.append(arg)
|
||||
if not np.isfinite(table.min()):
|
||||
return None
|
||||
b, c = np.unravel_index(int(table.argmin()), table.shape)
|
||||
path = [int(c), int(b)]
|
||||
for arg in reversed(back):
|
||||
path.append(int(arg[path[-1], path[-2]]))
|
||||
return np.array(path[::-1])
|
||||
|
||||
|
||||
def balanced_profile(
|
||||
chainage: np.ndarray,
|
||||
ground: np.ndarray,
|
||||
sections: list[list[dict[str, Any]]],
|
||||
modes: list[str],
|
||||
*,
|
||||
max_grade: float,
|
||||
design_speed: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""상한(종단기울기 · 종단곡선)을 지키며 절 · 성토를 횡단 ㎥ 로 맞춘 계획선과 토량.
|
||||
|
||||
시점 · 종점은 지반고(확정 체인 기본 오프셋 0 과 같음) — 그 길이 없을 때만 풀어 다시 찾는다.
|
||||
"""
|
||||
started = time.perf_counter()
|
||||
spans = np.diff(chainage)
|
||||
weight = np.zeros(len(chainage))
|
||||
weight[:-1] += spans / 2.0
|
||||
weight[1:] += spans / 2.0
|
||||
offsets = np.arange(-LEVEL_RANGE_M, LEVEL_RANGE_M + 1e-9, LEVEL_STEP_M)
|
||||
levels = _reference(ground, spans, max_grade)[:, None] + offsets[None, :]
|
||||
cut, cut_c, fill = (weight[:, None] * a for a in _area_curves(sections, modes, ground, levels))
|
||||
criteria = (
|
||||
FOREST_ROAD_PROFILE_CRITERIA["design_speed"].get(design_speed)
|
||||
or (FOREST_ROAD_PROFILE_CRITERIA["design_speed"][20])
|
||||
)
|
||||
skip = FOREST_ROAD_PROFILE_CRITERIA["vertical_curve_skip_delta_pct"] / 100.0
|
||||
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
|
||||
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 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)
|
||||
if path is None:
|
||||
path = _dp(levels, base, spans, max_grade, delta_max)
|
||||
if path is None:
|
||||
return None
|
||||
pick = np.arange(len(path))
|
||||
c, f = float(cut_c[pick, path].sum()), float(fill[pick, path].sum())
|
||||
return {
|
||||
"lambda": lam,
|
||||
"path": path,
|
||||
"cut": float(cut[pick, path].sum()),
|
||||
"cut_c": c,
|
||||
"fill": f,
|
||||
"imbalance": (f - c) / max(c, f, 1e-9),
|
||||
}
|
||||
|
||||
tried = [solve(0.0)]
|
||||
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
|
||||
low, high = 0.0, LAMBDA_MAX
|
||||
for _ in range(LAMBDA_STEPS):
|
||||
mid = 0.5 * (low + high)
|
||||
result = solve(sign * mid)
|
||||
if result is None:
|
||||
break
|
||||
tried.append(result)
|
||||
if abs(result["imbalance"]) <= tolerance or result["imbalance"] * sign < 0:
|
||||
high = mid
|
||||
else:
|
||||
low = mid
|
||||
within = [r for r in tried if abs(r["imbalance"]) <= tolerance]
|
||||
best = (
|
||||
min(within, key=lambda r: abs(r["lambda"]))
|
||||
if within
|
||||
else min(tried, key=lambda r: abs(r["imbalance"]))
|
||||
)
|
||||
plan = levels[np.arange(len(chainage)), best["path"]]
|
||||
grades = np.diff(plan) / spans
|
||||
changes = np.abs(np.diff(grades))
|
||||
return {
|
||||
"stations_m": [round(float(v), 2) for v in chainage],
|
||||
"ground_m": [round(float(v), 3) for v in ground],
|
||||
"plan_m": [round(float(v), 3) for v in plan],
|
||||
"cut_m3": round(best["cut"], 1), # 자연 상태
|
||||
"cut_compacted_m3": round(best["cut_c"], 1),
|
||||
"fill_m3": round(best["fill"], 1),
|
||||
"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),
|
||||
"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()),
|
||||
"curve_delta_max_pct": round(delta_max * 100.0, 2),
|
||||
"max_cut_depth_m": round(float(np.max(ground - plan)), 2),
|
||||
"max_fill_height_m": round(float(np.max(plan - ground)), 2),
|
||||
"seconds": round(time.perf_counter() - started, 2),
|
||||
}
|
||||
|
||||
|
||||
def route_profile(
|
||||
vertices: list[tuple[float, float]], terrain: Terrain, *, max_grade: float, design_speed: int
|
||||
) -> dict[str, Any]:
|
||||
"""후보 노선 하나의 균형 종단 — 측점 횡단을 뜨고 `balanced_profile`."""
|
||||
chainage, ground, sections, modes = route_sections(vertices, terrain)
|
||||
if len(chainage) < 2:
|
||||
raise ValueError("노선이 짧아 종단을 세울 수 없습니다.")
|
||||
return balanced_profile(
|
||||
chainage, ground, sections, modes, max_grade=max_grade, design_speed=design_speed
|
||||
)
|
||||
|
||||
|
||||
def cover_shortfall(
|
||||
profile: dict[str, Any],
|
||||
vertices: list[tuple[float, float]],
|
||||
pipes: list[tuple[float, float, float]],
|
||||
) -> dict[str, int]:
|
||||
"""관(x, y, 덮개 여유 m) 중 이 노선 위(`PIPE_SNAP_M` 안)인 것 · 그중 덮개가 모자란 수.
|
||||
|
||||
덮개 = 관 자리 계획고 − 지반고(측점 사이 직선 보간). 제약으로 쓰지 않고 세기만 한다."""
|
||||
line = LineString(vertices)
|
||||
stations = np.asarray(profile["stations_m"])
|
||||
rise = np.asarray(profile["plan_m"]) - np.asarray(profile["ground_m"])
|
||||
on, short = 0, 0
|
||||
for x, y, clearance in pipes:
|
||||
point = Point(x, y)
|
||||
if line.distance(point) > PIPE_SNAP_M:
|
||||
continue
|
||||
on += 1
|
||||
if float(np.interp(line.project(point), stations, rise)) < clearance:
|
||||
short += 1
|
||||
return {"pipes_on_route": on, "cover_short": short}
|
||||
|
||||
|
||||
def project_pipes(project_root: Path) -> list[tuple[float, float, float]]:
|
||||
"""사업지에 놓인 관(x, y, 덮개 여유 m) — 배수유역 편집분을 읽기만 한다(좌표 없는 관은 뺌)."""
|
||||
from common_util.common_util_drainage_pipes import facility_clearance_m, read_pipe_points_file
|
||||
from config.config_system import (
|
||||
DRAINAGE_CACHE_DIRNAME,
|
||||
DRAINAGE_EDITS_DIRNAME,
|
||||
DRAINAGE_PIPE_POINTS_FILENAME,
|
||||
)
|
||||
|
||||
path = (
|
||||
project_root
|
||||
/ "B04_PreProcess"
|
||||
/ DRAINAGE_CACHE_DIRNAME
|
||||
/ DRAINAGE_EDITS_DIRNAME
|
||||
/ DRAINAGE_PIPE_POINTS_FILENAME
|
||||
)
|
||||
return [
|
||||
(float(p.x), float(p.y), facility_clearance_m(p.facility, p.options))
|
||||
for p in read_pipe_points_file(path)
|
||||
if p.x is not None and p.y is not None
|
||||
]
|
||||
|
||||
|
||||
def attach_profile(
|
||||
metrics: dict[str, Any],
|
||||
vertices: list[tuple[float, float]],
|
||||
terrain: Terrain,
|
||||
*,
|
||||
max_grade: float,
|
||||
design_speed: int,
|
||||
) -> None:
|
||||
"""후보 비교값에 균형 종단을 붙이고 토공 점수를 그것으로 바꾼다(한 번만 · 묶음 캐시에 남음).
|
||||
|
||||
점수 = 절토(자연) + 성토 + 반입 + 사토 ㎥ — 가늠값(폭 4 m 띠 · 비탈면 뺌)은
|
||||
`earth_estimate_m3` 로 남기고, 공사비 어림의 토공 몫도 이 점수로 바꾼다."""
|
||||
if "profile" in metrics:
|
||||
return
|
||||
try:
|
||||
profile = route_profile(vertices, terrain, max_grade=max_grade, design_speed=design_speed)
|
||||
except ValueError as error:
|
||||
metrics["profile"] = {"error": str(error)}
|
||||
return
|
||||
estimate = metrics["cut_m3"] + metrics["fill_m3"]
|
||||
metrics["profile"] = profile
|
||||
metrics["earth_estimate_m3"] = round(estimate, 1)
|
||||
metrics["cost_index"] = round(
|
||||
metrics["cost_index"] + COST_EARTH_M3 * (profile_earth(profile) - estimate)
|
||||
)
|
||||
|
||||
|
||||
def profile_earth(profile: dict[str, Any]) -> float:
|
||||
"""균형 종단 토공 점수 — 절토(자연) + 성토 + 반입 + 사토 ㎥."""
|
||||
return profile["cut_m3"] + profile["fill_m3"] + profile["borrow_m3"] + profile["spoil_m3"]
|
||||
@@ -35,6 +35,7 @@ async def initial_route(
|
||||
generate_initial_route,
|
||||
load_terrain_and_streams,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import project_pipes
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
|
||||
weights = request.weights.model_dump() if request.weights else None
|
||||
@@ -79,6 +80,7 @@ async def initial_route(
|
||||
terrain, streams = await asyncio.to_thread(
|
||||
load_terrain_and_streams, project_root, selection
|
||||
)
|
||||
pipes = await asyncio.to_thread(project_pipes, project_root)
|
||||
result = await asyncio.to_thread(
|
||||
functools.partial(
|
||||
generate_initial_route,
|
||||
@@ -92,6 +94,8 @@ async def initial_route(
|
||||
stream_offset_m=stream_offset_m,
|
||||
grade_smooth_m=grade_smooth_m,
|
||||
target_grade=None if target_pct is None else target_pct / 100.0,
|
||||
design_speed=design_speed or 20,
|
||||
pipes=pipes,
|
||||
)
|
||||
)
|
||||
except (FileNotFoundError, ValueError) as error:
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from "./B05_Profile_Api_RouteInitial";
|
||||
import { flattenServerPlan } from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
||||
import { parseBalance, type BalanceProfile } from "./B05_Profile_UI_RouteEdit_Recommend_Profile";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
@@ -185,6 +186,8 @@ export interface InitialChoice {
|
||||
similar?: SimilarRoute[] | null;
|
||||
/** 제약 안에서 갈 수 있는 길의 띠 폭 어림(m) — 서버 `band_width_m`. */
|
||||
bandWidthM?: number | null;
|
||||
/** 균형 종단(계획서 63-4) — 모달 미리 보기에만. 캐시에는 안 적는다(`writeInitialChoice`). */
|
||||
balance?: BalanceProfile | null;
|
||||
}
|
||||
|
||||
/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */
|
||||
@@ -330,6 +333,7 @@ export function normalizeRouteInitial(
|
||||
terrainBoundPct: body.relaxed === true ? num(body.terrain_bound_pct) : null,
|
||||
similar: parseSimilar(body.similar),
|
||||
bandWidthM: num(body.band_width_m),
|
||||
balance: parseBalance(m.profile),
|
||||
criteria: body.criteria
|
||||
? {
|
||||
maxGradePct: pick(c, "max_grade_pct"),
|
||||
@@ -549,5 +553,6 @@ export function readInitialChoice(projectId: string): InitialChoice | null {
|
||||
}
|
||||
|
||||
export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void {
|
||||
writeState("routeedit-initial", choice, projectId);
|
||||
// 균형 종단은 편집 창 안에서만(계획서 63-4) — 캐시에 싣지 않는다.
|
||||
writeState("routeedit-initial", choice && { ...choice, balance: undefined }, projectId);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* 미리 보기를 버린다. 다시 계산(배수유역부터)은 여기서 안 돈다 — 편집 창 [확인]에서만.
|
||||
* 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독).
|
||||
* 서버 부르기 · 캐시 · 「현재 추천」은 `_Initial` 몫이고 여기는 화면만 만든다.
|
||||
* 균형 종단(계획서 63-4) — 후보마다 서버가 세운 종단의 한 줄 · 그림 · 셈한 후보 표는 이 모달
|
||||
* 안에서만 보인다(`_Recommend_Profile`) · 닫으면 버린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
@@ -44,6 +46,12 @@ import {
|
||||
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
|
||||
import { recommendHelpHtml, syncRecommendHelp } from "./B05_Profile_UI_RouteEdit_Recommend_Help";
|
||||
import {
|
||||
balanceLine,
|
||||
candidatesTable,
|
||||
profileSvg,
|
||||
type BalanceProfile,
|
||||
} from "./B05_Profile_UI_RouteEdit_Recommend_Profile";
|
||||
import "./B05_Profile_UI_Style_RouteRecommend.css";
|
||||
|
||||
type Vertex = [number, number];
|
||||
@@ -146,6 +154,9 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
let dirty = false;
|
||||
let closed = false;
|
||||
let seq = 0;
|
||||
/** 이 모달에서 셈한 후보의 균형 종단(계획서 63-4) — 모달이 닫히면 버린다(캐시 · 저장 없음). */
|
||||
const seen = new Map<string, BalanceProfile>();
|
||||
let ticker: number | undefined;
|
||||
|
||||
let bodyEl: HTMLElement | null = null;
|
||||
let map: ReturnType<typeof createRecommendMap> | null = null;
|
||||
@@ -158,6 +169,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
// ✕ · Esc · 바깥 누르기 모두 [닫기]와 같다 — 미리 보기를 버린다.
|
||||
beforeClose: () => {
|
||||
closed = true;
|
||||
window.clearInterval(ticker);
|
||||
map?.dispose();
|
||||
return true;
|
||||
},
|
||||
@@ -216,6 +228,9 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
<div class="b05-routerec__lines">
|
||||
<div data-line="metrics"></div>
|
||||
<div data-line="metrics-work"></div>
|
||||
<div data-line="balance" hidden></div>
|
||||
<div class="b05-routerec__profile" data-line="profile" hidden></div>
|
||||
<div class="b05-routerec__cands" data-line="candidates" hidden></div>
|
||||
<div data-line="violations" hidden></div>
|
||||
<div class="is-warn" data-line="relaxed" hidden></div>
|
||||
<div data-line="similar" hidden></div>
|
||||
@@ -326,6 +341,13 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
: "비교값 — 알약을 누르면 서버가 셈해 미리 보여 줍니다.";
|
||||
line("metrics-work").textContent = preview ? shown.work : "";
|
||||
line("metrics-work").hidden = !line("metrics-work").textContent;
|
||||
const balance = preview?.balance ?? null;
|
||||
line("balance").textContent = balanceLine(balance);
|
||||
line("balance").hidden = !balance;
|
||||
line("profile").innerHTML = profileSvg(balance);
|
||||
line("profile").hidden = !balance;
|
||||
line("candidates").innerHTML = candidatesTable([...seen]);
|
||||
line("candidates").hidden = seen.size === 0;
|
||||
const violations = violationsLine(preview?.metrics ?? null);
|
||||
line("violations").textContent = violations;
|
||||
line("violations").title = (preview?.metrics?.violations ?? [])
|
||||
@@ -359,7 +381,16 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
busy = true;
|
||||
dirty = false;
|
||||
preview = null;
|
||||
stateEl.textContent = `${choiceLabel(logic, sentWeights)} 셈하는 중…`;
|
||||
// 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4).
|
||||
const started = Date.now();
|
||||
const label = choiceLabel(logic, sentWeights);
|
||||
const tick = (): void => {
|
||||
const seconds = Math.round((Date.now() - started) / 1000);
|
||||
stateEl.textContent = `${label} 셈하는 중… ${seconds}초 (후보마다 균형 종단을 셈합니다)`;
|
||||
};
|
||||
window.clearInterval(ticker);
|
||||
tick();
|
||||
ticker = window.setInterval(tick, 1000);
|
||||
render();
|
||||
try {
|
||||
const choice = await params.request(
|
||||
@@ -375,6 +406,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
if (resolved !== null) params.defaults[key] = resolved;
|
||||
}
|
||||
preview = choice;
|
||||
if (choice.balance) seen.set(label, choice.balance);
|
||||
stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)} — [적용]을 눌러야 편집 창에 놓입니다.`;
|
||||
} catch (error) {
|
||||
if (closed || mine !== seq) return;
|
||||
@@ -384,6 +416,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
if (mine === seq) window.clearInterval(ticker);
|
||||
if (!closed && mine === seq) {
|
||||
busy = false;
|
||||
render();
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Recommend_Profile.ts
|
||||
* [노선 추천] 모달의 **균형 종단 미리 보기**(계획서 63-4) — 서버가 후보마다 세운 균형 종단
|
||||
* (`metrics.profile`)을 편다 · 한 줄 · 종단 그림 · 후보별 표.
|
||||
*
|
||||
* 이 값은 **편집 창 안에서만** 산다 — 캐시(`routeedit-initial`)에 적지 않고(`writeInitialChoice`
|
||||
* 가 뺀다) 저장 · initial_snapshot · 확정 종단과 무관하다. 확정 종단은 [확인] 체인이 지금 규칙
|
||||
* (계획고 = 지반고)으로 다시 세운다. 계산 자리 = 서버 단독 — 여기는 받은 값을 그리기만 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 후보 하나의 균형 종단 — 서버 `metrics.profile`. */
|
||||
export interface BalanceProfile {
|
||||
stationsM: number[];
|
||||
groundM: number[];
|
||||
planM: number[];
|
||||
/** 절토(자연) · 성토 · 반입 · 사토 ㎥. */
|
||||
cutM3: number;
|
||||
fillM3: number;
|
||||
borrowM3: number;
|
||||
spoilM3: number;
|
||||
/** (성 − 절 다짐 환산) / 큰 쪽 % · 허용 %. */
|
||||
imbalancePct: number;
|
||||
tolerancePct: number;
|
||||
maxGradePct: number;
|
||||
gradeLimitPct: number;
|
||||
/** 종단곡선 넣을 자리가 모자란 변화점 수(대수차가 생략 한도를 넘음). */
|
||||
curveOver: number;
|
||||
/** 이 노선 위 관 수 · 그중 덮개가 모자란 수 — 서버가 관을 몰랐으면 null. */
|
||||
pipesOnRoute: number | null;
|
||||
coverShort: number | null;
|
||||
}
|
||||
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
|
||||
const numbers = (value: unknown): number[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is number => num(item) !== null) : [];
|
||||
|
||||
/** 서버 `metrics.profile` 을 편다. 없거나(옛 답) 셈 실패면 null. */
|
||||
export function parseBalance(raw: unknown): BalanceProfile | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const p = raw as Record<string, unknown>;
|
||||
const stationsM = numbers(p.stations_m);
|
||||
const groundM = numbers(p.ground_m);
|
||||
const planM = numbers(p.plan_m);
|
||||
const need = [p.cut_m3, p.fill_m3, p.borrow_m3, p.spoil_m3, p.max_grade_pct].map(num);
|
||||
if (stationsM.length < 2 || groundM.length !== stationsM.length) return null;
|
||||
if (planM.length !== stationsM.length || need.some((value) => value === null)) return null;
|
||||
return {
|
||||
stationsM,
|
||||
groundM,
|
||||
planM,
|
||||
cutM3: num(p.cut_m3)!,
|
||||
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,
|
||||
maxGradePct: num(p.max_grade_pct)!,
|
||||
gradeLimitPct: num(p.grade_limit_pct) ?? 0,
|
||||
curveOver: num(p.curve_over) ?? 0,
|
||||
pipesOnRoute: num(p.pipes_on_route),
|
||||
coverShort: num(p.cover_short),
|
||||
};
|
||||
}
|
||||
|
||||
const m3 = (value: number): string => `${Math.round(value).toLocaleString("ko-KR")}㎥`;
|
||||
const pct = (value: number): string => `${value.toFixed(1)}%`;
|
||||
|
||||
/** 한 줄 — 「균형 종단: 절 7,646 · 성 7,944 · 반입 0 · 사토 848㎥ · 불균형 −9.6%(허용 10%) · …」. */
|
||||
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)})`,
|
||||
`최대 종단경사 ${pct(balance.maxGradePct)}(상한 ${pct(balance.gradeLimitPct)})`,
|
||||
];
|
||||
if (balance.curveOver > 0) parts.push(`종단곡선 자리 모자람 ${balance.curveOver}곳`);
|
||||
if (balance.pipesOnRoute !== null && balance.pipesOnRoute > 0)
|
||||
parts.push(`덮개 모자란 관 ${balance.coverShort ?? 0}/${balance.pipesOnRoute}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
const PROFILE_W = 600;
|
||||
const PROFILE_H = 110;
|
||||
const PAD = 6;
|
||||
|
||||
/** 종단 그림(SVG) — 지반선 · 균형 계획선. 가로 = 측점 · 세로 = 표고(창에 맞춰 늘림). */
|
||||
export function profileSvg(balance: BalanceProfile | null): string {
|
||||
if (!balance) return "";
|
||||
const { stationsM, groundM, planM } = balance;
|
||||
const total = stationsM[stationsM.length - 1] || 1;
|
||||
const low = Math.min(...groundM, ...planM);
|
||||
const span = Math.max(Math.max(...groundM, ...planM) - low, 1);
|
||||
const path = (values: number[]): string =>
|
||||
values
|
||||
.map((z, i) => {
|
||||
const x = PAD + ((PROFILE_W - 2 * PAD) * stationsM[i]) / total;
|
||||
const y = PROFILE_H - PAD - ((PROFILE_H - 2 * PAD) * (z - low)) / span;
|
||||
return `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
})
|
||||
.join("");
|
||||
return `<svg viewBox="0 0 ${PROFILE_W} ${PROFILE_H}" preserveAspectRatio="none"
|
||||
role="img" aria-label="균형 종단 미리 보기 — 지반선과 계획선">
|
||||
<path class="is-ground" d="${path(groundM)}" vector-effect="non-scaling-stroke" />
|
||||
<path class="is-plan" d="${path(planM)}" vector-effect="non-scaling-stroke" /></svg>`;
|
||||
}
|
||||
|
||||
/** 이 모달에서 셈한 후보들 — 갈래 이름 · 절 · 성 · 반입 · 최대 종단경사 표. 비면 빈 글. */
|
||||
export function candidatesTable(rows: Array<[string, BalanceProfile]>): string {
|
||||
if (!rows.length) return "";
|
||||
const body = rows
|
||||
.map(
|
||||
([name, b]) =>
|
||||
`<tr><th scope="row">${name}</th><td>${m3(b.cutM3)}</td><td>${m3(b.fillM3)}</td>` +
|
||||
`<td>${m3(b.borrowM3)}</td><td>${pct(b.maxGradePct)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
return `<table><thead><tr><th scope="col">후보</th><th scope="col">절토</th>
|
||||
<th scope="col">성토</th><th scope="col">반입</th><th scope="col">최대 종단경사</th></tr></thead>
|
||||
<tbody>${body}</tbody></table>`;
|
||||
}
|
||||
@@ -457,3 +457,46 @@
|
||||
.b05-routerec__target[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 균형 종단 미리 보기(계획서 63-4) — 지반선 · 계획선. */
|
||||
.b05-routerec__profile svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 110px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b05-routerec__profile .is-ground {
|
||||
fill: none;
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b05-routerec__profile .is-plan {
|
||||
fill: none;
|
||||
stroke: var(--color-primary);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* 이 모달에서 셈한 후보 표 — 절 · 성 · 반입 · 최대 종단경사. */
|
||||
.b05-routerec__cands table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-caption);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.b05-routerec__cands th,
|
||||
.b05-routerec__cands td {
|
||||
padding: 2px var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b05-routerec__cands th[scope="row"],
|
||||
.b05-routerec__cands thead th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -419,6 +419,47 @@ const badLogic = model.readInitialChoice("p-1");
|
||||
model.writeInitialChoice("p-1", null);
|
||||
const cleared = model.readInitialChoice("p-1");
|
||||
|
||||
// ⑤ 균형 종단(계획서 63-4) — 답에서 펴되 캐시에는 안 적는다(편집 창 안에서만).
|
||||
const recProfile = loadTs(
|
||||
path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Recommend_Profile.ts"),
|
||||
);
|
||||
const withProfile = model.normalizeRouteInitial(
|
||||
{
|
||||
...plainResponse,
|
||||
metrics: {
|
||||
...plainResponse.metrics,
|
||||
profile: {
|
||||
stations_m: [0, 20, 40],
|
||||
ground_m: [100, 103, 101],
|
||||
plan_m: [100, 101.5, 101],
|
||||
cut_m3: 120.4,
|
||||
fill_m3: 98.6,
|
||||
borrow_m3: 0,
|
||||
spoil_m3: 39.9,
|
||||
imbalance_pct: -28.6,
|
||||
tolerance_pct: 10,
|
||||
max_grade_pct: 7.5,
|
||||
grade_limit_pct: 14,
|
||||
curve_over: 1,
|
||||
pipes_on_route: 2,
|
||||
cover_short: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
"earthwork",
|
||||
null,
|
||||
);
|
||||
model.writeInitialChoice("p-63", withProfile);
|
||||
const balance63 = {
|
||||
parsed: withProfile.balance,
|
||||
cached: model.readInitialChoice("p-63"),
|
||||
line: recProfile.balanceLine(withProfile.balance),
|
||||
svg: recProfile.profileSvg(withProfile.balance),
|
||||
table: recProfile.candidatesTable([["토공 최소", withProfile.balance]]),
|
||||
missing: model.normalizeRouteInitial(plainResponse, "earthwork", null).balance,
|
||||
broken: recProfile.parseBalance({ error: "노선이 짧아 종단을 세울 수 없습니다." }),
|
||||
};
|
||||
|
||||
// 옛 저장값(short)이 캐시에 있어도 읽지 않는다 — 서버 기억(추적)을 쓴다.
|
||||
session._map.clear();
|
||||
session.setItem(
|
||||
@@ -448,5 +489,6 @@ process.stdout.write(
|
||||
sweep,
|
||||
weightLocks,
|
||||
sixth,
|
||||
balance63,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""노선 추천 후보의 균형 종단(PLAN 63-4) — `B05_Profile_Engine_RouteInitial_Profile` 시험.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_63_4_route_profile.py -q
|
||||
|
||||
합성 지형(노선 따라 ±20 % 물결 · 옆경사 50 %)에서 균형 계획선이 종단기울기 상한을 지키고,
|
||||
절(다짐 환산) · 성토가 허용(10 %) 안에서 맞고, 시 · 종점은 지반고에 붙는다. 토공 최소 갈래는
|
||||
균형 종단 토공(절 + 성 + 반입 + 사토)으로 고른다. 균형 종단은 화면 캐시에 안 적힌다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
|
||||
LEVEL_STEP_M,
|
||||
cover_shortfall,
|
||||
profile_earth,
|
||||
route_profile,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import Terrain
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
GRADE = 0.14
|
||||
ROUTE = [(50.0, 200.0), (350.0, 200.0)]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def wavy() -> Terrain:
|
||||
x = np.arange(0.0, 402.0, 2.0)
|
||||
y = np.arange(100.0, 302.0, 2.0)
|
||||
xx, yy = np.meshgrid(x, y)
|
||||
z = 100.0 + 6.0 * np.sin(xx / 30.0) + 0.5 * (yy - 200.0)
|
||||
return Terrain(x, y, z, np.ones(xx.shape, dtype=bool))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def profile(wavy: Terrain) -> dict:
|
||||
return route_profile(ROUTE, wavy, max_grade=GRADE, design_speed=20)
|
||||
|
||||
|
||||
def test_상한을_지키고_지반_기울기는_넘는다(profile: dict) -> None:
|
||||
stations = np.asarray(profile["stations_m"])
|
||||
plan = np.asarray(profile["plan_m"])
|
||||
ground = np.asarray(profile["ground_m"])
|
||||
grades = np.abs(np.diff(plan) / np.diff(stations))
|
||||
assert grades.max() <= GRADE + 1e-6
|
||||
assert profile["max_grade_pct"] <= profile["grade_limit_pct"] + 1e-6
|
||||
# 지반을 그대로 따르면 상한을 넘는 지형 — 계획선이 지반에서 떨어져야 함
|
||||
assert (np.abs(np.diff(ground) / np.diff(stations)) > GRADE).any()
|
||||
assert profile["max_cut_depth_m"] > 0.5 and profile["max_fill_height_m"] > 0.5
|
||||
|
||||
|
||||
def test_절성토_균형은_허용_안(profile: dict) -> None:
|
||||
assert profile["balanced"] is True
|
||||
assert abs(profile["imbalance_pct"]) <= profile["tolerance_pct"]
|
||||
# 반입 − 사토 = 성토 − 절토(다짐 환산)
|
||||
net = profile["fill_m3"] - profile["cut_compacted_m3"]
|
||||
assert abs((profile["borrow_m3"] - profile["spoil_m3"]) - net) <= 0.2
|
||||
assert min(profile["borrow_m3"], profile["spoil_m3"]) == 0.0
|
||||
assert profile["cut_m3"] > 0 and profile["fill_m3"] > 0
|
||||
|
||||
|
||||
def test_시종점은_지반고(profile: dict) -> None:
|
||||
for end in (0, -1):
|
||||
assert abs(profile["plan_m"][end] - profile["ground_m"][end]) <= LEVEL_STEP_M / 2 + 1e-6
|
||||
|
||||
|
||||
def test_대수차_넘침_수는_계획선과_맞음(profile: dict) -> None:
|
||||
plan = np.asarray(profile["plan_m"])
|
||||
grades = np.diff(plan) / np.diff(profile["stations_m"])
|
||||
over = int((np.abs(np.diff(grades)) > profile["curve_delta_max_pct"] / 100.0 + 1e-6).sum())
|
||||
assert over == profile["curve_over"]
|
||||
assert profile["curve_delta_max_pct"] == pytest.approx(5.0) # 20 m 변화점 → 생략 한도만
|
||||
|
||||
|
||||
def test_토공_최소_점수는_균형_종단(profile: dict) -> None:
|
||||
metrics = {"cut_m3": 10.0, "fill_m3": 20.0}
|
||||
assert GOALS["earthwork"](metrics) == (30.0,)
|
||||
metrics["profile"] = profile
|
||||
assert GOALS["earthwork"](metrics) == (profile_earth(profile),)
|
||||
expected = profile["cut_m3"] + profile["fill_m3"] + profile["borrow_m3"] + profile["spoil_m3"]
|
||||
assert profile_earth(profile) == pytest.approx(expected)
|
||||
metrics["profile"] = {"error": "노선이 짧아 종단을 세울 수 없습니다."}
|
||||
assert GOALS["earthwork"](metrics) == (30.0,)
|
||||
|
||||
|
||||
def test_덮개_모자란_관만_센다(profile: dict) -> None:
|
||||
on_route = (200.0, 200.0, 99.0) # 여유 99 m — 반드시 모자람
|
||||
shallow = (120.0, 201.0, -99.0) # 여유 음수 — 늘 넉넉
|
||||
far = (200.0, 260.0, 99.0) # 노선에서 60 m — 이 노선 관이 아님
|
||||
assert cover_shortfall(profile, ROUTE, [on_route, shallow, far]) == {
|
||||
"pipes_on_route": 2,
|
||||
"cover_short": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_균형_종단은_캐시에_안_남는다() -> None:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_routeedit_initial.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
result = json.loads(proc.stdout)["balance63"]
|
||||
assert result["parsed"]["borrowM3"] == 0 and result["parsed"]["spoilM3"] == 39.9
|
||||
assert result["parsed"]["planM"] == [100, 101.5, 101]
|
||||
assert "balance" not in result["cached"]
|
||||
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 result["svg"].count("<path") == 2
|
||||
assert '<th scope="row">토공 최소</th>' in result["table"]
|
||||
@@ -33,6 +33,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
|
||||
station_label,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
|
||||
SEARCHED = [mode for mode in MODES if mode != "follow"]
|
||||
@@ -111,6 +112,10 @@ def test_follow_is_initial_polyline_and_lists_violations(hill: Terrain) -> None:
|
||||
|
||||
|
||||
def _earth(result: dict) -> float:
|
||||
# 균형 종단을 붙인 후보는 그 토공 점수(PLAN 63-4) · 못 붙였으면 가늠값
|
||||
profile = result["metrics"].get("profile")
|
||||
if profile and "error" not in profile:
|
||||
return profile_earth(profile)
|
||||
return result["metrics"]["cut_m3"] + result["metrics"]["fill_m3"]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user