"""유용토운반작업장(구 사토장) — 노선 옆에 남는 흙을 쌓는 **성토 단면**. ⚠⚠ TS 짝 파일과 **한 벌**이다 — `common_util/common_util_spoil_fill.ts`. 거울 시험: `tmp/tests/test_spoil_fill_mirror.py` (고칠 때 같이 돌릴 것). 화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽에 같은 수가 있어야 한다. ── 무엇을 재나 ───────────────────────────────────────────────────── 사용자 확정(2026-09-09): **폭의 시작점은 노면 끝**(노견이 시작하는 자리)이다. 노견 바깥 끝이 아니다 — 그래서 **그 구간의 노견도 이 성토 안에 들어간다**. 노면 끝(x0, z0) ├──── 평상(폭 w, 노면 끝 높이 그대로) ────┐ │ ╲ 1:n 비탈 │ ╲ ────────────── 원지반 ─────────────────────────╳ 비탈 끝(toe) 단면적 = 이 선과 원지반 사이(설계선이 지반보다 높은 몫). 대략 `w·h + w²/(2n)` 이지만 지반이 기울어 있으므로 **적분으로 정확히** 센다. ── 지어내지 않는 것 ──────────────────────────────────────────────── 지식DB `01_임도/02_상세설계/유용토운반작업장.md` §4: 「부지 면적에서 처리용량을 자동 산정하는 현행 법정 공식, 기본 적치높이, 기본 비탈기울기는 확보 근거에 없다. **사용자 협의 없이 기본값을 만들지 않는다**」 ⇒ 기울기 `n` 은 **받아서 쓴다**(비면 그 측점의 노선 성토 기울기를 그대로 씀 — 새 값을 만드는 것이 아니라 이미 설계된 값을 따르는 것이다). 높이는 **노면 끝 높이**로 정해진다. ⚠ **폭 상한은 지반 샘플이 있는 데까지**다. 샘플 밖은 지반을 모르므로 넓히지 않는다. """ from __future__ import annotations import math from dataclasses import dataclass, field #: 적분·비탈 추적을 잘게 나누는 폭(m). TS 짝과 같은 값이어야 수가 맞는다. STEP_M = 0.05 #: 지반과 만나는 자리를 좁히는 이분법 반복 수. SOLVE_STEPS = 24 @dataclass(slots=True) class SpoilFillSection: """사토장 성토 단면 하나.""" area_m2: float = 0.0 #: 평상 + 비탈을 그린 선 [(offset_m, elevation_m)] — 그리기와 적분이 같은 선을 쓴다. line: list[tuple[float, float]] = field(default_factory=list) #: 비탈 끝 오프셋 — 지반과 만난 자리. 못 만나면 샘플 끝에서 잘린다. toe_offset_m: float = 0.0 #: 비탈이 지반을 못 만나 잘렸나 — 화면이 「닫히지 않음」을 알린다. unclosed: bool = False #: 이 측점에서 넓힐 수 있는 최대 평상 폭(m). max_width_m: float = 0.0 def _interpolator(ground: list[tuple[float, float]]): points = sorted( (float(offset), float(elevation)) for offset, elevation in ground if offset is not None and elevation is not None ) def at(offset_m: float) -> float: if not points: return float("nan") if offset_m <= points[0][0]: return points[0][1] if offset_m >= points[-1][0]: return points[-1][1] for index in range(1, len(points)): left = points[index - 1] right = points[index] if offset_m <= right[0]: span = right[0] - left[0] if span <= 0: return right[1] ratio = (offset_m - left[0]) / span return left[1] + (right[1] - left[1]) * ratio return points[-1][1] return at def spoil_max_width_m(ground: list[tuple[float, float]], start_offset_m: float, side: str) -> float: """그 쪽으로 지반 샘플이 남아 있는 거리(m). 이보다 넓게는 못 쌓는다.""" offsets = [float(offset) for offset, _ in ground if offset is not None] if not offsets: return 0.0 edge = max(offsets) if side == "left" else min(offsets) reach = edge - start_offset_m if side == "left" else start_offset_m - edge return max(reach, 0.0) def spoil_fill_section( ground: list[tuple[float, float]], start_offset_m: float, start_elevation_m: float, side: str, width_m: float, slope_ratio_n: float, ) -> SpoilFillSection: """사토장 성토 단면 하나. 평상(노면 끝 높이) → 1:n 비탈 → 지반과 만나는 데까지. ⚠ 지반보다 낮아지는 몫은 세지 않는다 — 그것은 절토이지 쌓은 흙이 아니다. """ sign = 1.0 if side == "left" else -1.0 ground_at = _interpolator(ground) max_width = spoil_max_width_m(ground, start_offset_m, side) empty = SpoilFillSection(toe_offset_m=start_offset_m, max_width_m=round(max_width, 4)) width = max(float(width_m), 0.0) ratio = float(slope_ratio_n) if ratio <= 0 or not ground: return empty platform = min(width, max_width) def design_at(u: float) -> float: return start_elevation_m if u <= platform else start_elevation_m - (u - platform) / ratio def offset_at(u: float) -> float: return start_offset_m + sign * u def height_at(u: float) -> float: return design_at(u) - ground_at(offset_at(u)) # ① 비탈이 지반을 만나는 자리 — 평상 끝부터 바깥으로 훑는다. toe_u = max_width unclosed = True if height_at(platform) <= 0: toe_u = platform unclosed = False else: previous = platform steps = int((max_width - platform) / STEP_M) + 2 for step in range(1, steps + 1): current = min(platform + STEP_M * step, max_width) if height_at(current) <= 0: low, high = previous, current for _ in range(SOLVE_STEPS): mid = (low + high) / 2 if height_at(mid) > 0: low = mid else: high = mid toe_u = high unclosed = False break previous = current if current >= max_width: break # ② 면적 — 설계선이 지반보다 높은 몫만 사다리꼴로 적분한다. area = 0.0 cuts = [0.0] if 0 < platform < toe_u: cuts.append(platform) cuts.append(toe_u) for index in range(1, len(cuts)): start, end = cuts[index - 1], cuts[index] span = end - start if span <= 0: continue count = max(1, math.ceil(span / STEP_M)) step_width = span / count for step in range(count): u_a = start + step_width * step u_b = u_a + step_width h_a = max(height_at(u_a), 0.0) h_b = max(height_at(u_b), 0.0) area += (h_a + h_b) / 2 * step_width # ③ 그리는 선 — 적분과 같은 선을 쓴다(그림과 수량이 어긋나지 않게). line: list[tuple[float, float]] = [(start_offset_m, start_elevation_m)] if platform > 0: line.append((offset_at(platform), start_elevation_m)) if toe_u > platform: line.append((offset_at(toe_u), design_at(toe_u))) if sign < 0: line.reverse() return SpoilFillSection( area_m2=round(area, 4), line=[(round(offset, 4), round(elevation, 4)) for offset, elevation in line], toe_offset_m=round(offset_at(toe_u), 4), unclosed=unclosed, max_width_m=round(max_width, 4), ) def solve_spoil_width_m( ground: list[tuple[float, float]], start_offset_m: float, start_elevation_m: float, side: str, slope_ratio_n: float, target_area_m2: float, ) -> tuple[float, SpoilFillSection]: """원하는 단면적이 나오도록 **평상 폭을 되풀이로 찾는다**. ⚠ 면적은 폭에 대해 단조증가라 이분법으로 충분하다. ⚠ 상한(지반 샘플이 있는 데까지)에서도 모자라면 **그 폭을 돌려준다** — 못 담는 몫은 부르는 쪽이 「남은 사토」로 드러낸다(임의로 넓히지 않는다). """ max_width = spoil_max_width_m(ground, start_offset_m, side) def at(width_m: float) -> SpoilFillSection: return spoil_fill_section( ground, start_offset_m, start_elevation_m, side, width_m, slope_ratio_n ) if not target_area_m2 > 0: return 0.0, at(0.0) full = at(max_width) if full.area_m2 <= target_area_m2: return round(max_width, 4), full low, high = 0.0, max_width for _ in range(SOLVE_STEPS): mid = (low + high) / 2 if at(mid).area_m2 < target_area_m2: low = mid else: high = mid width_m = round(high, 4) return width_m, at(width_m)