This commit is contained in:
2026-07-05 21:27:23 +09:00
parent 23d907265a
commit 3abc2edba6
83 changed files with 10351 additions and 1217 deletions
@@ -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