diff --git a/B08_Quantity/B08_Quantity_Engine_SlopeLength.py b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py index 9f404359..11837f9e 100644 --- a/B08_Quantity/B08_Quantity_Engine_SlopeLength.py +++ b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py @@ -166,19 +166,28 @@ def _side_segments( berm = design.get("berm") or {} berm_width = _num(berm.get("width_m")) or 0.0 + # 소단 안쪽 기울기(°) 만큼은 평탄부로 봄 — 기울인 소단이 비탈로 읽혀 끊기지 않게. + berm_grade = math.tan(math.radians(_num(berm.get("slope_deg")) or 0.0)) + # 설계선 표고는 넷째 자리 반올림 — 기울인 소단 조각이 그만큼 더 가파르게 읽힘(0.0069/0.1953). + berm_slack = 2e-4 if berm_width > 0 else 0.0 segments: list[SlopeSegment] = [] started = False + # ⚠ 설계선이 격자(0.5m)로 찍혀 소단 평탄부가 **여러 조각**으로 쪼개짐 — 이어진 평탄 조각을 모아 + # 합이 소단 폭일 때만 소단으로 넘김(2026-09-15 · 한 조각만 보다가 첫 소단에서 사면이 끊겼음). + flat_run = 0.0 for start, end, run, rise in _outward(line, float(edge), side): if run <= 1e-9: continue - if abs(rise) <= _FLAT_RISE_M: - # 평탄부 — 측구 바닥·소단. 사면이 시작된 뒤라면 소단으로 보고 이어 간다. - if started and berm_width > 0 and abs(run - berm_width) < 0.05: - continue + if abs(rise) <= _FLAT_RISE_M + run * berm_grade + berm_slack: + # 평탄부 — 측구 바닥·소단. 사면이 시작된 뒤라면 모아 두고 다음 조각에서 판정. if started: - break # 사면이 끝나고 평지를 만난 것이다 + flat_run += run continue + if flat_run > 0: + if not (berm_width > 0 and abs(flat_run - berm_width) < 0.05): + break # 사면이 끝나고 평지를 만난 것이다 + flat_run = 0.0 ratio = run / abs(rise) # 절토는 바깥으로 갈수록 오르고, 성토는 내려간다. role = "cut" if rise > 0 else "fill" diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index b3da9e31..f2989f4a 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -54,6 +54,11 @@ from common_util.common_util_structure_lengths import structure_lengths from config.config_db import run_with_connection logger = logging.getLogger(__name__) +#: 소단 — 원단위 줄을 안 세우는 까닭(2026-09-15 조사). +BERM_COUNTED = ( + "횡단 설계의 계단으로 토공(절·성토)·사면 면적(면고르기·파종·녹화)에 이미 들어감" + " — 따로 줄 안 세움" +) router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"]) @@ -95,6 +100,11 @@ def _collect_structures( f"{definition.name}: {definition.design_owner} 가 이미 셈 — 중복 계상 방지" ) continue + if type_id == "berm": + # 소단 계단은 횡단 설계가 절·성토 면적·사면길이에 이미 넣음 — 줄을 세우면 + # 「산출식 없음」 거짓 막힘이 내역에 섬(2026-09-15 조사 · 임도 소단 품셈 공종 없음). + skipped.append(f"{definition.name}: {BERM_COUNTED}") + continue if definition.reference_only: skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만") continue diff --git a/resources/tester/test_b08_slope_length_berm.py b/resources/tester/test_b08_slope_length_berm.py new file mode 100644 index 00000000..4ae1cd09 --- /dev/null +++ b/resources/tester/test_b08_slope_length_berm.py @@ -0,0 +1,51 @@ +"""소단이 선 사면의 사면길이 — 첫 소단에서 끊기지 않을 것 (2026-09-15 소단 착수 전 조사). + +잰 것(936be972 읽기만) — 전 측점에 소단(0.5m · 3m · 0°)을 놓자 성토 사면길이가 측점 20 에서 +8.91 → 3.0m 로 끊겨 면고르기·파종 성토면 15,919.95 → 3,136.23㎡(−80%)로 줄었음. +까닭 — 설계선이 0.5m 격자로 찍혀 소단 평탄부 0.5m 가 0.195 + 0.305 두 조각으로 쪼개짐 → +「평탄부 한 조각 = 소단 폭」 판정이 안 맞아 거기서 사면이 끝난 것으로 봄. 안쪽 기울기(°)를 주면 +평탄부가 아예 평탄으로 안 읽혀 같은 자리에서 끊김. +""" + +from __future__ import annotations + +import pytest + +from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slope +from common_util.common_util_cross_berm import BermSpec + +# 노면보다 8m 낮은 평지 — 양쪽 성토 사면이 길게 섬. +SAMPLES = [{"offset_m": x / 2, "elevation_m": -8.0} for x in range(-160, 161)] + + +def _design(berm: BermSpec | None) -> dict: + return compute_cross_design( + SAMPLES, 0.0, ground_type="soil", section_mode="both_fill", berm=berm + ) + + +@pytest.mark.parametrize("slope_deg", [0.0, 2.0]) +def test_소단이_있어도_사면길이가_끝까지_이어진다(slope_deg: float) -> None: + plain = station_slope(0.0, _design(None)) + stepped_design = _design(BermSpec(0.5, 3.0, slope_deg)) + stepped = station_slope(0.0, stepped_design) + assert plain.fill_length_m > 15 # 양쪽 합 — 시험이 뜻을 가지는 크기 + # 소단 평탄부는 사면길이에 안 들고, 비탈 조각은 끝까지 셈 — 같은 낙차라 거의 같음. + assert stepped.fill_length_m == pytest.approx(plain.fill_length_m, rel=0.05) + assert stepped.fill_height_m == pytest.approx(plain.fill_height_m, abs=0.2) + + +def test_소단_구조물은_원단위_줄을_안_세우고_까닭을_남긴다(monkeypatch, tmp_path) -> None: + """소단 계단은 횡단 설계가 절·성토 면적·사면길이에 이미 넣음 — 따로 줄을 세우면 「산출식 없음」 + 거짓 막힘(formula_missing)이 내역에 섬(2026-09-15 조사).""" + from B05_Profile.B05_Profile_Structures_Schema import StructureInstance + from B08_Quantity import B08_Quantity_Router_Material as material + + berm = StructureInstance( + structure_id="b", type_id="berm", placement="interval", start_m=100.0, end_m=120.0 + ) + monkeypatch.setattr(material, "load_structures", lambda root: (1, [berm])) + targets, _names, skipped = material._collect_structures(str(tmp_path)) + assert targets == [] + assert any("소단" in note and "토공" in note and "사면" in note for note in skipped)