Files
Aislo/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py
T
2026-07-24 14:53:09 +09:00

253 lines
10 KiB
Python

"""B05 경로 계산 후 종횡단을 생성하는 엔진 오케스트레이터.
확정 경로 GeoJSON과 확정 지표면 모델 sampler로 종단·횡단을 생성하고, 종단은
longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB 기록용
데이터(경로·요약·측점별 파일)를 준비해 반환한다. 라우터에서 asyncio.to_thread로
호출한다.
"""
import json
import logging
from pathlib import Path
from typing import Any
from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, design_grade_line
from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import design_alignment_profile
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import (
SectionGenerationOptions,
generate_sections,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import build_surface_sampler
from common_util.common_util_json import atomic_write_json
logger = logging.getLogger(__name__)
_STAGE_SUBDIR = Path("B06_wf3_ProfileCross")
_MODELS_SUBDIR = Path("B04_wf1_Surface") / "models"
def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[float]]:
"""B05가 저장한 경로 GeoJSON에서 3D 폴리라인 좌표열을 로드한다."""
geojson_path = project_root / Path(route_data_path)
if not geojson_path.is_file():
raise FileNotFoundError(f"경로 GeoJSON을 찾을 수 없습니다: {route_data_path}")
geo = json.loads(geojson_path.read_text(encoding="utf-8"))
coords = geo.get("geometry", {}).get("coordinates")
if not coords or len(coords) < 2:
raise ValueError("경로 GeoJSON에 유효한 LineString 좌표가 없습니다.")
return [[float(c[0]), float(c[1]), float(c[2]) if len(c) > 2 else 0.0] for c in coords]
def cross_filename(chainage_m: float) -> str:
"""측점 chainage에 대응하는 횡단면 파일명(단일 규칙)."""
return f"cross_{int(round(float(chainage_m))):05d}m.json"
def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]:
"""stations에 없는 잔재 cross_*.json을 삭제하고 유효 파일명 집합을 반환한다.
측점 정보가 비어 있으면 오삭제를 피하기 위해 아무것도 지우지 않고
빈 집합을 반환한다(호출부는 빈 집합이면 필터를 생략한다).
"""
valid = {
cross_filename(station["chainage_m"])
for station in stations
if isinstance(station, dict) and isinstance(station.get("chainage_m"), (int, float))
}
if not valid:
return valid
for path in cross_dir.glob("cross_*.json"):
if path.name not in valid:
path.unlink(missing_ok=True)
return valid
def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]:
"""횡단면 상세에서 DB data 컬럼에 저장할 요약을 만든다."""
samples = cross_section.get("samples", [])
valid_z = [
s["elevation_m"] for s in samples if s.get("valid") and s.get("elevation_m") is not None
]
return {
"chainage_m": cross_section.get("chainage_m"),
"center_z": cross_section.get("center_z"),
"azimuth_deg": cross_section.get("azimuth_deg"),
"sample_count": len(samples),
"min_elevation_m": min(valid_z) if valid_z else None,
"max_elevation_m": max(valid_z) if valid_z else None,
}
def _append_design_profiles(
longitudinal: dict[str, Any],
grade_options: GradeDesignOptions | None,
station_interval_m: float | None = None,
) -> dict[str, Any] | None:
"""종단 계획선을 산출해 longitudinal에 붙이고 요약을 반환한다.
1순위는 측점 제약 직선 분할 선형(`profile_alignment`)이며, 여기서 산출된
변화점 구조가 사용자 편집의 기준선이 된다. 산출이 실패하면 구 균형 최적화
계획선으로 폴백하고(편집 불가), 그마저 실패해도 종횡단 생성 자체는 유지한다.
횡단 설계 기반 계획선을 나중에 추가할 수 있게 배열로 보관한다.
"""
longitudinal.setdefault("design_profiles", [])
if grade_options is None:
return None
try:
alignment, profile = design_alignment_profile(
longitudinal, grade_options, station_interval_m=station_interval_m
)
longitudinal["profile_alignment"] = alignment
except (ValueError, KeyError, ArithmeticError):
logger.exception("B05 계획선 선형 산출 실패 — 균형 최적화 계획선으로 대체")
try:
profile = design_grade_line(longitudinal, grade_options)
except (ValueError, KeyError, ArithmeticError):
logger.exception("B05 종단 계획선 산출 실패 (종횡단은 유지)")
return None
longitudinal["design_profiles"].append(profile)
return {"id": profile["id"], **profile["summary"]}
def run_section_generation(
project_root: Path,
route_data_path: str,
filter_key: str,
method: str,
smooth: bool,
*,
options: SectionGenerationOptions | None = None,
grade_options: GradeDesignOptions | None = None,
grade_overrides: dict[str, Any] | None = None,
crs: str | None = None,
) -> dict[str, Any]:
"""종횡단을 생성·저장하고 DB 기록용 데이터를 반환한다.
반환 dict:
- longitudinal: {file_path, data(요약)}
- cross_sections: [{chainage_m, sequence_num, data(요약), cross_section_file_path}]
- result: generate_sections 원본 결과
"""
polyline = _load_route_polyline(project_root, route_data_path)
models_dir = project_root / _MODELS_SUBDIR
sampler = build_surface_sampler(models_dir, filter_key, method, smooth)
result = generate_sections(
polyline,
sampler,
options,
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
crs=crs,
)
stage_root = project_root / _STAGE_SUBDIR
long_dir = stage_root / "longitudinal"
cross_dir = stage_root / "cross_sections"
long_dir.mkdir(parents=True, exist_ok=True)
cross_dir.mkdir(parents=True, exist_ok=True)
# 종단면 저장 (계획선은 저장 직전에 종단 데이터에 붙인다)
grade_summary = _append_design_profiles(
result["longitudinal"],
grade_options,
(result.get("options") or {}).get("station_interval_m"),
)
long_file = long_dir / "longitudinal.json"
atomic_write_json(long_file, result["longitudinal"])
long_summary = {
"length_m": result["longitudinal"]["length_m"],
"station_count": result["summary"]["station_count"],
"invalid_samples": result["summary"]["invalid_longitudinal_samples"],
# 사용자 선택값의 단일 소스(DB): 재생성·재탐색 시 이 값을 우선 사용한다.
"options": result["options"],
# 해석이 끝난 기준값은 기록·표시용으로만 보관한다. 이 값을 재계산 시
# 폴백으로 되쓰면 등급·지형을 바꿔도 옛 기본값이 법정값을 이겨 갱신되지 않는다.
"grade_options": grade_options.as_dict() if grade_options else None,
# 재계산 폴백에 쓰는 단일 소스: 사용자가 명시적으로 입력한 값만 담는다.
"grade_overrides": {
key: value for key, value in (grade_overrides or {}).items() if value is not None
},
"grade_summary": grade_summary,
}
# 측점별 횡단면 저장 (detail 조회가 폴더 전체를 glob하므로 이전 실행 잔재를 먼저 비운다)
for stale_file in cross_dir.glob("cross_*.json"):
stale_file.unlink()
cross_records: list[dict[str, Any]] = []
for seq, cross_section in enumerate(result["cross_sections"]):
chainage = float(cross_section["chainage_m"])
cross_file = cross_dir / cross_filename(chainage)
atomic_write_json(cross_file, cross_section)
cross_records.append(
{
"chainage_m": chainage,
"sequence_num": seq,
"data": _cross_summary(cross_section),
"cross_section_file_path": cross_file.relative_to(project_root).as_posix(),
}
)
return {
"longitudinal": {
"file_path": long_file.relative_to(project_root).as_posix(),
"data": long_summary,
},
"cross_sections": cross_records,
"result": result,
}
def generate_irregular_sections(
project_root: Path,
route_data_path: str,
filter_key: str,
method: str,
smooth: bool,
*,
extra_stations: tuple[tuple[float, str], ...],
options: SectionGenerationOptions | None = None,
crs: str | None = None,
) -> list[dict[str, Any]]:
"""비정규 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다.
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다. 격자 측점과 똑같은 접선·오프셋
샘플링을 거치되(`generate_sections` 재사용) kind="irregular"인 것만 골라내 각각
`cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
"""
if not extra_stations:
return []
base = options or SectionGenerationOptions()
merged_options = SectionGenerationOptions(
station_interval_m=base.station_interval_m,
cross_half_width_m=base.cross_half_width_m,
cross_sample_interval_m=base.cross_sample_interval_m,
long_sample_interval_m=base.long_sample_interval_m,
include_endpoint=base.include_endpoint,
extra_stations=tuple(extra_stations),
)
polyline = _load_route_polyline(project_root, route_data_path)
sampler = build_surface_sampler(project_root / _MODELS_SUBDIR, filter_key, method, smooth)
result = generate_sections(
polyline,
sampler,
merged_options,
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
crs=crs,
)
irregular_stations = [
station
for station in result["longitudinal"]["stations"]
if station.get("kind") == "irregular"
]
if not irregular_stations:
return []
cross_dir = project_root / _STAGE_SUBDIR / "cross_sections"
cross_dir.mkdir(parents=True, exist_ok=True)
for cross_section in result["cross_sections"]:
if cross_section.get("kind") != "irregular":
continue
cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"]))
atomic_write_json(cross_file, cross_section)
return irregular_stations