2026-09-06 사용자 확정. 별표2 Ⅰ.2.나.(4) 확폭표(R 10~45m → 2.25~0.25m)를 측점별 평면 곡선반경에 물려 차도 폭을 넓힌다. - 확폭 방향은 **곡선 바깥쪽 편측** — 노선 폴리라인의 외적 부호로 회전 방향을 보고 바깥쪽을 정한다(좌회전이면 우측). 측점 기록에 `curve_outer_side` 로 실린다. - 차도 반폭을 좌·우로 나눠 들어 한쪽만 넓어지게 함. 확폭이 0이면 예전과 같은 대칭 단면이다. 노견·측구·사면은 그 바깥으로 그대로 밀린다. - 확폭을 더한 유효너비는 법정 상한 5m 에서 자른다(규격 3.0m 면 최대 2.0m 까지). - 계산 짝을 함께 고침 — 파이썬 `compute_cross_design` 과 브라우저 `computeCrossDesign`, 표는 양쪽에 두되 짝임을 주석으로 못 박음. 확폭 입력은 측점 기록에서 뽑는 헬퍼 하나로 9개 호출부(횡단·확정·B07 도면)에 같은 값이 가게 함. - 횡단도에 노폭 라벨 — 확폭이 걸리면 「노폭 4.5m (규격 3.0 + 확폭 1.5)」로 적는다. - 확인: 표 경계·편측 적용·5m 상한·회전 방향 판정 5건(pytest) + 브라우저 표 15건 일치. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
424 lines
19 KiB
Python
424 lines
19 KiB
Python
"""B06 라우터의 횡단 설계 파일 읽기와 프리뷰 계산."""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
|
|
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
|
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
|
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
|
|
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
|
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
|
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 세월교 노면 하강이 걸리는 측점 판정 허용 오차(m) — 세트 부착(`attach_culvert_sets`)과
|
|
# 같은 기준이라 "구체가 그려진 측점"과 "노면이 내려간 측점"이 어긋나지 않는다.
|
|
_FORD_DROP_TOLERANCE_M = 0.02
|
|
|
|
PREVIEW_DESIGN_FIELDS = (
|
|
"ground_type",
|
|
"roadbed_width_m",
|
|
"cut_area_m2",
|
|
"cut_soil_area_m2",
|
|
"cut_rock_area_m2",
|
|
"cut_rock_kind",
|
|
"fill_area_m2",
|
|
"fill_ground_slope",
|
|
"design_elevation_m",
|
|
"slope_unclosed",
|
|
)
|
|
|
|
|
|
def pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]:
|
|
mapping: dict[float, bool] = {}
|
|
stations = longitudinal.get("stations")
|
|
for station in stations if isinstance(stations, list) else []:
|
|
suggested = station.get("pavement_suggested")
|
|
if isinstance(suggested, bool):
|
|
mapping[round(float(station.get("chainage_m", 0.0)), 3)] = suggested
|
|
return mapping
|
|
|
|
|
|
def pavement_ranges(project_root: Path) -> list[tuple[float, float]]:
|
|
"""포장으로 볼 누가거리 구간 — 구조물 정본 G군 + 물넘이포장(포장 필수).
|
|
|
|
포장 지정은 **사용자가 구간으로 준다**(2026-08-28 사용자 확정). 종단경사 자동 판정은
|
|
더 이상 포장을 켜지 않는다 — 경고 표기(`pavement_suggested`)만 남는다.
|
|
물넘이포장은 콘크리트 등으로 노면을 만드는 시설이라 범위 안이 항상 포장이다.
|
|
읽기 실패는 비치명 — 빈 목록이면 사용자 지정이 없는 것과 같다.
|
|
"""
|
|
ranges: list[tuple[float, float]] = []
|
|
try:
|
|
types = structure_type_map()
|
|
for structure in load_structures(str(project_root))[1]:
|
|
definition = types.get(structure.type_id)
|
|
if definition is None or definition.group != "G":
|
|
continue
|
|
anchor = structure.chainage_m
|
|
start = structure.start_m if structure.start_m is not None else anchor
|
|
end = structure.end_m if structure.end_m is not None else anchor
|
|
if start is None or end is None:
|
|
continue
|
|
ranges.append((min(float(start), float(end)), max(float(start), float(end))))
|
|
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
|
|
logger.exception("B06 포장 구간을 읽지 못했습니다 (사용자 지정 없음으로 본다)")
|
|
for chainage, spec in load_culvert_sets(project_root).items():
|
|
if spec.get("type") != "ford_pavement":
|
|
continue
|
|
half = float(spec.get("span_m") or 0.0) / 2.0
|
|
ranges.append((chainage - half, chainage + half))
|
|
return ranges
|
|
|
|
|
|
def ford_surface_drops(project_root: Path | None) -> list[tuple[float, float]]:
|
|
"""세월교가 앉은 측점의 노면 하강량 — [(누가거리, 월류 높이 m)].
|
|
|
|
구체 위 노면은 월류 높이만큼 낮게 앉는다(2026-08-30 사용자 확정). 계획고를 그만큼
|
|
내려 잡아야 측벽·바닥판이 맞고 절·성토 면적도 따라온다. 포장 구간과 같은 방식으로
|
|
관 지점 정본에서 읽는다 — 읽기 실패는 비치명(하강 없음으로 본다).
|
|
"""
|
|
if project_root is None:
|
|
return []
|
|
drops: list[tuple[float, float]] = []
|
|
try:
|
|
for chainage, spec in load_culvert_sets(project_root).items():
|
|
if spec.get("type") != "ford":
|
|
continue
|
|
depth = float(spec.get("overflow_depth_m") or 0.0)
|
|
if depth > 0:
|
|
drops.append((chainage, depth))
|
|
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
|
|
logger.exception("B06 세월교 월류 높이를 읽지 못했습니다 (노면 하강 없음으로 본다)")
|
|
return drops
|
|
|
|
|
|
def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float:
|
|
"""그 측점의 노면 하강량(m). 구체가 앉은 측점만 — 붙는 기준은 세트 부착과 같다."""
|
|
for at, depth in drops:
|
|
if abs(chainage_m - at) <= _FORD_DROP_TOLERANCE_M:
|
|
return depth
|
|
return 0.0
|
|
|
|
|
|
def paved_at(chainage_m: float, ranges: list[tuple[float, float]], stored: Any = None) -> bool:
|
|
"""이 측점을 포장으로 볼 것인가 — 구간 안이면 강제, 밖이면 사용자 저장값(기본 비포장)."""
|
|
for start, end in ranges:
|
|
if start - 1e-9 <= chainage_m <= end + 1e-9:
|
|
return True
|
|
return bool(stored) if isinstance(stored, bool) else False
|
|
|
|
|
|
# 사용자 조작값 — 포장 강제로 다시 계산해도 그대로 승계한다.
|
|
_USER_TOUCHED_KEYS = (
|
|
"display_half_width_m",
|
|
"inlet_structure",
|
|
"basin_adjust",
|
|
"revet_adjust",
|
|
"ford_adjust",
|
|
"box_adjust",
|
|
"extra_wall_counts",
|
|
"revet_link_detached",
|
|
"revet_follow_grade",
|
|
)
|
|
|
|
|
|
def stored_standard_cross_section(longitudinal_row: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
"""확정 때 저장해 둔 표준횡단면 설정값(없으면 None = config 기본값).
|
|
|
|
사용자가 「표준 횡단면 설정」에서 고친 값은 확정 시 종단 정본 options에 실린다.
|
|
포장 강제 재계산도 같은 값을 써야 카드가 패널과 어긋나지 않는다(2026-08-28).
|
|
"""
|
|
options = (longitudinal_row or {}).get("data") or {}
|
|
options = options.get("options") if isinstance(options, dict) else None
|
|
standard = options.get("standard_cross_section") if isinstance(options, dict) else None
|
|
return standard if isinstance(standard, dict) else None
|
|
|
|
|
|
def enforce_pavement_ranges(
|
|
longitudinal: dict[str, Any],
|
|
cross_sections: list[dict[str, Any]],
|
|
project_root: Path,
|
|
standard: dict[str, Any] | None = None,
|
|
) -> int:
|
|
"""포장 구간·물넘이 범위 안 측점을 포장으로 맞춘다 — 저장분이 비포장이어도 그렇다.
|
|
|
|
사용자가 구간을 지정하면 그 안 횡단도에 **자동 반영**된다(2026-08-28 사용자 확정).
|
|
포장 여부는 횡단경사·포장층을 바꾸므로 플래그만 갈아 끼우지 않고 다시 계산한다.
|
|
사용자 조작값(반폭·구조물 조정)은 그대로 승계한다.
|
|
"""
|
|
ranges = pavement_ranges(project_root)
|
|
if not ranges:
|
|
return 0
|
|
ford_drops = ford_surface_drops(project_root)
|
|
changed = 0
|
|
for section in cross_sections:
|
|
design = section.get("design")
|
|
if not isinstance(design, dict) or design.get("paved"):
|
|
continue
|
|
chainage = float(section.get("chainage_m", 0.0))
|
|
if not paved_at(chainage, ranges):
|
|
continue
|
|
try:
|
|
recomputed = compute_cross_design(
|
|
section.get("samples", []),
|
|
design_elevation_from_longitudinal(longitudinal, chainage),
|
|
ground_type=str(design.get("ground_type") or "ripping_rock"),
|
|
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=True,
|
|
standard=standard,
|
|
rock_boundary_offset_m=design.get(
|
|
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
|
),
|
|
two_stage_slope=bool(design.get("two_stage_slope", True)),
|
|
ditch_enabled=design.get("ditch_enabled"),
|
|
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
|
**curve_widening_args(section),
|
|
)
|
|
except (ValueError, KeyError):
|
|
continue
|
|
for key in ("status", "pavement_suggested", *_USER_TOUCHED_KEYS):
|
|
if design.get(key) is not None:
|
|
recomputed[key] = design[key]
|
|
section["design"] = recomputed
|
|
changed += 1
|
|
return changed
|
|
|
|
|
|
def enforce_ford_surface_drops(
|
|
longitudinal: dict[str, Any],
|
|
cross_sections: list[dict[str, Any]],
|
|
project_root: Path,
|
|
standard: dict[str, Any] | None = None,
|
|
) -> int:
|
|
"""세월교가 앉은 측점의 계획고를 월류 높이만큼 내려 다시 계산한다.
|
|
|
|
저장분은 월류 높이를 넣기 전에 계산된 것이라 노면이 그대로다 — 포장 구간과 같은
|
|
방식으로 **저장분과 지금 값이 다를 때만** 다시 계산한다(2026-08-30 사용자 확정).
|
|
월류 높이를 지우면 같은 경로로 원래 계획고로 되돌아온다. 사용자 조작값은 승계한다.
|
|
"""
|
|
drops = ford_surface_drops(project_root)
|
|
changed = 0
|
|
for section in cross_sections:
|
|
design = section.get("design")
|
|
if not isinstance(design, dict):
|
|
continue
|
|
chainage = float(section.get("chainage_m", 0.0))
|
|
wanted = ford_drop_at(chainage, drops)
|
|
stored = float(design.get("surface_drop_m") or 0.0)
|
|
if abs(wanted - stored) < 1e-6:
|
|
continue
|
|
try:
|
|
recomputed = compute_cross_design(
|
|
section.get("samples", []),
|
|
design_elevation_from_longitudinal(longitudinal, chainage),
|
|
ground_type=str(design.get("ground_type") or "ripping_rock"),
|
|
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,
|
|
rock_boundary_offset_m=design.get(
|
|
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
|
),
|
|
two_stage_slope=bool(design.get("two_stage_slope", True)),
|
|
ditch_enabled=design.get("ditch_enabled"),
|
|
surface_drop_m=wanted,
|
|
**curve_widening_args(section),
|
|
)
|
|
except (ValueError, KeyError):
|
|
continue
|
|
for key in ("status", "pavement_suggested", *_USER_TOUCHED_KEYS):
|
|
if design.get(key) is not None:
|
|
recomputed[key] = design[key]
|
|
section["design"] = recomputed
|
|
changed += 1
|
|
return changed
|
|
|
|
|
|
def default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]:
|
|
mapping: dict[float, str] = {}
|
|
stations = longitudinal.get("stations")
|
|
for station in stations if isinstance(stations, list) else []:
|
|
side = station.get("uphill_side")
|
|
if side in ("left", "right"):
|
|
mapping[round(float(station.get("chainage_m", 0.0)), 3)] = f"{side}_cut"
|
|
return mapping
|
|
|
|
|
|
def read_cross_design_inputs(
|
|
project_root: Path, longitudinal_file_path: str, chainage_m: float
|
|
) -> tuple[list[dict], float | None, bool, dict[str, Any]]:
|
|
"""(지반 샘플, 계획고, 포장 제안, 측점 기록) — 측점 기록은 곡선부 확폭 입력을 담고 있다."""
|
|
root = project_root.resolve()
|
|
longitudinal_path = (root / longitudinal_file_path).resolve()
|
|
if root not in longitudinal_path.parents:
|
|
raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.")
|
|
if not longitudinal_path.is_file():
|
|
raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.")
|
|
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
|
|
design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m)
|
|
suggested = pavement_suggestions(longitudinal).get(round(chainage_m, 3), False)
|
|
cross_dir = longitudinal_path.parent.parent / "cross_sections"
|
|
cross_path = (cross_dir / cross_filename(chainage_m)).resolve()
|
|
if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file():
|
|
raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.")
|
|
cross = json.loads(cross_path.read_text(encoding="utf-8"))
|
|
samples = cross.get("samples") if isinstance(cross, dict) else None
|
|
if not isinstance(samples, list):
|
|
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
|
|
return samples, design_elevation, suggested, cross
|
|
|
|
|
|
def attach_default_designs(
|
|
longitudinal: dict[str, Any],
|
|
cross_sections: list[dict[str, Any]],
|
|
project_root: Path | None = None,
|
|
standard: dict[str, Any] | None = None,
|
|
) -> None:
|
|
modes = default_section_modes(longitudinal)
|
|
pavement = pavement_suggestions(longitudinal)
|
|
paved_ranges = pavement_ranges(project_root) if project_root else []
|
|
ford_drops = ford_surface_drops(project_root)
|
|
for section in cross_sections:
|
|
if section.get("design"):
|
|
continue
|
|
try:
|
|
chainage = float(section.get("chainage_m", 0.0))
|
|
suggested = pavement.get(round(chainage, 3), False)
|
|
design = compute_cross_design(
|
|
section.get("samples", []),
|
|
design_elevation_from_longitudinal(longitudinal, chainage),
|
|
ground_type="ripping_rock",
|
|
section_mode=modes.get(round(chainage, 3), "left_cut"),
|
|
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
|
|
paved=paved_at(chainage, paved_ranges),
|
|
standard=standard,
|
|
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
|
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
|
**curve_widening_args(section),
|
|
)
|
|
design.update(status="provisional", pavement_suggested=suggested)
|
|
section["design"] = design
|
|
except (ValueError, KeyError):
|
|
continue
|
|
|
|
|
|
def recompute_designs_for_alignment(
|
|
longitudinal: dict[str, Any],
|
|
cross_sections: list[dict[str, Any]],
|
|
stored_designs: list[dict[str, Any]],
|
|
standard: dict[str, Any] | None,
|
|
rock_boundary_offsets: dict[str, float] | None = None,
|
|
project_root: Path | None = None,
|
|
) -> None:
|
|
modes = default_section_modes(longitudinal)
|
|
pavement = pavement_suggestions(longitudinal)
|
|
paved_ranges = pavement_ranges(project_root) if project_root else []
|
|
ford_drops = ford_surface_drops(project_root)
|
|
stored_by_chainage = {
|
|
round(float(record["chainage_m"]), 3): (record.get("design") or {})
|
|
for record in stored_designs
|
|
}
|
|
session_offsets: dict[float, float] = {}
|
|
for raw_key, offset in (rock_boundary_offsets or {}).items():
|
|
try:
|
|
session_offsets[round(float(raw_key), 3)] = float(offset)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
for section in cross_sections:
|
|
chainage = float(section.get("chainage_m", 0.0))
|
|
key = round(chainage, 3)
|
|
stored = stored_by_chainage.get(key) or {}
|
|
suggested = pavement.get(key, False)
|
|
try:
|
|
design = compute_cross_design(
|
|
section.get("samples", []),
|
|
design_elevation_from_longitudinal(longitudinal, chainage),
|
|
ground_type=str(stored.get("ground_type") or "ripping_rock"),
|
|
section_mode=str(stored.get("section_mode") or modes.get(key, "left_cut")),
|
|
ditch_side=stored.get("ditch_side"),
|
|
ditch_type=str(stored.get("ditch_type") or "standard"),
|
|
paved=paved_at(chainage, paved_ranges, stored.get("paved")),
|
|
standard=standard,
|
|
rock_boundary_offset_m=session_offsets.get(
|
|
key,
|
|
stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M),
|
|
),
|
|
two_stage_slope=bool(stored.get("two_stage_slope", True)),
|
|
ditch_enabled=stored.get("ditch_enabled"),
|
|
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
|
**curve_widening_args(section),
|
|
)
|
|
except (ValueError, KeyError):
|
|
continue
|
|
design.update(status="provisional", pavement_suggested=suggested)
|
|
if stored.get("display_half_width_m") is not None:
|
|
design["display_half_width_m"] = stored["display_half_width_m"]
|
|
if stored.get("inlet_structure") is not None:
|
|
design["inlet_structure"] = stored["inlet_structure"]
|
|
if stored.get("basin_adjust") is not None:
|
|
design["basin_adjust"] = stored["basin_adjust"]
|
|
if stored.get("ford_adjust") is not None:
|
|
design["ford_adjust"] = stored["ford_adjust"]
|
|
if stored.get("box_adjust") is not None:
|
|
design["box_adjust"] = stored["box_adjust"]
|
|
section["design"] = design
|
|
|
|
|
|
def compute_default_designs(
|
|
project_root: Path,
|
|
longitudinal_file_path: str,
|
|
chainages: list[float],
|
|
standard: dict[str, Any] | None = None,
|
|
) -> list[tuple[float, dict[str, Any]]]:
|
|
"""미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다.
|
|
|
|
standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다.
|
|
계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함).
|
|
"""
|
|
root = project_root.resolve()
|
|
longitudinal_path = (root / longitudinal_file_path).resolve()
|
|
if root not in longitudinal_path.parents or not longitudinal_path.is_file():
|
|
return []
|
|
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
|
|
cross_dir = longitudinal_path.parent.parent / "cross_sections"
|
|
default_modes = default_section_modes(longitudinal)
|
|
pavement = pavement_suggestions(longitudinal)
|
|
paved_ranges = pavement_ranges(root)
|
|
ford_drops = ford_surface_drops(root)
|
|
results: list[tuple[float, dict[str, Any]]] = []
|
|
for chainage_m in chainages:
|
|
cross_path = cross_dir / cross_filename(chainage_m)
|
|
if not cross_path.is_file():
|
|
continue
|
|
try:
|
|
cross = json.loads(cross_path.read_text(encoding="utf-8"))
|
|
samples = cross.get("samples")
|
|
if not isinstance(samples, list):
|
|
continue
|
|
suggested = pavement.get(round(chainage_m, 3), False)
|
|
design = compute_cross_design(
|
|
samples,
|
|
design_elevation_from_longitudinal(longitudinal, chainage_m),
|
|
ground_type="ripping_rock",
|
|
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
|
|
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
|
|
paved=paved_at(chainage_m, paved_ranges),
|
|
standard=standard,
|
|
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
|
surface_drop_m=ford_drop_at(chainage_m, ford_drops),
|
|
**curve_widening_args(cross),
|
|
)
|
|
design["status"] = "provisional"
|
|
design["pavement_suggested"] = suggested
|
|
results.append((chainage_m, design))
|
|
except (ValueError, KeyError, OSError, json.JSONDecodeError):
|
|
continue
|
|
return results
|