feat(B06): 측구터파기 단면적을 토사/암으로 가름 — 새 입력 없이 암반 경계선으로

별표2 Ⅰ.1.나.(5) 「측구터파기 단면적」이 횡단도 표의 법정 칸인데 한 값뿐이라
「측구 토사 / 측구 암석」 두 칸이 반만 채워졌다.

- 가르는 근거는 **절토 분리와 같은 것**(지반 유형 + 암반 경계선). 새 입력을 만들지 않았다.
- 측구 상단에서 암반 경계선까지의 깊이로 공칭 도형(사다리꼴·L형)을 가로로 가른다.
- ⚠ 근거가 없으면 **나누지 않는다.** 사유를 `ditch_split_basis` 로 함께 냄:
  rock_boundary / soil_ground / rock_ground_no_boundary / no_ditch.
- 기존 `ditch_area_m2` 는 **합계로 그대로** 두고 갈래를 덧붙였다 — B08 이 순서대로 옮겨 갈 수 있게.
- 파이썬·TS 짝을 함께 고침. 거울 테스트 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 19:22:58 +09:00
co-authored by Claude Opus 5
parent f60fb9c355
commit 6a7c339f8a
4 changed files with 141 additions and 1 deletions
+35
View File
@@ -131,3 +131,38 @@ def _bench_cut_length(offsets: list[float], grounds: list[float], diffs: list[fl
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
+29
View File
@@ -34,6 +34,7 @@ from typing import Any
from B06_Section.B06_Section_Engine_Areas import (
_bench_cut_length,
_split_cut_areas,
_split_ditch_area,
_trapezoid_areas,
)
from common_util.common_util_cross_berm import (
@@ -749,6 +750,28 @@ def compute_cross_design(
"depth_m": group["ditch_depth_m"],
}
# 측구터파기 토사/암 분리 — **새 입력을 만들지 않는다.** 절토 분리와 같은 근거
# (지반 유형 + 암반 경계선)를 그대로 쓴다. 별표2 Ⅰ.1.나.(5) 「측구터파기 단면적」이
# 횡단도 표의 법정 칸이라 반만 채워 나가면 안 된다(2026-09-09).
# 근거가 없으면 **나누지 않고** 사유를 함께 내보낸다 — 절반을 임의로 가르지 않는다.
if not geometry.has_ditch:
ditch_soil_area, ditch_rock_area = 0.0, 0.0
ditch_split_basis = "no_ditch"
elif preset_key != "rock":
# 토사 지반 — 암반 경계선 자체가 없다. 전량 토사(절토 분리와 같은 판정).
ditch_soil_area, ditch_rock_area = ditch_area, 0.0
ditch_split_basis = "soil_ground"
elif rock_boundary_offset_m is None or not geometry.ditch_points:
# 암 지반인데 경계선 값이 없다(구 데이터) — 가를 근거가 없으므로 전량 암.
ditch_soil_area, ditch_rock_area = 0.0, ditch_area
ditch_split_basis = "rock_ground_no_boundary"
else:
ditch_top_z = geometry.ditch_points[0][1]
mid_offset = sum(point[0] for point in geometry.ditch_points) / len(geometry.ditch_points)
boundary_z = ground_at(mid_offset) - abs(float(rock_boundary_offset_m))
ditch_soil_area, ditch_rock_area = _split_ditch_area(ditch_spec, ditch_top_z - boundary_z)
ditch_split_basis = "rock_boundary"
# 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo한다(D-2, 표시·저장용).
if geometry.left_role == "cut" and geometry.right_role == "cut":
resolved_mode = "both_cut"
@@ -824,6 +847,12 @@ def compute_cross_design(
round(fill_ground_slope, 4) if fill_ground_slope is not None else None
),
"ditch_area_m2": round(ditch_area, 4),
# 측구터파기 내역(합=ditch_area_m2). 가른 근거는 `ditch_split_basis` 로 함께 낸다:
# rock_boundary(암반 경계선으로 가름) · soil_ground(토사 지반이라 전량 토사) ·
# rock_ground_no_boundary(암 지반인데 경계선 없음 — 전량 암) · no_ditch(측구 없음).
"ditch_soil_area_m2": round(ditch_soil_area, 4),
"ditch_rock_area_m2": round(ditch_rock_area, 4),
"ditch_split_basis": ditch_split_basis,
"design_line": design_line,
# 절토 사면을 경사 구간별로 쪼갠 목록(소단 제외).
# ⚠ **지금 이 값을 읽는 곳은 없다**(2026-09-07). 원래 임자였던 별표2 법정 경사 검사는
+44 -1
View File
@@ -24,7 +24,12 @@
* ========================================================================== */
import type { BermSpec } from "./common_util_cross_berm";
import { benchCutLength, splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas";
import {
benchCutLength,
splitCutAreas,
splitDitchArea,
trapezoidAreas,
} from "./common_util_cross_design_areas";
// 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04).
import {
CURVE_WIDENING_MAX_WIDTH_M,
@@ -136,6 +141,10 @@ export interface CrossDesignResult {
slope_unclosed: boolean;
fill_ground_slope: number | null;
ditch_area_m2: number;
/** 측구터파기 내역(합=ditch_area_m2)과 가른 근거. 짝: 파이썬 `ditch_split_basis`. */
ditch_soil_area_m2: number;
ditch_rock_area_m2: number;
ditch_split_basis: string;
design_line: CrossDesignEdge[];
/** 절토 사면 경사 구간(소단 제외). 짝: `cut_slope_segments`.
* ⚠ **지금 읽는 곳은 없다**(2026-09-07) — 임자였던 별표2 검사는 폐기됐고 저장분에도
@@ -395,6 +404,37 @@ export function computeCrossDesign(
};
}
// 측구터파기 토사/암 분리 — **새 입력을 만들지 않는다.** 절토 분리와 같은 근거
// (지반 유형 + 암반 경계선)를 그대로 쓴다. 근거가 없으면 나누지 않고 사유를 낸다.
// ⚠ 파이썬 짝: `B06_Section_Engine_Design` 의 같은 자리.
let ditchSoilArea: number;
let ditchRockArea: number;
let ditchSplitBasis: string;
if (!geometry.hasDitch) {
ditchSoilArea = 0;
ditchRockArea = 0;
ditchSplitBasis = "no_ditch";
} else if (presetKey !== "rock") {
ditchSoilArea = ditchArea;
ditchRockArea = 0;
ditchSplitBasis = "soil_ground";
} else if (
rockBoundaryOffsetM === null ||
rockBoundaryOffsetM === undefined ||
!geometry.ditchPoints.length
) {
ditchSoilArea = 0;
ditchRockArea = ditchArea;
ditchSplitBasis = "rock_ground_no_boundary";
} else {
const ditchTopZ = geometry.ditchPoints[0][1];
const midOffset =
geometry.ditchPoints.reduce((sum, point) => sum + point[0], 0) / geometry.ditchPoints.length;
const boundaryZ = groundAt(midOffset) - Math.abs(rockBoundaryOffsetM);
[ditchSoilArea, ditchRockArea] = splitDitchArea(ditchSpec, ditchTopZ - boundaryZ);
ditchSplitBasis = "rock_boundary";
}
// 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo 한다(D-2).
let resolvedMode: string;
if (geometry.leftRole === "cut" && geometry.rightRole === "cut") resolvedMode = "both_cut";
@@ -457,6 +497,9 @@ export function computeCrossDesign(
slope_unclosed: slopeUnclosed,
fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope),
ditch_area_m2: round4(ditchArea),
ditch_soil_area_m2: round4(ditchSoilArea),
ditch_rock_area_m2: round4(ditchRockArea),
ditch_split_basis: ditchSplitBasis,
design_line: designLine,
cut_slope_segments: geometry.cutSlopeSegments(),
};
@@ -126,3 +126,36 @@ export function benchCutLength(offsets: number[], grounds: number[], diffs: numb
}
return total;
}
/**
* 측구 단면적을 [토사, 암반]으로 가른다 — 측구 상단에서 암반 경계선까지의 깊이(m) 기준.
* ⚠ 파이썬 짝: `B06_Section_Engine_Areas._split_ditch_area`. 한 벌로 움직인다.
* 측구 단면은 공칭 도형이라 지반선을 따라 적분하지 않는다 — 경계선도 그 자리 한 높이로 본다.
*/
export function splitDitchArea(
ditchSpec: Record<string, unknown>,
depthToBoundaryM: number | null,
): [number, number] {
const kind = String(ditchSpec.type ?? "none");
const clamp = (depth: number): number => Math.min(Math.max(depthToBoundaryM ?? 0, 0), depth);
if (kind === "l_type") {
const width = Number(ditchSpec.width_m ?? 0);
const depth = Number(ditchSpec.depth_m ?? 0);
if (depth <= 0 || width <= 0) return [0, 0];
const total = (width * depth) / 2;
const d0 = clamp(depth);
const soil = width * d0 - (width * d0 * d0) / (2 * depth);
return [soil, Math.max(total - soil, 0)];
}
if (kind === "standard") {
const top = Number(ditchSpec.top_width_m ?? 0);
const bottom = Math.min(Number(ditchSpec.bottom_width_m ?? 0), top);
const depth = Number(ditchSpec.depth_m ?? 0);
if (depth <= 0 || top <= 0) return [0, 0];
const total = ((top + bottom) / 2) * depth;
const d0 = clamp(depth);
const soil = top * d0 - ((top - bottom) * d0 * d0) / (2 * depth);
return [soil, Math.max(total - soil, 0)];
}
return [0, 0];
}