Files
Aislo/B06_Section/B06_Section_Engine_Areas.py
eomsangdonandClaude Opus 5 d9b801aeaa feat(B06): 횡단 설계가 사토장 단면을 냄 — 노선 성토와 갈라서
사토장이 선 측점은 **노면 끝 바깥이 사토장 몫**이라 노선 성토에서 빼야 함.
안 빼면 같은 흙을 두 번 셈(2026-09-09 확정 ㉠).

- `compute_cross_design(spoil_fill=…)` / `computeCrossDesign({spoilFill})` 신설(짝)
- 새 칸: `spoil_fill_area_m2` · `spoil_fill_side` · `spoil_fill_width_m` ·
  `spoil_fill_max_width_m` · `spoil_fill_line` · `spoil_fill_unclosed` ·
  `spoil_fill_replaced_fill_m2`(노선 성토에서 뺀 몫 — 되짚기용)
- **합쳐서 하나로 내지 않음** — 받는 쪽이 갈라 볼 수 있어야 함
- 기울기가 비면 그 측점의 노선 성토 기울기를 그대로 씀(새 값 안 만듦)
- `fillAreaBeyond`/`_fill_area_beyond` — 경계 종거를 보간해 자름(한 칸도 안 흘림)
- 거울 시험에 사토장 사례 둘 추가 + 값이 0 이면 잡히는 가드

⚠ `B06_Section_Engine_Design.py` 가 924줄 — 700줄 제한 초과 상태임(이번 전에도 881줄).
  기능이 다 선 뒤 분리할 자리.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 07:51:41 +09:00

203 lines
9.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B06 횡단 단면적 적분 — 절·성토 면적과 절토의 토사/암반 분리.
⚠⚠ TS 짝 파일과 **한 벌**이다 — 한쪽만 고치면 화면과 저장본이 갈린다 ⚠⚠
짝: `common_util/common_util_cross_design_areas.ts`
회귀 테스트: `tmp/tests/test_b06_cross_design_mirror.py` (고칠 때 같이 돌릴 것).
두 벌인 이유는 `B06_Section_Engine_Design.py` 머리 참조.
`_Engine_Design.py`가 700줄을 넘겨, 「설계선을 어떻게 세우나」(그쪽)와 「그 선과 지반 사이
넓이를 어떻게 재나」(여기)로 갈랐다. 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로
설계 로직을 전혀 모른다 — 그래서 따로 떼어 검산하기도 쉽다.
"""
def _trapezoid_areas(offsets: list[float], diffs: list[float]) -> tuple[float, float]:
"""오프셋 순 (지반-설계) 차이를 사다리꼴 적분해 (절토, 성토) 면적을 반환한다.
diff>0(지반이 설계보다 높음)=절토, diff<0=성토. 부호가 바뀌는 구간은 영교점에서
나눠 절·성토가 섞이지 않게 한다.
"""
cut_area = 0.0
fill_area = 0.0
for index in range(1, len(offsets)):
x0, x1 = offsets[index - 1], offsets[index]
d0, d1 = diffs[index - 1], diffs[index]
width = x1 - x0
if width <= 0:
continue
if d0 == 0 and d1 == 0:
continue
if d0 * d1 < 0:
# 부호 변화: 영교점에서 두 삼각형으로 분리
zero_ratio = d0 / (d0 - d1)
x_zero = x0 + width * zero_ratio
left_area = 0.5 * (x_zero - x0) * abs(d0)
right_area = 0.5 * (x1 - x_zero) * abs(d1)
if d0 > 0:
cut_area += left_area
fill_area += right_area
else:
fill_area += left_area
cut_area += right_area
continue
area = 0.5 * (d0 + d1) * width
if area >= 0:
cut_area += area
else:
fill_area += -area
return cut_area, fill_area
def _split_cut_areas(
offsets: list[float], diffs: list[float], soil_depth_m: float
) -> tuple[float, float]:
"""절토 면적을 암반 경계선 기준으로 (토사, 암반)으로 나눈다.
암반 경계선은 지반선 평행 복사(`지반고 + rock_boundary_offset_m`)이므로 토사층 두께
`t0`가 절토 구간 전체에서 균일하다. 따라서 오프셋별 절토 종거 `d = 지반고 - 설계고`에
대해 토사분은 `min(max(d, 0), t0)`, 암반분은 `max(d - t0, 0)`이며 두 값의 합은 항상
`max(d, 0)`이라 `_trapezoid_areas`의 절토 면적과 정확히 일치한다.
두 함수 모두 `d = 0`과 `d = t0`에서 꺾이므로 그 교차점을 구간 분할점으로 넣어야
사다리꼴 적분이 근사가 아닌 정확값이 된다.
"""
t0 = max(float(soil_depth_m), 0.0)
soil_area = 0.0
rock_area = 0.0
for index in range(1, len(offsets)):
x0, x1 = offsets[index - 1], offsets[index]
d0, d1 = diffs[index - 1], diffs[index]
width = x1 - x0
if width <= 0:
continue
ratios = [0.0, 1.0]
for level in (0.0, t0):
if (d0 - level) * (d1 - level) < 0:
ratios.append((level - d0) / (d1 - d0))
ratios.sort()
for step in range(1, len(ratios)):
ratio_a, ratio_b = ratios[step - 1], ratios[step]
span = width * (ratio_b - ratio_a)
if span <= 0:
continue
d_a = d0 + (d1 - d0) * ratio_a
d_b = d0 + (d1 - d0) * ratio_b
soil_area += (min(max(d_a, 0.0), t0) + min(max(d_b, 0.0), t0)) / 2.0 * span
rock_area += (max(d_a - t0, 0.0) + max(d_b - t0, 0.0)) / 2.0 * span
return soil_area, rock_area
# 층따기 대상 판정 기울기 — 원지반 횡단기울기 1:4(=25%)보다 급한 곳에만 한다.
# 근거: 임도설치 및 관리 등에 관한 규정 별표2 · 임도기술교본 6장 4절 「경사지의 층따기에
# 있어 그 경사가 1:4보다 급한 경사를 가진 지반 위에 성토를 하는 경우 … 층따기를 설치」.
# 지식DB `01_임도/02_상세설계/성토_비탈면.md` §4 [구현] 「원지반 횡단경사 > 25% 구간의 성토부」.
_BENCH_CUT_MIN_GROUND_SLOPE = 0.25
def _bench_cut_length(offsets: list[float], grounds: list[float], diffs: list[float]) -> float:
"""층따기 밑수 — **성토부 아래 원지반 표면의 경사길이(m)**.
무엇을 재나
성토(diff<0)가 원지반에 얹히는 구간에서, 원지반 횡단기울기가 1:4 보다 급한
조각만 골라 **지표면을 따라간 길이**를 더한다. 수평 폭이 아니라 빗변이다 —
층따기는 그 경사면을 계단으로 깎는 일이라 대상 면이 곧 지표면이다.
왜 성토면이 아니라 원지반인가
층따기는 **원지반 표면**에 하는 것이다(교본 6장 4절). 성토 비탈면 길이로 재면
대상이 아닌 면을 세는 것이 된다.
단위
여기서 나오는 것은 **길이(m)** 다. 면적(㎡)은 측점 사이를 평균단면적법으로 이어
B08 이 낸다 — 사면 4계열과 같은 방식이라 계산을 두 벌로 짜지 않는다.
(2026-09-09 사용자 확정: 층따기 단위는 ㎡.)
"""
total = 0.0
for index in range(1, len(offsets)):
run = offsets[index] - offsets[index - 1]
if run <= 0:
continue
d0, d1 = diffs[index - 1], diffs[index]
# 성토 조각만 — 부호가 바뀌면 영교점까지만 성토다.
if d0 >= 0 and d1 >= 0:
continue
share = 1.0
if d0 * d1 < 0:
zero_ratio = d0 / (d0 - d1)
share = (1.0 - zero_ratio) if d0 > 0 else zero_ratio
if share <= 0:
continue
rise = grounds[index] - grounds[index - 1]
if abs(rise) / run < _BENCH_CUT_MIN_GROUND_SLOPE:
continue
total += ((run**2 + rise**2) ** 0.5) * share
return total
def _split_ditch_area(ditch_spec: dict, depth_to_boundary_m: float | None) -> tuple[float, float]:
"""측구 단면적을 (토사, 암반)으로 가른다 — 암반 경계선까지의 깊이 기준.
`depth_to_boundary_m` 은 **측구 상단에서 암반 경계선까지의 깊이(m)** 다.
`None` 이면 가를 근거가 없다는 뜻이라 부르는 쪽이 처리한다(여기서는 안 부른다).
⚠ 측구 단면은 **공칭 도형**(사다리꼴·L형 근사)이라 지반선을 따라 적분하지 않는다.
경계선도 그 자리 한 높이로 본다 — 폭 1m 안팎에서 지반선 기울기 차이는 도형 근사보다
작다. 절토 면적 분리(`_split_cut_areas`)가 균일 두께를 쓰는 것과 같은 태도다.
"""
kind = str(ditch_spec.get("type") or "none")
if kind == "l_type":
width = float(ditch_spec.get("width_m") or 0.0)
depth = float(ditch_spec.get("depth_m") or 0.0)
total = width * depth / 2.0
if depth <= 0 or width <= 0:
return 0.0, 0.0
d0 = min(max(depth_to_boundary_m or 0.0, 0.0), depth)
# 깊이 d 에서의 가로 폭 = W(1 − d/D). 위에서 d0 까지 적분한다.
soil = width * d0 - width * d0 * d0 / (2.0 * depth)
return soil, max(total - soil, 0.0)
if kind == "standard":
top = float(ditch_spec.get("top_width_m") or 0.0)
bottom = min(float(ditch_spec.get("bottom_width_m") or 0.0), top)
depth = float(ditch_spec.get("depth_m") or 0.0)
total = (top + bottom) / 2.0 * depth
if depth <= 0 or top <= 0:
return 0.0, 0.0
d0 = min(max(depth_to_boundary_m or 0.0, 0.0), depth)
# 깊이 d 에서의 폭 = top (topbottom)·d/depth. 위에서 d0 까지 적분한다.
soil = top * d0 - (top - bottom) * d0 * d0 / (2.0 * depth)
return soil, max(total - soil, 0.0)
return 0.0, 0.0
def _fill_area_beyond(offsets: list[float], diffs: list[float], x0: float, side: str) -> float:
"""`x0` **바깥쪽**(사토장이 서는 쪽)의 성토 면적(㎡)만 따로 낸다.
⚠ 왜 있나 — 사토장이 선 측점에서는 노면 끝 바깥이 **사토장 몫**이라 노선 성토
(`fill_area_m2`)에서 빼야 한다. 안 빼면 **같은 흙을 두 번 센다**(2026-09-09 확정 ㉠).
⚠ 경계(`x0`)의 종거는 **보간해서** 넣는다 — 그냥 버리면 경계 한 칸이 통째로 빠져
값이 작아진다. 좌는 `x0` 위쪽, 우는 `x0` 아래쪽이며 **둘 다 오름차순**으로 넘긴다.
짝: TS `fillAreaBeyond`.
"""
if len(offsets) < 2:
return 0.0
inside = (lambda x: x >= x0) if side == "left" else (lambda x: x <= x0)
sub_offsets: list[float] = []
sub_diffs: list[float] = []
for index, x in enumerate(offsets):
if index > 0:
x_prev = offsets[index - 1]
crosses = (x_prev < x0 < x) or (x < x0 < x_prev)
if crosses:
ratio = (x0 - x_prev) / (x - x_prev)
sub_offsets.append(x0)
sub_diffs.append(diffs[index - 1] + (diffs[index] - diffs[index - 1]) * ratio)
if inside(x):
sub_offsets.append(x)
sub_diffs.append(diffs[index])
order = sorted(range(len(sub_offsets)), key=lambda i: sub_offsets[i])
sub_offsets = [sub_offsets[i] for i in order]
sub_diffs = [sub_diffs[i] for i in order]
if len(sub_offsets) < 2:
return 0.0
return _trapezoid_areas(sub_offsets, sub_diffs)[1]