Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1
This commit is contained in:
@@ -166,3 +166,37 @@ def _split_ditch_area(ditch_spec: dict, depth_to_boundary_m: float | None) -> tu
|
|||||||
soil = top * d0 - (top - bottom) * d0 * d0 / (2.0 * depth)
|
soil = top * d0 - (top - bottom) * d0 * d0 / (2.0 * depth)
|
||||||
return soil, max(total - soil, 0.0)
|
return soil, max(total - soil, 0.0)
|
||||||
return 0.0, 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]
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from typing import Any
|
|||||||
|
|
||||||
from B06_Section.B06_Section_Engine_Areas import (
|
from B06_Section.B06_Section_Engine_Areas import (
|
||||||
_bench_cut_length,
|
_bench_cut_length,
|
||||||
|
_fill_area_beyond,
|
||||||
_split_cut_areas,
|
_split_cut_areas,
|
||||||
_split_ditch_area,
|
_split_ditch_area,
|
||||||
_trapezoid_areas,
|
_trapezoid_areas,
|
||||||
@@ -43,6 +44,7 @@ from common_util.common_util_cross_berm import (
|
|||||||
fill_profile_points,
|
fill_profile_points,
|
||||||
)
|
)
|
||||||
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at
|
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at
|
||||||
|
from common_util.common_util_spoil_fill import spoil_fill_section
|
||||||
from config.config_system import (
|
from config.config_system import (
|
||||||
CURVE_WIDENING_MAX_WIDTH_M,
|
CURVE_WIDENING_MAX_WIDTH_M,
|
||||||
SECTION_DITCH_SIDES,
|
SECTION_DITCH_SIDES,
|
||||||
@@ -581,6 +583,7 @@ def compute_cross_design(
|
|||||||
curve_outer_side: str | None = None,
|
curve_outer_side: str | None = None,
|
||||||
curve_widening_m: float | None = None,
|
curve_widening_m: float | None = None,
|
||||||
berm: BermSpec | None = None,
|
berm: BermSpec | None = None,
|
||||||
|
spoil_fill: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
|
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
|
||||||
|
|
||||||
@@ -600,6 +603,10 @@ def compute_cross_design(
|
|||||||
surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류
|
surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류
|
||||||
높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로
|
높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로
|
||||||
횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자).
|
횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자).
|
||||||
|
spoil_fill: 이 측점에 선 유용토운반작업장(구 사토장) — `{"side", "width_m", "slope_ratio_n"}`.
|
||||||
|
폭은 **노면 끝**(노견이 시작하는 자리)에서 재고, 그 바깥 성토는 **노선 몫이 아니라
|
||||||
|
사토장 몫**이라 `fill_area_m2` 에서 뺀다(2026-09-09 확정 ㉠ — 두 번 세지 않기).
|
||||||
|
`slope_ratio_n` 이 비면 그 측점의 **노선 성토 기울기**를 그대로 쓴다.
|
||||||
"""
|
"""
|
||||||
if ground_type not in SECTION_GROUND_TYPE_PRESET:
|
if ground_type not in SECTION_GROUND_TYPE_PRESET:
|
||||||
raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}")
|
raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}")
|
||||||
@@ -710,6 +717,26 @@ def compute_cross_design(
|
|||||||
abs(diffs[0]) > _SLOPE_CLOSE_TOLERANCE_M or abs(diffs[-1]) > _SLOPE_CLOSE_TOLERANCE_M
|
abs(diffs[0]) > _SLOPE_CLOSE_TOLERANCE_M or abs(diffs[-1]) > _SLOPE_CLOSE_TOLERANCE_M
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토. 짝: TS `computeCrossDesign`.
|
||||||
|
# ⚠ 그 바깥 성토는 **노선 몫이 아니다** — 빼지 않으면 같은 흙을 두 번 센다(확정 ㉠).
|
||||||
|
spoil_section = None
|
||||||
|
spoil_replaced = 0.0
|
||||||
|
spoil_side = str((spoil_fill or {}).get("side") or "")
|
||||||
|
spoil_width = _as_float((spoil_fill or {}).get("width_m"), 0.0)
|
||||||
|
if spoil_side in ("left", "right") and spoil_width > 0:
|
||||||
|
spoil_x0 = geometry.half_road_left if spoil_side == "left" else -geometry.half_road_right
|
||||||
|
spoil_ratio = _as_float((spoil_fill or {}).get("slope_ratio_n"), 0.0) or geometry.fill_ratio
|
||||||
|
spoil_section = spoil_fill_section(
|
||||||
|
valid,
|
||||||
|
spoil_x0,
|
||||||
|
geometry.road_z(spoil_x0),
|
||||||
|
spoil_side,
|
||||||
|
spoil_width,
|
||||||
|
spoil_ratio,
|
||||||
|
)
|
||||||
|
spoil_replaced = _fill_area_beyond(offsets, diffs, spoil_x0, spoil_side)
|
||||||
|
fill_area = max(fill_area - spoil_replaced, 0.0)
|
||||||
|
|
||||||
# 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가
|
# 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가
|
||||||
# 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다.
|
# 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다.
|
||||||
# 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터)
|
# 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터)
|
||||||
@@ -836,6 +863,22 @@ def compute_cross_design(
|
|||||||
"cut_rock_area_m2": round(cut_rock_area, 4),
|
"cut_rock_area_m2": round(cut_rock_area, 4),
|
||||||
"cut_rock_kind": cut_rock_kind,
|
"cut_rock_kind": cut_rock_kind,
|
||||||
"fill_area_m2": round(fill_area, 4),
|
"fill_area_m2": round(fill_area, 4),
|
||||||
|
# 사토장 몫 — **`fill_area_m2` 와 합치지 않는다**(받는 쪽이 갈라 볼 수 있어야 한다).
|
||||||
|
"spoil_fill_area_m2": round(spoil_section.area_m2, 4) if spoil_section else 0.0,
|
||||||
|
"spoil_fill_side": spoil_side if spoil_section else None,
|
||||||
|
"spoil_fill_width_m": round(spoil_width, 4) if spoil_section else 0.0,
|
||||||
|
"spoil_fill_max_width_m": round(spoil_section.max_width_m, 4) if spoil_section else 0.0,
|
||||||
|
"spoil_fill_line": (
|
||||||
|
[
|
||||||
|
{"offset_m": offset, "elevation_m": elevation}
|
||||||
|
for offset, elevation in spoil_section.line
|
||||||
|
]
|
||||||
|
if spoil_section
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
"spoil_fill_unclosed": bool(spoil_section.unclosed) if spoil_section else False,
|
||||||
|
# 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것.
|
||||||
|
"spoil_fill_replaced_fill_m2": round(spoil_replaced, 4),
|
||||||
# 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m).
|
# 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m).
|
||||||
# B08 이 측점 사이를 이어 ㎡ 로 만든다. 여기서 ㎥ 로 바꾸지 않는다 —
|
# B08 이 측점 사이를 이어 ㎡ 로 만든다. 여기서 ㎥ 로 바꾸지 않는다 —
|
||||||
# 단의 높이·폭이 설계도서 값이라 지어낼 수 없다.
|
# 단의 높이·폭이 설계도서 값이라 지어낼 수 없다.
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""사토장(유용토운반작업장) — 용량에서 **폭을 정해** 측점마다 단면을 세운다.
|
||||||
|
|
||||||
|
왜 여기 있나
|
||||||
|
사용자는 「이 구간에 ○㎥ 를 쌓겠다」고 정한다. 그런데 횡단 단면은 **폭**을 알아야
|
||||||
|
그려진다. 그래서 그 구간 측점들을 한꺼번에 보고 **폭 하나**를 되풀이로 찾는다.
|
||||||
|
(측점마다 폭을 달리하면 실제로 못 쌓는 모양이 나온다 — 작업장은 폭이 일정하다.)
|
||||||
|
|
||||||
|
`enforce_ford_surface_drops` 와 같은 자리·같은 방식이다 — **저장분을 쓰는 시점에**
|
||||||
|
바로잡고, 저장분과 지금 값이 다를 때만 다시 계산한다.
|
||||||
|
|
||||||
|
정하는 것과 안 정하는 것
|
||||||
|
⚠ **기울기·적치높이 기본값을 지어내지 않는다** — 지식DB
|
||||||
|
`01_임도/02_상세설계/유용토운반작업장.md` §4 가 「근거에 없다. 사용자 협의 없이
|
||||||
|
기본값을 만들지 않는다」로 못 박았다. 기울기가 비면 **그 측점의 노선 성토 기울기**를
|
||||||
|
그대로 쓰고(이미 설계된 값), 높이는 **노면 끝 높이**로 정해진다.
|
||||||
|
⚠ **용량이 없으면 아무것도 안 세운다** — 폭을 정할 근거가 없다.
|
||||||
|
⚠ **지반 샘플이 있는 데까지만 넓힌다.** 상한에서도 용량이 남으면 그 몫은
|
||||||
|
`unplaced_m3` 로 드러낸다 — 임의로 더 넓히지 않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||||
|
from common_util.common_util_structure_face_role import structure_face_role
|
||||||
|
|
||||||
|
#: 폭을 좁혀 가는 이분법 반복 수. TS 짝(`solveSpoilWidthM`)과 같은 값이다.
|
||||||
|
_SOLVE_STEPS = 24
|
||||||
|
#: 상한을 재려고 한 번 크게 넣어 보는 폭(m). 실제로는 지반 샘플에서 잘린다.
|
||||||
|
_MAX_PROBE_WIDTH_M = 1000.0
|
||||||
|
#: 사토장 종류 이름 — 등록부 `spoil_bank`(현행 명칭 유용토운반작업장).
|
||||||
|
SPOIL_TYPE_ID = "spoil_bank"
|
||||||
|
#: 「자동(성토 쪽)」 — 등록부 `side` 의 기본 선택지. C군 구조물과 같은 낱말이다.
|
||||||
|
_SIDE_AUTO = "자동(성토 쪽)"
|
||||||
|
_SIDE_WORDS = {"좌": "left", "우": "right"}
|
||||||
|
|
||||||
|
|
||||||
|
def _sections_in(cross_sections: list[dict[str, Any]], start_m: float, end_m: float) -> list[dict]:
|
||||||
|
"""구간 안에 든 측점만. **새 측점을 만들지 않는다**(2026-09-09 사용자 확정 ③)."""
|
||||||
|
picked = []
|
||||||
|
for section in cross_sections:
|
||||||
|
chainage = section.get("chainage_m")
|
||||||
|
if chainage is None:
|
||||||
|
continue
|
||||||
|
value = float(chainage)
|
||||||
|
if start_m - 1e-6 <= value <= end_m + 1e-6:
|
||||||
|
picked.append(section)
|
||||||
|
return sorted(picked, key=lambda item: float(item["chainage_m"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _spans(sections: list[dict[str, Any]], start_m: float, end_m: float) -> list[float]:
|
||||||
|
"""측점마다 대표 길이(m) — 앞뒤 측점과의 절반씩. 구간 끝은 경계까지만."""
|
||||||
|
spans: list[float] = []
|
||||||
|
for index, section in enumerate(sections):
|
||||||
|
chainage = float(section["chainage_m"])
|
||||||
|
left = float(sections[index - 1]["chainage_m"]) if index else max(start_m, chainage)
|
||||||
|
right = (
|
||||||
|
float(sections[index + 1]["chainage_m"])
|
||||||
|
if index + 1 < len(sections)
|
||||||
|
else min(end_m, chainage)
|
||||||
|
)
|
||||||
|
spans.append(max((chainage - left) / 2 + (right - chainage) / 2, 0.0))
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
def _side_of(design: dict[str, Any], option: Any) -> str | None:
|
||||||
|
"""쌓는 쪽 — 「좌·우」면 그대로, 「자동」이면 그 측점의 **성토 쪽**."""
|
||||||
|
word = str(option or "").strip()
|
||||||
|
if word in _SIDE_WORDS:
|
||||||
|
return _SIDE_WORDS[word]
|
||||||
|
if word and word != _SIDE_AUTO:
|
||||||
|
return None
|
||||||
|
mode = str(design.get("section_mode") or "")
|
||||||
|
for korean, key in _SIDE_WORDS.items():
|
||||||
|
role, _reason = structure_face_role(mode, korean)
|
||||||
|
if role == "성토":
|
||||||
|
return key
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def spoil_sites(structures: list[Any]) -> list[dict[str, Any]]:
|
||||||
|
"""배치된 사토장만 골라 쓰기 좋은 모양으로. 구간·용량이 없으면 뺀다."""
|
||||||
|
sites: list[dict[str, Any]] = []
|
||||||
|
for item in structures:
|
||||||
|
type_id = getattr(item, "type_id", None) or (
|
||||||
|
item.get("type_id") if isinstance(item, dict) else None
|
||||||
|
)
|
||||||
|
if str(type_id) != SPOIL_TYPE_ID:
|
||||||
|
continue
|
||||||
|
options = getattr(item, "options", None)
|
||||||
|
if options is None and isinstance(item, dict):
|
||||||
|
options = item.get("options")
|
||||||
|
options = options or {}
|
||||||
|
start = getattr(item, "start_m", None)
|
||||||
|
end = getattr(item, "end_m", None)
|
||||||
|
if isinstance(item, dict):
|
||||||
|
start = item.get("start_m")
|
||||||
|
end = item.get("end_m")
|
||||||
|
capacity = options.get("capacity_m3")
|
||||||
|
if start is None or end is None or capacity in (None, ""):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
capacity_value = float(capacity)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if capacity_value <= 0:
|
||||||
|
continue
|
||||||
|
sites.append(
|
||||||
|
{
|
||||||
|
"structure_id": getattr(item, "structure_id", None)
|
||||||
|
or (item.get("structure_id") if isinstance(item, dict) else None),
|
||||||
|
"start_m": min(float(start), float(end)),
|
||||||
|
"end_m": max(float(start), float(end)),
|
||||||
|
"capacity_m3": capacity_value,
|
||||||
|
"side_option": options.get("side"),
|
||||||
|
"slope_ratio_n": options.get("fill_slope_ratio"),
|
||||||
|
"extra_distance_m": options.get("extra_distance_m"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sites
|
||||||
|
|
||||||
|
|
||||||
|
def _volume_at(
|
||||||
|
width_m: float,
|
||||||
|
sections: list[dict[str, Any]],
|
||||||
|
spans: list[float],
|
||||||
|
sides: list[str | None],
|
||||||
|
slope_ratio_n: Any,
|
||||||
|
longitudinal: dict[str, Any],
|
||||||
|
standard: dict[str, Any] | None,
|
||||||
|
recompute,
|
||||||
|
) -> tuple[float, list[dict[str, Any] | None]]:
|
||||||
|
"""그 폭으로 쌓이는 총 부피(㎥)와 측점별 설계. 평균단면적법이 아니라 대표길이 곱이다."""
|
||||||
|
designs: list[dict[str, Any] | None] = []
|
||||||
|
total = 0.0
|
||||||
|
for section, span, side in zip(sections, spans, sides, strict=True):
|
||||||
|
if side is None or width_m <= 0:
|
||||||
|
designs.append(None)
|
||||||
|
continue
|
||||||
|
design = recompute(section, side, width_m, slope_ratio_n, longitudinal, standard)
|
||||||
|
designs.append(design)
|
||||||
|
if design:
|
||||||
|
total += float(design.get("spoil_fill_area_m2") or 0.0) * span
|
||||||
|
return total, designs
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_spoil_fills(
|
||||||
|
longitudinal: dict[str, Any],
|
||||||
|
cross_sections: list[dict[str, Any]],
|
||||||
|
project_root: Path,
|
||||||
|
standard: dict[str, Any] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""사토장이 선 측점의 설계를 다시 계산한다. 바뀐 측점 수를 돌려준다.
|
||||||
|
|
||||||
|
폭은 **구간 하나에 하나** — 용량에 맞춰 이분법으로 찾는다. 상한(지반 샘플이 있는
|
||||||
|
데까지)에서도 모자라면 그 폭으로 두고 못 담은 몫을 `spoil_fill_unplaced_m3` 로 낸다.
|
||||||
|
"""
|
||||||
|
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||||
|
from B06_Section.B06_Section_Engine_Design import curve_widening_args
|
||||||
|
from B06_Section.B06_Section_Router_Design import (
|
||||||
|
USER_TOUCHED_KEYS,
|
||||||
|
stored_berm,
|
||||||
|
stored_cut_slope,
|
||||||
|
)
|
||||||
|
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||||
|
|
||||||
|
try:
|
||||||
|
_revision, structures = load_structures(str(project_root))
|
||||||
|
except Exception: # noqa: BLE001 — 정본이 없으면 사토장도 없다
|
||||||
|
return 0
|
||||||
|
sites = spoil_sites(structures)
|
||||||
|
if not sites:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def recompute(section, side, width_m, slope_ratio_n, longitudinal_data, standard_spec):
|
||||||
|
design = section.get("design")
|
||||||
|
if not isinstance(design, dict):
|
||||||
|
return None
|
||||||
|
chainage = float(section.get("chainage_m", 0.0))
|
||||||
|
try:
|
||||||
|
return compute_cross_design(
|
||||||
|
section.get("samples", []),
|
||||||
|
design_elevation_from_longitudinal(longitudinal_data, chainage),
|
||||||
|
ground_type=str(design.get("ground_type") or "soil"),
|
||||||
|
section_mode=str(design.get("section_mode") or "left_cut"),
|
||||||
|
ditch_side=design.get("ditch_side"),
|
||||||
|
ditch_type=str(design.get("ditch_type") or "standard"),
|
||||||
|
paved=bool(design.get("paved", False)),
|
||||||
|
standard=standard_spec,
|
||||||
|
rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
|
||||||
|
two_stage_slope=bool(design.get("two_stage_slope", True)),
|
||||||
|
cut_slope_ratio=stored_cut_slope(design),
|
||||||
|
ditch_enabled=design.get("ditch_enabled"),
|
||||||
|
surface_drop_m=float(design.get("surface_drop_m") or 0.0),
|
||||||
|
berm=stored_berm(design),
|
||||||
|
spoil_fill={
|
||||||
|
"side": side,
|
||||||
|
"width_m": width_m,
|
||||||
|
"slope_ratio_n": slope_ratio_n,
|
||||||
|
},
|
||||||
|
**curve_widening_args(section),
|
||||||
|
)
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
changed = 0
|
||||||
|
for site in sites:
|
||||||
|
sections = _sections_in(cross_sections, site["start_m"], site["end_m"])
|
||||||
|
if not sections:
|
||||||
|
continue
|
||||||
|
spans = _spans(sections, site["start_m"], site["end_m"])
|
||||||
|
sides = [_side_of(section.get("design") or {}, site["side_option"]) for section in sections]
|
||||||
|
ratio = site["slope_ratio_n"]
|
||||||
|
|
||||||
|
def volume(width_m: float):
|
||||||
|
return _volume_at(
|
||||||
|
width_m, sections, spans, sides, ratio, longitudinal, standard, recompute
|
||||||
|
)
|
||||||
|
|
||||||
|
# 상한 = 그 구간에서 가장 좁은 측점이 허락하는 폭. 한 측점이라도 지반 샘플이
|
||||||
|
# 모자라면 거기서 잘리므로, 넓혀도 그 측점은 안 늘어난다.
|
||||||
|
top_total, top_designs = volume(_MAX_PROBE_WIDTH_M)
|
||||||
|
limit = min(
|
||||||
|
(
|
||||||
|
float(design.get("spoil_fill_max_width_m") or 0.0)
|
||||||
|
for design in top_designs
|
||||||
|
if design
|
||||||
|
),
|
||||||
|
default=0.0,
|
||||||
|
)
|
||||||
|
if limit <= 0:
|
||||||
|
continue
|
||||||
|
total, designs = volume(limit)
|
||||||
|
width = limit
|
||||||
|
if total > site["capacity_m3"]:
|
||||||
|
low, high = 0.0, limit
|
||||||
|
for _ in range(_SOLVE_STEPS):
|
||||||
|
mid = (low + high) / 2
|
||||||
|
if volume(mid)[0] < site["capacity_m3"]:
|
||||||
|
low = mid
|
||||||
|
else:
|
||||||
|
high = mid
|
||||||
|
width = round(high, 4)
|
||||||
|
total, designs = volume(width)
|
||||||
|
|
||||||
|
unplaced = max(site["capacity_m3"] - total, 0.0)
|
||||||
|
for section, design in zip(sections, designs, strict=True):
|
||||||
|
if not design:
|
||||||
|
continue
|
||||||
|
stored = section.get("design") or {}
|
||||||
|
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
|
||||||
|
if stored.get(key) is not None:
|
||||||
|
design[key] = stored[key]
|
||||||
|
# 구간 전체 값도 측점마다 실어 둔다 — 화면 말풍선·수량이 되짚을 수 있게.
|
||||||
|
design["spoil_fill_capacity_m3"] = round(site["capacity_m3"], 4)
|
||||||
|
design["spoil_fill_placed_m3"] = round(total, 4)
|
||||||
|
design["spoil_fill_unplaced_m3"] = round(unplaced, 4)
|
||||||
|
design["spoil_fill_structure_id"] = site["structure_id"]
|
||||||
|
design["spoil_fill_extra_distance_m"] = site["extra_distance_m"]
|
||||||
|
section["design"] = design
|
||||||
|
changed += 1
|
||||||
|
return changed
|
||||||
@@ -127,6 +127,7 @@ def _enforce_stored_designs(
|
|||||||
예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로).
|
예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로).
|
||||||
2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다.
|
2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다.
|
||||||
"""
|
"""
|
||||||
|
from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills
|
||||||
from B06_Section.B06_Section_Router_Design import (
|
from B06_Section.B06_Section_Router_Design import (
|
||||||
enforce_ford_surface_drops,
|
enforce_ford_surface_drops,
|
||||||
enforce_pavement_ranges,
|
enforce_pavement_ranges,
|
||||||
@@ -134,6 +135,9 @@ def _enforce_stored_designs(
|
|||||||
|
|
||||||
enforce_pavement_ranges(longitudinal, sections, project_root, standard)
|
enforce_pavement_ranges(longitudinal, sections, project_root, standard)
|
||||||
enforce_ford_surface_drops(longitudinal, sections, project_root, standard)
|
enforce_ford_surface_drops(longitudinal, sections, project_root, standard)
|
||||||
|
# ⚠ 사토장은 **맨 뒤**다 — 앞의 두 보정이 설계를 다시 계산하면서 사토장 칸을 지운다.
|
||||||
|
# 맨 뒤에 두면 그 결과 위에 사토장 단면이 얹힌다(2026-09-09).
|
||||||
|
enforce_spoil_fills(longitudinal, sections, project_root, standard)
|
||||||
|
|
||||||
|
|
||||||
async def recompute_server_side(project_id: UUID | str, route_id: int) -> int:
|
async def recompute_server_side(project_id: UUID | str, route_id: int) -> int:
|
||||||
|
|||||||
@@ -24,8 +24,11 @@
|
|||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import type { BermSpec } from "./common_util_cross_berm";
|
import type { BermSpec } from "./common_util_cross_berm";
|
||||||
|
import type { SpoilFillSection } from "./common_util_spoil_fill";
|
||||||
|
import { spoilFillSection } from "./common_util_spoil_fill";
|
||||||
import {
|
import {
|
||||||
benchCutLength,
|
benchCutLength,
|
||||||
|
fillAreaBeyond,
|
||||||
splitCutAreas,
|
splitCutAreas,
|
||||||
splitDitchArea,
|
splitDitchArea,
|
||||||
trapezoidAreas,
|
trapezoidAreas,
|
||||||
@@ -100,6 +103,13 @@ export interface CrossDesignOptions {
|
|||||||
curveWideningM?: number | null;
|
curveWideningM?: number | null;
|
||||||
/** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */
|
/** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */
|
||||||
berm?: BermSpec | null;
|
berm?: BermSpec | null;
|
||||||
|
/**
|
||||||
|
* 이 측점에 선 유용토운반작업장(구 사토장). 폭은 **노면 끝**(노견이 시작하는 자리)에서
|
||||||
|
* 재고, 그 바깥 성토는 **노선 몫이 아니라 사토장 몫**이라 `fill_area_m2` 에서 뺀다
|
||||||
|
* (2026-09-09 확정 ㉠ — 두 번 세지 않기). `slopeRatioN` 이 비면 노선 성토 기울기를 쓴다.
|
||||||
|
* 짝: 파이썬 `compute_cross_design(spoil_fill=…)`.
|
||||||
|
*/
|
||||||
|
spoilFill?: { side: "left" | "right"; widthM: number; slopeRatioN?: number | null } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CrossDesignEdge {
|
export interface CrossDesignEdge {
|
||||||
@@ -138,6 +148,15 @@ export interface CrossDesignResult {
|
|||||||
fill_area_m2: number;
|
fill_area_m2: number;
|
||||||
/** 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m). */
|
/** 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m). */
|
||||||
bench_cut_length_m: number;
|
bench_cut_length_m: number;
|
||||||
|
/** 사토장 몫 — **`fill_area_m2` 와 합치지 않는다**(받는 쪽이 갈라 볼 수 있어야 한다). */
|
||||||
|
spoil_fill_area_m2: number;
|
||||||
|
spoil_fill_side: "left" | "right" | null;
|
||||||
|
spoil_fill_width_m: number;
|
||||||
|
spoil_fill_max_width_m: number;
|
||||||
|
spoil_fill_line: CrossDesignEdge[];
|
||||||
|
spoil_fill_unclosed: boolean;
|
||||||
|
/** 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것. */
|
||||||
|
spoil_fill_replaced_fill_m2: number;
|
||||||
slope_unclosed: boolean;
|
slope_unclosed: boolean;
|
||||||
fill_ground_slope: number | null;
|
fill_ground_slope: number | null;
|
||||||
ditch_area_m2: number;
|
ditch_area_m2: number;
|
||||||
@@ -355,7 +374,8 @@ export function computeCrossDesign(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음).
|
// 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음).
|
||||||
const [cutArea, fillArea] = trapezoidAreas(offsets, diffs);
|
const [cutArea, baseFillArea] = trapezoidAreas(offsets, diffs);
|
||||||
|
let fillArea = baseFillArea;
|
||||||
// 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이.
|
// 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이.
|
||||||
const benchCut = benchCutLength(offsets, grounds, diffs);
|
const benchCut = benchCutLength(offsets, grounds, diffs);
|
||||||
const fillGroundSlope = geometry.fillGroundSlope();
|
const fillGroundSlope = geometry.fillGroundSlope();
|
||||||
@@ -364,6 +384,28 @@ export function computeCrossDesign(
|
|||||||
(Math.abs(diffs[0]) > SLOPE_CLOSE_TOLERANCE_M ||
|
(Math.abs(diffs[0]) > SLOPE_CLOSE_TOLERANCE_M ||
|
||||||
Math.abs(diffs[diffs.length - 1]) > SLOPE_CLOSE_TOLERANCE_M);
|
Math.abs(diffs[diffs.length - 1]) > SLOPE_CLOSE_TOLERANCE_M);
|
||||||
|
|
||||||
|
// 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토. 짝: 파이썬 `compute_cross_design`.
|
||||||
|
// ⚠ 그 바깥 성토는 **노선 몫이 아니다** — 빼지 않으면 같은 흙을 두 번 센다(확정 ㉠).
|
||||||
|
const spoilSide = options.spoilFill?.side ?? null;
|
||||||
|
const spoilWidth = Math.max(Number(options.spoilFill?.widthM ?? 0), 0);
|
||||||
|
let spoilSection: SpoilFillSection | null = null;
|
||||||
|
let spoilReplaced = 0;
|
||||||
|
if ((spoilSide === "left" || spoilSide === "right") && spoilWidth > 0) {
|
||||||
|
const spoilX0 = spoilSide === "left" ? geometry.halfRoadLeft : -geometry.halfRoadRight;
|
||||||
|
const askedRatio = Number(options.spoilFill?.slopeRatioN ?? 0);
|
||||||
|
const spoilRatio = askedRatio > 0 ? askedRatio : geometry.fillRatio;
|
||||||
|
spoilSection = spoilFillSection({
|
||||||
|
ground: valid.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
|
||||||
|
startOffsetM: spoilX0,
|
||||||
|
startElevationM: geometry.roadZ(spoilX0),
|
||||||
|
side: spoilSide,
|
||||||
|
widthM: spoilWidth,
|
||||||
|
slopeRatioN: spoilRatio,
|
||||||
|
});
|
||||||
|
spoilReplaced = fillAreaBeyond(offsets, diffs, spoilX0, spoilSide);
|
||||||
|
fillArea = Math.max(fillArea - spoilReplaced, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암.
|
// 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암.
|
||||||
let cutSoilArea: number;
|
let cutSoilArea: number;
|
||||||
let cutRockArea: number;
|
let cutRockArea: number;
|
||||||
@@ -493,6 +535,18 @@ export function computeCrossDesign(
|
|||||||
cut_rock_area_m2: round4(cutRockArea),
|
cut_rock_area_m2: round4(cutRockArea),
|
||||||
cut_rock_kind: cutRockKind,
|
cut_rock_kind: cutRockKind,
|
||||||
fill_area_m2: round4(fillArea),
|
fill_area_m2: round4(fillArea),
|
||||||
|
spoil_fill_area_m2: spoilSection ? round4(spoilSection.area_m2) : 0,
|
||||||
|
spoil_fill_side: spoilSection ? spoilSide : null,
|
||||||
|
spoil_fill_width_m: spoilSection ? round4(spoilWidth) : 0,
|
||||||
|
spoil_fill_max_width_m: spoilSection ? round4(spoilSection.maxWidthM) : 0,
|
||||||
|
spoil_fill_line: spoilSection
|
||||||
|
? spoilSection.line.map((point) => ({
|
||||||
|
offset_m: point.offset_m,
|
||||||
|
elevation_m: point.elevation_m,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
spoil_fill_unclosed: spoilSection ? spoilSection.unclosed : false,
|
||||||
|
spoil_fill_replaced_fill_m2: round4(spoilReplaced),
|
||||||
bench_cut_length_m: round4(benchCut),
|
bench_cut_length_m: round4(benchCut),
|
||||||
slope_unclosed: slopeUnclosed,
|
slope_unclosed: slopeUnclosed,
|
||||||
fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope),
|
fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope),
|
||||||
|
|||||||
@@ -159,3 +159,46 @@ export function splitDitchArea(
|
|||||||
}
|
}
|
||||||
return [0, 0];
|
return [0, 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `x0` **바깥쪽**(사토장이 서는 쪽)의 성토 면적(㎡)만 따로 낸다.
|
||||||
|
*
|
||||||
|
* ⚠ 왜 있나 — 사토장이 선 측점에서는 노면 끝 바깥이 **사토장 몫**이라 노선 성토
|
||||||
|
* (`fill_area_m2`)에서 빼야 한다. 안 빼면 **같은 흙을 두 번 센다**(2026-09-09 확정 ㉠).
|
||||||
|
* ⚠ 경계(`x0`)에서 잘라 쓰므로 그 자리의 종거를 **보간해서** 넣는다 — 그냥 버리면
|
||||||
|
* 경계 한 칸이 통째로 빠져 값이 작아진다.
|
||||||
|
*
|
||||||
|
* 짝: 파이썬 `_fill_area_beyond`.
|
||||||
|
*/
|
||||||
|
export function fillAreaBeyond(
|
||||||
|
offsets: number[],
|
||||||
|
diffs: number[],
|
||||||
|
x0: number,
|
||||||
|
side: "left" | "right",
|
||||||
|
): number {
|
||||||
|
if (offsets.length < 2) return 0;
|
||||||
|
const inside = side === "left" ? (x: number) => x >= x0 : (x: number) => x <= x0;
|
||||||
|
const subOffsets: number[] = [];
|
||||||
|
const subDiffs: number[] = [];
|
||||||
|
for (let index = 0; index < offsets.length; index += 1) {
|
||||||
|
const x = offsets[index];
|
||||||
|
if (index > 0) {
|
||||||
|
const xPrev = offsets[index - 1];
|
||||||
|
const crosses = (xPrev < x0 && x0 < x) || (x < x0 && x0 < xPrev);
|
||||||
|
if (crosses) {
|
||||||
|
const ratio = (x0 - xPrev) / (x - xPrev);
|
||||||
|
subOffsets.push(x0);
|
||||||
|
subDiffs.push(diffs[index - 1] + (diffs[index] - diffs[index - 1]) * ratio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inside(x)) {
|
||||||
|
subOffsets.push(x);
|
||||||
|
subDiffs.push(diffs[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const order = subOffsets.map((_, index) => index).sort((a, b) => subOffsets[a] - subOffsets[b]);
|
||||||
|
const sortedOffsets = order.map((index) => subOffsets[index]);
|
||||||
|
const sortedDiffs = order.map((index) => subDiffs[index]);
|
||||||
|
if (sortedOffsets.length < 2) return 0;
|
||||||
|
return trapezoidAreas(sortedOffsets, sortedDiffs)[1];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user