260705_2
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"""B06 종횡단 생성 엔진 오케스트레이터.
|
||||
|
||||
확정 경로 GeoJSON과 확정 지표면 모델 sampler로 종단·횡단을 생성하고, 종단은
|
||||
longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB 기록용
|
||||
데이터(경로·요약·측점별 파일)를 준비해 반환한다. 라우터에서 asyncio.to_thread로
|
||||
호출한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import build_surface_sampler
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import (
|
||||
SectionGenerationOptions,
|
||||
generate_sections,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
_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_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 run_section_generation(
|
||||
project_root: Path,
|
||||
route_data_path: str,
|
||||
filter_key: str,
|
||||
method: str,
|
||||
smooth: bool,
|
||||
*,
|
||||
options: SectionGenerationOptions | 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)
|
||||
|
||||
# 종단면 저장
|
||||
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"],
|
||||
}
|
||||
|
||||
# 측점별 횡단면 저장
|
||||
cross_records: list[dict[str, Any]] = []
|
||||
for seq, cross_section in enumerate(result["cross_sections"]):
|
||||
chainage = float(cross_section["chainage_m"])
|
||||
filename = f"cross_{int(round(chainage)):05d}m.json"
|
||||
cross_file = cross_dir / filename
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"""B06 지표면 표고 sampler.
|
||||
|
||||
종·횡단 생성기가 의존하는 최소 표고 조회 인터페이스와, 확정된 지표면 모델
|
||||
(B04_wf1_Surface/models)을 일괄 XY 표고 sampler로 여는 팩토리를 제공한다.
|
||||
DTM valid_mask를 footprint로 결합해 데이터가 없는 영역을 임의 표고로 메우지
|
||||
않는다.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
import numpy as np
|
||||
from scipy.interpolate import RegularGridInterpolator
|
||||
|
||||
|
||||
class SurfaceElevationSampler(Protocol):
|
||||
"""종·횡단 생성기가 의존하는 최소 표고 조회 인터페이스."""
|
||||
|
||||
def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""(N, 2) 모델좌표 XY 배열에 대해 (z, valid)를 반환한다."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DtmGridSampler:
|
||||
"""정규 DTM의 표고와 valid_mask를 보수적으로 조회한다.
|
||||
|
||||
보간점 주변 네 격자 꼭짓점이 모두 유효할 때만 valid=True로 반환한다.
|
||||
"""
|
||||
|
||||
x: np.ndarray
|
||||
y: np.ndarray
|
||||
z: np.ndarray
|
||||
valid_mask: np.ndarray
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.x = np.asarray(self.x, dtype=np.float64).reshape(-1)
|
||||
self.y = np.asarray(self.y, dtype=np.float64).reshape(-1)
|
||||
self.z = np.asarray(self.z, dtype=np.float64)
|
||||
self.valid_mask = np.asarray(self.valid_mask, dtype=bool)
|
||||
if len(self.x) < 2 or len(self.y) < 2:
|
||||
raise ValueError("DTM 표고 조회에는 X/Y 축이 각각 2개 이상 필요합니다.")
|
||||
if self.z.shape != (len(self.y), len(self.x)):
|
||||
raise ValueError("DTM Z 격자 크기가 X/Y 축과 일치하지 않습니다.")
|
||||
if self.valid_mask.shape != self.z.shape:
|
||||
raise ValueError("DTM valid_mask 크기가 Z 격자와 일치하지 않습니다.")
|
||||
if self.x[0] > self.x[-1]:
|
||||
self.x = self.x[::-1]
|
||||
self.z = self.z[:, ::-1]
|
||||
self.valid_mask = self.valid_mask[:, ::-1]
|
||||
if self.y[0] > self.y[-1]:
|
||||
self.y = self.y[::-1]
|
||||
self.z = self.z[::-1, :]
|
||||
self.valid_mask = self.valid_mask[::-1, :]
|
||||
self._interpolator = RegularGridInterpolator(
|
||||
(self.y, self.x), self.z, method="linear", bounds_error=False, fill_value=np.nan
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_npz(cls, path: Path | str) -> "DtmGridSampler":
|
||||
with np.load(Path(path), allow_pickle=False) as data:
|
||||
return cls(data["x"], data["y"], data["z"], data["valid_mask"])
|
||||
|
||||
def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
xy = np.asarray(xy, dtype=np.float64)
|
||||
if xy.ndim != 2 or xy.shape[1] != 2:
|
||||
raise ValueError("표고 조회 좌표는 (N, 2) XY 배열이어야 합니다.")
|
||||
if not len(xy):
|
||||
return np.empty(0, dtype=np.float64), np.empty(0, dtype=bool)
|
||||
|
||||
z = np.asarray(
|
||||
self._interpolator(np.column_stack([xy[:, 1], xy[:, 0]])), dtype=np.float64
|
||||
)
|
||||
ix = np.searchsorted(self.x, xy[:, 0], side="right") - 1
|
||||
iy = np.searchsorted(self.y, xy[:, 1], side="right") - 1
|
||||
inside = (ix >= 0) & (iy >= 0) & (ix < len(self.x) - 1) & (iy < len(self.y) - 1)
|
||||
valid = np.zeros(len(xy), dtype=bool)
|
||||
selected = np.flatnonzero(inside)
|
||||
if len(selected):
|
||||
sx = ix[selected]
|
||||
sy = iy[selected]
|
||||
valid[selected] = (
|
||||
self.valid_mask[sy, sx]
|
||||
& self.valid_mask[sy, sx + 1]
|
||||
& self.valid_mask[sy + 1, sx]
|
||||
& self.valid_mask[sy + 1, sx + 1]
|
||||
& np.isfinite(z[selected])
|
||||
)
|
||||
z[~valid] = np.nan
|
||||
return z, valid
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallableSurfaceSampler:
|
||||
"""테스트와 어댑터에 사용할 함수 기반 sampler."""
|
||||
|
||||
function: Callable[[np.ndarray], np.ndarray]
|
||||
|
||||
def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
values = np.asarray(self.function(np.asarray(xy, dtype=np.float64)), dtype=np.float64)
|
||||
if values.shape != (len(xy),):
|
||||
raise ValueError("표고 함수는 입력 좌표 수와 같은 길이의 배열을 반환해야 합니다.")
|
||||
valid = np.isfinite(values)
|
||||
return values, valid
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterpolatedSurfaceSampler:
|
||||
"""불규칙/곡면 모델 보간기와 DTM footprint 유효성을 결합한다."""
|
||||
|
||||
interpolator: Callable[[np.ndarray], np.ndarray]
|
||||
footprint: SurfaceElevationSampler | None = None
|
||||
|
||||
def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
xy = np.asarray(xy, dtype=np.float64)
|
||||
values = np.asarray(self.interpolator(xy), dtype=np.float64).reshape(-1)
|
||||
valid = np.isfinite(values)
|
||||
if self.footprint is not None:
|
||||
_, footprint_valid = self.footprint.sample_xy(xy)
|
||||
valid &= footprint_valid
|
||||
values[~valid] = np.nan
|
||||
return values, valid
|
||||
|
||||
|
||||
def build_surface_sampler(
|
||||
models_dir: Path | str, source_filter: str, method: str, smooth: bool
|
||||
) -> SurfaceElevationSampler:
|
||||
"""1단계 확정 모델을 종·횡단용 일괄 XY 표고 sampler로 연다."""
|
||||
models_dir = Path(models_dir)
|
||||
smooth_suffix = "_smooth" if smooth and method in {"dtm", "tin"} else ""
|
||||
dtm_smooth = models_dir / f"dtm_{source_filter}_smooth.npz"
|
||||
dtm_original = models_dir / f"dtm_{source_filter}.npz"
|
||||
dtm_path = dtm_smooth if smooth and dtm_smooth.exists() else dtm_original
|
||||
if not dtm_path.exists():
|
||||
raise FileNotFoundError(f"기준 DTM이 없습니다: {dtm_path.name}")
|
||||
footprint = DtmGridSampler.from_npz(dtm_path)
|
||||
if method == "dtm":
|
||||
return footprint
|
||||
|
||||
if method == "tin":
|
||||
from scipy.interpolate import LinearNDInterpolator
|
||||
|
||||
path = models_dir / f"tin_{source_filter}{smooth_suffix}.npz"
|
||||
if not path.exists() and smooth_suffix:
|
||||
path = models_dir / f"tin_{source_filter}.npz"
|
||||
with np.load(path, allow_pickle=False) as data:
|
||||
vertices = np.asarray(data["vertices"], dtype=np.float64)
|
||||
interpolator = LinearNDInterpolator(vertices[:, :2], vertices[:, 2], fill_value=np.nan)
|
||||
return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint)
|
||||
|
||||
if method == "nurbs":
|
||||
from scipy.interpolate import RectBivariateSpline
|
||||
|
||||
path = models_dir / f"nurbs_{source_filter}.npz"
|
||||
with np.load(path, allow_pickle=False) as data:
|
||||
control_x = np.asarray(data["control_x"], dtype=np.float64)
|
||||
control_y = np.asarray(data["control_y"], dtype=np.float64)
|
||||
control_z = np.asarray(data["control_z"], dtype=np.float64)
|
||||
degree = int(data["degree"][0]) if "degree" in data else 3
|
||||
spline = RectBivariateSpline(
|
||||
control_y,
|
||||
control_x,
|
||||
control_z,
|
||||
kx=min(degree, len(control_y) - 1),
|
||||
ky=min(degree, len(control_x) - 1),
|
||||
)
|
||||
return InterpolatedSurfaceSampler(lambda xy: spline.ev(xy[:, 1], xy[:, 0]), footprint)
|
||||
|
||||
if method == "implicit":
|
||||
from scipy.interpolate import RBFInterpolator
|
||||
|
||||
path = models_dir / f"implicit_{source_filter}.npz"
|
||||
with np.load(path, allow_pickle=False) as data:
|
||||
centers = np.asarray(data["centers_xy"], dtype=np.float64)
|
||||
center_z = np.asarray(data["center_z"], dtype=np.float64)
|
||||
smoothing = float(data["smoothing"][0]) if "smoothing" in data else 0.0
|
||||
interpolator = RBFInterpolator(
|
||||
centers,
|
||||
center_z,
|
||||
neighbors=min(64, len(centers)),
|
||||
smoothing=smoothing,
|
||||
kernel="thin_plate_spline",
|
||||
)
|
||||
return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint)
|
||||
|
||||
if method == "meshfree":
|
||||
from scipy.interpolate import LinearNDInterpolator
|
||||
|
||||
path = models_dir / f"meshfree_{source_filter}.npz"
|
||||
with np.load(path, allow_pickle=False) as data:
|
||||
points = np.asarray(data["points"], dtype=np.float64)
|
||||
interpolator = LinearNDInterpolator(points[:, :2], points[:, 2], fill_value=np.nan)
|
||||
return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint)
|
||||
|
||||
raise ValueError(f"지원하지 않는 지표면 모델입니다: {method}")
|
||||
@@ -0,0 +1,266 @@
|
||||
"""B06 종단·횡단 원시 데이터 생성.
|
||||
|
||||
확정 경로 폴리라인과 표고 sampler로 CAD 인계 가능한 종단(longitudinal)·횡단
|
||||
(cross) 데이터를 생성한다. BP(0m)부터 station_interval 간격 측점을 만들고
|
||||
각 측점에서 경로 접선의 좌우 방향으로 횡단을 샘플링한다.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import SurfaceElevationSampler
|
||||
from config.config_system import (
|
||||
SECTION_CROSS_HALF_WIDTH_M,
|
||||
SECTION_CROSS_SAMPLE_INTERVAL_M,
|
||||
SECTION_INCLUDE_ENDPOINT,
|
||||
SECTION_LONG_SAMPLE_INTERVAL_M,
|
||||
SECTION_STATION_INTERVAL_M,
|
||||
)
|
||||
|
||||
SECTION_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SectionGenerationOptions:
|
||||
station_interval_m: float = SECTION_STATION_INTERVAL_M
|
||||
cross_half_width_m: float = SECTION_CROSS_HALF_WIDTH_M
|
||||
cross_sample_interval_m: float = SECTION_CROSS_SAMPLE_INTERVAL_M
|
||||
long_sample_interval_m: float = SECTION_LONG_SAMPLE_INTERVAL_M
|
||||
include_endpoint: bool = SECTION_INCLUDE_ENDPOINT
|
||||
|
||||
def validate(self) -> None:
|
||||
values = {
|
||||
"횡단 측점 간격": self.station_interval_m,
|
||||
"횡단 좌우 폭": self.cross_half_width_m,
|
||||
"횡단 샘플 간격": self.cross_sample_interval_m,
|
||||
"종단 샘플 간격": self.long_sample_interval_m,
|
||||
}
|
||||
for label, value in values.items():
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise ValueError(f"{label}은 0보다 큰 유한한 값이어야 합니다.")
|
||||
if self.cross_sample_interval_m > self.cross_half_width_m * 2:
|
||||
raise ValueError("횡단 샘플 간격이 전체 횡단 폭보다 클 수 없습니다.")
|
||||
|
||||
|
||||
def format_station(chainage_m: float) -> str:
|
||||
"""WebCAD 도면에서도 재사용할 수 있는 STA.k+mmm.mmm 표기."""
|
||||
chainage_m = max(float(chainage_m), 0.0)
|
||||
km = int(chainage_m // 1000.0)
|
||||
remainder = chainage_m - km * 1000.0
|
||||
return f"STA.{km}+{remainder:07.3f}"
|
||||
|
||||
|
||||
def _clean_polyline(polyline: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
points = np.asarray(polyline, dtype=np.float64)
|
||||
if points.ndim != 2 or points.shape[1] < 2 or len(points) < 2:
|
||||
raise ValueError("경로는 최소 2개의 (x, y, z) 좌표로 구성되어야 합니다.")
|
||||
if points.shape[1] == 2:
|
||||
points = np.column_stack([points, np.full(len(points), np.nan)])
|
||||
else:
|
||||
points = points[:, :3]
|
||||
if not np.all(np.isfinite(points[:, :2])):
|
||||
raise ValueError("경로 XY 좌표에 NaN 또는 Infinity가 있습니다.")
|
||||
|
||||
distances = np.hypot(np.diff(points[:, 0]), np.diff(points[:, 1]))
|
||||
keep = np.r_[True, distances > 1e-8]
|
||||
points = points[keep]
|
||||
if len(points) < 2:
|
||||
raise ValueError("수평 길이가 있는 경로 구간이 없습니다.")
|
||||
distances = np.hypot(np.diff(points[:, 0]), np.diff(points[:, 1]))
|
||||
chainage = np.r_[0.0, np.cumsum(distances)]
|
||||
return points, chainage
|
||||
|
||||
|
||||
def _chainages(total: float, interval: float, include_endpoint: bool) -> np.ndarray:
|
||||
values = np.arange(0.0, total + 1e-9, interval, dtype=np.float64)
|
||||
if not len(values) or abs(values[0]) > 1e-9:
|
||||
values = np.r_[0.0, values]
|
||||
if include_endpoint and total - values[-1] > 1e-6:
|
||||
values = np.r_[values, total]
|
||||
return values
|
||||
|
||||
|
||||
def _interpolate_xy(points: np.ndarray, chainage: np.ndarray, targets: np.ndarray) -> np.ndarray:
|
||||
return np.column_stack(
|
||||
[
|
||||
np.interp(targets, chainage, points[:, 0]),
|
||||
np.interp(targets, chainage, points[:, 1]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _tangent_at(
|
||||
points: np.ndarray, chainage: np.ndarray, target: float, probe: float
|
||||
) -> np.ndarray:
|
||||
total = float(chainage[-1])
|
||||
before = max(0.0, target - probe)
|
||||
after = min(total, target + probe)
|
||||
if after - before <= 1e-9:
|
||||
before = max(0.0, target - 1e-3)
|
||||
after = min(total, target + 1e-3)
|
||||
pair = _interpolate_xy(points, chainage, np.array([before, after]))
|
||||
vector = pair[1] - pair[0]
|
||||
length = float(np.hypot(vector[0], vector[1]))
|
||||
if length <= 1e-9:
|
||||
raise ValueError(f"측점 {target:.3f}m에서 경로 접선 방향을 계산할 수 없습니다.")
|
||||
return vector / length
|
||||
|
||||
|
||||
def _float_or_none(value: float) -> float | None:
|
||||
return round(float(value), 6) if math.isfinite(float(value)) else None
|
||||
|
||||
|
||||
def generate_sections(
|
||||
polyline: np.ndarray | list[list[float]],
|
||||
sampler: SurfaceElevationSampler,
|
||||
options: SectionGenerationOptions | None = None,
|
||||
*,
|
||||
source_snapshot: dict[str, Any] | None = None,
|
||||
crs: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다."""
|
||||
options = options or SectionGenerationOptions()
|
||||
options.validate()
|
||||
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
|
||||
total = float(route_chainage[-1])
|
||||
|
||||
long_chainage = _chainages(total, options.long_sample_interval_m, True)
|
||||
long_xy = _interpolate_xy(points, route_chainage, long_chainage)
|
||||
long_z, long_valid = sampler.sample_xy(long_xy)
|
||||
|
||||
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
|
||||
station_xy = _interpolate_xy(points, route_chainage, station_chainage)
|
||||
tangents = np.vstack(
|
||||
[
|
||||
_tangent_at(
|
||||
points, route_chainage, float(value), max(options.long_sample_interval_m, 0.5)
|
||||
)
|
||||
for value in station_chainage
|
||||
]
|
||||
)
|
||||
left_axes = np.column_stack([-tangents[:, 1], tangents[:, 0]])
|
||||
|
||||
offsets = np.arange(
|
||||
-options.cross_half_width_m,
|
||||
options.cross_half_width_m + options.cross_sample_interval_m * 0.5,
|
||||
options.cross_sample_interval_m,
|
||||
dtype=np.float64,
|
||||
)
|
||||
offsets = offsets[offsets <= options.cross_half_width_m + 1e-9]
|
||||
if not np.any(np.isclose(offsets, 0.0, atol=1e-9)):
|
||||
offsets = np.sort(np.r_[offsets, 0.0])
|
||||
|
||||
all_cross_xy = (
|
||||
station_xy[:, None, :] + left_axes[:, None, :] * offsets[None, :, None]
|
||||
).reshape(-1, 2)
|
||||
all_cross_z, all_cross_valid = sampler.sample_xy(all_cross_xy)
|
||||
all_cross_z = all_cross_z.reshape(len(station_chainage), len(offsets))
|
||||
all_cross_valid = all_cross_valid.reshape(len(station_chainage), len(offsets))
|
||||
|
||||
stations: list[dict[str, Any]] = []
|
||||
cross_sections: list[dict[str, Any]] = []
|
||||
for index, value in enumerate(station_chainage):
|
||||
station_id = f"station_{int(round(float(value) * 1000)):012d}"
|
||||
kind = "bp" if index == 0 else "ep" if abs(float(value) - total) <= 1e-6 else "regular"
|
||||
center_index = int(np.argmin(np.abs(offsets)))
|
||||
center_z = all_cross_z[index, center_index]
|
||||
tangent = tangents[index]
|
||||
left = left_axes[index]
|
||||
azimuth = (math.degrees(math.atan2(tangent[0], tangent[1])) + 360.0) % 360.0
|
||||
frame = {
|
||||
"origin": {
|
||||
"x": round(float(station_xy[index, 0]), 6),
|
||||
"y": round(float(station_xy[index, 1]), 6),
|
||||
"z": _float_or_none(center_z),
|
||||
},
|
||||
"tangent_xy": [round(float(tangent[0]), 9), round(float(tangent[1]), 9)],
|
||||
"left_xy": [round(float(left[0]), 9), round(float(left[1]), 9)],
|
||||
"up_xyz": [0.0, 0.0, 1.0],
|
||||
}
|
||||
station = {
|
||||
"station_id": station_id,
|
||||
"chainage_m": round(float(value), 6),
|
||||
"label": format_station(float(value)),
|
||||
"kind": kind,
|
||||
"center_x": round(float(station_xy[index, 0]), 6),
|
||||
"center_y": round(float(station_xy[index, 1]), 6),
|
||||
"center_z": _float_or_none(center_z),
|
||||
"azimuth_deg": round(azimuth, 6),
|
||||
"frame": frame,
|
||||
}
|
||||
stations.append(station)
|
||||
|
||||
samples = []
|
||||
cross_xy_view = all_cross_xy.reshape(len(station_chainage), len(offsets), 2)
|
||||
for offset_index, offset in enumerate(offsets):
|
||||
valid = bool(all_cross_valid[index, offset_index])
|
||||
xy = cross_xy_view[index, offset_index]
|
||||
samples.append(
|
||||
{
|
||||
"offset_m": round(float(offset), 6),
|
||||
"x": round(float(xy[0]), 6),
|
||||
"y": round(float(xy[1]), 6),
|
||||
"z": _float_or_none(all_cross_z[index, offset_index]) if valid else None,
|
||||
"elevation_m": _float_or_none(all_cross_z[index, offset_index])
|
||||
if valid
|
||||
else None,
|
||||
"valid": valid,
|
||||
}
|
||||
)
|
||||
cross_sections.append({**station, "samples": samples})
|
||||
|
||||
longitudinal_samples = [
|
||||
{
|
||||
"chainage_m": round(float(chainage), 6),
|
||||
"x": round(float(xy[0]), 6),
|
||||
"y": round(float(xy[1]), 6),
|
||||
"z": _float_or_none(z) if valid else None,
|
||||
"elevation_m": _float_or_none(z) if valid else None,
|
||||
"valid": bool(valid),
|
||||
}
|
||||
for chainage, xy, z, valid in zip(long_chainage, long_xy, long_z, long_valid)
|
||||
]
|
||||
|
||||
finite_z = np.asarray(
|
||||
[sample["z"] for sample in longitudinal_samples if sample["z"] is not None]
|
||||
)
|
||||
datum = math.floor(float(finite_z.min()) / 10.0) * 10.0 if finite_z.size else None
|
||||
return {
|
||||
"schema_version": SECTION_SCHEMA_VERSION,
|
||||
"status": "completed",
|
||||
"source": source_snapshot or {},
|
||||
"coordinate_reference": {
|
||||
"crs": crs,
|
||||
"world_axes": {"x": "project_easting", "y": "project_northing", "z": "elevation"},
|
||||
"units": {"horizontal": "m", "vertical": "m", "angle": "degree"},
|
||||
},
|
||||
"cad_exchange": {
|
||||
"station_origin": "BP",
|
||||
"chainage_direction": "BP_to_EP",
|
||||
"cross_offset_sign": {"negative": "right", "positive": "left"},
|
||||
"cross_local_axes": {"x": "offset_m", "y": "elevation_m"},
|
||||
"recommended_drawing_datum_m": datum,
|
||||
},
|
||||
"options": {
|
||||
"station_interval_m": options.station_interval_m,
|
||||
"cross_half_width_m": options.cross_half_width_m,
|
||||
"cross_sample_interval_m": options.cross_sample_interval_m,
|
||||
"long_sample_interval_m": options.long_sample_interval_m,
|
||||
"include_endpoint": options.include_endpoint,
|
||||
},
|
||||
"longitudinal": {
|
||||
"length_m": round(total, 6),
|
||||
"samples": longitudinal_samples,
|
||||
"stations": stations,
|
||||
},
|
||||
"cross_sections": cross_sections,
|
||||
"summary": {
|
||||
"station_count": len(stations),
|
||||
"cross_sample_count": int(len(stations) * len(offsets)),
|
||||
"invalid_longitudinal_samples": int((~long_valid).sum()),
|
||||
"invalid_cross_samples": int((~all_cross_valid).sum()),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""B06 종횡단 결과의 aiomysql Raw SQL 접근.
|
||||
|
||||
longitudinal_sections(종단면 1건), cross_sections(측점별 다건) 테이블에
|
||||
메타데이터와 상대 경로를 기록한다. 상세 샘플 데이터는 파일에 저장하고 DB에는
|
||||
요약 data(JSON)와 경로만 기록한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
|
||||
_STAGE_ROOT = "B06_wf3_ProfileCross"
|
||||
|
||||
|
||||
def _validate_stage_path(relative_path: str) -> str:
|
||||
normalized = PurePosixPath(relative_path.replace("\\", "/"))
|
||||
if normalized.is_absolute() or ".." in normalized.parts:
|
||||
raise ValueError("DB에는 프로젝트 루트 기준 상대 경로만 저장할 수 있습니다.")
|
||||
if not normalized.parts or normalized.parts[0] != _STAGE_ROOT:
|
||||
raise ValueError(f"B06 산출물 경로는 {_STAGE_ROOT} 아래여야 합니다.")
|
||||
return normalized.as_posix()
|
||||
|
||||
|
||||
async def delete_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None:
|
||||
"""경로 재생성 전에 기존 종횡단 레코드를 삭제한다 (멱등 재실행)."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute("DELETE FROM cross_sections WHERE route_id = %s", (route_id,))
|
||||
await cursor.execute("DELETE FROM longitudinal_sections WHERE route_id = %s", (route_id,))
|
||||
|
||||
|
||||
async def create_longitudinal_section(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
project_id: UUID,
|
||||
route_id: int,
|
||||
data: dict[str, Any] | None,
|
||||
longitudinal_file_path: str,
|
||||
status: str = "DRAFT",
|
||||
) -> int:
|
||||
"""종단면 메타데이터를 저장하고 생성된 ID를 반환한다."""
|
||||
file_rel = _validate_stage_path(longitudinal_file_path)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO longitudinal_sections (
|
||||
project_id, route_id, computed_at, data, longitudinal_file_path, status
|
||||
)
|
||||
VALUES (%s, %s, CURRENT_TIMESTAMP, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
str(project_id),
|
||||
route_id,
|
||||
json.dumps(data, ensure_ascii=False) if data is not None else None,
|
||||
file_rel,
|
||||
status,
|
||||
),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
if not new_id:
|
||||
raise RuntimeError("longitudinal_sections 레코드 생성 결과에 ID가 없습니다.")
|
||||
return int(new_id)
|
||||
|
||||
|
||||
async def insert_cross_sections(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
project_id: UUID,
|
||||
route_id: int,
|
||||
sections: list[dict[str, Any]],
|
||||
) -> int:
|
||||
"""측점별 횡단면 레코드를 일괄 저장하고 저장 건수를 반환한다.
|
||||
|
||||
각 section dict: {chainage_m, sequence_num, data, cross_section_file_path, status?}
|
||||
"""
|
||||
if not sections:
|
||||
return 0
|
||||
rows = []
|
||||
for section in sections:
|
||||
file_rel = _validate_stage_path(section["cross_section_file_path"])
|
||||
data = section.get("data")
|
||||
rows.append(
|
||||
(
|
||||
str(project_id),
|
||||
route_id,
|
||||
section.get("chainage_m"),
|
||||
section.get("sequence_num"),
|
||||
json.dumps(data, ensure_ascii=False) if data is not None else None,
|
||||
file_rel,
|
||||
section.get("status", "DRAFT"),
|
||||
)
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.executemany(
|
||||
"""
|
||||
INSERT INTO cross_sections (
|
||||
project_id, route_id, chainage_m, sequence_num,
|
||||
data, cross_section_file_path, status
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def get_longitudinal_section(
|
||||
connection: aiomysql.Connection, project_id: UUID, route_id: int
|
||||
) -> dict[str, Any] | None:
|
||||
"""경로의 종단면 메타데이터를 조회한다 (없으면 None)."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT id, longitudinal_file_path, status, computed_at
|
||||
FROM longitudinal_sections
|
||||
WHERE project_id = %s AND route_id = %s
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(project_id), route_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": int(row[0]),
|
||||
"longitudinal_file_path": row[1],
|
||||
"status": row[2],
|
||||
"computed_at": row[3].isoformat() if row[3] else None,
|
||||
}
|
||||
|
||||
|
||||
async def confirm_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None:
|
||||
"""경로의 종횡단면 상태를 CONFIRMED로 변경한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"UPDATE longitudinal_sections SET status = 'CONFIRMED' WHERE route_id = %s",
|
||||
(route_id,),
|
||||
)
|
||||
await cursor.execute(
|
||||
"UPDATE cross_sections SET status = 'CONFIRMED' WHERE route_id = %s",
|
||||
(route_id,),
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""B06 종횡단 생성 FastAPI 라우터."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import get_latest_route
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine import run_section_generation
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import SectionGenerationOptions
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
||||
confirm_sections_for_route,
|
||||
create_longitudinal_section,
|
||||
delete_sections_for_route,
|
||||
get_longitudinal_section,
|
||||
insert_cross_sections,
|
||||
)
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
|
||||
SectionConfirmResponse,
|
||||
SectionGenerateRequest,
|
||||
SectionGenerateResponse,
|
||||
SectionSummaryResponse,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
||||
|
||||
|
||||
def _build_options(request: SectionGenerateRequest) -> SectionGenerationOptions:
|
||||
"""요청의 옵션(미지정은 config 기본값)으로 SectionGenerationOptions를 만든다."""
|
||||
defaults = SectionGenerationOptions()
|
||||
return SectionGenerationOptions(
|
||||
station_interval_m=request.station_interval_m or defaults.station_interval_m,
|
||||
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
|
||||
cross_sample_interval_m=request.cross_sample_interval_m or defaults.cross_sample_interval_m,
|
||||
long_sample_interval_m=request.long_sample_interval_m or defaults.long_sample_interval_m,
|
||||
include_endpoint=defaults.include_endpoint,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/sections/generate", response_model=SectionGenerateResponse)
|
||||
async def generate_sections(
|
||||
project_id: UUID, request: SectionGenerateRequest
|
||||
) -> SectionGenerateResponse | JSONResponse:
|
||||
"""확정 경로에서 종횡단을 생성·저장하고 DB에 기록한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
if not latest or latest["id"] != request.route_id:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "대상 경로를 찾을 수 없습니다."},
|
||||
)
|
||||
route_data_path = latest["route_data_path"]
|
||||
|
||||
options = _build_options(request)
|
||||
design = await asyncio.to_thread(
|
||||
run_section_generation,
|
||||
project_root,
|
||||
route_data_path,
|
||||
request.filter_key,
|
||||
request.method,
|
||||
request.smooth,
|
||||
options=options,
|
||||
crs=request.crs,
|
||||
)
|
||||
|
||||
await connection.begin()
|
||||
try:
|
||||
await delete_sections_for_route(connection, request.route_id)
|
||||
longitudinal_id = await create_longitudinal_section(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
route_id=request.route_id,
|
||||
data=design["longitudinal"]["data"],
|
||||
longitudinal_file_path=design["longitudinal"]["file_path"],
|
||||
)
|
||||
await insert_cross_sections(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
route_id=request.route_id,
|
||||
sections=design["cross_sections"],
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
return SectionGenerateResponse(
|
||||
project_id=str(project_id),
|
||||
route_id=request.route_id,
|
||||
longitudinal_id=longitudinal_id,
|
||||
cross_section_count=len(design["cross_sections"]),
|
||||
length_m=design["longitudinal"]["data"]["length_m"],
|
||||
longitudinal_file_path=design["longitudinal"]["file_path"],
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B06 종횡단 생성 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "종횡단 생성 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/sections/{route_id}", response_model=SectionSummaryResponse)
|
||||
async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryResponse | JSONResponse:
|
||||
"""경로의 종단면 요약을 조회한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
|
||||
return SectionSummaryResponse(
|
||||
project_id=str(project_id), route_id=route_id, longitudinal=longitudinal
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B06 종횡단 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "종횡단 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse)
|
||||
async def confirm_sections(project_id: UUID, route_id: int) -> SectionConfirmResponse | JSONResponse:
|
||||
"""경로의 종횡단면을 확정(CONFIRMED)한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
existing = await get_longitudinal_section(connection, project_id, route_id)
|
||||
if not existing:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "확정할 종횡단이 없습니다."},
|
||||
)
|
||||
await connection.begin()
|
||||
try:
|
||||
await confirm_sections_for_route(connection, route_id)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return SectionConfirmResponse(project_id=str(project_id), route_id=route_id)
|
||||
except Exception:
|
||||
logger.exception("B06 종횡단 확정 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "종횡단 확정 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""B06 종횡단 생성 요청·응답 검증 모델."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SectionGenerateRequest(BaseModel):
|
||||
"""종횡단 생성 실행 요청."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
route_id: int = Field(gt=0, description="종횡단을 생성할 확정 경로 routes.id")
|
||||
filter_key: str = Field(description="지면 필터 키 (grid_min_z/csf/pmf)")
|
||||
method: str = Field(default="dtm", description="지표면 표현")
|
||||
smooth: bool = Field(default=False)
|
||||
crs: str | None = Field(default=None, description="좌표계 (예: EPSG:5178)")
|
||||
|
||||
# 측점/횡단 옵션 (미지정 시 config 기본값)
|
||||
station_interval_m: float | None = Field(default=None, gt=0)
|
||||
cross_half_width_m: float | None = Field(default=None, gt=0)
|
||||
cross_sample_interval_m: float | None = Field(default=None, gt=0)
|
||||
long_sample_interval_m: float | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class SectionGenerateResponse(BaseModel):
|
||||
"""종횡단 생성 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
route_id: int
|
||||
longitudinal_id: int
|
||||
cross_section_count: int
|
||||
length_m: float
|
||||
longitudinal_file_path: str
|
||||
|
||||
|
||||
class SectionConfirmResponse(BaseModel):
|
||||
"""종횡단 확정 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
route_id: int
|
||||
confirmed: bool = True
|
||||
|
||||
|
||||
class SectionSummaryResponse(BaseModel):
|
||||
"""종횡단 요약 조회 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
route_id: int
|
||||
longitudinal: dict[str, Any] | None = None
|
||||
Reference in New Issue
Block a user