"""E03 보강 가정 — 비탈 기울기 완화 · 소단 덧붙임 → 설계선 다시 그림 · 최소 완화 찾기 (PLAN 536-3). storage 를 모르는 순수 함수. 가정 = 모형 형상만 바꿈(설계 · 임도 쪽으로 안 돌려 씀 · 요구 345 E). - `reshape_side` = 한 쪽 비탈을 비탈 시작점에서 1:n(+ 높이 h 마다 폭 w 소단)으로 원지반까지 다시 그림 · 안쪽(노면 · 측구)은 그대로 · 2단(암 · 토사) 경사는 한 경사로 갈음. - `slope_start` = 비탈 시작점 — 깎기 = 측구 바깥(`cut_slope_segments` 시작) · 쌓기 = 노면 끝. - `search_ratio` = 목표 안전율을 맞추는 가장 급한 1:n(이분 · `step` 단위 올림) — 해석은 부르는 쪽 함수. 좌표 = B06 offset(좌 +) · 표고. """ from __future__ import annotations import math from typing import Any, Callable import numpy as np #: 최소 완화 찾기 — 가장 완만한 기울기 · 찾는 단위 MAX_RATIO = 4.0 RATIO_STEP = 0.05 def current_ratio(design: dict[str, Any], side: str, role: str) -> float: """지금 설계 기울기 1:n 의 n — 깎기 = 그 쪽 첫 구간 · 쌓기 = 쌓기 기울기.""" if role == "fill": return float(design.get("fill_slope_ratio") or 1.5) segs = [s for s in design.get("cut_slope_segments") or [] if s.get("side") == side] if segs: return float(min(segs, key=lambda s: abs(s["start_offset_m"]))["ratio"]) return float(design.get("cut_slope_ratio") or 1.0) def slope_start(design: dict[str, Any], side: str, role: str) -> float: """비탈 시작 offset.""" edge = float(((design.get("road_edges") or {}).get(side) or {}).get("offset_m", 0.0)) if role == "cut": segs = [s for s in design.get("cut_slope_segments") or [] if s.get("side") == side] if segs: return float(min(segs, key=lambda s: abs(s["start_offset_m"]))["start_offset_m"]) return edge def _profile( x0: float, z0: float, sign: float, up: float, ratio: float, bench_w: float, bench_h: float ) -> list[tuple[float, float]]: """비탈 꼭짓점(시작 → 바깥) — 원지반을 넉넉히 지나도록 길게(뒤에서 자름).""" pts = [(x0, z0)] x, z = x0, z0 while abs(x - x0) < 200.0: rise = bench_h if bench_w > 0 and bench_h > 0 else 200.0 x, z = x + sign * rise * ratio, z + up * rise pts.append((x, z)) if bench_w > 0 and bench_h > 0: x += sign * bench_w pts.append((x, z)) return pts def _daylight( pts: list[tuple[float, float]], ground: list, sign: float, up: float ) -> list[tuple[float, float]] | None: """비탈이 원지반과 처음 만나는 점까지 — 못 만나면 None(지형 밖). 시작부터 원지반 밖이면 시작점만.""" gx = np.array([p[0] for p in ground], dtype=float) gz = np.array([p[1] for p in ground], dtype=float) out = [pts[0]] for (xa, za), (xb, zb) in zip(pts, pts[1:]): inside = gx[(gx > min(xa, xb)) & (gx < max(xa, xb))] xs = np.unique(np.r_[xa, xb, inside]) xs = xs[np.argsort(sign * xs)] zl = za + (xs - xa) / (xb - xa) * (zb - za) if xb != xa else np.full(len(xs), zb) f = up * (zl - np.interp(xs, gx, gz)) # ≥ 0 = 원지반 밖으로 나옴 if len(out) == 1 and f[0] >= 0: return out for k in range(1, len(xs)): if not gx[0] <= xs[k] <= gx[-1]: return None if f[k] >= 0: s = f[k - 1] / (f[k - 1] - f[k]) x = float(xs[k - 1] + s * (xs[k] - xs[k - 1])) return out + [(x, float(zl[k - 1] + s * (zl[k] - zl[k - 1])))] out.append((xb, zb)) return None def reshape_side( design_line: list, ground: list, *, side: str, role: str, start_x: float, ratio: float, bench_w: float = 0.0, bench_h: float = 0.0, ) -> list[list[float]] | None: """한 쪽 비탈만 다시 그린 설계선([[offset, z] …]) — 원지반을 못 만나면 None.""" line = sorted((float(x), float(z)) for x, z in design_line) sign = 1.0 if side == "left" else -1.0 up = 1.0 if role == "cut" else -1.0 lx = np.array([p[0] for p in line]) lz = np.array([p[1] for p in line]) z0 = float(np.interp(start_x, lx, lz)) inner = [p for p in line if sign * p[0] < sign * start_x - 1e-9] slope = _daylight(_profile(start_x, z0, sign, up, ratio, bench_w, bench_h), ground, sign, up) if slope is None: return None return [list(p) for p in sorted(inner + slope)] def design_variant( design: dict[str, Any], ground: list, assume: dict[str, Any] ) -> dict[str, Any] | None: """가정 하나(side · role · ratio · bench_w · bench_h) → 설계선만 바꾼 설계 사본(None = 원지반 못 만남).""" side, role = assume["side"], assume["role"] pts = [(p["offset_m"], p["elevation_m"]) for p in design.get("design_line") or []] line = reshape_side( pts, ground, side=side, role=role, start_x=slope_start(design, side, role), ratio=float(assume["ratio"]), bench_w=float(assume.get("bench_w") or 0.0), bench_h=float(assume.get("bench_h") or 0.0), ) if line is None: return None return {**design, "design_line": [{"offset_m": x, "elevation_m": z} for x, z in line]} def gentlest_ratio( design: dict[str, Any], ground: list, assume: dict[str, Any], start: float, step: float = RATIO_STEP, ) -> float | None: """원지반을 만나는 가장 완만한 n(start ~ MAX_RATIO · step 단위 · 형상만 · 이분) — 없으면 None.""" def meets(n: float) -> bool: return design_variant(design, ground, {**assume, "ratio": n}) is not None lo = math.ceil(start / step - 1e-9) * step hi = math.floor(MAX_RATIO / step + 1e-9) * step if not meets(lo): return None if meets(hi): return round(hi, 4) while hi - lo > step + 1e-9: # lo = 만남 · hi = 못 만남 mid = round(round((lo + hi) / 2 / step) * step, 6) if mid in (lo, hi): break lo, hi = (mid, hi) if meets(mid) else (lo, mid) return round(lo, 4) def search_ratio( passes: Callable[[float], bool], start: float, *, max_ratio: float = MAX_RATIO, step: float = RATIO_STEP, coarse: int = 5, ) -> dict[str, Any]: """목표를 맞추는 가장 급한 n(start ≤ n ≤ max_ratio · step 단위). passes(n) = 해석 · 판정. 완만할수록 늘 안전하지 않음(급한 자연 비탈 = 완화가 깎기 높이를 키움) — 성긴 걸음(최대 `coarse` 칸)으로 처음 맞는 칸을 찾고 그 앞 칸 사이를 이분으로 좁힘 · 끝까지 못 맞추면 found None.""" tried: list[tuple[float, bool]] = [] def test(n: float) -> bool: ok = passes(n) tried.append((round(n, 4), ok)) return ok def snap(n: float) -> float: return round(round(n / step) * step, 6) lo = snap(math.ceil(start / step - 1e-9) * step) top = snap(math.floor(max_ratio / step + 1e-9) * step) if test(lo): return {"found": lo, "tried": tried} gap = max(2 * step, snap((top - lo) / coarse)) hi = None n = lo while n < top - 1e-9: n = min(snap(n + gap), top) if test(n): hi = n break lo = n if hi is None: return {"found": None, "tried": tried} while hi - lo > step + 1e-9: # lo = 못 맞춤 · hi = 맞춤 mid = snap((lo + hi) / 2) if mid in (lo, hi): break lo, hi = (lo, mid) if test(mid) else (mid, hi) return {"found": round(hi, 4), "tried": tried}