사토장이 선 측점은 **노면 끝 바깥이 사토장 몫**이라 노선 성토에서 빼야 함.
안 빼면 같은 흙을 두 번 셈(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>
203 lines
9.5 KiB
Python
203 lines
9.5 KiB
Python
"""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 − (top−bottom)·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]
|