feat(B08): 사면길이 유도·사면 4계열 면적 엔진
PLAN 8-4b 의 「사면적 없음」 구멍을 메움. 일감 3 의 엔진 덩어리. ⚠ B06 무접촉 — 저장분만으로 유도 설계 엔진이 cut_slope_segments 를 내기는 하나 **정본에 저장되지 않음** (실측: route 150 측점 20.0 의 저장 design 키 33개에 그 키 없음. 저장되는 것은 화면이 보낸 설계 지정이고 조회는 저장분을 그대로 실음). 그 값을 원천으로 쓰면 사면적이 조용히 0 이 됨. 대신 design_line + 저장된 경사비로 유도함 — 노체 끝 바깥으로 나아가며 기울기가 설계 경사비와 맞는 연속 구간이 사면이고, 원지반은 경사비가 안 맞아 저절로 끊김. 성토도 같은 원리로 잡힘(원지반선이 없어도 됨). 2단 사면(암/토사)은 경사비가 달라 그대로 갈림. ⚠ 측구를 사면으로 세지 않음 — 개발 중 실제로 났던 결함 측구 벽 경사가 n=1.0 으로 토사 절토비와 같아 그대로 걸렸음. 사면 시작점을 노체 끝이 아니라 **측구 바깥 끝**(road_edges ± ditch.top_width_m)으로 옮겨 고침. 그 형상을 실측 그대로 시험에 세워 재발을 막음. 사면 4계열 x 성토면/절토면 = (거리, 면적) 7쌍 층따기[성토면만] · 면고르기 · 법면보호공 · 지장목제거. 거리 = 그 측점 사면길이, 면적 = 토적표와 **같은 평균단면적법**(계산을 두 벌로 안 짬). 법면보호공은 면고르기를 참조하되 끊을 수 있게 둠 — 실무 시트가 값이 같았던 것은 엑셀 복사였고 오솔길 산출에는 보호공 4열이 0 이었음. 반영률은 법정값 아님 — 기본 100 %, 설계자 입력(PLAN 8-11 · 8-10 법대로). TODO(미결) — 실무 관측 80/50/80 중 지장목제거는 밑수가 안 맞아(14,061 != 10,782) 쫓지 않고 100 % 로 둠. 잘린 측점을 감추지 않음 — slope_unclosed 를 물고 와 unclosed_stations 목록으로 냄. 사면이 원지반을 못 만나면 사면길이도 잘려 있으므로 화면이 드러내야 함. 검증 — tmp/tests/test_b08_slope_area.py 15건 통과. 실측 설계선을 그대로 세워 측구 제외·2단 암토사 분리·성토 유도·원지반 끊김을 못 박음. 실데이터(route 150, 65측점) — 면적이 있는데 사면길이가 0 인 경우 0건, 잘린 측점 17곳 검출. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""사면 4계열 면적 — 실무 토적표의 오른쪽 절반 (B08 일감 3 · PLAN 8-4b).
|
||||
|
||||
무엇을 내나
|
||||
실무 토적표 V~AI 열에 해당한다. 계열 넷 × 성토면/절토면 2벌 = **(거리, 면적) 7쌍**
|
||||
(층따기는 성토면만이라 7쌍이다).
|
||||
|
||||
층따기[성토면] · 면고르기[성토면·절토면] · 법면보호공[성토면·절토면] · 지장목제거[성토면·절토면]
|
||||
|
||||
여기서 「거리」는 그 측점의 **사면길이**이고, 면적은 토적표와 **같은 평균단면적법**으로
|
||||
낸다 — 계산을 두 벌로 짜지 않는다.
|
||||
|
||||
법면보호공은 면고르기를 참조한다 (PLAN 8-4b)
|
||||
실무 시트에서 둘의 값이 완전히 같았는데, 그것은 **엑셀에서 면고르기 열을 복사한 것**이고
|
||||
오솔길 산출(`1.BOM`)에는 보호공 4열이 **0** 으로 비어 있었다. 즉 산출값이 아니라 참조다.
|
||||
그래서 기본은 참조로 두되 **끊을 수 있게** 한다 — 실제 보호 대상이 면고르기 대상과
|
||||
다를 수 있기 때문이다.
|
||||
|
||||
⚠ 반영률은 법정값이 아니다 (PLAN 8-11 · 8-10 ★)
|
||||
실무 시트가 「성토면 80 % 반영」처럼 비고란에 손으로 적어 둔 값이다. **프로그램 기본은
|
||||
100 %** 이고 설계자가 바꾼다. 실무 관측치(80/50/80)는 기본값 후보가 아니라 참고다.
|
||||
|
||||
⚠ 소단 평탄부는 사면적에 넣지 않는다
|
||||
면고르기·종자파종의 대상은 「사면」이고 소단은 평평한 턱이다. `SlopeSegment` 자체가
|
||||
평탄부를 빼고 나오므로 여기서 다시 거를 것이 없다. 다만 **소단이 늘수록 사면적이 줄어드는
|
||||
것이 눈에 보여야** 하므로 측점마다 소단 폭을 함께 싣는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_SlopeLength import StationSlope
|
||||
|
||||
# 계열 이름 — 실무 토적표 머리글 그대로. `fill`/`cut` 은 성토면/절토면이다.
|
||||
SERIES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("bench_cut", ("fill",)), # 층따기 — 성토면만(원지반이 급한 곳을 계단으로 깎는다)
|
||||
("face_dressing", ("fill", "cut")), # 면고르기
|
||||
("slope_protection", ("fill", "cut")), # 법면보호공(종자파종)
|
||||
("tree_removal", ("fill", "cut")), # 지장목제거
|
||||
)
|
||||
|
||||
# 법면보호공이 참조하는 계열 — 기본은 면고르기다(위 설명 참조).
|
||||
PROTECTION_SOURCE = "face_dressing"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SlopeRatios:
|
||||
"""계열별 반영률(0~1). 기본 100 % — 실무 관측치는 참고일 뿐 기본값이 아니다.
|
||||
|
||||
⚠ TODO(미결 · PLAN 8-11) — 실무 관측 80/50/80 중 **지장목제거는 밑수가 안 맞는다**
|
||||
(성토+절토 합의 80 % = 14,061 ≠ 시트값 10,782). 밑수를 못 찾았으므로 쫓지 않고
|
||||
100 % 로 둔다. 근거가 나오면 이 값만 바꾼다.
|
||||
"""
|
||||
|
||||
bench_cut: float = 1.0
|
||||
face_dressing: float = 1.0
|
||||
slope_protection: float = 1.0
|
||||
tree_removal: float = 1.0
|
||||
|
||||
def of(self, series: str) -> float:
|
||||
return float(getattr(self, series, 1.0))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SlopeAreaRow:
|
||||
"""측점 하나의 사면 계열 값. `lengths` 는 거리(사면길이), `areas` 는 면적."""
|
||||
|
||||
chainage_m: float
|
||||
distance_m: float = 0.0
|
||||
berm_width_m: float = 0.0
|
||||
unclosed: bool = False
|
||||
lengths: dict[str, float] = field(default_factory=dict)
|
||||
areas: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _key(series: str, face: str) -> str:
|
||||
return f"{series}_{face}"
|
||||
|
||||
|
||||
def _length_of(slope: StationSlope, series: str, face: str) -> float:
|
||||
"""계열·면별 「거리」 = 그 측점의 사면길이.
|
||||
|
||||
법면보호공은 면고르기를 참조한다 — 같은 사면길이를 쓴다. 끊고 싶으면 이 함수만 고친다.
|
||||
층따기는 성토면만 대상이다.
|
||||
"""
|
||||
if series == "bench_cut" and face != "fill":
|
||||
return 0.0
|
||||
return slope.fill_length_m if face == "fill" else slope.cut_length_m
|
||||
|
||||
|
||||
def build_rows(
|
||||
slopes: Iterable[StationSlope], ratios: SlopeRatios | None = None
|
||||
) -> list[SlopeAreaRow]:
|
||||
"""측점별 사면길이 → 계열별 (거리, 면적). 면적은 토적표와 같은 평균단면적법."""
|
||||
rates = ratios or SlopeRatios()
|
||||
ordered = sorted(slopes, key=lambda s: s.chainage_m)
|
||||
rows: list[SlopeAreaRow] = []
|
||||
previous: SlopeAreaRow | None = None
|
||||
|
||||
for slope in ordered:
|
||||
row = SlopeAreaRow(
|
||||
chainage_m=slope.chainage_m,
|
||||
berm_width_m=slope.berm_width_m,
|
||||
unclosed=slope.unclosed,
|
||||
)
|
||||
for series, faces in SERIES:
|
||||
for face in faces:
|
||||
row.lengths[_key(series, face)] = _length_of(slope, series, face)
|
||||
if previous is not None:
|
||||
distance = slope.chainage_m - previous.chainage_m
|
||||
row.distance_m = distance
|
||||
for key, length in row.lengths.items():
|
||||
series = key.rsplit("_", 1)[0]
|
||||
before = previous.lengths.get(key, 0.0)
|
||||
# 평균단면적법 — 토적표와 같은 식이다(체적 대신 면적을 낸다).
|
||||
row.areas[key] = (before + length) / 2.0 * distance * rates.of(series)
|
||||
else:
|
||||
row.areas = {key: 0.0 for key in row.lengths}
|
||||
rows.append(row)
|
||||
previous = row
|
||||
return rows
|
||||
|
||||
|
||||
def totals(rows: list[SlopeAreaRow]) -> dict[str, float]:
|
||||
"""계열별 면적 합계. 거리(사면길이)는 합이 뜻이 없어 싣지 않는다."""
|
||||
keys = [_key(series, face) for series, faces in SERIES for face in faces]
|
||||
return {key: sum(row.areas.get(key, 0.0) for row in rows) for key in keys}
|
||||
|
||||
|
||||
def unclosed_stations(rows: list[SlopeAreaRow]) -> list[float]:
|
||||
"""사면이 원지반을 못 만나 **면적이 잘린** 측점 목록.
|
||||
|
||||
조용히 적게 내면 안 되는 값이라 화면이 이 목록을 그대로 보인다(PLAN 8-4b).
|
||||
같은 사유로 토적표의 절·성토 면적도 잘려 있다.
|
||||
"""
|
||||
return [row.chainage_m for row in rows if row.unclosed]
|
||||
|
||||
|
||||
def build_table(
|
||||
slopes: Iterable[StationSlope], ratios: SlopeRatios | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""화면·API 가 그대로 쓰는 모양."""
|
||||
rates = ratios or SlopeRatios()
|
||||
rows = build_rows(slopes, rates)
|
||||
return {
|
||||
"method": "average_end_area",
|
||||
"series": [{"name": name, "faces": list(faces)} for name, faces in SERIES],
|
||||
"protection_source": PROTECTION_SOURCE,
|
||||
"ratios": {name: rates.of(name) for name, _ in SERIES},
|
||||
"rows": [
|
||||
{
|
||||
"chainage_m": row.chainage_m,
|
||||
"distance_m": row.distance_m,
|
||||
"berm_width_m": row.berm_width_m,
|
||||
"unclosed": row.unclosed,
|
||||
"lengths": row.lengths,
|
||||
"areas": row.areas,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
"totals": totals(rows),
|
||||
"unclosed_stations": unclosed_stations(rows),
|
||||
"station_count": len(rows),
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"""사면길이 유도 — 저장된 횡단 설계선에서 절토·성토 사면 구간을 가려낸다 (B08 일감 3).
|
||||
|
||||
왜 유도하나 (B06 무접촉)
|
||||
설계 엔진이 `cut_slope_segments` 를 내기는 하나 **정본에 저장되지 않는다**(실측: route 150
|
||||
측점 20.0 의 저장 `design` 키 33개에 그 키가 없음). 저장되는 것은 화면이 보낸 설계 지정이고
|
||||
조회는 저장분을 그대로 싣는다. 그래서 그 값을 원천으로 쓰면 사면적이 조용히 0 이 된다.
|
||||
|
||||
대신 **`design_line`(설계선 폴리라인) + 저장된 경사비**로 유도한다. 필요한 입력이 전부
|
||||
정본에 있어 B06 을 고치지 않아도 된다.
|
||||
|
||||
가려내는 방법
|
||||
노체 끝(`road_edges`)에서 바깥으로 나아가며, 구간 기울기가 **저장된 설계 경사비와 맞는
|
||||
동안**이 사면이다. 원지반은 기울기가 안 맞아 저절로 끊긴다. 2단 사면(암/토사)도 경사비가
|
||||
달라 그대로 갈린다.
|
||||
|
||||
실측(측점 20.0, 절토 0.4 · 토사절토 1.0 · 성토 1.2, 노체 끝 ±2.0):
|
||||
-2.90 → -2.60 n=1.0 측구 바깥 벽
|
||||
-3.50 → -2.90 n=0.4 절토 사면(암)
|
||||
-4.50 → -3.51 n=1.0 절토 사면(토사)
|
||||
-5.00 → -4.85 n=1.63 원지반 — 여기서 멈춘다
|
||||
|
||||
⚠ 두 가지를 조심한다
|
||||
· **끝 조각은 딱 안 떨어진다** — 샘플 격자에 걸려 잘리면 `n=1.025` 처럼 나온다. 허용오차를 둔다.
|
||||
· **지형이 우연히 같은 경사면** 사면이 길게 잡힐 수 있다. 노체에서 바깥으로 **연속**인
|
||||
구간만 세고 끊기면 멈추는 것으로 막는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
# 경사비 일치 허용오차(비율). 끝 조각이 격자에 잘려 생기는 오차를 덮는 크기다.
|
||||
_RATIO_TOLERANCE = 0.12
|
||||
# 평탄부로 볼 기울기 — 소단·측구 바닥은 오름이 거의 없다.
|
||||
_FLAT_RISE_M = 1e-6
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SlopeSegment:
|
||||
"""사면 한 조각. `side` 는 `left`/`right`, `role` 은 `cut`/`fill`."""
|
||||
|
||||
side: str
|
||||
role: str
|
||||
from_offset_m: float
|
||||
to_offset_m: float
|
||||
rise_m: float
|
||||
length_m: float
|
||||
ratio: float
|
||||
material: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StationSlope:
|
||||
"""측점 하나의 사면길이 묶음. 면적 적분이 이 값을 거리로 쓴다."""
|
||||
|
||||
chainage_m: float
|
||||
cut_length_m: float = 0.0
|
||||
fill_length_m: float = 0.0
|
||||
berm_width_m: float = 0.0
|
||||
segments: tuple[SlopeSegment, ...] = ()
|
||||
# 사면이 샘플 범위 끝까지 원지반을 못 만나 **면적이 잘린** 측점.
|
||||
# 설계 엔진이 `slope_unclosed` 로 이미 경고하는 값을 그대로 물고 온다. 잘린 측점은
|
||||
# 사면길이도 같이 잘려 있으므로 **조용히 적게 내지 말고 화면에 드러내야 한다.**
|
||||
unclosed: bool = False
|
||||
|
||||
|
||||
def _num(value: Any) -> float | None:
|
||||
return float(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def _ratios(design: dict[str, Any]) -> dict[str, list[float]]:
|
||||
"""역할별로 받아들일 경사비 목록. 2단 사면이면 암·토사 둘 다 절토로 본다."""
|
||||
cut = [
|
||||
value
|
||||
for value in (
|
||||
_num(design.get("cut_slope_ratio")),
|
||||
_num(design.get("soil_cut_slope_ratio")),
|
||||
)
|
||||
if value is not None and value > 0
|
||||
]
|
||||
fill = [value for value in (_num(design.get("fill_slope_ratio")),) if value and value > 0]
|
||||
return {"cut": cut, "fill": fill}
|
||||
|
||||
|
||||
def _match(ratio: float, candidates: list[float]) -> float | None:
|
||||
"""구간 경사비가 후보 중 하나와 맞으면 그 후보를 돌려준다."""
|
||||
for candidate in candidates:
|
||||
if abs(ratio - candidate) <= max(_RATIO_TOLERANCE * candidate, _RATIO_TOLERANCE):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _outward(
|
||||
line: list[dict[str, float]], edge_offset: float, side: str
|
||||
) -> list[tuple[float, float, float, float]]:
|
||||
"""노체 끝에서 **바깥으로** 향하는 구간 목록 `(시작오프셋, 끝오프셋, run, rise)`.
|
||||
|
||||
좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다(설계선 좌표 관례).
|
||||
"""
|
||||
points = sorted(
|
||||
((float(p["offset_m"]), float(p["elevation_m"])) for p in line), key=lambda p: p[0]
|
||||
)
|
||||
if side == "left":
|
||||
outer = [p for p in points if p[0] >= edge_offset]
|
||||
else:
|
||||
outer = [p for p in points if p[0] <= edge_offset][::-1]
|
||||
return [
|
||||
(
|
||||
outer[i - 1][0],
|
||||
outer[i][0],
|
||||
abs(outer[i][0] - outer[i - 1][0]),
|
||||
outer[i][1] - outer[i - 1][1],
|
||||
)
|
||||
for i in range(1, len(outer))
|
||||
]
|
||||
|
||||
|
||||
def slope_start_offset(design: dict[str, Any], side: str) -> float | None:
|
||||
"""사면이 시작하는 오프셋 — 노체 끝, 측구가 있으면 **측구 바깥 끝**.
|
||||
|
||||
⚠ 이것이 없으면 **측구 바깥 벽이 사면으로 잡힌다.** 실측(측점 20.0)에서 측구 벽 경사가
|
||||
n=1.0 으로 토사 절토비와 같아 그대로 걸렸다. 측구는 노체 배수 시설이지 사면이 아니므로
|
||||
그 바깥 끝에서부터 세어야 한다.
|
||||
"""
|
||||
edges = design.get("road_edges") or {}
|
||||
edge = _num((edges.get(side) or {}).get("offset_m"))
|
||||
if edge is None:
|
||||
return None
|
||||
if not design.get("ditch_enabled"):
|
||||
return edge
|
||||
ditch_side = design.get("ditch_side")
|
||||
if ditch_side not in (side, "both", None):
|
||||
return edge
|
||||
width = _num((design.get("ditch") or {}).get("top_width_m")) or 0.0
|
||||
# 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다.
|
||||
return edge + width if side == "left" else edge - width
|
||||
|
||||
|
||||
def _side_segments(
|
||||
design: dict[str, Any], side: str, ratios: dict[str, list[float]]
|
||||
) -> list[SlopeSegment]:
|
||||
"""한쪽 사면 구간 목록. 경사비가 안 맞는 구간을 만나면 거기서 멈춘다."""
|
||||
line = design.get("design_line") or []
|
||||
edge = slope_start_offset(design, side)
|
||||
if not line or edge is None:
|
||||
return []
|
||||
|
||||
berm = design.get("berm") or {}
|
||||
berm_width = _num(berm.get("width_m")) or 0.0
|
||||
|
||||
segments: list[SlopeSegment] = []
|
||||
started = False
|
||||
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 started:
|
||||
break # 사면이 끝나고 평지를 만난 것이다
|
||||
continue
|
||||
ratio = run / abs(rise)
|
||||
# 절토는 바깥으로 갈수록 오르고, 성토는 내려간다.
|
||||
role = "cut" if rise > 0 else "fill"
|
||||
matched = _match(ratio, ratios[role])
|
||||
if matched is None:
|
||||
if started:
|
||||
break # 원지반에 닿았다
|
||||
continue # 아직 노체·측구 구간이다
|
||||
started = True
|
||||
segments.append(
|
||||
SlopeSegment(
|
||||
side=side,
|
||||
role=role,
|
||||
from_offset_m=start,
|
||||
to_offset_m=end,
|
||||
rise_m=rise,
|
||||
length_m=math.hypot(run, rise),
|
||||
ratio=matched,
|
||||
material=_material(design, matched),
|
||||
)
|
||||
)
|
||||
return segments
|
||||
|
||||
|
||||
def _material(design: dict[str, Any], ratio: float) -> str | None:
|
||||
"""경사비로 재료를 가른다 — 그린 대로 적는다(B06 `cut_slope_segments` 주석과 같은 규칙)."""
|
||||
if not design.get("two_stage_slope"):
|
||||
return None
|
||||
rock = _num(design.get("cut_slope_ratio"))
|
||||
soil = _num(design.get("soil_cut_slope_ratio"))
|
||||
if rock is None or soil is None or abs(rock - soil) < 1e-9:
|
||||
return None
|
||||
return "rock" if abs(ratio - rock) < abs(ratio - soil) else "soil"
|
||||
|
||||
|
||||
def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope:
|
||||
"""측점 하나의 사면길이. 좌우를 합쳐 절토·성토 각각의 총 사면길이를 낸다."""
|
||||
ratios = _ratios(design)
|
||||
segments: list[SlopeSegment] = []
|
||||
for side in ("left", "right"):
|
||||
segments.extend(_side_segments(design, side, ratios))
|
||||
berm = design.get("berm") or {}
|
||||
return StationSlope(
|
||||
chainage_m=float(chainage_m),
|
||||
cut_length_m=sum(s.length_m for s in segments if s.role == "cut"),
|
||||
fill_length_m=sum(s.length_m for s in segments if s.role == "fill"),
|
||||
berm_width_m=_num(berm.get("width_m")) or 0.0,
|
||||
segments=tuple(segments),
|
||||
unclosed=bool(design.get("slope_unclosed")),
|
||||
)
|
||||
|
||||
|
||||
def station_slopes(records: Iterable[dict[str, Any]]) -> list[StationSlope]:
|
||||
"""`[{chainage_m, design}]` → 측점별 사면길이. 이정 순으로 낸다."""
|
||||
result = [
|
||||
station_slope(item["chainage_m"], item.get("design") or {})
|
||||
for item in records
|
||||
if item.get("chainage_m") is not None
|
||||
]
|
||||
result.sort(key=lambda s: s.chainage_m)
|
||||
return result
|
||||
Reference in New Issue
Block a user