feat(B03): 계획노선 shapefile 입력과 PRJ 2개 분리를 지원한다
원청 정식 계획노선이 shapefile(UTM-K)로, 지형이 별도 PRJ(동부원점 Bessel)로 들어오는데 입력 경로가 shapefile 확장자를 막고 PRJ를 프로젝트당 1개로 전제했다. - 업로드 허용에 .shp/.shx/.dbf/.cpg 추가, 한 번에 보낼 파일 수 5 -> 10 - B03_FileInput_Engine_Shapefile: ESRI 규격 직접 파싱(GDAL 미사용). 형제 파일이 아직 안 왔어도 .shp 하나로 기하를 읽는다. .cpg 내용이 949뿐인 실물을 CP949로 정규화해 한글 속성을 살린다. - 노선 판독을 read_planned_route로 일원화(CSV/shapefile), PlannedRoute에 crs_input 추가 - 변환 입력은 EPSG 코드가 아니라 crs_input_from_prj가 주는 값(EPSG:n 또는 원문 WKT)이다. 실물 PRJ 2종 모두 to_epsg가 None이다. - shapefile 세트를 input/shp/ 한 폴더에 모은다(GDAL 요건). 노선 PRJ가 그 안에 남으므로 지형 PRJ(input/prj/)와 파일명 정렬 운에 기대지 않고 갈린다. find_project_prj가 지형 PRJ를 프로젝트 좌표계로 고른다. - 필수 세트를 노선 1종(csv 또는 shp) + prj + tfw로 완화, shp면 shx/dbf 동반 필수. - UI: 확장자 단독 슬롯 매칭을 basename 그룹핑으로 바꿔 노선 PRJ와 지형 PRJ가 같은 슬롯을 다투지 않게 하고, 노선 슬롯이 파일 한 벌을 담아 함께 전송한다. 자체검증: tmp/tests/test_route_shapefile_input.py 9개 통과, tsc --noEmit 통과, ruff check/format 통과. 전체 스위트 잔여 실패 11건은 HEAD 사본(git archive)에서 동일하게 재현되는 기존 실패다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
정의한다. 어느 한쪽 페이지 폴더에 두면 반대 방향 import가 생긴다.
|
||||
|
||||
노선 원천은 두 가지다.
|
||||
· B03에 업로드된 **계획 노선 파일**(CSV) — 배수유역 분석의 입력
|
||||
· B03에 업로드된 **계획 노선 파일**(CSV·shapefile) — 배수유역 분석의 입력
|
||||
· DB `route_points` — B05에서 탐색·확정한 노선
|
||||
둘 다 같은 `RouteVertex` 목록으로 바꿔 아래 함수들이 그대로 받는다.
|
||||
"""
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import csv
|
||||
import logging
|
||||
import math
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -60,6 +61,10 @@ class PlannedRoute:
|
||||
epsg: int | None
|
||||
name: str | None
|
||||
source: Path
|
||||
# `Transformer.from_crs` 입력 문자열("EPSG:n" 또는 **원문 WKT**). 좌표계를 EPSG 코드로
|
||||
# 가리면 라벨이 안 붙는 PRJ가 통째로 막히므로, 변환은 언제나 이 값으로 한다.
|
||||
# `epsg`는 표시·로그용 라벨일 뿐이다 (2026-08-31 사용자 확정).
|
||||
crs_input: str | None = None
|
||||
|
||||
@property
|
||||
def line(self) -> LineString:
|
||||
@@ -109,17 +114,100 @@ def read_planned_route_csv(path: Path) -> PlannedRoute | None:
|
||||
logger.info(
|
||||
"계획 노선 %s: 정점 %d개, 연장 %.0fm, EPSG %s", path.name, len(vertices), cumulative, epsg
|
||||
)
|
||||
return PlannedRoute(vertices=vertices, epsg=epsg, name=name, source=path)
|
||||
return PlannedRoute(
|
||||
vertices=vertices,
|
||||
epsg=epsg,
|
||||
name=name,
|
||||
source=path,
|
||||
crs_input=f"EPSG:{epsg}" if epsg else None,
|
||||
)
|
||||
|
||||
|
||||
def read_planned_route_shapefile(path: Path) -> PlannedRoute | None:
|
||||
"""계획 노선 shapefile을 읽어 정점 목록으로 바꾼다.
|
||||
|
||||
원청 자료는 **2차원 폴리라인**이라 z가 없다. 지반고는 확정 서피스에서 뽑으므로
|
||||
(`build_surface_sampler`) 여기서는 0으로 채운다 — CSV의 z와 같은 취급이다.
|
||||
파트가 여럿이면 가장 긴 것을 노선으로 본다.
|
||||
"""
|
||||
from B03_FileInput.B03_FileInput_Engine_Shapefile import (
|
||||
read_shapefile_attributes,
|
||||
read_shapefile_parts,
|
||||
shapefile_crs_input,
|
||||
shapefile_epsg_label,
|
||||
)
|
||||
|
||||
try:
|
||||
parts = read_shapefile_parts(path)
|
||||
except (OSError, ValueError, struct.error) as error:
|
||||
logger.warning("계획 노선 shapefile을 읽지 못했습니다: %s (%s)", path, error)
|
||||
return None
|
||||
if not parts:
|
||||
logger.warning("계획 노선 shapefile에 폴리라인이 없습니다: %s", path)
|
||||
return None
|
||||
|
||||
points = max(parts, key=len)
|
||||
if len(points) < 2:
|
||||
logger.warning("계획 노선 shapefile에 좌표가 2점 미만입니다: %s", path)
|
||||
return None
|
||||
|
||||
vertices: list[RouteVertex] = []
|
||||
cumulative = 0.0
|
||||
previous: tuple[float, float] | None = None
|
||||
for x, y, z in points:
|
||||
if previous is not None:
|
||||
cumulative += math.dist(previous, (x, y))
|
||||
vertices.append(RouteVertex(x=x, y=y, z=z, chainage_m=cumulative))
|
||||
previous = (x, y)
|
||||
|
||||
attributes = read_shapefile_attributes(path)
|
||||
name = next(
|
||||
(
|
||||
attributes[key]
|
||||
for key in ("대상지", "노선명", "route_name", "name")
|
||||
if attributes.get(key)
|
||||
),
|
||||
path.stem,
|
||||
)
|
||||
epsg = shapefile_epsg_label(path)
|
||||
logger.info(
|
||||
"계획 노선 %s: 정점 %d개, 연장 %.0fm, EPSG %s(라벨)",
|
||||
path.name,
|
||||
len(vertices),
|
||||
cumulative,
|
||||
epsg,
|
||||
)
|
||||
return PlannedRoute(
|
||||
vertices=vertices,
|
||||
epsg=epsg,
|
||||
name=name,
|
||||
source=path,
|
||||
crs_input=shapefile_crs_input(path),
|
||||
)
|
||||
|
||||
|
||||
def read_planned_route(path: Path) -> PlannedRoute | None:
|
||||
"""계획 노선 파일을 확장자에 맞는 판독기로 읽는다(CSV·shapefile)."""
|
||||
if path.suffix.lower() == ".shp":
|
||||
return read_planned_route_shapefile(path)
|
||||
return read_planned_route_csv(path)
|
||||
|
||||
|
||||
def find_planned_route_file(input_dir: Path) -> Path | None:
|
||||
"""B03 입력 폴더에서 계획 노선 파일을 찾는다. 여러 개면 가장 최근 것."""
|
||||
"""B03 입력 폴더에서 계획 노선 파일을 찾는다.
|
||||
|
||||
shapefile을 CSV보다 우선한다 — 원청 정식 노선이 shapefile로 오고, CSV는 예전
|
||||
자료거나 좌표만 뽑아 둔 보조본인 경우가 많다. 같은 종류가 여럿이면 가장 최근 것.
|
||||
"""
|
||||
if not input_dir.exists():
|
||||
return None
|
||||
candidates = sorted(
|
||||
input_dir.rglob("*.csv"), key=lambda item: item.stat().st_mtime, reverse=True
|
||||
)
|
||||
return candidates[0] if candidates else None
|
||||
for pattern in ("*.shp", "*.csv"):
|
||||
candidates = sorted(
|
||||
input_dir.rglob(pattern), key=lambda item: item.stat().st_mtime, reverse=True
|
||||
)
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
return None
|
||||
|
||||
|
||||
def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]:
|
||||
|
||||
Reference in New Issue
Block a user