refactor(B04/B05): 배수유역 분석을 B04로 이관, B05는 저장분 소비만
분석이 30초 걸리는데 B05는 일반 사용자 화면이다. 관리자 확인용 B04에서
한 번 돌려 저장하고, B05는 그 결과를 읽어 관 보충과 세부유역만 처리한다
(2026-07-31 사용자 지시).
노선 원천 변경
- B05 확정 경로 -> B03 업로드 계획 노선 파일(CSV). 분석이 노선 설계보다
먼저 끝나 있어야 하기 때문. 샘플 planned_route_sample_epsg5187.csv 로 검증.
- common_util_route_geometry.py 신설 — RouteVertex/StructureCandidate/누가거리
보간/세류 교차점/계획 노선 CSV 리더. B04와 B05가 같은 표현을 쓰도록 공용화.
열 이름은 대소문자·한글 표기를 함께 받는다(B03이 여러 형식 수용 예정).
B04 (관리자 확인용, 신규)
- Engine_Watershed_{Grid,Stream,Descent,Flow,Expand,Export} — B05에서 git mv
- Engine_Watershed_Analyze.py — 1~8단계 오케스트레이션
- Router_Watershed.py — GET /drainage/primary-region
- UI_Watershed.ts — 2D 지도 GIS 레이어 그룹에 "배수유역" 토글 추가.
격자/화살표/세류망/1차영역/2차유역/기본관을 겹쳐 그린다.
- 저장 위치 B05_wf2_Route/drainage -> B04_wf1_Surface/drainage
- 03_road_routing 단계 추가: B05가 세부유역을 나눌 최소 배열(셀->도로셀 귀속,
유하장, 강도, 도로셀 제원, 셀 표고) + 계획도로선/기본배관/2차유역 기하
B05 (일반 사용자용, 축소)
- Engine_Drainage_Basin.py — B04 산출물 로더 + 관 보충(9) + 측구 라우팅/세부유역(10,11)
- Engine_Drainage.py 는 관경 산정만 남기고 322 -> 27줄
- Router_Drainage.py 509 -> 142줄. POST /drainage/basins 만 남김
- 화살표·격자·강도 띠 렌더 제거. 계획도로선/기본배관/2차유역만 받는다
삭제
- _legacy_watershed/ 4파일 (능선 행진 방식 원본 보관본)
- Engine_Watershed_Basin.py (B04 Analyze + B05 Drainage_Basin 으로 분할)
- GET /drainage/candidates 와 propose_structure_stations (구방식 후보 제안)
E2E 검증 (실데이터)
B04 분석 28.2s -> 저장(geojson 11KB + npz 2.6MB)
B05 로드 + 세부 설계 0.11s <-- 30초가 0.1초로
면적 457,404m2 로 B04 2차 유역과 정확히 일치
관 편집 재산정 0.12s, 관 3개 -> 세부유역 3개, 면적 보존
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -120,12 +120,17 @@ export async function finalizeUploadSession(
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
totalChunks: number,
|
||||
completeUpload: boolean,
|
||||
): Promise<FileUploadResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId, total_chunks: totalChunks }),
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
total_chunks: totalChunks,
|
||||
complete_upload: completeUpload,
|
||||
}),
|
||||
});
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""B03 원본 입력 파일 메타데이터 분석."""
|
||||
|
||||
import csv
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
@@ -269,10 +270,121 @@ def analyze_tif_metadata(path: str | Path) -> dict[str, Any]:
|
||||
rasterio_logger.removeFilter(warning_filter)
|
||||
|
||||
|
||||
_PLANNED_ROUTE_COLUMNS = ("route_name", "sequence", "x", "y", "z", "crs_epsg")
|
||||
|
||||
|
||||
def _parse_route_integer(value: str, *, field: str, row_number: int) -> int:
|
||||
normalized = value.strip()
|
||||
if not re.fullmatch(r"[0-9]+", normalized):
|
||||
raise ValueError(f"CSV {row_number}행의 {field} 값은 양의 정수여야 합니다.")
|
||||
parsed = int(normalized)
|
||||
if parsed <= 0:
|
||||
raise ValueError(f"CSV {row_number}행의 {field} 값은 양의 정수여야 합니다.")
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_route_coordinate(value: str, *, field: str, row_number: int) -> float:
|
||||
try:
|
||||
parsed = float(value.strip())
|
||||
except (AttributeError, ValueError) as exc:
|
||||
raise ValueError(f"CSV {row_number}행의 {field} 값은 숫자여야 합니다.") from exc
|
||||
if not math.isfinite(parsed):
|
||||
raise ValueError(f"CSV {row_number}행의 {field} 값은 유한한 숫자여야 합니다.")
|
||||
return parsed
|
||||
|
||||
|
||||
def analyze_planned_route_csv(path: str | Path) -> dict[str, Any]:
|
||||
"""원청 계획노선 CSV를 검증하고 경로 메타데이터를 반환한다."""
|
||||
source = Path(path)
|
||||
with source.open("r", encoding="utf-8-sig", newline="") as csv_file:
|
||||
reader = csv.DictReader(csv_file)
|
||||
if reader.fieldnames is None:
|
||||
raise ValueError("계획노선 CSV 헤더를 찾을 수 없습니다.")
|
||||
|
||||
normalized_headers = [header.strip() for header in reader.fieldnames]
|
||||
if len(set(normalized_headers)) != len(normalized_headers):
|
||||
raise ValueError("계획노선 CSV 헤더에 중복된 열이 있습니다.")
|
||||
header_map = dict(zip(normalized_headers, reader.fieldnames, strict=True))
|
||||
missing = [column for column in _PLANNED_ROUTE_COLUMNS if column not in header_map]
|
||||
if missing:
|
||||
raise ValueError(f"계획노선 CSV 필수 열이 없습니다: {', '.join(missing)}")
|
||||
|
||||
route_name: str | None = None
|
||||
crs_epsg: int | None = None
|
||||
points: list[tuple[float, float, float]] = []
|
||||
for expected_sequence, row in enumerate(reader, start=1):
|
||||
row_number = expected_sequence + 1
|
||||
current_name = (row.get(header_map["route_name"]) or "").strip()
|
||||
if not current_name:
|
||||
raise ValueError(f"CSV {row_number}행의 route_name 값이 비어 있습니다.")
|
||||
if route_name is None:
|
||||
route_name = current_name
|
||||
elif current_name != route_name:
|
||||
raise ValueError("계획노선 CSV에는 하나의 route_name만 사용할 수 있습니다.")
|
||||
|
||||
sequence = _parse_route_integer(
|
||||
row.get(header_map["sequence"]) or "",
|
||||
field="sequence",
|
||||
row_number=row_number,
|
||||
)
|
||||
if sequence != expected_sequence:
|
||||
raise ValueError(
|
||||
f"CSV {row_number}행의 sequence는 {expected_sequence}이어야 합니다."
|
||||
)
|
||||
|
||||
current_epsg = _parse_route_integer(
|
||||
row.get(header_map["crs_epsg"]) or "",
|
||||
field="crs_epsg",
|
||||
row_number=row_number,
|
||||
)
|
||||
if crs_epsg is None:
|
||||
crs_epsg = current_epsg
|
||||
elif current_epsg != crs_epsg:
|
||||
raise ValueError("계획노선 CSV의 crs_epsg는 모든 행에서 같아야 합니다.")
|
||||
|
||||
points.append(
|
||||
tuple(
|
||||
_parse_route_coordinate(
|
||||
row.get(header_map[field]) or "",
|
||||
field=field,
|
||||
row_number=row_number,
|
||||
)
|
||||
for field in ("x", "y", "z")
|
||||
)
|
||||
)
|
||||
|
||||
if len(points) < 2:
|
||||
raise ValueError("계획노선 CSV에는 좌표가 2개 이상 있어야 합니다.")
|
||||
|
||||
xs, ys, zs = zip(*points, strict=True)
|
||||
return {
|
||||
"file": source.name,
|
||||
"extension": "csv",
|
||||
"size_bytes": source.stat().st_size,
|
||||
"purpose": "planned_route",
|
||||
"route_name": route_name,
|
||||
"point_count": len(points),
|
||||
"epsg": crs_epsg,
|
||||
"columns": list(_PLANNED_ROUTE_COLUMNS),
|
||||
"bounds": {
|
||||
"x_min": min(xs),
|
||||
"x_max": max(xs),
|
||||
"y_min": min(ys),
|
||||
"y_max": max(ys),
|
||||
"z_min": min(zs),
|
||||
"z_max": max(zs),
|
||||
},
|
||||
"start_point": list(points[0]),
|
||||
"end_point": list(points[-1]),
|
||||
}
|
||||
|
||||
|
||||
def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""입력 파일 확장자에 맞는 B03 메타데이터 분석 함수를 호출한다."""
|
||||
source = Path(path)
|
||||
extension = source.suffix.lower()
|
||||
if extension == ".csv":
|
||||
return analyze_planned_route_csv(source)
|
||||
if extension in {".las", ".laz"}:
|
||||
return analyze_las_metadata(source)
|
||||
if extension == ".prj":
|
||||
|
||||
@@ -61,6 +61,31 @@ async def create_input_file(
|
||||
return int(input_file_id)
|
||||
|
||||
|
||||
async def get_project_input_readiness(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
) -> tuple[set[str], int | None]:
|
||||
"""현재 업로드 파일 유형과 최신 포인트클라우드 입력 ID를 반환한다."""
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT id, LOWER(file_type) AS file_type
|
||||
FROM input_files
|
||||
WHERE project_id = %s AND status IN ('UPLOADED', 'PROCESSED')
|
||||
ORDER BY id DESC
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
file_types = {str(row["file_type"]) for row in rows if row.get("file_type")}
|
||||
point_cloud_id = next(
|
||||
(int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}),
|
||||
None,
|
||||
)
|
||||
return file_types, point_cloud_id
|
||||
|
||||
|
||||
async def get_project_storage_relative_path(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""B03 파일 입력 FastAPI 라우터."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -25,6 +26,7 @@ from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata
|
||||
from B03_FileInput.B03_FileInput_Repository import (
|
||||
create_input_file,
|
||||
create_upload_session,
|
||||
get_project_input_readiness,
|
||||
get_project_storage_relative_path,
|
||||
get_upload_session,
|
||||
list_completed_chunk_indexes,
|
||||
@@ -60,13 +62,69 @@ from config.config_system import (
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B03 File Input"])
|
||||
|
||||
_REQUIRED_FILE_TYPES = frozenset({"csv", "prj", "tfw"})
|
||||
_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
|
||||
|
||||
|
||||
def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int:
|
||||
return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes)
|
||||
|
||||
|
||||
def _is_point_cloud_result(result: UploadedFileResult) -> bool:
|
||||
return result.file_type.lower() in {"las", "laz"}
|
||||
return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES
|
||||
|
||||
|
||||
def _missing_required_file_types(file_types: set[str]) -> list[str]:
|
||||
missing = sorted(_REQUIRED_FILE_TYPES - file_types)
|
||||
if not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
|
||||
missing.append("las/laz")
|
||||
return missing
|
||||
|
||||
|
||||
def _require_complete_file_set(file_types: set[str]) -> None:
|
||||
missing = _missing_required_file_types(file_types)
|
||||
if missing:
|
||||
raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}")
|
||||
|
||||
|
||||
async def _complete_file_input_if_ready(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
) -> int:
|
||||
file_types, point_cloud_input_id = await get_project_input_readiness(connection, project_id)
|
||||
_require_complete_file_set(file_types)
|
||||
if point_cloud_input_id is None:
|
||||
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
|
||||
async with connection.cursor() as cursor:
|
||||
await complete_stage(cursor, str(project_id), 0)
|
||||
return point_cloud_input_id
|
||||
|
||||
|
||||
def _write_stage_metadata(
|
||||
stage_root: Path,
|
||||
project_id: UUID,
|
||||
results: list[UploadedFileResult],
|
||||
) -> None:
|
||||
metadata_path = stage_root / "metadata.json"
|
||||
existing_files: list[dict[str, Any]] = []
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
existing_files = list(payload.get("files") or [])
|
||||
except (OSError, TypeError, ValueError):
|
||||
logger.warning("B03 metadata.json을 읽지 못해 새로 작성합니다: %s", metadata_path)
|
||||
|
||||
merged = {
|
||||
str(item.get("relative_path") or item.get("original_filename")): item
|
||||
for item in existing_files
|
||||
}
|
||||
for result in results:
|
||||
dumped = result.model_dump()
|
||||
merged[result.relative_path] = dumped
|
||||
atomic_write_json(
|
||||
metadata_path,
|
||||
{"project_id": str(project_id), "files": list(merged.values())},
|
||||
)
|
||||
|
||||
|
||||
def _schedule_background_task(coro: Any, *, task_name: str) -> None:
|
||||
@@ -170,11 +228,31 @@ async def upload_project_files(
|
||||
"message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.",
|
||||
},
|
||||
)
|
||||
csv_count = sum(Path(filename).suffix.lower() == ".csv" for filename in filenames)
|
||||
if csv_count != 1:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "계획노선 CSV 파일을 정확히 1개 포함해야 합니다.",
|
||||
},
|
||||
)
|
||||
request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames}
|
||||
missing_required = _missing_required_file_types(request_file_types)
|
||||
if missing_required:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": f"B03 필수 입력 파일이 없습니다: {', '.join(missing_required)}",
|
||||
},
|
||||
)
|
||||
|
||||
pool = get_db_pool()
|
||||
saved_paths: list[Path] = []
|
||||
try:
|
||||
results: list[UploadedFileResult] = []
|
||||
point_cloud_input_id: int | None = None
|
||||
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
@@ -219,18 +297,14 @@ async def upload_project_files(
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await complete_stage(cursor, str(project_id), 0)
|
||||
point_cloud_input_id = await _complete_file_input_if_ready(connection, project_id)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
stage_root = project_root / "B03_FileInput"
|
||||
atomic_write_json(
|
||||
stage_root / "metadata.json",
|
||||
{"project_id": str(project_id), "files": [result.model_dump() for result in results]},
|
||||
)
|
||||
_write_stage_metadata(stage_root, project_id, results)
|
||||
workflow_path = project_root / "workflow.json"
|
||||
if not workflow_path.exists():
|
||||
atomic_write_json(workflow_path, load_project_workflow(project_root))
|
||||
@@ -246,10 +320,11 @@ async def upload_project_files(
|
||||
),
|
||||
task_name=f"b03-upload-email-{project_id}",
|
||||
)
|
||||
if point_cloud_input_id is not None:
|
||||
_schedule_background_task(
|
||||
trigger_wf1_analysis_and_email(
|
||||
project_id=project_id,
|
||||
input_file_id=point_cloud_result.input_file_id,
|
||||
input_file_id=point_cloud_input_id,
|
||||
user_role=str(session["role"]),
|
||||
),
|
||||
task_name=f"b04-wf1-auto-{project_id}",
|
||||
@@ -390,6 +465,7 @@ async def finalize_project_upload(
|
||||
"""청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다."""
|
||||
pool = get_db_pool()
|
||||
final_path: Path | None = None
|
||||
point_cloud_input_id: int | None = None
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
session = await get_upload_session(
|
||||
@@ -444,8 +520,11 @@ async def finalize_project_upload(
|
||||
metadata=metadata,
|
||||
)
|
||||
await mark_upload_session_completed(connection, session_id=payload.session_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await complete_stage(cursor, str(project_id), 0)
|
||||
if payload.complete_upload:
|
||||
point_cloud_input_id = await _complete_file_input_if_ready(
|
||||
connection,
|
||||
project_id,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
@@ -462,10 +541,7 @@ async def finalize_project_upload(
|
||||
metadata=metadata,
|
||||
)
|
||||
stage_root = project_root / "B03_FileInput"
|
||||
atomic_write_json(
|
||||
stage_root / "metadata.json",
|
||||
{"project_id": str(project_id), "files": [result.model_dump()]},
|
||||
)
|
||||
_write_stage_metadata(stage_root, project_id, [result])
|
||||
if _is_point_cloud_result(result):
|
||||
_schedule_background_task(
|
||||
_send_upload_complete_notification(
|
||||
@@ -474,10 +550,11 @@ async def finalize_project_upload(
|
||||
),
|
||||
task_name=f"b03-upload-email-{project_id}",
|
||||
)
|
||||
if point_cloud_input_id is not None:
|
||||
_schedule_background_task(
|
||||
trigger_wf1_analysis_and_email(
|
||||
project_id=project_id,
|
||||
input_file_id=result.input_file_id,
|
||||
input_file_id=point_cloud_input_id,
|
||||
user_role=str(session["role"]),
|
||||
),
|
||||
task_name=f"b04-wf1-auto-{project_id}",
|
||||
|
||||
@@ -89,6 +89,7 @@ class UploadFinalizeRequest(BaseModel):
|
||||
|
||||
session_id: str = Field(min_length=1, max_length=36)
|
||||
total_chunks: int = Field(gt=0)
|
||||
complete_upload: bool = True
|
||||
|
||||
|
||||
class UploadStatusResponse(BaseModel):
|
||||
|
||||
@@ -368,6 +368,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
async function uploadOneFile(
|
||||
projectId: string,
|
||||
state: FileSlotState,
|
||||
completeUpload: boolean,
|
||||
): Promise<UploadedFileResult[]> {
|
||||
const file = state.file;
|
||||
if (!file) return [];
|
||||
@@ -421,7 +422,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await finalizeUploadSession(projectId, session, totalChunks);
|
||||
const response = await finalizeUploadSession(projectId, session, totalChunks, completeUpload);
|
||||
localStorage.removeItem(storageKey);
|
||||
saveB03UploadedFile(projectId, {
|
||||
slot: state.slot,
|
||||
@@ -470,8 +471,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
pageError.textContent = "";
|
||||
const uploaded: UploadedFileResult[] = [];
|
||||
try {
|
||||
for (const state of targetStates) {
|
||||
uploaded.push(...(await uploadOneFile(activeProjectId, state)));
|
||||
for (let index = 0; index < targetStates.length; index += 1) {
|
||||
const state = targetStates[index];
|
||||
uploaded.push(
|
||||
...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1)),
|
||||
);
|
||||
}
|
||||
renderUploadResults(uploaded);
|
||||
showToast(L("B03_File_Upload_Success"), "success");
|
||||
@@ -544,11 +548,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
uploadControlPanel.className = "b03-file__control-panel";
|
||||
uploadControlPanel.append(subtitle, dropzone, resumeBanner, pageError, uploadButton, resultList);
|
||||
|
||||
const filesGroup = createCardGroup("", ["las_laz", "prj", "tfw", "tif"]); // 타이틀 공백으로 전달
|
||||
const routeGroup = createCardGroup(L("B03_File_Group_Route"), ["csv"]);
|
||||
routeGroup.classList.add("b03-file__group--route");
|
||||
const filesGroup = createCardGroup(L("B03_File_Group_Terrain"), ["las_laz", "prj", "tfw", "tif"]);
|
||||
|
||||
const cardsContainer = document.createElement("div");
|
||||
cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||||
cardsContainer.append(filesGroup);
|
||||
cardsContainer.append(routeGroup, filesGroup);
|
||||
|
||||
const workflowState = activeProjectId
|
||||
? await fetchWorkflowState(activeProjectId).catch(() => undefined)
|
||||
|
||||
@@ -136,6 +136,15 @@
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b03-file__group--route .b03-file__group-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.b03-file__group--route .b03-file__card {
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-mist-violet, #edecff);
|
||||
}
|
||||
|
||||
/* Wiza 8px radius 카드 */
|
||||
.b03-file__card {
|
||||
min-height: 220px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ui_locales } from "@ui/ui_template_locale";
|
||||
|
||||
export type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf";
|
||||
export type FileSlot = "csv" | "las_laz" | "prj" | "tfw" | "tif" | "dxf";
|
||||
export type UploadStatus = "pending" | "uploading" | "completed" | "failed";
|
||||
|
||||
export interface SlotConfig {
|
||||
@@ -35,6 +35,13 @@ export interface StoredUploadSession {
|
||||
}
|
||||
|
||||
const SLOT_CONFIGS: readonly SlotConfig[] = [
|
||||
{
|
||||
slot: "csv",
|
||||
labelKey: "B03_File_Slot_PlannedRoute",
|
||||
icon: "⌁",
|
||||
extensions: [".csv"],
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
slot: "las_laz",
|
||||
labelKey: "B03_File_Slot_PointCloud",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_planned_route_csv
|
||||
|
||||
|
||||
class PlannedRouteCsvTest(unittest.TestCase):
|
||||
def analyze(self, content: str) -> dict:
|
||||
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||
path = Path(temporary_dir) / "planned_route.csv"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return analyze_planned_route_csv(path)
|
||||
|
||||
def test_valid_route_returns_metadata(self) -> None:
|
||||
metadata = self.analyze(
|
||||
"route_name,sequence,x,y,z,crs_epsg\n"
|
||||
"sample,1,183493.5,489290.335,544.659,5187\n"
|
||||
"sample,2,183500.0,489300.0,545.0,5187\n"
|
||||
)
|
||||
|
||||
self.assertEqual(metadata["purpose"], "planned_route")
|
||||
self.assertEqual(metadata["route_name"], "sample")
|
||||
self.assertEqual(metadata["point_count"], 2)
|
||||
self.assertEqual(metadata["epsg"], 5187)
|
||||
self.assertEqual(metadata["start_point"], [183493.5, 489290.335, 544.659])
|
||||
|
||||
def test_missing_header_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "필수 열"):
|
||||
self.analyze("route_name,sequence,x,y,crs_epsg\nsample,1,183493.5,489290.335,5187\n")
|
||||
|
||||
def test_non_numeric_coordinate_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "x 값은 숫자"):
|
||||
self.analyze(
|
||||
"route_name,sequence,x,y,z,crs_epsg\n"
|
||||
"sample,1,not-a-number,489290.335,544.659,5187\n"
|
||||
"sample,2,183500.0,489300.0,545.0,5187\n"
|
||||
)
|
||||
|
||||
def test_non_contiguous_sequence_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "sequence는 2"):
|
||||
self.analyze(
|
||||
"route_name,sequence,x,y,z,crs_epsg\n"
|
||||
"sample,1,183493.5,489290.335,544.659,5187\n"
|
||||
"sample,3,183500.0,489300.0,545.0,5187\n"
|
||||
)
|
||||
|
||||
def test_mixed_epsg_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "모든 행에서 같아야"):
|
||||
self.analyze(
|
||||
"route_name,sequence,x,y,z,crs_epsg\n"
|
||||
"sample,1,183493.5,489290.335,544.659,5187\n"
|
||||
"sample,2,183500.0,489300.0,545.0,5186\n"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from B03_FileInput.B03_FileInput_Router import (
|
||||
_missing_required_file_types,
|
||||
_write_stage_metadata,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Schema import UploadedFileResult
|
||||
|
||||
|
||||
class B03RouterHelperTest(unittest.TestCase):
|
||||
def test_required_file_types_include_planned_route(self) -> None:
|
||||
self.assertEqual(
|
||||
_missing_required_file_types({"las", "prj", "tfw"}),
|
||||
["csv"],
|
||||
)
|
||||
self.assertEqual(
|
||||
_missing_required_file_types({"csv", "laz", "prj", "tfw"}),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_stage_metadata_preserves_existing_files(self) -> None:
|
||||
project_id = UUID("acb9170b-9ac8-49b3-82a0-51cfa32bb42d")
|
||||
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||
stage_root = Path(temporary_dir)
|
||||
(stage_root / "metadata.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"project_id": str(project_id),
|
||||
"files": [
|
||||
{
|
||||
"original_filename": "terrain.las",
|
||||
"relative_path": "B03_FileInput/input/las/terrain.las",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
route = UploadedFileResult(
|
||||
input_file_id=100,
|
||||
original_filename="planned_route.csv",
|
||||
file_type="csv",
|
||||
relative_path="B03_FileInput/input/csv/planned_route.csv",
|
||||
size_bytes=1000,
|
||||
metadata={"purpose": "planned_route", "epsg": 5187},
|
||||
)
|
||||
|
||||
_write_stage_metadata(stage_root, project_id, [route])
|
||||
|
||||
payload = json.loads((stage_root / "metadata.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(len(payload["files"]), 2)
|
||||
self.assertEqual(payload["files"][1]["metadata"]["purpose"], "planned_route")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,7 +11,7 @@
|
||||
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
|
||||
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
|
||||
export interface SurfaceAnalyzeRequest {
|
||||
@@ -111,10 +111,17 @@ export interface SurfaceModelListResponse {
|
||||
models: SurfaceModelSummary[];
|
||||
}
|
||||
|
||||
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
|
||||
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환.
|
||||
*
|
||||
* `timeoutMs`를 주면 그 값으로 끊는다. 배수유역 격자 해석처럼 수십 초가 걸리는 요청은
|
||||
* `API_ANALYSIS_TIMEOUT_MS`를 넘긴다 — 기본값으로 두면 계산 도중 abort 된다. */
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number = API_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
@@ -130,6 +137,12 @@ async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
// AbortError 원문("signal is aborted without reason")은 원인을 알 수 없으니 바꿔 준다.
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new Error(`요청이 ${Math.round(timeoutMs / 1000)}초 안에 끝나지 않았습니다.`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
@@ -230,3 +243,102 @@ export async function fetchGisGeoJson(projectId: string, layer: string): Promise
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 배수유역 분석 (B04_wf1_Surface_Router_Watershed.py) ────────────────────
|
||||
* 관리자 확인용. 계획 노선(B03 CSV) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고
|
||||
* 결과를 영구저장소에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 돌린다.
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
/** 관 매설 지점 1개. reason: stream=세류 교차, spacing=간격 보충, confirmed=사용자 확정. */
|
||||
export interface WatershedPipe {
|
||||
chainage_m: number;
|
||||
x: number;
|
||||
y: number;
|
||||
lon: number;
|
||||
lat: number;
|
||||
reason: string;
|
||||
stream_name: string | null;
|
||||
}
|
||||
|
||||
/** 1차 배수유역 근거(단계 검증용). TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 준다. */
|
||||
export interface WatershedAnalysis {
|
||||
status: string;
|
||||
project_id: string;
|
||||
/** 분석에 쓴 계획 노선 파일명(B03 업로드). */
|
||||
route_source: string;
|
||||
radius_m: number;
|
||||
/** 도로와 만난 세류선의 상류측 = 1차 영역의 기준선. */
|
||||
upstream_lines: Array<Array<[number, number]>>;
|
||||
/** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */
|
||||
downstream_lines: Array<Array<[number, number]>>;
|
||||
/** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */
|
||||
no_contact_count: number;
|
||||
/** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */
|
||||
road_outside_m: number;
|
||||
/** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */
|
||||
region_rings: Array<Array<[number, number]>>;
|
||||
grid: {
|
||||
cell_m: number;
|
||||
rows: number;
|
||||
cols: number;
|
||||
/** bbox 전체 셀 수(참고값). */
|
||||
bbox_cells: number;
|
||||
/** 1차 영역에 걸쳐 실제로 생성된 셀 수. */
|
||||
cells: number;
|
||||
width_m: number;
|
||||
height_m: number;
|
||||
/** 격자 bbox 링. 화면은 이 사각형을 rows×cols로 나눠 셀 좌표를 얻는다. */
|
||||
bbox_lonlat: Array<[number, number]>;
|
||||
/** 실제 생성된 셀 구간 [행, 시작열, 끝열(포함)]. 낱개 셀 대신 구간으로 온다. */
|
||||
row_spans: Array<[number, number, number]>;
|
||||
};
|
||||
/** 최외곽 적색 셀 주변 확장 결과. */
|
||||
expansion: {
|
||||
rounds: number;
|
||||
/** 새로 추가한 셀에 적색이 없어 스스로 멈췄는가. */
|
||||
closed: boolean;
|
||||
added_cells: number;
|
||||
/** 확장 전(1차 영역) 셀 수. */
|
||||
initial_cells: number;
|
||||
};
|
||||
/** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 null. */
|
||||
flow: {
|
||||
encoding: "base64-uint8";
|
||||
/** 방위 분해능(32). 코드 0 = 화면 오른쪽, 시계방향 증가. */
|
||||
azimuth_steps: number;
|
||||
/** 제자리(더 낮은 이웃 없음)를 뜻하는 코드. */
|
||||
sink_code: number;
|
||||
/** 표고가 없어 판정 못한 셀 코드. */
|
||||
invalid_code: number;
|
||||
cells: number;
|
||||
reaches_road: number;
|
||||
no_road: number;
|
||||
/** 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. */
|
||||
unanalyzed: number;
|
||||
/** 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. */
|
||||
burned: number;
|
||||
outer_seeds: number;
|
||||
interior_seeds: number;
|
||||
/** 셀당 1바이트. 하위 6비트=32방위 코드(32=제자리, 33=무효), 0x80=도로 도달.
|
||||
* 순서는 grid.row_spans를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. */
|
||||
data: string;
|
||||
} | null;
|
||||
/** 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체의 외곽. */
|
||||
basin_polygon_lonlat: Array<[number, number]>;
|
||||
basin_area_m2: number;
|
||||
/** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. */
|
||||
strength_profile: Array<[number, number]>;
|
||||
/** 기본 관 매설 위치 — 도로 × 세류선 교차점. */
|
||||
pipes: WatershedPipe[];
|
||||
/** 영구저장소에 남긴 검증용 GeoJSON 경로. */
|
||||
saved_to: string | null;
|
||||
}
|
||||
|
||||
export async function fetchWatershedAnalysis(projectId: string): Promise<WatershedAnalysis> {
|
||||
// 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다.
|
||||
return requestJson<WatershedAnalysis>(
|
||||
`/projects/${projectId}/drainage/primary-region`,
|
||||
{ method: "GET" },
|
||||
API_ANALYSIS_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""배수유역 분석 오케스트레이터 (B04 — 관리자 확인용 전처리).
|
||||
|
||||
계획 노선(B03 업로드 파일)과 도엽 등고선·세류선만으로 배수유역을 끝까지 분석해
|
||||
영구저장소에 남긴다. 30초 안팎이 걸리는 무거운 작업이라 여기서 한 번만 돌리고,
|
||||
일반 사용자가 쓰는 B05는 그 결과를 읽어 쓰기만 한다(2026-07-31 사용자 지시).
|
||||
|
||||
① 도로 교차 세류망 상류측 추출 → 반경 버퍼 = 1차 배수유역
|
||||
② 도로 시작점 기준 격자 생성 (1차 영역에 걸치는 셀만)
|
||||
③ 등고선 하강 방향 — 높은 등고 라인에서 낮은 등고 라인으로. 보간면을 쓰지 않으므로
|
||||
가짜 웅덩이·평탄면이 원리적으로 생기지 않는다
|
||||
④ 세류망 흐름 새김 → 최외곽부터 사슬 추적 → 도로 도달 여부(적/청) 판정
|
||||
⑤ 최외곽 적색 셀 주변 확장 — 새로 추가한 셀에 적색이 없을 때까지
|
||||
⑥ 도로 셀별 흐름 강도
|
||||
⑦ 2차 전체 배수유역 외곽선
|
||||
⑧ 기본 관 매설 위치 (도로 × 세류선 교차점)
|
||||
|
||||
⑨ 이후(관 최소 개수 보충, 세부유역 분할)는 사용자가 관을 옮길 수 있어야 하므로
|
||||
B05에 남긴다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from shapely.geometry import LineString
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Descent import ContourDescent
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Expand import expand_by_red_boundary
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import (
|
||||
FlowClassification,
|
||||
RoadRaster,
|
||||
largest_ring,
|
||||
outer_boundary,
|
||||
trace_flow,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import (
|
||||
GridSpec,
|
||||
TerrainGrid,
|
||||
build_contour_cloud,
|
||||
route_elevation_floor,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Stream import (
|
||||
PrimaryRegion,
|
||||
build_primary_region,
|
||||
)
|
||||
from common_util.common_util_route_geometry import (
|
||||
RouteVertex,
|
||||
StructureCandidate,
|
||||
find_stream_crossings,
|
||||
)
|
||||
from config.config_system import (
|
||||
DRAINAGE_GRID_SIZE_M,
|
||||
DRAINAGE_INITIAL_RADIUS_M,
|
||||
DRAINAGE_PIPE_MIN_SPACING_M,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 강도 곡선 응답 간격(m). 도로 위 흐름 강도 표기는 이 간격으로 내보낸다.
|
||||
_STRENGTH_OUTPUT_STEP_M = 5.0
|
||||
|
||||
|
||||
# ── ①~② 1차 배수유역 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_primary_region(
|
||||
vertices: list[RouteVertex],
|
||||
route_line: LineString,
|
||||
contour_features: list[dict[str, Any]],
|
||||
stream_features: list[dict[str, Any]],
|
||||
) -> PrimaryRegion | None:
|
||||
"""도로 교차 세류선(상류측)과 노선을 반경 버퍼한 1차 배수유역과 격자 범위를 정한다.
|
||||
|
||||
상·하류 판정에 쓸 등고선은 노선 주변만 있으면 된다(교차점이 전부 노선 위이므로).
|
||||
도엽 전체를 읽으면 이 단계에서만 수십 초가 날아간다.
|
||||
"""
|
||||
floor = route_elevation_floor([vertex.z for vertex in vertices])
|
||||
near_bounds = route_line.buffer(DRAINAGE_INITIAL_RADIUS_M * 2.0).bounds
|
||||
cloud = build_contour_cloud(contour_features, floor, near_bounds)
|
||||
if cloud.is_empty:
|
||||
logger.warning("배수유역: 노선 주변에 등고선이 없어 1차 영역을 정할 수 없습니다.")
|
||||
return None
|
||||
return build_primary_region(
|
||||
route_line, stream_features, cloud, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_GRID_SIZE_M
|
||||
)
|
||||
|
||||
|
||||
def preview_primary_region(
|
||||
vertices: list[RouteVertex],
|
||||
contour_features: list[dict[str, Any]],
|
||||
stream_features: list[dict[str, Any]],
|
||||
) -> PrimaryRegion | None:
|
||||
"""단계 검증용 — TIN·흐름 계산 없이 1차 배수유역 근거만 뽑는다."""
|
||||
if len(vertices) < 2:
|
||||
return None
|
||||
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
|
||||
return resolve_primary_region(vertices, route_line, contour_features, stream_features)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StagePreview:
|
||||
"""단계 검증 산출물 묶음. 기능을 붙일 때마다 여기에 항목이 하나씩 늘어난다.
|
||||
|
||||
확장을 거치면 격자와 해석 영역이 1차 영역보다 커진다. 화면·저장은 `region.spec`이
|
||||
아니라 여기 `spec`/`domain`을 봐야 한다.
|
||||
"""
|
||||
|
||||
region: PrimaryRegion
|
||||
spec: GridSpec | None = None
|
||||
domain: np.ndarray | None = None
|
||||
terrain: TerrainGrid | None = None
|
||||
road: RoadRaster | None = None
|
||||
flow: FlowClassification | None = None
|
||||
descent: ContourDescent | None = None
|
||||
expand_rounds: int = 0
|
||||
expand_closed: bool = False
|
||||
expand_added_cells: int = 0
|
||||
# ⑥ 도로 위 흐름 강도 — (누가거리 m, 그 구간으로 모이는 상류 면적 ㎡).
|
||||
strength_profile: list[tuple[float, float]] = field(default_factory=list)
|
||||
# ⑦ 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체를 폴리곤화한 것.
|
||||
basin_boundary_xy: list[tuple[float, float]] = field(default_factory=list)
|
||||
basin_area_m2: float = 0.0
|
||||
# 셀 → 도로 셀 귀속. B05가 세부유역을 나눌 때 이 배열이 있어야 한다.
|
||||
routing: Any = None
|
||||
# ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점.
|
||||
pipes: list[StructureCandidate] = field(default_factory=list)
|
||||
|
||||
|
||||
def preview_stages(
|
||||
vertices: list[RouteVertex],
|
||||
contour_features: list[dict[str, Any]],
|
||||
stream_features: list[dict[str, Any]],
|
||||
) -> StagePreview | None:
|
||||
"""지금까지 구현·검증된 단계를 순서대로 돌려 결과를 모은다.
|
||||
|
||||
현재 포함: ① 1차 배수유역 ② 격자 생성 ③ **등고선 하강 방향** ④ 도로 도달 판정
|
||||
⑤ **최외곽 적색 셀 주변 확장**.
|
||||
|
||||
③은 보간면(TIN)을 쓰지 않는다. 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을
|
||||
세우므로 가짜 웅덩이·평탄면이 원리적으로 생기지 않는다(2026-07-31 사용자 지시로 방식 교체).
|
||||
|
||||
⑤는 최외곽에 적색이 남아 있으면 그 주변으로 넓혀 다시 분석하고, **새로 추가한 셀에
|
||||
적색이 없으면** 멈춘다.
|
||||
"""
|
||||
if len(vertices) < 2:
|
||||
return None
|
||||
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
|
||||
region = resolve_primary_region(vertices, route_line, contour_features, stream_features)
|
||||
if region is None:
|
||||
return None
|
||||
|
||||
started = time.perf_counter()
|
||||
floor = route_elevation_floor([vertex.z for vertex in vertices])
|
||||
expansion = expand_by_red_boundary(
|
||||
region.spec,
|
||||
region.cell_mask,
|
||||
contour_features,
|
||||
route_line,
|
||||
region.split.upstream,
|
||||
floor,
|
||||
)
|
||||
if expansion is None:
|
||||
logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.")
|
||||
return StagePreview(region=region)
|
||||
|
||||
analysis = expansion.analysis
|
||||
spec = analysis.spec
|
||||
red = analysis.flow.reaches_road & analysis.flow.analyzed
|
||||
|
||||
# ⑥ 흐름 강도 — 셀마다 물이 실제로 들어가는 도로 셀을 구해 도로 셀별로 센다.
|
||||
# 색 판정은 세류 셀에서 멈추지만(거기서 도달이 확정되므로), 강도는 그 물이 세류를 타고
|
||||
# 최종적으로 어느 도로 셀로 들어가는지를 봐야 하므로 도로만 흡수점으로 두고 다시 따라간다.
|
||||
routing = trace_flow(analysis.terrain, analysis.road) if analysis.road.count else None
|
||||
strength_curve = _preview_strength(analysis, routing, red, route_line.length)
|
||||
|
||||
# ⑦ 2차 전체 배수유역 외곽선 = 적색 셀 전체의 외곽.
|
||||
boundary = outer_boundary(spec, red.reshape(spec.n_rows, spec.n_cols))
|
||||
basin_ring = largest_ring(boundary) if boundary is not None else []
|
||||
|
||||
# ⑧ 기본 관 매설 위치 = 도로 × 세류선 교차점(너무 가까운 것은 하나로 합침).
|
||||
pipes = _base_pipes(vertices, stream_features)
|
||||
|
||||
logger.info(
|
||||
"배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — "
|
||||
"2차 유역 %.0f㎡, 기본 관 %d개, 강도 곡선 %d점",
|
||||
time.perf_counter() - started,
|
||||
expansion.rounds,
|
||||
spec.size,
|
||||
int(red.sum()) * spec.cell_area_m2,
|
||||
len(pipes),
|
||||
int((strength_curve > 0).sum()),
|
||||
)
|
||||
return StagePreview(
|
||||
region=region,
|
||||
spec=spec,
|
||||
domain=analysis.domain,
|
||||
terrain=analysis.terrain,
|
||||
road=analysis.road,
|
||||
flow=analysis.flow,
|
||||
descent=analysis.descent,
|
||||
expand_rounds=expansion.rounds,
|
||||
expand_closed=expansion.closed,
|
||||
expand_added_cells=expansion.added_cells,
|
||||
strength_profile=_downsample_strength(strength_curve),
|
||||
basin_boundary_xy=basin_ring,
|
||||
basin_area_m2=int(red.sum()) * spec.cell_area_m2,
|
||||
routing=routing,
|
||||
pipes=pipes,
|
||||
)
|
||||
|
||||
|
||||
def _preview_strength(
|
||||
analysis: Any, routing: Any, red: np.ndarray, route_length_m: float
|
||||
) -> np.ndarray:
|
||||
"""적색 셀이 실제로 들어가는 도로 셀을 세어 누가거리별 유입 면적 곡선을 만든다."""
|
||||
road = analysis.road
|
||||
if routing is None or road.count == 0:
|
||||
return np.zeros(1)
|
||||
slots = routing.road_slot
|
||||
counted = red & (slots >= 0)
|
||||
strength = np.bincount(slots[counted], minlength=road.count).astype(np.float64)
|
||||
return _strength_by_chainage(
|
||||
road.chainage, strength * analysis.spec.cell_area_m2, route_length_m
|
||||
)
|
||||
|
||||
|
||||
def _base_pipes(
|
||||
vertices: list[RouteVertex], stream_features: list[dict[str, Any]]
|
||||
) -> list[StructureCandidate]:
|
||||
"""도로 × 세류선 교차점을 기본 관 위치로 삼는다. 300m 보충 배치는 다음 단계다."""
|
||||
pipes: list[StructureCandidate] = []
|
||||
for candidate in find_stream_crossings(vertices, stream_features):
|
||||
if pipes and candidate.chainage_m - pipes[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M:
|
||||
continue
|
||||
pipes.append(candidate)
|
||||
return pipes
|
||||
|
||||
|
||||
# ── 흐름 강도 곡선 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _strength_by_chainage(
|
||||
road_chainage: np.ndarray, strength_area: np.ndarray, total_length: float
|
||||
) -> np.ndarray:
|
||||
"""도로 셀 강도를 1m 누가거리 구간으로 합산한 곡선(㎡/m 구간 합)."""
|
||||
bins = max(1, int(np.ceil(total_length)) + 1)
|
||||
if road_chainage.size == 0:
|
||||
return np.zeros(bins)
|
||||
index = np.clip(np.round(road_chainage).astype(np.int64), 0, bins - 1)
|
||||
return np.bincount(index, weights=strength_area, minlength=bins)
|
||||
|
||||
|
||||
def _downsample_strength(curve: np.ndarray) -> list[tuple[float, float]]:
|
||||
"""응답용으로 강도 곡선을 일정 간격으로 줄인다(구간 합 유지).
|
||||
|
||||
끝자락을 잘라내면 종점 부근 유입 면적이 통째로 사라지므로 0으로 채워 맞춘다.
|
||||
"""
|
||||
step = max(1, int(_STRENGTH_OUTPUT_STEP_M))
|
||||
if curve.size == 0:
|
||||
return []
|
||||
padding = (-curve.size) % step
|
||||
padded = np.append(curve, np.zeros(padding)) if padding else curve
|
||||
summed = padded.reshape(-1, step).sum(axis=1)
|
||||
return [
|
||||
(float(position * step), float(value)) for position, value in enumerate(summed) if value > 0
|
||||
]
|
||||
+1
-1
@@ -32,7 +32,7 @@ from rasterio.features import rasterize
|
||||
from scipy.ndimage import distance_transform_edt
|
||||
from shapely.geometry import shape
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import (
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import (
|
||||
AZIMUTH_INVALID,
|
||||
AZIMUTH_SINK,
|
||||
AZIMUTH_STEPS,
|
||||
+3
-3
@@ -21,11 +21,11 @@ from typing import Any
|
||||
import numpy as np
|
||||
from shapely.geometry import LineString
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Descent import (
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Descent import (
|
||||
ContourDescent,
|
||||
build_contour_descent,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import (
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import (
|
||||
FlowClassification,
|
||||
RoadRaster,
|
||||
burn_stream_flow,
|
||||
@@ -33,7 +33,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import (
|
||||
outermost_cells,
|
||||
rasterize_road,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import GridSpec, TerrainGrid
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec, TerrainGrid
|
||||
from config.config_system import (
|
||||
DRAINAGE_RED_EXPAND_BAND_M,
|
||||
DRAINAGE_RED_EXPAND_MAX_ROUNDS,
|
||||
+6
-2
@@ -5,7 +5,7 @@
|
||||
`STAGES`에 이름을 하나 더 넣고 `write_stage()`를 호출하면 된다 — 파일명 규칙과 매니페스트
|
||||
갱신은 여기서 일괄로 처리한다.
|
||||
|
||||
저장 위치: `storage/{회사}/{사용자}/{프로젝트}/B05_wf2_Route/drainage/`
|
||||
저장 위치: `storage/{회사}/{사용자}/{프로젝트}/B04_wf1_Surface/drainage/`
|
||||
- `{단계번호}_{단계이름}.geojson` — WGS84 FeatureCollection, 피처마다 `kind` 속성
|
||||
- `manifest.json` — 지금까지 남긴 단계 목록과 요약값
|
||||
"""
|
||||
@@ -31,6 +31,8 @@ logger = logging.getLogger(__name__)
|
||||
STAGES: dict[str, str] = {
|
||||
"primary_region": "01",
|
||||
"flow_direction": "02",
|
||||
# B05가 읽어 세부유역을 나누는 데 필요한 최소 배열·기하. 화살표·표고는 넣지 않는다.
|
||||
"road_routing": "03",
|
||||
}
|
||||
|
||||
_MANIFEST_FILENAME = "manifest.json"
|
||||
@@ -38,7 +40,9 @@ LonLat = Callable[[float, float], tuple[float, float]]
|
||||
|
||||
|
||||
def drainage_dir(stored_path: str) -> Path:
|
||||
return Path(resolve_stored_project_path(stored_path)) / "B05_wf2_Route" / DRAINAGE_CACHE_DIRNAME
|
||||
return (
|
||||
Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / DRAINAGE_CACHE_DIRNAME
|
||||
)
|
||||
|
||||
|
||||
def write_stage(
|
||||
+1
-1
@@ -24,7 +24,7 @@ from scipy.spatial import cKDTree
|
||||
from shapely.geometry import LineString, MultiPolygon, Polygon, shape
|
||||
from shapely.ops import unary_union
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import (
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import (
|
||||
AZIMUTH_STEPS,
|
||||
ContourCloud,
|
||||
GridSpec,
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
전체**를 잡는다(2026-07-31 사용자 지시).
|
||||
|
||||
여기서 정해진 1차 배수유역의 bbox가 곧 격자 해석 범위가 된다.
|
||||
표고 해석·격자 생성은 `B05_wf2_Route_Engine_Watershed_Grid.py`가 맡는다.
|
||||
표고 해석·격자 생성은 `B04_wf1_Surface_Engine_Watershed_Grid.py`가 맡는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,7 +20,7 @@ from scipy.spatial import cKDTree
|
||||
from shapely.geometry import LineString, MultiPolygon, Polygon, shape
|
||||
from shapely.ops import substring, unary_union
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import (
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import (
|
||||
ContourCloud,
|
||||
GridSpec,
|
||||
build_cell_mask,
|
||||
@@ -0,0 +1,482 @@
|
||||
"""배수유역 분석 API 라우터 (B04 — 관리자 확인용).
|
||||
|
||||
계획 노선(B03 업로드 CSV)과 도엽 등고선·세류선으로 배수유역을 끝까지 분석하고, 결과를
|
||||
`storage/{프로젝트}/B04_wf1_Surface/drainage/`에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만
|
||||
돌리고, 일반 사용자가 쓰는 B05는 저장분을 읽어 쓴다(2026-07-31 사용자 지시).
|
||||
|
||||
좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pyproj import Transformer
|
||||
from shapely.geometry import Point, Polygon, box
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Analyze import preview_stages
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import write_grid_arrays, write_stage
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import (
|
||||
AZIMUTH_INVALID,
|
||||
AZIMUTH_SINK,
|
||||
AZIMUTH_STEPS,
|
||||
mask_row_spans,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import get_surface_crs_epsg
|
||||
from common_util.common_util_route_geometry import (
|
||||
StructureCandidate,
|
||||
find_planned_route_file,
|
||||
read_planned_route_csv,
|
||||
)
|
||||
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=["B04 Surface Watershed"])
|
||||
|
||||
# 도엽 레이어 파일명 (B04 전처리 산출물과 같은 위치)
|
||||
_CONTOUR_FILE = "도엽_등고선.geojson"
|
||||
_STREAM_FILE = "도엽_하천중심선.geojson"
|
||||
|
||||
|
||||
def _sheet_dir(stored_path: str) -> Path:
|
||||
return Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / "processed"
|
||||
|
||||
|
||||
def _route_input_dir(stored_path: str) -> Path:
|
||||
"""B03 업로드 폴더 — 계획 노선 파일이 여기 들어온다."""
|
||||
return Path(resolve_stored_project_path(stored_path)) / "B03_FileInput" / "input"
|
||||
|
||||
|
||||
def _load_features(directory: Path, filename: str) -> list[dict[str, Any]]:
|
||||
"""도엽 GeoJSON을 읽어 피처 목록만 돌려준다. 없으면 빈 목록."""
|
||||
path = directory / filename
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("도엽 GeoJSON을 읽지 못했습니다: %s", path)
|
||||
return []
|
||||
features = data.get("features")
|
||||
return features if isinstance(features, list) else []
|
||||
|
||||
|
||||
def _reproject_features(
|
||||
features: list[dict[str, Any]],
|
||||
transformer: Transformer | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""WGS84 도엽 좌표를 사업지 CRS(m)로 바꾼다. 거리·면적을 미터로 계산하기 위함."""
|
||||
if transformer is None:
|
||||
return features
|
||||
converted: list[dict[str, Any]] = []
|
||||
for feature in features:
|
||||
geometry = feature.get("geometry")
|
||||
if not geometry:
|
||||
continue
|
||||
coordinates = _map_coordinates(geometry.get("coordinates"), transformer)
|
||||
if coordinates is None:
|
||||
continue
|
||||
converted.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": feature.get("properties") or {},
|
||||
"geometry": {"type": geometry.get("type"), "coordinates": coordinates},
|
||||
}
|
||||
)
|
||||
return converted
|
||||
|
||||
|
||||
def _map_coordinates(coordinates: Any, transformer: Transformer) -> Any:
|
||||
"""중첩 좌표 배열을 재귀적으로 변환한다."""
|
||||
if not isinstance(coordinates, list) or not coordinates:
|
||||
return None
|
||||
first = coordinates[0]
|
||||
if isinstance(first, (int, float)):
|
||||
x, y = transformer.transform(float(coordinates[0]), float(coordinates[1]))
|
||||
return [x, y]
|
||||
mapped = [_map_coordinates(item, transformer) for item in coordinates]
|
||||
return [item for item in mapped if item is not None]
|
||||
|
||||
|
||||
def _candidate_payload(
|
||||
candidate: StructureCandidate,
|
||||
to_lonlat: Any,
|
||||
) -> dict[str, Any]:
|
||||
lon, lat = to_lonlat(candidate.x, candidate.y)
|
||||
return {
|
||||
"chainage_m": round(candidate.chainage_m, 2),
|
||||
"x": candidate.x,
|
||||
"y": candidate.y,
|
||||
"lon": lon,
|
||||
"lat": lat,
|
||||
"reason": candidate.reason,
|
||||
"stream_name": candidate.stream_name,
|
||||
}
|
||||
|
||||
|
||||
async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"""계획 노선 파일과 도엽 피처, 좌표 변환기를 준비한다.
|
||||
|
||||
노선은 **B03에 업로드된 계획 노선 파일**에서 읽는다 — B05의 확정 경로가 아니다.
|
||||
배수유역 분석은 노선 설계보다 먼저 끝나 있어야 하기 때문이다(2026-07-31 사용자 지시).
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
epsg = await get_surface_crs_epsg(connection, project_id, 0)
|
||||
|
||||
route_file = find_planned_route_file(_route_input_dir(stored_path))
|
||||
if route_file is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "계획 노선 파일이 업로드되지 않았습니다."},
|
||||
)
|
||||
planned = read_planned_route_csv(route_file)
|
||||
if planned is None or len(planned.vertices) < 2:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": f"계획 노선 파일을 읽지 못했습니다: {route_file.name}",
|
||||
},
|
||||
)
|
||||
|
||||
# 노선 파일이 CRS를 명시하면 그 값을 따른다. 도엽 재투영도 같은 좌표계로 맞춘다.
|
||||
source_crs = f"EPSG:{planned.epsg or epsg or 5186}"
|
||||
to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
|
||||
to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True)
|
||||
|
||||
directory = _sheet_dir(stored_path)
|
||||
streams = _reproject_features(_load_features(directory, _STREAM_FILE), to_metric_transformer)
|
||||
contour_features = _reproject_features(
|
||||
_load_features(directory, _CONTOUR_FILE), to_metric_transformer
|
||||
)
|
||||
return {
|
||||
"route_source": route_file.name,
|
||||
"vertices": planned.vertices,
|
||||
"route_line": planned.line,
|
||||
"streams": streams,
|
||||
"contours": contour_features,
|
||||
"stored_path": stored_path,
|
||||
"to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{project_id}/drainage/primary-region", response_model=None)
|
||||
async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"""1차 배수유역 근거를 돌려준다 — 단계 검증용, TIN·흐름 계산은 하지 않는다.
|
||||
|
||||
도로 교차점 상류로 이어진 세류망, 제외된 하류망, 그 상류망을 반경 버퍼한 1차 영역,
|
||||
그 bbox로 잡은 격자 정보를 함께 준다. 같은 내용을 영구저장소에 GeoJSON으로도 남겨
|
||||
QGIS 등으로 직접 열어 대조할 수 있게 한다.
|
||||
"""
|
||||
prepared = await _prepare(project_id)
|
||||
if isinstance(prepared, JSONResponse):
|
||||
return prepared
|
||||
preview = await asyncio.to_thread(
|
||||
preview_stages,
|
||||
prepared["vertices"],
|
||||
prepared["contours"],
|
||||
prepared["streams"],
|
||||
)
|
||||
if preview is None:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."},
|
||||
)
|
||||
region = preview.region
|
||||
to_lonlat = prepared["to_lonlat"]
|
||||
# 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다.
|
||||
spec = preview.spec or region.spec
|
||||
domain = preview.domain if preview.domain is not None else region.cell_mask
|
||||
payload = {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
"route_source": prepared["route_source"],
|
||||
"radius_m": region.radius_m,
|
||||
# 채택된 상류 세류망 = 1차 영역의 기준선.
|
||||
"upstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.upstream],
|
||||
# 도로 아래로 이어진 하류망 — 판정이 맞는지 눈으로 대조하기 위해 함께 준다.
|
||||
"downstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.downstream],
|
||||
"no_contact_count": region.split.no_contact,
|
||||
# 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호.
|
||||
"road_outside_m": round(region.road_outside_m, 1),
|
||||
# 1차 영역(버퍼 합집합) 외곽 링 목록.
|
||||
"region_rings": _polygon_rings(region.area, to_lonlat),
|
||||
"grid": {
|
||||
"cell_m": spec.cell_m,
|
||||
"rows": spec.n_rows,
|
||||
"cols": spec.n_cols,
|
||||
# bbox 전체 셀 수와, 해석 영역에 실제로 생성된 셀 수(확장 반영).
|
||||
"bbox_cells": spec.size,
|
||||
"cells": int(domain.sum()) if domain is not None else 0,
|
||||
"width_m": round(spec.n_cols * spec.cell_m, 1),
|
||||
"height_m": round(spec.n_rows * spec.cell_m, 1),
|
||||
# 격자 bbox 링. 프론트는 이 사각형을 rows×cols로 나눠 행·열 좌표를 얻는다.
|
||||
"bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat),
|
||||
# 실제 생성된 셀을 행별 연속 구간 [행, 시작열, 끝열]으로 압축해 보낸다.
|
||||
# 셀을 낱개로 보내면 수십만 건이라 응답이 감당되지 않는다.
|
||||
"row_spans": [list(span) for span in mask_row_spans(domain)]
|
||||
if domain is not None
|
||||
else [],
|
||||
},
|
||||
# 최외곽 적색 셀 주변 확장 결과.
|
||||
"expansion": {
|
||||
"rounds": preview.expand_rounds,
|
||||
"closed": preview.expand_closed,
|
||||
"added_cells": preview.expand_added_cells,
|
||||
"initial_cells": region.active_cells,
|
||||
},
|
||||
# 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩.
|
||||
"flow": _flow_payload(preview, domain),
|
||||
# ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 면적.
|
||||
"basin_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in preview.basin_boundary_xy],
|
||||
"basin_area_m2": round(preview.basin_area_m2, 1),
|
||||
# ⑥ 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡].
|
||||
"strength_profile": [
|
||||
[round(chainage, 1), round(area, 1)] for chainage, area in preview.strength_profile
|
||||
],
|
||||
# ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점.
|
||||
"pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes],
|
||||
}
|
||||
# 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다.
|
||||
payload["saved_to"] = write_stage(
|
||||
prepared["stored_path"],
|
||||
"primary_region",
|
||||
{
|
||||
"primary_region": _as_polygons(region.area),
|
||||
"upstream": region.split.upstream,
|
||||
"downstream": region.split.downstream,
|
||||
"route": [prepared["route_line"]],
|
||||
"grid_bbox": [_grid_bbox_polygon(spec)],
|
||||
# ⑦ 2차 전체 배수유역 외곽선, ⑧ 기본 관 위치(누가거리·근거 포함).
|
||||
"basin_boundary": _boundary_geometry(preview.basin_boundary_xy),
|
||||
"pipe": [
|
||||
(
|
||||
Point(pipe.x, pipe.y),
|
||||
{
|
||||
"chainage_m": round(pipe.chainage_m, 2),
|
||||
"reason": pipe.reason,
|
||||
"stream_name": pipe.stream_name,
|
||||
},
|
||||
)
|
||||
for pipe in preview.pipes
|
||||
],
|
||||
},
|
||||
{
|
||||
"radius_m": region.radius_m,
|
||||
"road_outside_m": payload["road_outside_m"],
|
||||
"no_contact_count": region.split.no_contact,
|
||||
"basin_area_m2": payload["basin_area_m2"],
|
||||
"pipe_count": len(preview.pipes),
|
||||
# 기하(bbox·셀 구간)는 파일 본문에 있으므로 요약 수치만 남긴다.
|
||||
"grid": {
|
||||
key: value
|
||||
for key, value in payload["grid"].items()
|
||||
if key not in {"bbox_lonlat", "row_spans"}
|
||||
},
|
||||
},
|
||||
to_lonlat,
|
||||
)
|
||||
_write_stage_arrays(prepared["stored_path"], preview, domain, spec)
|
||||
_write_road_routing(prepared["stored_path"], preview, spec, prepared["route_line"], to_lonlat)
|
||||
return payload
|
||||
|
||||
|
||||
def _write_road_routing(
|
||||
stored_path: str, preview: Any, spec: Any, route_line: Any, to_lonlat: Any
|
||||
) -> None:
|
||||
"""B05가 세부유역을 나눌 때 쓸 최소 산출물을 남긴다.
|
||||
|
||||
B05는 일반 사용자용이라 가벼워야 한다. 화살표(방향 코드)·밴드 표고 같은 확인용 배열은
|
||||
빼고, **셀 → 도로 셀 귀속**과 도로 셀 제원만 담는다. 여기에 표고를 함께 넣는 이유는
|
||||
유역 낙차를 내려면 셀 표고가 필요해서다(2026-07-31 사용자 지시).
|
||||
"""
|
||||
routing = preview.routing
|
||||
road = preview.road
|
||||
if routing is None or road is None or road.count == 0:
|
||||
return
|
||||
write_grid_arrays(
|
||||
stored_path,
|
||||
"road_routing",
|
||||
spec,
|
||||
{
|
||||
"road_slot": routing.road_slot,
|
||||
"path_length": routing.path_length,
|
||||
"strength": routing.strength,
|
||||
"road_cell_index": road.cell_index,
|
||||
"road_chainage": road.chainage,
|
||||
"elevation": preview.terrain.elevation.reshape(-1),
|
||||
},
|
||||
{
|
||||
"road_cells": road.count,
|
||||
"reached_cells": int((routing.road_slot >= 0).sum()),
|
||||
"basin_area_m2": round(preview.basin_area_m2, 1),
|
||||
"pipe_count": len(preview.pipes),
|
||||
},
|
||||
)
|
||||
# B05가 그대로 그릴 기하 — 계획도로선 · 기본 배관 · 2차 전체 배수유역, 이 셋뿐이다.
|
||||
write_stage(
|
||||
stored_path,
|
||||
"road_routing",
|
||||
{
|
||||
"route": [route_line],
|
||||
"basin_boundary": _boundary_geometry(preview.basin_boundary_xy),
|
||||
"pipe": [
|
||||
(
|
||||
Point(pipe.x, pipe.y),
|
||||
{"chainage_m": round(pipe.chainage_m, 2), "reason": pipe.reason},
|
||||
)
|
||||
for pipe in preview.pipes
|
||||
],
|
||||
},
|
||||
{
|
||||
"basin_area_m2": round(preview.basin_area_m2, 1),
|
||||
"pipe_count": len(preview.pipes),
|
||||
"route_length_m": round(route_line.length, 1),
|
||||
},
|
||||
to_lonlat,
|
||||
)
|
||||
|
||||
|
||||
def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None:
|
||||
"""격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다."""
|
||||
if domain is not None:
|
||||
write_grid_arrays(
|
||||
stored_path,
|
||||
"primary_region",
|
||||
spec,
|
||||
{"mask": domain},
|
||||
{
|
||||
"cells": int(domain.sum()),
|
||||
"bbox_cells": spec.size,
|
||||
"expand_rounds": preview.expand_rounds,
|
||||
"expand_closed": preview.expand_closed,
|
||||
},
|
||||
)
|
||||
flow = preview.flow
|
||||
if flow is None:
|
||||
return
|
||||
arrays = {
|
||||
"direction": flow.direction.reshape(spec.n_rows, spec.n_cols),
|
||||
"reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols),
|
||||
"analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols),
|
||||
# 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다.
|
||||
"receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols),
|
||||
}
|
||||
if flow.burned is not None:
|
||||
arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols)
|
||||
if preview.descent is not None:
|
||||
arrays["band_elevation"] = preview.descent.band_elevation
|
||||
# ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다.
|
||||
if preview.strength_profile:
|
||||
curve = np.asarray(preview.strength_profile, dtype=np.float64)
|
||||
arrays["strength_chainage_m"] = curve[:, 0]
|
||||
arrays["strength_area_m2"] = curve[:, 1]
|
||||
write_grid_arrays(
|
||||
stored_path,
|
||||
"flow_direction",
|
||||
spec,
|
||||
arrays,
|
||||
{
|
||||
"azimuth_steps": AZIMUTH_STEPS,
|
||||
"sink_code": AZIMUTH_SINK,
|
||||
"invalid_code": AZIMUTH_INVALID,
|
||||
"analyzed": int(flow.analyzed.sum()),
|
||||
"reaches_road": int((flow.reaches_road & flow.analyzed).sum()),
|
||||
"no_road": int((~flow.reaches_road & flow.analyzed).sum()),
|
||||
"burned": 0 if flow.burned is None else int(flow.burned.sum()),
|
||||
"outer_seeds": flow.outer_seeds,
|
||||
"interior_seeds": flow.interior_seeds,
|
||||
"strength_points": len(preview.strength_profile),
|
||||
"strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None:
|
||||
"""셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다.
|
||||
|
||||
셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다:
|
||||
하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음
|
||||
최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표).
|
||||
바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다.
|
||||
"""
|
||||
flow = preview.flow
|
||||
if flow is None or domain is None:
|
||||
return None
|
||||
order = np.flatnonzero(domain.reshape(-1))
|
||||
analyzed = flow.analyzed[order]
|
||||
reaches = flow.reaches_road[order]
|
||||
packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8)
|
||||
packed |= np.where(reaches, 0x80, 0).astype(np.uint8)
|
||||
burned = flow.burned
|
||||
return {
|
||||
"encoding": "base64-uint8",
|
||||
"azimuth_steps": AZIMUTH_STEPS,
|
||||
"sink_code": AZIMUTH_SINK,
|
||||
"invalid_code": AZIMUTH_INVALID,
|
||||
"cells": int(order.size),
|
||||
"reaches_road": int((reaches & analyzed).sum()),
|
||||
"no_road": int((~reaches & analyzed).sum()),
|
||||
# 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀.
|
||||
"unanalyzed": int((~analyzed).sum()),
|
||||
# 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수.
|
||||
"burned": 0 if burned is None else int(burned[order].sum()),
|
||||
"outer_seeds": flow.outer_seeds,
|
||||
"interior_seeds": flow.interior_seeds,
|
||||
"data": base64.b64encode(packed.tobytes()).decode("ascii"),
|
||||
}
|
||||
|
||||
|
||||
def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]:
|
||||
"""2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다)."""
|
||||
return [Polygon(ring)] if len(ring) >= 4 else []
|
||||
|
||||
|
||||
def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]:
|
||||
"""2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다)."""
|
||||
return [Polygon(ring)] if len(ring) >= 4 else []
|
||||
|
||||
|
||||
def _as_polygons(geometry: Any) -> list[Any]:
|
||||
if geometry is None or geometry.is_empty:
|
||||
return []
|
||||
return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry]
|
||||
|
||||
|
||||
def _grid_bbox_polygon(spec: Any) -> Polygon:
|
||||
x_max = spec.x_min + spec.n_cols * spec.cell_m
|
||||
y_min = spec.y_max - spec.n_rows * spec.cell_m
|
||||
return box(spec.x_min, y_min, x_max, spec.y_max)
|
||||
|
||||
|
||||
def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]:
|
||||
return [list(to_lonlat(x, y)) for x, y in line.coords]
|
||||
|
||||
|
||||
def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]:
|
||||
"""폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다."""
|
||||
if geometry is None or geometry.is_empty:
|
||||
return []
|
||||
parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry]
|
||||
return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts]
|
||||
|
||||
|
||||
def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]:
|
||||
x_min = spec.x_min
|
||||
x_max = spec.x_min + spec.n_cols * spec.cell_m
|
||||
y_max = spec.y_max
|
||||
y_min = spec.y_max - spec.n_rows * spec.cell_m
|
||||
corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min))
|
||||
return [list(to_lonlat(x, y)) for x, y in corners]
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type VWorldMeta,
|
||||
} from "./B04_wf1_Surface_Api_Fetch";
|
||||
import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera";
|
||||
import { createWatershedOverlay } from "./B04_wf1_Surface_UI_Watershed";
|
||||
import {
|
||||
computeMapRect,
|
||||
createNormalizer,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
prepareLayer,
|
||||
type GeoJsonCollection,
|
||||
type MapRect,
|
||||
type Normalizer,
|
||||
type PreparedLayer,
|
||||
type ViewState,
|
||||
} from "./B04_wf1_Surface_UI_MapRender";
|
||||
@@ -130,6 +132,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
let currentProjectId: string | null = null;
|
||||
let referenceBounds: SurfaceBounds | null = null;
|
||||
let meta: VWorldMeta | null = null;
|
||||
// 배수유역 오버레이가 lon/lat을 화면 좌표로 옮길 때 쓴다. 레이어 로드 시 1회 만든다.
|
||||
let normalizer: Normalizer | null = null;
|
||||
// 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다.
|
||||
const preparedLayers = new Map<GisLayer, PreparedLayer>();
|
||||
const activeBackgrounds = new Set<BackgroundLayer>(BACKGROUND_LAYERS);
|
||||
@@ -217,6 +221,14 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
});
|
||||
gisButtons.append(contourLabelButton);
|
||||
|
||||
// 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다.
|
||||
const watershed = createWatershedOverlay(() => {
|
||||
const text = watershed.status();
|
||||
if (text) status.textContent = text;
|
||||
scheduleDraw();
|
||||
});
|
||||
gisButtons.append(watershed.button);
|
||||
|
||||
function updateImageTransform(): void {
|
||||
backgroundImages.forEach((image) => {
|
||||
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||
@@ -354,12 +366,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
if (sequence !== loadSequence) return;
|
||||
meta = nextMeta;
|
||||
// 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다.
|
||||
const normalizer = createNormalizer(nextMeta);
|
||||
normalizer = createNormalizer(nextMeta);
|
||||
let featureCount = 0;
|
||||
loadedLayers.forEach(([layer, data]) => {
|
||||
if (!data) return;
|
||||
featureCount += data.features?.length ?? 0;
|
||||
preparedLayers.set(layer, prepareLayer(data, normalizer, CONTOUR_LABEL_KEYS[layer]));
|
||||
preparedLayers.set(layer, prepareLayer(data, normalizer!, CONTOUR_LABEL_KEYS[layer]));
|
||||
});
|
||||
BACKGROUND_LAYERS.forEach((layer) => {
|
||||
backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`;
|
||||
@@ -422,6 +434,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
root,
|
||||
render(projectId, nextReferenceBounds) {
|
||||
currentProjectId = projectId;
|
||||
watershed.reset();
|
||||
watershed.setProject(projectId);
|
||||
referenceBounds = nextReferenceBounds ?? null;
|
||||
void loadLayers();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch";
|
||||
import type { Normalizer, ViewState } from "./B04_wf1_Surface_UI_MapRender";
|
||||
|
||||
/* =============================================================================
|
||||
* 배수유역 분석 오버레이 (B04 — 관리자 확인용)
|
||||
*
|
||||
* 2D 배경지도 위에 배수유역 분석 결과를 겹쳐 그린다. 계산은 백엔드가 하고 여기서는
|
||||
* 그리기만 한다. 30초 안팎이 걸리는 요청이라 버튼을 눌렀을 때만 돈다.
|
||||
*
|
||||
* 겹쳐 그리는 것
|
||||
* · 해석 격자 — 1차 영역에 걸치는 셀만, 흰 선
|
||||
* · 셀별 흐름 — 도로 도달 적색 / 미도달 파랑 채움 + 백색 화살표 / 표고없음 회색
|
||||
* · 상류 세류망(굵은 파랑) · 하류망(회색 파선) · 1차 영역(초록 채움)
|
||||
* · 2차 전체 배수유역 외곽선(갈색 파선) · 기본 관 위치
|
||||
* ========================================================================== */
|
||||
|
||||
// 해석 격자 셀 선 — 등고선·세류 위에 얹으므로 흰색으로 둔다.
|
||||
const GRID_LINE_COLOR = "rgba(255, 255, 255, 0.55)";
|
||||
// 흐름 판정 색 — 도로로 물이 오는 셀은 적색, 오지 않는 셀은 파랑 채움 + 백색 화살표.
|
||||
const FLOW_TO_ROAD_FILL = "rgba(220, 38, 38, 0.28)";
|
||||
const FLOW_TO_ROAD_LINE = "rgba(153, 27, 27, 0.95)";
|
||||
const FLOW_AWAY_FILL = "rgba(37, 99, 235, 0.22)";
|
||||
const FLOW_AWAY_LINE = "rgba(255, 255, 255, 0.95)";
|
||||
/** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */
|
||||
const FLOW_UNKNOWN_FILL = "rgba(120, 113, 108, 0.18)";
|
||||
/** 셀이 이보다 작으면 화살표가 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */
|
||||
const ARROW_MIN_PX = 7;
|
||||
/** 2차 전체 배수유역 외곽선 = 분수령. */
|
||||
const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)";
|
||||
/** 기본 관 마커. */
|
||||
const PIPE_COLOR = "rgba(249, 115, 22, 0.95)";
|
||||
|
||||
export interface WatershedOverlay {
|
||||
/** 레이어 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */
|
||||
button: HTMLButtonElement;
|
||||
/** 켜져 있는지. draw() 호출 전에 확인한다. */
|
||||
visible: () => boolean;
|
||||
/** 상태 문구(분석 요약 또는 오류). 없으면 빈 문자열. */
|
||||
status: () => string;
|
||||
/** 프로젝트가 바뀌면 받아 둔 분석 결과를 버린다. */
|
||||
reset: () => void;
|
||||
/** 현재 프로젝트를 알려 준다. 지정 전에는 버튼이 아무 일도 하지 않는다. */
|
||||
setProject: (projectId: string) => void;
|
||||
draw: (context: CanvasRenderingContext2D, map: Normalizer, view: ViewState) => void;
|
||||
}
|
||||
|
||||
export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
|
||||
let analysis: WatershedAnalysis | null = null;
|
||||
let shown = false;
|
||||
let statusText = "";
|
||||
let flowCache: { source: string; bytes: Uint8Array } | null = null;
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b04-map__layer-button b04-map__layer-button--gis";
|
||||
button.textContent = "배수유역";
|
||||
button.style.setProperty("--b04-layer-color", "#dc2626");
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
button.title =
|
||||
"계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 분석합니다. " +
|
||||
"30초 안팎이 걸리며 결과는 영구저장소에 남습니다.";
|
||||
|
||||
let projectId: string | null = null;
|
||||
|
||||
function strokeLonLat(
|
||||
context: CanvasRenderingContext2D,
|
||||
line: ReadonlyArray<readonly [number, number]>,
|
||||
map: Normalizer,
|
||||
view: ViewState,
|
||||
): void {
|
||||
if (line.length < 2) return;
|
||||
const ax = view.mapRect.width * view.scale;
|
||||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||||
const ay = view.mapRect.height * view.scale;
|
||||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||||
context.beginPath();
|
||||
line.forEach(([lon, lat], index) => {
|
||||
const x = ((lon - map.lonMin) / map.lonRange) * ax + bx;
|
||||
const y = (1 - (lat - map.latMin) / map.latRange) * ay + by;
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
/** 흐름 방향 바이트를 셀 순서대로 디코드한다(캐시 — 매 프레임 다시 풀지 않는다). */
|
||||
function flowBytes(region: WatershedAnalysis): Uint8Array | null {
|
||||
if (!region.flow) return null;
|
||||
if (flowCache?.source === region.flow.data) return flowCache.bytes;
|
||||
const binary = atob(region.flow.data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
flowCache = { source: region.flow.data, bytes };
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** 1차 영역에 걸쳐 실제로 생성된 셀만 그린다.
|
||||
*
|
||||
* bbox 전체를 채우지 않는다 — 백엔드가 준 행별 구간(row_spans)만 그린다. 흐름 판정이
|
||||
* 있으면 셀마다 방향 화살표를 얹고, 도로에 물이 닿는 셀은 적색·닿지 않으면 파랑으로
|
||||
* 칠한다. 셀이 화면에서 작아지면 화살표가 안 보이므로 채움색만 남긴다. */
|
||||
function drawGridCells(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: Normalizer,
|
||||
view: ViewState,
|
||||
region: WatershedAnalysis,
|
||||
): void {
|
||||
const ring = region.grid.bbox_lonlat;
|
||||
if (ring.length < 4) return;
|
||||
const lons = ring.map(([lon]) => lon);
|
||||
const lats = ring.map(([, lat]) => lat);
|
||||
const ax = view.mapRect.width * view.scale;
|
||||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||||
const ay = view.mapRect.height * view.scale;
|
||||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||||
const left = ((Math.min(...lons) - map.lonMin) / map.lonRange) * ax + bx;
|
||||
const right = ((Math.max(...lons) - map.lonMin) / map.lonRange) * ax + bx;
|
||||
const top = (1 - (Math.max(...lats) - map.latMin) / map.latRange) * ay + by;
|
||||
const bottom = (1 - (Math.min(...lats) - map.latMin) / map.latRange) * ay + by;
|
||||
|
||||
const { rows, cols, row_spans: spans } = region.grid;
|
||||
const cellW = (right - left) / Math.max(cols, 1);
|
||||
const cellH = (bottom - top) / Math.max(rows, 1);
|
||||
const cellPx = Math.min(Math.abs(cellW), Math.abs(cellH));
|
||||
const bytes = flowBytes(region);
|
||||
|
||||
context.save();
|
||||
context.setLineDash([]);
|
||||
context.lineCap = "round";
|
||||
let cursor = 0; // row_spans를 훑은 순서 = 흐름 바이트 순서
|
||||
spans.forEach(([row, colStart, colEnd]) => {
|
||||
const count = colEnd - colStart + 1;
|
||||
const base = cursor;
|
||||
cursor += count;
|
||||
const y = top + cellH * row;
|
||||
if (y + cellH < -40 || y > view.height + 40) return;
|
||||
const x = left + cellW * colStart;
|
||||
const width = cellW * count;
|
||||
if (x + width < -40 || x > view.width + 40) return;
|
||||
|
||||
if (!bytes) {
|
||||
// 흐름 판정 전 — 격자만 흰 선으로 보여 준다.
|
||||
if (cellPx >= 2) {
|
||||
context.strokeStyle = GRID_LINE_COLOR;
|
||||
context.lineWidth = 0.5;
|
||||
context.beginPath();
|
||||
for (let col = colStart; col <= colEnd; col += 1) {
|
||||
context.rect(left + cellW * col, y, cellW, cellH);
|
||||
}
|
||||
context.stroke();
|
||||
} else {
|
||||
context.fillStyle = "rgba(255, 255, 255, 0.2)";
|
||||
context.fillRect(x, y, width, cellH);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sink = region.flow?.sink_code ?? 32;
|
||||
const invalid = region.flow?.invalid_code ?? 33;
|
||||
const steps = region.flow?.azimuth_steps ?? 32;
|
||||
for (let offset = 0; offset < count; offset += 1) {
|
||||
drawFlowCell(
|
||||
context,
|
||||
bytes[base + offset],
|
||||
left + cellW * (colStart + offset),
|
||||
y,
|
||||
cellW,
|
||||
cellH,
|
||||
cellPx,
|
||||
{ sink, invalid, steps },
|
||||
);
|
||||
}
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** 셀 하나 — 도달 여부로 칠하고, 충분히 크면 32방위 흐름 화살표를 얹는다. */
|
||||
function drawFlowCell(
|
||||
context: CanvasRenderingContext2D,
|
||||
code: number,
|
||||
x: number,
|
||||
y: number,
|
||||
cellW: number,
|
||||
cellH: number,
|
||||
cellPx: number,
|
||||
codes: { sink: number; invalid: number; steps: number },
|
||||
): void {
|
||||
const azimuth = code & 0x3f;
|
||||
const reaches = (code & 0x80) !== 0;
|
||||
// 표고가 없어 판정 못한 셀 — 미도달(파랑 채움)과 구분해야 오독이 없다.
|
||||
const unanalyzed = azimuth === codes.invalid;
|
||||
context.fillStyle = unanalyzed
|
||||
? FLOW_UNKNOWN_FILL
|
||||
: reaches
|
||||
? FLOW_TO_ROAD_FILL
|
||||
: FLOW_AWAY_FILL;
|
||||
context.fillRect(x, y, cellW, cellH);
|
||||
if (cellPx >= 2) {
|
||||
context.strokeStyle = GRID_LINE_COLOR;
|
||||
context.lineWidth = 0.5;
|
||||
context.strokeRect(x, y, cellW, cellH);
|
||||
}
|
||||
if (cellPx < ARROW_MIN_PX || unanalyzed) return;
|
||||
const stroke = reaches ? FLOW_TO_ROAD_LINE : FLOW_AWAY_LINE;
|
||||
const midX = x + cellW / 2;
|
||||
const midY = y + cellH / 2;
|
||||
if (azimuth === codes.sink) {
|
||||
// 제자리(싱크) — 방향이 없으므로 점으로 표시한다.
|
||||
context.fillStyle = stroke;
|
||||
context.beginPath();
|
||||
context.arc(midX, midY, Math.max(1, cellPx * 0.12), 0, Math.PI * 2);
|
||||
context.fill();
|
||||
return;
|
||||
}
|
||||
// 코드 0 = 화면 오른쪽(+x), 시계방향(캔버스 y는 아래가 +).
|
||||
const angle = (azimuth * 2 * Math.PI) / codes.steps;
|
||||
const unitX = Math.cos(angle);
|
||||
const unitY = Math.sin(angle);
|
||||
const reach = cellPx * 0.38;
|
||||
const tipX = midX + unitX * reach;
|
||||
const tipY = midY + unitY * reach;
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = Math.max(0.6, cellPx * 0.09);
|
||||
context.beginPath();
|
||||
context.moveTo(midX - unitX * reach, midY - unitY * reach);
|
||||
context.lineTo(tipX, tipY);
|
||||
context.stroke();
|
||||
// 촉 — 진행 방향 기준 좌우로 짧게 접는다.
|
||||
const head = cellPx * 0.18;
|
||||
context.beginPath();
|
||||
context.moveTo(tipX, tipY);
|
||||
context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head);
|
||||
context.moveTo(tipX, tipY);
|
||||
context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
/** 1차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */
|
||||
function drawPrimaryRegion(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: Normalizer,
|
||||
view: ViewState,
|
||||
region: WatershedAnalysis,
|
||||
): void {
|
||||
context.save();
|
||||
// ① 해석 격자 — bbox 테두리 + 실제 셀 눈금.
|
||||
drawGridCells(context, map, view, region);
|
||||
// ② 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합.
|
||||
context.setLineDash([]);
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "rgba(5, 150, 105, 0.95)";
|
||||
context.fillStyle = "rgba(16, 185, 129, 0.12)";
|
||||
region.region_rings.forEach((ring) => {
|
||||
strokeLonLat(context, ring, map, view);
|
||||
context.fill();
|
||||
});
|
||||
// ③ 도로 아래로 이어진 하류망 — 판정이 맞는지 대조하도록 회색 파선으로 남긴다.
|
||||
context.setLineDash([6, 5]);
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "rgba(120, 113, 108, 0.85)";
|
||||
region.downstream_lines.forEach((line) => strokeLonLat(context, line, map, view));
|
||||
// ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에.
|
||||
context.setLineDash([]);
|
||||
context.lineWidth = 4;
|
||||
context.strokeStyle = "rgba(29, 78, 216, 0.95)";
|
||||
region.upstream_lines.forEach((line) => strokeLonLat(context, line, map, view));
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 ⑧ 기본 관 위치. */
|
||||
function drawBasinAndPipes(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: Normalizer,
|
||||
view: ViewState,
|
||||
region: WatershedAnalysis,
|
||||
): void {
|
||||
context.save();
|
||||
if (region.basin_polygon_lonlat.length > 2) {
|
||||
context.setLineDash([8, 5]);
|
||||
context.lineWidth = 2.5;
|
||||
context.strokeStyle = BASIN_RING_COLOR;
|
||||
strokeLonLat(context, region.basin_polygon_lonlat, map, view);
|
||||
}
|
||||
context.setLineDash([]);
|
||||
const ax = view.mapRect.width * view.scale;
|
||||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||||
const ay = view.mapRect.height * view.scale;
|
||||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||||
region.pipes.forEach((pipe, index) => {
|
||||
const x = ((pipe.lon - map.lonMin) / map.lonRange) * ax + bx;
|
||||
const y = (1 - (pipe.lat - map.latMin) / map.latRange) * ay + by;
|
||||
context.beginPath();
|
||||
context.arc(x, y, 7, 0, Math.PI * 2);
|
||||
context.fillStyle = PIPE_COLOR;
|
||||
context.fill();
|
||||
context.lineWidth = 1.5;
|
||||
context.strokeStyle = "#111827";
|
||||
context.stroke();
|
||||
context.fillStyle = "#111827";
|
||||
context.font = "bold 10px sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText(String(index + 1), x, y);
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function regionSummary(region: WatershedAnalysis): string {
|
||||
const cells = region.grid.cells.toLocaleString();
|
||||
const outside =
|
||||
region.road_outside_m > 0 ? ` · 노선 이탈 ${Math.round(region.road_outside_m)}m` : "";
|
||||
const unknown =
|
||||
region.flow && region.flow.unanalyzed > 0
|
||||
? ` / 표고없음 ${region.flow.unanalyzed.toLocaleString()}(회)`
|
||||
: "";
|
||||
const burned =
|
||||
region.flow && region.flow.burned > 0
|
||||
? ` · 세류망 새김 ${region.flow.burned.toLocaleString()}셀`
|
||||
: "";
|
||||
const flow = region.flow
|
||||
? ` · 흐름 도로도달 ${region.flow.reaches_road.toLocaleString()}(적) / ` +
|
||||
`미도달 ${region.flow.no_road.toLocaleString()}(청)${unknown}, ` +
|
||||
`최외곽 출발 ${region.flow.outer_seeds.toLocaleString()} + ` +
|
||||
`내부 보충 ${region.flow.interior_seeds.toLocaleString()}${burned}`
|
||||
: " · 흐름 판정 없음";
|
||||
const expansion = region.expansion
|
||||
? ` · 확장 ${region.expansion.rounds}회` +
|
||||
`(${region.expansion.initial_cells.toLocaleString()}→${cells}셀, ` +
|
||||
`${region.expansion.closed ? "닫힘" : "상한 도달"})`
|
||||
: "";
|
||||
const basin = region.basin_area_m2
|
||||
? ` · 2차 유역 ${formatArea(region.basin_area_m2)}, 기본 관 ${region.pipes.length}개`
|
||||
: "";
|
||||
return (
|
||||
`1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` +
|
||||
`하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` +
|
||||
`격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${expansion}${basin}${flow}`
|
||||
);
|
||||
}
|
||||
|
||||
function formatArea(areaM2: number): string {
|
||||
return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`;
|
||||
}
|
||||
|
||||
/** 켤 때마다 다시 요청한다 — config를 바꾸고 재시작했는데 캐시된 옛 결과가 나오면
|
||||
* 검증이 성립하지 않는다. 끌 때만 요청 없이 숨긴다. */
|
||||
async function toggle(): Promise<void> {
|
||||
if (!projectId) return;
|
||||
if (shown) {
|
||||
shown = false;
|
||||
button.classList.remove("is-active");
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
statusText = "";
|
||||
onChange();
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
statusText = "배수유역을 분석하는 중… (30초 안팎)";
|
||||
onChange();
|
||||
try {
|
||||
analysis = await fetchWatershedAnalysis(projectId);
|
||||
shown = true;
|
||||
button.classList.add("is-active");
|
||||
button.setAttribute("aria-pressed", "true");
|
||||
statusText = regionSummary(analysis);
|
||||
} catch (error) {
|
||||
statusText = error instanceof Error ? error.message : "배수유역 분석에 실패했습니다.";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
onChange();
|
||||
}
|
||||
}
|
||||
|
||||
button.addEventListener("click", () => void toggle());
|
||||
|
||||
return {
|
||||
button,
|
||||
visible: () => shown && analysis !== null,
|
||||
status: () => statusText,
|
||||
reset() {
|
||||
analysis = null;
|
||||
flowCache = null;
|
||||
shown = false;
|
||||
statusText = "";
|
||||
button.classList.remove("is-active");
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
},
|
||||
setProject(next: string) {
|
||||
projectId = next;
|
||||
},
|
||||
draw(context, map, view) {
|
||||
if (!shown || !analysis) return;
|
||||
drawGridCells(context, map, view, analysis);
|
||||
drawPrimaryRegion(context, map, view, analysis);
|
||||
drawBasinAndPipes(context, map, view, analysis);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -300,107 +300,15 @@ export interface DrainageBasinResponse {
|
||||
route_id: number;
|
||||
/** 산정에 실제 사용된 배관 지점 — 유역이 없는 관도 포함(마커 동기화용). */
|
||||
pipes: DrainageCandidate[];
|
||||
/** 2차 전체 배수유역 외곽선 = 분수령. 세부유역은 전부 이 안쪽이라 능선을 따로 그리지 않는다. */
|
||||
/** B04가 분석에 쓴 계획 노선 선형(lon/lat). */
|
||||
route_lonlat: Array<[number, number]>;
|
||||
/** 2차 전체 배수유역 외곽선 = 분수령. B04 산출물을 그대로 받는다. */
|
||||
main_polygon_lonlat: Array<[number, number]>;
|
||||
/** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. 관 추가 판단 근거. */
|
||||
strength_profile: Array<[number, number]>;
|
||||
/** 해석에 실제 사용된 격자 한 변(m). 셀 수 상한에 걸리면 백엔드가 키워서 돌려준다. */
|
||||
/** B04 해석 격자 한 변(m). */
|
||||
grid_cell_m: number;
|
||||
basins: DrainageBasin[];
|
||||
}
|
||||
|
||||
/** 1차 배수유역 근거(단계 검증용). TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 준다. */
|
||||
export interface DrainagePrimaryRegion {
|
||||
status: string;
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
radius_m: number;
|
||||
/** 도로와 만난 세류선의 상류측 = 1차 영역의 기준선. */
|
||||
upstream_lines: Array<Array<[number, number]>>;
|
||||
/** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */
|
||||
downstream_lines: Array<Array<[number, number]>>;
|
||||
/** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */
|
||||
no_contact_count: number;
|
||||
/** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */
|
||||
road_outside_m: number;
|
||||
/** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */
|
||||
region_rings: Array<Array<[number, number]>>;
|
||||
grid: {
|
||||
cell_m: number;
|
||||
rows: number;
|
||||
cols: number;
|
||||
/** bbox 전체 셀 수(참고값). */
|
||||
bbox_cells: number;
|
||||
/** 1차 영역에 걸쳐 실제로 생성된 셀 수. */
|
||||
cells: number;
|
||||
width_m: number;
|
||||
height_m: number;
|
||||
/** 격자 bbox 링. 화면은 이 사각형을 rows×cols로 나눠 셀 좌표를 얻는다. */
|
||||
bbox_lonlat: Array<[number, number]>;
|
||||
/** 실제 생성된 셀 구간 [행, 시작열, 끝열(포함)]. 낱개 셀 대신 구간으로 온다. */
|
||||
row_spans: Array<[number, number, number]>;
|
||||
};
|
||||
/** 최외곽 적색 셀 주변 확장 결과. */
|
||||
expansion: {
|
||||
rounds: number;
|
||||
/** 새로 추가한 셀에 적색이 없어 스스로 멈췄는가. */
|
||||
closed: boolean;
|
||||
added_cells: number;
|
||||
/** 확장 전(1차 영역) 셀 수. */
|
||||
initial_cells: number;
|
||||
};
|
||||
/** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 null. */
|
||||
flow: {
|
||||
encoding: "base64-uint8";
|
||||
/** 방위 분해능(32). 코드 0 = 화면 오른쪽, 시계방향 증가. */
|
||||
azimuth_steps: number;
|
||||
/** 제자리(더 낮은 이웃 없음)를 뜻하는 코드. */
|
||||
sink_code: number;
|
||||
/** 표고가 없어 판정 못한 셀 코드. */
|
||||
invalid_code: number;
|
||||
cells: number;
|
||||
reaches_road: number;
|
||||
no_road: number;
|
||||
/** 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. */
|
||||
unanalyzed: number;
|
||||
/** 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. */
|
||||
burned: number;
|
||||
outer_seeds: number;
|
||||
interior_seeds: number;
|
||||
/** 셀당 1바이트. 하위 6비트=32방위 코드(32=제자리, 33=무효), 0x80=도로 도달.
|
||||
* 순서는 grid.row_spans를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. */
|
||||
data: string;
|
||||
} | null;
|
||||
/** 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체의 외곽. */
|
||||
basin_polygon_lonlat: Array<[number, number]>;
|
||||
basin_area_m2: number;
|
||||
/** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. */
|
||||
strength_profile: Array<[number, number]>;
|
||||
/** 기본 관 매설 위치 — 도로 × 세류선 교차점. */
|
||||
pipes: DrainageCandidate[];
|
||||
/** 영구저장소에 남긴 검증용 GeoJSON 경로. */
|
||||
saved_to: string | null;
|
||||
}
|
||||
|
||||
export async function fetchDrainagePrimaryRegion(
|
||||
projectId: string,
|
||||
): Promise<DrainagePrimaryRegion> {
|
||||
// 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다.
|
||||
return requestJson<DrainagePrimaryRegion>(
|
||||
`/projects/${projectId}/drainage/primary-region`,
|
||||
{ method: "GET" },
|
||||
API_ANALYSIS_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchDrainageCandidates(
|
||||
projectId: string,
|
||||
): Promise<DrainageCandidateResponse> {
|
||||
return requestJson<DrainageCandidateResponse>(`/projects/${projectId}/drainage/candidates`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
/** chainages를 주면 그 위치로 확정 산정하고, 비우면 자동 제안분으로 산정한다. */
|
||||
export async function fetchDrainageBasins(
|
||||
projectId: string,
|
||||
|
||||
@@ -1,309 +1,14 @@
|
||||
"""배수유역 산정 엔진.
|
||||
|
||||
관 매설 구조물 측점 후보를 제안하고, 각 측점이 받는 배수유역 경계를 산정한다.
|
||||
지형 판단은 **도엽 등고선·세류선(하천중심선)**만 사용한다 — 3D 포인트클라우드나 지형
|
||||
메시는 쓰지 않고(2026-07-28 사용자 지시), 표고점도 유효 데이터가 적어 뺐다(2026-07-31).
|
||||
유역 경계 산정 자체는 격자 흐름 해석(`..._Engine_Watershed_Basin`)이 맡고, 이 모듈은
|
||||
측점 후보 제안과 노선 정점·누가거리 보간만 담당한다.
|
||||
"""배수 관경 산정.
|
||||
|
||||
유역을 나누는 최종 목적은 각 지점의 파이프 관경 결정이다. 유역 경사면에 100년 강우빈도를
|
||||
적용해 모이는 물의 양을 산정하고 그 유량으로 관경을 정한다. 관경 수식은 아직 미확정이라
|
||||
`estimate_pipe_diameter_mm()`은 골격만 두고 비워 둔다.
|
||||
적용해 모이는 물의 양을 산정하고 그 유량으로 관경을 정한다.
|
||||
|
||||
노선 기하(정점·누가거리·세류 교차점)는 `common_util_route_geometry`로 옮겼다 — B04 분석과
|
||||
B05 세부 설계가 같은 표현을 써야 하기 때문이다(2026-07-31 구조 개편).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import LineString, Point, shape
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 구조물 측점 사이 최대 허용 간격(m). 세류 교차가 없어도 이 간격을 넘으면 절토부에 추가 배치한다.
|
||||
MAX_STRUCTURE_SPACING_M = 300
|
||||
# 같은 세류 교차로 볼 최소 이격(m). 이보다 가까운 교차점은 하나로 묶는다.
|
||||
MIN_STRUCTURE_SPACING_M = 5.0
|
||||
# 유역 경계 탐색 반경(m). 측점에서 이 거리를 넘는 지형은 해당 유역으로 보지 않는다.
|
||||
MAX_BASIN_RADIUS_M = 1000.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteVertex:
|
||||
"""노선 폴리라인의 한 점. chainage는 시점 기준 누가거리(m)."""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
chainage_m: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class StructureCandidate:
|
||||
"""관 매설 구조물 측점 후보."""
|
||||
|
||||
chainage_m: float
|
||||
x: float
|
||||
y: float
|
||||
# "stream"=세류 교차, "spacing"=300m 규칙에 따른 보충 배치
|
||||
reason: str
|
||||
stream_name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DrainageBasin:
|
||||
"""한 구조물 측점이 받는 배수유역."""
|
||||
|
||||
index: int
|
||||
chainage_m: float
|
||||
outlet_x: float
|
||||
outlet_y: float
|
||||
polygon_lonlat: list[list[float]] = field(default_factory=list)
|
||||
area_m2: float = 0.0
|
||||
# 유역 최고 표고 − 측점 표고(m). 경사면 낙차.
|
||||
relief_m: float = 0.0
|
||||
# 유하거리: 측점에서 유역 최상단까지 물길 길이(m).
|
||||
flow_length_m: float = 0.0
|
||||
pipe_diameter_mm: float | None = None
|
||||
|
||||
|
||||
def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]:
|
||||
"""DB route_points 행을 누가거리가 채워진 정점 목록으로 바꾼다."""
|
||||
vertices: list[RouteVertex] = []
|
||||
cumulative = 0.0
|
||||
previous: tuple[float, float] | None = None
|
||||
for row in points:
|
||||
x = float(row["x"])
|
||||
y = float(row["y"])
|
||||
z = float(row.get("z") or 0.0)
|
||||
if previous is not None:
|
||||
cumulative += math.dist(previous, (x, y))
|
||||
chainage = row.get("chainage_m")
|
||||
vertices.append(
|
||||
RouteVertex(
|
||||
x=x,
|
||||
y=y,
|
||||
z=z,
|
||||
chainage_m=float(chainage) if chainage is not None else cumulative,
|
||||
)
|
||||
)
|
||||
previous = (x, y)
|
||||
return vertices
|
||||
|
||||
|
||||
def _interpolate_vertex(
|
||||
vertices: list[RouteVertex], chainage_m: float
|
||||
) -> tuple[float, float, float]:
|
||||
"""누가거리 위치의 (x, y, z)를 선형 보간한다."""
|
||||
if not vertices:
|
||||
return (0.0, 0.0, 0.0)
|
||||
if chainage_m <= vertices[0].chainage_m:
|
||||
return (vertices[0].x, vertices[0].y, vertices[0].z)
|
||||
for previous, current in zip(vertices, vertices[1:]):
|
||||
if chainage_m <= current.chainage_m:
|
||||
span = current.chainage_m - previous.chainage_m
|
||||
ratio = 0.0 if span <= 0 else (chainage_m - previous.chainage_m) / span
|
||||
return (
|
||||
previous.x + (current.x - previous.x) * ratio,
|
||||
previous.y + (current.y - previous.y) * ratio,
|
||||
previous.z + (current.z - previous.z) * ratio,
|
||||
)
|
||||
last = vertices[-1]
|
||||
return (last.x, last.y, last.z)
|
||||
|
||||
|
||||
def is_uphill_at(vertices: list[RouteVertex], chainage_m: float, window_m: float = 20.0) -> bool:
|
||||
"""해당 위치가 오르막(절토부)인지 판정한다.
|
||||
|
||||
내리막(성토부)은 물이 노선 바깥으로 흘러나가므로 배수유역을 만들지 않는다
|
||||
(2026-07-28 사용자 지시). 판정은 종단 계획선의 국소 기울기 부호로 한다.
|
||||
"""
|
||||
_, _, back_z = _interpolate_vertex(vertices, max(0.0, chainage_m - window_m))
|
||||
_, _, forward_z = _interpolate_vertex(vertices, chainage_m + window_m)
|
||||
return forward_z >= back_z
|
||||
|
||||
|
||||
def find_stream_crossings(
|
||||
vertices: list[RouteVertex],
|
||||
stream_features: list[dict[str, Any]],
|
||||
) -> list[StructureCandidate]:
|
||||
"""노선 평면 선형과 세류선의 교차 지점을 찾는다."""
|
||||
if len(vertices) < 2:
|
||||
return []
|
||||
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
|
||||
candidates: list[StructureCandidate] = []
|
||||
for feature in stream_features:
|
||||
geometry = feature.get("geometry")
|
||||
if not geometry:
|
||||
continue
|
||||
try:
|
||||
stream = shape(geometry)
|
||||
except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다
|
||||
continue
|
||||
if stream.is_empty:
|
||||
continue
|
||||
intersection = route_line.intersection(stream)
|
||||
if intersection.is_empty:
|
||||
continue
|
||||
name = _stream_name(feature)
|
||||
for point in _collect_points(intersection):
|
||||
candidates.append(
|
||||
StructureCandidate(
|
||||
chainage_m=route_line.project(point),
|
||||
x=point.x,
|
||||
y=point.y,
|
||||
reason="stream",
|
||||
stream_name=name,
|
||||
)
|
||||
)
|
||||
candidates.sort(key=lambda item: item.chainage_m)
|
||||
return candidates
|
||||
|
||||
|
||||
def _stream_name(feature: dict[str, Any]) -> str | None:
|
||||
properties = feature.get("properties") or {}
|
||||
for key in ("명칭", "하천명", "NAME", "name"):
|
||||
value = properties.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
def _collect_points(geometry: Any) -> list[Point]:
|
||||
"""교차 결과(Point/MultiPoint/LineString 등)에서 대표 점들을 뽑는다."""
|
||||
if geometry.geom_type == "Point":
|
||||
return [geometry]
|
||||
if geometry.geom_type in {"MultiPoint", "GeometryCollection"}:
|
||||
points: list[Point] = []
|
||||
for part in geometry.geoms:
|
||||
points.extend(_collect_points(part))
|
||||
return points
|
||||
# 선분끼리 겹쳐 선으로 나온 경우는 중점을 대표로 쓴다.
|
||||
if geometry.geom_type in {"LineString", "MultiLineString"}:
|
||||
return [geometry.interpolate(0.5, normalized=True)]
|
||||
return []
|
||||
|
||||
|
||||
def propose_structure_stations(
|
||||
vertices: list[RouteVertex],
|
||||
stream_features: list[dict[str, Any]],
|
||||
) -> list[StructureCandidate]:
|
||||
"""구조물 측점 후보를 제안한다.
|
||||
|
||||
① 세류 교차 지점 ② 내리막(성토부) 제외 ③ 직전 측점에서 300m 초과 시 절토부에 보충 배치.
|
||||
"""
|
||||
if len(vertices) < 2:
|
||||
return []
|
||||
total_length = vertices[-1].chainage_m
|
||||
crossings = [
|
||||
candidate
|
||||
for candidate in find_stream_crossings(vertices, stream_features)
|
||||
if is_uphill_at(vertices, candidate.chainage_m)
|
||||
]
|
||||
|
||||
# 너무 가까운 교차는 하나로 본다(같은 계곡을 여러 선분이 지나는 경우).
|
||||
merged: list[StructureCandidate] = []
|
||||
for candidate in crossings:
|
||||
if merged and candidate.chainage_m - merged[-1].chainage_m < MIN_STRUCTURE_SPACING_M:
|
||||
continue
|
||||
merged.append(candidate)
|
||||
|
||||
# 300m 규칙: 빈 구간에 절토부 지점을 찾아 보충한다.
|
||||
filled: list[StructureCandidate] = []
|
||||
previous_chainage = 0.0
|
||||
for candidate in [*merged, None]:
|
||||
boundary = candidate.chainage_m if candidate else total_length
|
||||
filled.extend(_fill_spacing(vertices, previous_chainage, boundary))
|
||||
if candidate:
|
||||
filled.append(candidate)
|
||||
previous_chainage = candidate.chainage_m
|
||||
else:
|
||||
previous_chainage = boundary
|
||||
filled.sort(key=lambda item: item.chainage_m)
|
||||
return filled
|
||||
|
||||
|
||||
def _fill_spacing(
|
||||
vertices: list[RouteVertex],
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
) -> list[StructureCandidate]:
|
||||
"""[start, end] 구간이 300m를 넘으면 보충 측점을 만든다.
|
||||
|
||||
종단도상 상대적으로 물이 모일 것으로 예상되는 지점(절토부 내 종단 저점)을
|
||||
우선 배치한다(2026-07-29 사용자 지시). 저점이 없으면 목표 인근 절토부로 대체한다.
|
||||
"""
|
||||
added: list[StructureCandidate] = []
|
||||
cursor = start_m
|
||||
while end_m - cursor > MAX_STRUCTURE_SPACING_M:
|
||||
target = cursor + MAX_STRUCTURE_SPACING_M
|
||||
placed = _gather_low_point(vertices, cursor, target, end_m)
|
||||
if placed is None:
|
||||
placed = _nearest_uphill(vertices, target, end_m)
|
||||
if placed is None:
|
||||
# 도로 연장 기준 300m 규칙 — 저점·절토부가 없어도 관 배치는 보장한다
|
||||
# (2026-07-29 사용자 지시: 도로 340m면 최소 1개).
|
||||
placed = min(target, (cursor + end_m) / 2.0)
|
||||
x, y, _ = _interpolate_vertex(vertices, placed)
|
||||
added.append(StructureCandidate(chainage_m=placed, x=x, y=y, reason="spacing"))
|
||||
cursor = placed
|
||||
return added
|
||||
|
||||
|
||||
def _gather_low_point(
|
||||
vertices: list[RouteVertex],
|
||||
cursor_m: float,
|
||||
target_m: float,
|
||||
limit_m: float,
|
||||
step_m: float = 10.0,
|
||||
) -> float | None:
|
||||
"""탐색창 [cursor+150, target] 안 절토부의 종단 국소 저점(사그) 중 가장 낮은 지점.
|
||||
|
||||
창 하한을 간격의 절반으로 두어 보충 측점이 과밀하게 몰리지 않게 하고,
|
||||
국소 저점만 인정해 일정 오르막에서는 None(300m 규칙 폴백)을 돌려준다.
|
||||
"""
|
||||
window_start = cursor_m + MAX_STRUCTURE_SPACING_M / 2.0
|
||||
probes: list[float] = []
|
||||
probe = window_start - step_m
|
||||
while probe <= target_m + step_m:
|
||||
probes.append(probe)
|
||||
probe += step_m
|
||||
heights = [_interpolate_vertex(vertices, position)[2] for position in probes]
|
||||
best: tuple[float, float] | None = None # (계획고 z, 누가거리)
|
||||
for i in range(1, len(probes) - 1):
|
||||
position = probes[i]
|
||||
if position >= limit_m or position > target_m or position < window_start:
|
||||
continue
|
||||
# 국소 저점(양쪽이 같거나 높음) = 물이 모여 더 못 흐르는 지점. 앞쪽이 오르막인
|
||||
# 조건을 내포하므로 별도의 절토부(is_uphill_at) 판정은 두지 않는다.
|
||||
if heights[i] > heights[i - 1] or heights[i] > heights[i + 1]:
|
||||
continue
|
||||
if best is None or heights[i] < best[0]:
|
||||
best = (heights[i], position)
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def _nearest_uphill(
|
||||
vertices: list[RouteVertex],
|
||||
target_m: float,
|
||||
limit_m: float,
|
||||
step_m: float = 10.0,
|
||||
) -> float | None:
|
||||
"""목표 위치에서 가장 가까운 절토부(오르막) 지점을 찾는다. 없으면 None."""
|
||||
if is_uphill_at(vertices, target_m):
|
||||
return target_m
|
||||
offset = step_m
|
||||
while offset <= MAX_STRUCTURE_SPACING_M / 2:
|
||||
for probe in (target_m - offset, target_m + offset):
|
||||
if probe <= 0 or probe >= limit_m:
|
||||
continue
|
||||
if is_uphill_at(vertices, probe):
|
||||
return probe
|
||||
offset += step_m
|
||||
return None
|
||||
|
||||
|
||||
def estimate_pipe_diameter_mm(
|
||||
area_m2: float,
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
"""배수유역 세부 설계 (B05 — 일반 사용자용).
|
||||
|
||||
**분석은 하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어, 사용자가 실제로 손대는 두 가지만
|
||||
처리한다(2026-07-31 사용자 지시).
|
||||
|
||||
⑨ 관 간격이 최대치를 넘는 구간에 **최소 개수**로 관을 보충
|
||||
⑩ 측구 흐름으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의 담당 관을 그대로
|
||||
그 셀의 유역 번호로 삼아 세부유역을 나눈다
|
||||
⑪ 사용자가 관을 옮기거나 추가하면 ⑩만 다시 돈다 — 격자 해석은 재사용한다
|
||||
|
||||
읽어 오는 것(`B04_wf1_Surface/drainage/`):
|
||||
· `03_road_routing.geojson` — 계획도로선 · 기본 배관 · 2차 전체 배수유역
|
||||
· `03_road_routing.npz` — 셀 → 도로 셀 귀속, 유하장, 강도, 도로 셀 제원, 셀 표고
|
||||
화살표(방향 코드)나 밴드 표고 같은 관리자 확인용 배열은 읽지 않는다 — 여기서는 필요 없고
|
||||
파일만 무거워진다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import STAGES, drainage_dir
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import largest_ring, polygonize_labels
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import estimate_pipe_diameter_mm
|
||||
from common_util.common_util_route_geometry import (
|
||||
RouteVertex,
|
||||
StructureCandidate,
|
||||
interpolate_vertex,
|
||||
is_uphill_at,
|
||||
)
|
||||
from config.config_system import (
|
||||
DRAINAGE_DITCH_SAMPLE_M,
|
||||
DRAINAGE_PIPE_MAX_SPACING_M,
|
||||
DRAINAGE_PIPE_MIN_SPACING_M,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조.
|
||||
_SCORE_WEIGHT_STRENGTH = 0.7
|
||||
_SCORE_WEIGHT_SAG = 0.3
|
||||
# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다.
|
||||
_SCORE_FILL_PENALTY = 0.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class DrainageDetail:
|
||||
"""B05 산출물 — 화면에 그릴 기하와 세부유역."""
|
||||
|
||||
route_lonlat: list[list[float]] = field(default_factory=list)
|
||||
basin_lonlat: list[list[float]] = field(default_factory=list)
|
||||
pipes: list[StructureCandidate] = field(default_factory=list)
|
||||
basins: list[WatershedBasin] = field(default_factory=list)
|
||||
grid_cell_m: float = 1.0
|
||||
|
||||
|
||||
def build_drainage_detail(
|
||||
stored_path: str,
|
||||
vertices: list[RouteVertex],
|
||||
confirmed_chainages: list[float] | None = None,
|
||||
) -> DrainageDetail | None:
|
||||
"""B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다.
|
||||
|
||||
`confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집), 비우면 B04의
|
||||
기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다.
|
||||
"""
|
||||
routing = load_road_routing(stored_path)
|
||||
if routing is None or len(vertices) < 2:
|
||||
return None
|
||||
|
||||
if confirmed_chainages:
|
||||
pipes = _pipes_from_chainages(vertices, confirmed_chainages)
|
||||
else:
|
||||
# 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다.
|
||||
base = [
|
||||
StructureCandidate(
|
||||
chainage_m=pipe.chainage_m,
|
||||
x=interpolate_vertex(vertices, pipe.chainage_m)[0],
|
||||
y=interpolate_vertex(vertices, pipe.chainage_m)[1],
|
||||
reason=pipe.reason,
|
||||
)
|
||||
for pipe in routing.base_pipes
|
||||
]
|
||||
pipes = place_pipes(vertices, base, routing.strength_curve)
|
||||
|
||||
detail = DrainageDetail(
|
||||
route_lonlat=routing.route_lonlat,
|
||||
basin_lonlat=routing.basin_lonlat,
|
||||
pipes=pipes,
|
||||
grid_cell_m=routing.spec.cell_m,
|
||||
)
|
||||
if not pipes:
|
||||
return detail
|
||||
pipe_of_slot = assign_road_cells_to_pipes(vertices, pipes, routing.road_chainage)
|
||||
detail.basins = assemble_basins(routing, pipes, pipe_of_slot)
|
||||
logger.info(
|
||||
"배수유역: 세부 설계 — 관 %d개(기본 %d + 보충 %d), 세부유역 %d개",
|
||||
len(pipes),
|
||||
sum(1 for pipe in pipes if pipe.reason != "spacing"),
|
||||
sum(1 for pipe in pipes if pipe.reason == "spacing"),
|
||||
len(detail.basins),
|
||||
)
|
||||
return detail
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatershedBasin:
|
||||
"""관 하나가 받는 세부 배수유역."""
|
||||
|
||||
index: int
|
||||
chainage_m: float
|
||||
outlet_x: float
|
||||
outlet_y: float
|
||||
boundary_xy: list[tuple[float, float]] = field(default_factory=list)
|
||||
area_m2: float = 0.0
|
||||
relief_m: float = 0.0
|
||||
flow_length_m: float = 0.0
|
||||
pipe_diameter_mm: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoadRouting:
|
||||
"""B04가 남긴 배수유역 분석 결과 — B05가 세부유역을 나누는 데 필요한 최소 묶음."""
|
||||
|
||||
spec: GridSpec
|
||||
# (R*C,) int32 — 셀이 물길을 따라 도달하는 도로 셀 슬롯(−1 = 미도달).
|
||||
road_slot: np.ndarray
|
||||
path_length: np.ndarray # (R*C,) float32 — 그 도로 셀까지 물길 길이(m)
|
||||
elevation: np.ndarray # (R*C,) float32 — 셀 표고(유역 낙차 계산용)
|
||||
road_cell_index: np.ndarray # (K,) int32 — 도로 셀의 평탄 인덱스
|
||||
road_chainage: np.ndarray # (K,) float64 — 도로 셀의 누가거리(m)
|
||||
strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수
|
||||
# 화면에 그대로 그릴 기하(WGS84 lon/lat).
|
||||
route_lonlat: list[list[float]] = field(default_factory=list)
|
||||
basin_lonlat: list[list[float]] = field(default_factory=list)
|
||||
base_pipes: list[StructureCandidate] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def strength_curve(self) -> np.ndarray:
|
||||
"""누가거리 1m 구간별 유입 면적(㎡) 곡선 — 관 보충 위치 점수의 근거."""
|
||||
if self.road_chainage.size == 0:
|
||||
return np.zeros(1)
|
||||
bins = max(1, int(np.ceil(self.road_chainage.max())) + 1)
|
||||
index = np.clip(np.round(self.road_chainage).astype(np.int64), 0, bins - 1)
|
||||
weights = self.strength.astype(np.float64) * self.spec.cell_area_m2
|
||||
return np.bincount(index, weights=weights, minlength=bins)
|
||||
|
||||
|
||||
def load_road_routing(stored_path: str) -> RoadRouting | None:
|
||||
"""B04가 남긴 `03_road_routing` 산출물을 읽는다. 없으면 None."""
|
||||
directory = drainage_dir(stored_path)
|
||||
prefix = STAGES["road_routing"]
|
||||
array_path = directory / f"{prefix}_road_routing.npz"
|
||||
if not array_path.exists():
|
||||
logger.warning("배수유역: B04 분석 결과가 없습니다 (%s).", array_path)
|
||||
return None
|
||||
try:
|
||||
with np.load(array_path, allow_pickle=False) as data:
|
||||
spec = GridSpec(
|
||||
x_min=float(data["x_min"]),
|
||||
y_max=float(data["y_max"]),
|
||||
cell_m=float(data["cell_m"]),
|
||||
n_rows=int(data["n_rows"]),
|
||||
n_cols=int(data["n_cols"]),
|
||||
)
|
||||
routing = RoadRouting(
|
||||
spec=spec,
|
||||
road_slot=data["road_slot"].reshape(-1),
|
||||
path_length=data["path_length"].reshape(-1),
|
||||
elevation=data["elevation"].reshape(-1),
|
||||
road_cell_index=data["road_cell_index"],
|
||||
road_chainage=data["road_chainage"],
|
||||
strength=data["strength"],
|
||||
)
|
||||
except (OSError, KeyError, ValueError):
|
||||
logger.warning("배수유역: B04 분석 결과를 읽지 못했습니다 (%s).", array_path)
|
||||
return None
|
||||
|
||||
_read_geometry(directory / f"{prefix}_road_routing.geojson", routing)
|
||||
logger.info(
|
||||
"배수유역: B04 결과 로드 — 격자 %d×%d, 도로 셀 %d, 기본 관 %d",
|
||||
spec.n_rows,
|
||||
spec.n_cols,
|
||||
routing.road_cell_index.size,
|
||||
len(routing.base_pipes),
|
||||
)
|
||||
return routing
|
||||
|
||||
|
||||
def _read_geometry(path: Path, routing: RoadRouting) -> None:
|
||||
"""계획도로선·2차 유역 외곽선·기본 관을 GeoJSON에서 읽어 채운다."""
|
||||
if not path.exists():
|
||||
logger.warning("배수유역: B04 기하 산출물이 없습니다 (%s).", path)
|
||||
return
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
document = json.load(file)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("배수유역: B04 기하 산출물을 읽지 못했습니다 (%s).", path)
|
||||
return
|
||||
for feature in document.get("features", []):
|
||||
properties = feature.get("properties") or {}
|
||||
geometry = feature.get("geometry") or {}
|
||||
coordinates = geometry.get("coordinates")
|
||||
kind = properties.get("kind")
|
||||
if kind == "route" and geometry.get("type") == "LineString":
|
||||
routing.route_lonlat = coordinates
|
||||
elif kind == "basin_boundary" and geometry.get("type") == "Polygon" and coordinates:
|
||||
routing.basin_lonlat = coordinates[0]
|
||||
elif kind == "pipe" and geometry.get("type") == "Point":
|
||||
routing.base_pipes.append(
|
||||
StructureCandidate(
|
||||
chainage_m=float(properties.get("chainage_m") or 0.0),
|
||||
x=0.0,
|
||||
y=0.0,
|
||||
reason=str(properties.get("reason") or "stream"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ── ⑨ 관 최소 개수 보충 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def place_pipes(
|
||||
vertices: list[RouteVertex],
|
||||
base_pipes: list[StructureCandidate],
|
||||
strength_curve: np.ndarray,
|
||||
) -> list[StructureCandidate]:
|
||||
"""B04가 정한 기본 관(세류 교차점)에, 최대 간격을 넘는 구간만 최소 개수로 보충한다.
|
||||
|
||||
기본 관은 여기서 다시 찾지 않는다 — B04 산출물에 이미 들어 있다.
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
base: list[StructureCandidate] = []
|
||||
for candidate in sorted(base_pipes, key=lambda item: item.chainage_m):
|
||||
if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M:
|
||||
continue
|
||||
base.append(candidate)
|
||||
|
||||
filled: list[StructureCandidate] = []
|
||||
previous = 0.0
|
||||
for candidate in [*base, None]:
|
||||
boundary = candidate.chainage_m if candidate else total_length
|
||||
filled.extend(_fill_gap(vertices, strength_curve, previous, boundary))
|
||||
if candidate:
|
||||
filled.append(candidate)
|
||||
previous = candidate.chainage_m
|
||||
else:
|
||||
previous = boundary
|
||||
filled.sort(key=lambda item: item.chainage_m)
|
||||
return filled
|
||||
|
||||
|
||||
def _fill_gap(
|
||||
vertices: list[RouteVertex],
|
||||
strength_curve: np.ndarray,
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
) -> list[StructureCandidate]:
|
||||
"""[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다.
|
||||
|
||||
필요 개수 n은 구간 길이로 정해지고(ceil(L/max) − 1), 각 관은 등분 위치를 중심으로
|
||||
허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고
|
||||
종단이 낮은 지점으로 붙는다.
|
||||
"""
|
||||
span = end_m - start_m
|
||||
if span <= DRAINAGE_PIPE_MAX_SPACING_M:
|
||||
return []
|
||||
count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1
|
||||
if count <= 0:
|
||||
return []
|
||||
spacing = span / (count + 1)
|
||||
slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0)
|
||||
placed: list[StructureCandidate] = []
|
||||
for order in range(1, count + 1):
|
||||
nominal = start_m + spacing * order
|
||||
low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack)
|
||||
high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack)
|
||||
chosen = _best_position(vertices, strength_curve, low, high, nominal)
|
||||
x, y, _ = interpolate_vertex(vertices, chosen)
|
||||
placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing"))
|
||||
return placed
|
||||
|
||||
|
||||
def _best_position(
|
||||
vertices: list[RouteVertex],
|
||||
strength_curve: np.ndarray,
|
||||
low_m: float,
|
||||
high_m: float,
|
||||
fallback_m: float,
|
||||
) -> float:
|
||||
"""허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다."""
|
||||
if high_m <= low_m:
|
||||
return fallback_m
|
||||
positions = np.arange(low_m, high_m + 1.0, 1.0)
|
||||
if positions.size == 0:
|
||||
return fallback_m
|
||||
index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1)
|
||||
strength = strength_curve[index]
|
||||
heights = np.array([interpolate_vertex(vertices, float(p))[2] for p in positions])
|
||||
|
||||
strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength)
|
||||
height_span = float(heights.max() - heights.min())
|
||||
sag_score = (
|
||||
(heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights)
|
||||
)
|
||||
score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score
|
||||
for order, position in enumerate(positions):
|
||||
if not is_uphill_at(vertices, float(position)):
|
||||
score[order] *= _SCORE_FILL_PENALTY
|
||||
return float(positions[int(np.argmax(score))])
|
||||
|
||||
|
||||
def _pipes_from_chainages(
|
||||
vertices: list[RouteVertex], chainages: list[float]
|
||||
) -> list[StructureCandidate]:
|
||||
"""사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다.
|
||||
|
||||
노선 밖 값은 시·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 −50m처럼
|
||||
나와 좌표와 표기가 어긋난다.
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages}
|
||||
pipes: list[StructureCandidate] = []
|
||||
for value in sorted(clamped):
|
||||
x, y, _ = interpolate_vertex(vertices, value)
|
||||
pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed"))
|
||||
return pipes
|
||||
|
||||
|
||||
# ── ⑩ 측구 흐름으로 도로 셀 → 담당 관 ───────────────────────────────────────
|
||||
|
||||
|
||||
def assign_road_cells_to_pipes(
|
||||
vertices: list[RouteVertex],
|
||||
pipes: list[StructureCandidate],
|
||||
road_chainage: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다.
|
||||
|
||||
노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고
|
||||
같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은
|
||||
가장 가까운 관이 받는 것으로 본다.
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5)
|
||||
stations = np.arange(0.0, total_length + step, step)
|
||||
heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations])
|
||||
pipe_chainages = np.array([pipe.chainage_m for pipe in pipes])
|
||||
pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1)
|
||||
|
||||
# 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리).
|
||||
back_z = np.full(stations.size, np.inf)
|
||||
back_z[1:] = heights[:-1]
|
||||
forward_z = np.full(stations.size, np.inf)
|
||||
forward_z[:-1] = heights[1:]
|
||||
go_back = (back_z < heights) & (back_z <= forward_z)
|
||||
go_forward = (forward_z < heights) & ~go_back
|
||||
receiver = np.arange(stations.size, dtype=np.int64)
|
||||
receiver[go_back] -= 1
|
||||
receiver[go_forward] += 1
|
||||
receiver[pipe_station] = pipe_station # 관은 물을 흡수한다
|
||||
|
||||
owner = np.full(stations.size, -1, dtype=np.int64)
|
||||
owner[pipe_station] = np.arange(pipe_chainages.size)
|
||||
jump = receiver
|
||||
for _ in range(40):
|
||||
next_jump = jump[jump]
|
||||
if np.array_equal(next_jump, jump):
|
||||
break
|
||||
jump = next_jump
|
||||
resolved = owner[jump]
|
||||
# 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다.
|
||||
orphan = resolved < 0
|
||||
if orphan.any() and pipe_chainages.size:
|
||||
nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1)
|
||||
resolved[orphan] = nearest
|
||||
|
||||
slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1)
|
||||
return resolved[slot_station].astype(np.int32)
|
||||
|
||||
|
||||
# ── ⑩ 세부유역 조립 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def assemble_basins(
|
||||
solution: RoadRouting,
|
||||
pipes: list[StructureCandidate],
|
||||
pipe_of_slot: np.ndarray,
|
||||
) -> list[WatershedBasin]:
|
||||
"""셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다."""
|
||||
spec = solution.spec
|
||||
labels = np.full(spec.size, -1, dtype=np.int32)
|
||||
reached = solution.road_slot >= 0
|
||||
labels[reached] = pipe_of_slot[solution.road_slot[reached]]
|
||||
|
||||
polygons = polygonize_labels(spec, labels)
|
||||
cell_area = spec.cell_area_m2
|
||||
basins: list[WatershedBasin] = []
|
||||
for order, pipe in enumerate(pipes):
|
||||
member = labels == order
|
||||
count = int(member.sum())
|
||||
if count == 0:
|
||||
continue
|
||||
geometry = polygons.get(order)
|
||||
elevations = solution.elevation[member]
|
||||
highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0
|
||||
outlet_z = _outlet_elevation(solution, order, pipe_of_slot)
|
||||
area = count * cell_area
|
||||
relief = max(0.0, highest - outlet_z)
|
||||
flow_length = float(solution.path_length[member].max())
|
||||
basins.append(
|
||||
WatershedBasin(
|
||||
index=len(basins) + 1,
|
||||
chainage_m=pipe.chainage_m,
|
||||
outlet_x=pipe.x,
|
||||
outlet_y=pipe.y,
|
||||
boundary_xy=largest_ring(geometry) if geometry is not None else [],
|
||||
area_m2=area,
|
||||
relief_m=relief,
|
||||
flow_length_m=flow_length,
|
||||
pipe_diameter_mm=estimate_pipe_diameter_mm(area, relief, flow_length),
|
||||
)
|
||||
)
|
||||
return basins
|
||||
|
||||
|
||||
def _outlet_elevation(solution: RoadRouting, pipe_order: int, pipe_of_slot: np.ndarray) -> float:
|
||||
"""관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고."""
|
||||
slots = np.flatnonzero(pipe_of_slot == pipe_order)
|
||||
if slots.size == 0:
|
||||
return 0.0
|
||||
elevations = solution.elevation[solution.road_cell_index[slots]]
|
||||
finite = elevations[np.isfinite(elevations)]
|
||||
return float(finite.min()) if finite.size else 0.0
|
||||
@@ -1,723 +0,0 @@
|
||||
"""배수유역 산정 오케스트레이터 — 격자 해석 · 관 배치 · 세부유역 조립.
|
||||
|
||||
전체 흐름
|
||||
① 등고선 정리 → 상류 세류선 추출 → 1차 격자 범위(반경 버퍼 bbox)
|
||||
② 격자 지형 해석(TIN 보간 · 채움 · D8) → 도로에서 상류 추적
|
||||
③ 활성 셀이 격자 최외곽에 닿으면 그 방향으로만 넓혀 다시 해석 (경계 링이 전부
|
||||
비활성이 되면 정지 — 하드 반경 상한이 아니라 흐름 자체가 종료 조건이다)
|
||||
④ 도로 셀별 흐름 강도(상류 셀 수) 산출 → 2차 전체 배수유역 외곽선 확정
|
||||
⑤ 관 배치: 세류 교차점이 기본, 간격이 최대치를 넘으면 흐름 강도·종단 저점을 보고
|
||||
**최소 개수**만 보충
|
||||
⑥ 측구 흐름(종단 내리막)으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의
|
||||
담당 관을 그대로 그 셀의 유역 번호로 삼아 세부유역을 나눈다
|
||||
|
||||
②~④는 관 배치와 무관하므로 `.npz`로 캐시한다. 사용자가 관을 옮기거나 추가하면
|
||||
⑥만 다시 돌면 되고 격자 해석은 재사용한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from shapely.geometry import LineString
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import (
|
||||
RouteVertex,
|
||||
StructureCandidate,
|
||||
_interpolate_vertex,
|
||||
estimate_pipe_diameter_mm,
|
||||
find_stream_crossings,
|
||||
is_uphill_at,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Descent import ContourDescent
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Expand import expand_by_red_boundary
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import (
|
||||
FlowClassification,
|
||||
RoadRaster,
|
||||
expand_until_closed,
|
||||
largest_ring,
|
||||
outer_boundary,
|
||||
polygonize_labels,
|
||||
trace_flow,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import (
|
||||
GridSpec,
|
||||
TerrainGrid,
|
||||
build_contour_cloud,
|
||||
route_elevation_floor,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Stream import (
|
||||
PrimaryRegion,
|
||||
build_primary_region,
|
||||
)
|
||||
from config.config_system import (
|
||||
DRAINAGE_DITCH_SAMPLE_M,
|
||||
DRAINAGE_EXPAND_STEP_M,
|
||||
DRAINAGE_GRID_SIZE_M,
|
||||
DRAINAGE_INITIAL_RADIUS_M,
|
||||
DRAINAGE_MAX_EXPAND_ROUNDS,
|
||||
DRAINAGE_PIPE_MAX_SPACING_M,
|
||||
DRAINAGE_PIPE_MIN_SPACING_M,
|
||||
DRAINAGE_ROAD_WIDTH_M,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 강도 곡선 응답 간격(m). 도로 위 흐름 강도 히트 표기는 이 간격으로 내보낸다.
|
||||
_STRENGTH_OUTPUT_STEP_M = 5.0
|
||||
# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조.
|
||||
_SCORE_WEIGHT_STRENGTH = 0.7
|
||||
_SCORE_WEIGHT_SAG = 0.3
|
||||
# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다.
|
||||
_SCORE_FILL_PENALTY = 0.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatershedBasin:
|
||||
"""관 하나가 받는 세부 배수유역."""
|
||||
|
||||
index: int
|
||||
chainage_m: float
|
||||
outlet_x: float
|
||||
outlet_y: float
|
||||
boundary_xy: list[tuple[float, float]] = field(default_factory=list)
|
||||
area_m2: float = 0.0
|
||||
relief_m: float = 0.0
|
||||
flow_length_m: float = 0.0
|
||||
pipe_diameter_mm: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatershedResult:
|
||||
"""배수유역 산정 결과 일체."""
|
||||
|
||||
basins: list[WatershedBasin] = field(default_factory=list)
|
||||
pipes: list[StructureCandidate] = field(default_factory=list)
|
||||
# 2차 전체 배수유역 외곽선(= 분수령). 세부유역 경계는 이 안쪽에서만 그어진다.
|
||||
main_boundary_xy: list[tuple[float, float]] = field(default_factory=list)
|
||||
# 도로 위 흐름 강도 곡선 — (누가거리 m, 그 지점으로 모이는 상류 면적 ㎡).
|
||||
strength_profile: list[tuple[float, float]] = field(default_factory=list)
|
||||
grid_cell_m: float = DRAINAGE_GRID_SIZE_M
|
||||
|
||||
|
||||
@dataclass
|
||||
class _GridSolution:
|
||||
"""관 배치와 무관한 격자 해석 결과 묶음(캐시 대상)."""
|
||||
|
||||
spec: GridSpec
|
||||
elevation: np.ndarray # (R*C,) float32
|
||||
road_cell_index: np.ndarray # (K,) int32
|
||||
road_chainage: np.ndarray # (K,) float64
|
||||
road_slot: np.ndarray # (R*C,) int32 — 셀이 도달한 도로 셀 슬롯(−1=미도달)
|
||||
path_length: np.ndarray # (R*C,) float32
|
||||
strength: np.ndarray # (K,) int64
|
||||
active: np.ndarray # (R*C,) bool
|
||||
signature: str
|
||||
|
||||
|
||||
# ── 진입점 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_drainage_watershed(
|
||||
vertices: list[RouteVertex],
|
||||
contour_features: list[dict[str, Any]],
|
||||
stream_features: list[dict[str, Any]],
|
||||
confirmed_chainages: list[float] | None = None,
|
||||
cache_path: Path | None = None,
|
||||
) -> WatershedResult:
|
||||
"""배수유역과 관 배치를 산정한다.
|
||||
|
||||
`confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집), 비우면 세류
|
||||
교차 + 최소 보충으로 자동 배치한다. 두 경우 모두 격자 해석은 캐시를 재사용한다.
|
||||
"""
|
||||
if len(vertices) < 2:
|
||||
return WatershedResult()
|
||||
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
|
||||
solution = _solve_grid(vertices, route_line, contour_features, stream_features, cache_path)
|
||||
if solution is None or solution.road_cell_index.size == 0:
|
||||
return WatershedResult()
|
||||
|
||||
strength_area = solution.strength.astype(np.float64) * solution.spec.cell_area_m2
|
||||
strength_curve = _strength_by_chainage(solution.road_chainage, strength_area, route_line.length)
|
||||
|
||||
if confirmed_chainages:
|
||||
pipes = _pipes_from_chainages(vertices, confirmed_chainages)
|
||||
else:
|
||||
pipes = _place_pipes(vertices, stream_features, strength_curve)
|
||||
if not pipes:
|
||||
return WatershedResult(
|
||||
main_boundary_xy=_main_boundary(solution),
|
||||
strength_profile=_downsample_strength(strength_curve),
|
||||
grid_cell_m=solution.spec.cell_m,
|
||||
)
|
||||
|
||||
pipe_of_slot = _assign_road_cells_to_pipes(vertices, pipes, solution.road_chainage)
|
||||
basins = _assemble_basins(solution, pipes, pipe_of_slot)
|
||||
return WatershedResult(
|
||||
basins=basins,
|
||||
pipes=pipes,
|
||||
main_boundary_xy=_main_boundary(solution),
|
||||
strength_profile=_downsample_strength(strength_curve),
|
||||
grid_cell_m=solution.spec.cell_m,
|
||||
)
|
||||
|
||||
|
||||
# ── ①~② 1차 배수유역 (단계 검증 대상) ──────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_primary_region(
|
||||
vertices: list[RouteVertex],
|
||||
route_line: LineString,
|
||||
contour_features: list[dict[str, Any]],
|
||||
stream_features: list[dict[str, Any]],
|
||||
) -> PrimaryRegion | None:
|
||||
"""도로 교차 세류선(상류측)과 노선을 반경 버퍼한 1차 배수유역과 격자 범위를 정한다.
|
||||
|
||||
상·하류 판정에 쓸 등고선은 노선 주변만 있으면 된다(교차점이 전부 노선 위이므로).
|
||||
도엽 전체를 읽으면 이 단계에서만 수십 초가 날아간다.
|
||||
"""
|
||||
floor = route_elevation_floor([vertex.z for vertex in vertices])
|
||||
near_bounds = route_line.buffer(DRAINAGE_INITIAL_RADIUS_M * 2.0).bounds
|
||||
cloud = build_contour_cloud(contour_features, floor, near_bounds)
|
||||
if cloud.is_empty:
|
||||
logger.warning("배수유역: 노선 주변에 등고선이 없어 1차 영역을 정할 수 없습니다.")
|
||||
return None
|
||||
return build_primary_region(
|
||||
route_line, stream_features, cloud, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_GRID_SIZE_M
|
||||
)
|
||||
|
||||
|
||||
def preview_primary_region(
|
||||
vertices: list[RouteVertex],
|
||||
contour_features: list[dict[str, Any]],
|
||||
stream_features: list[dict[str, Any]],
|
||||
) -> PrimaryRegion | None:
|
||||
"""단계 검증용 — TIN·흐름 계산 없이 1차 배수유역 근거만 뽑는다."""
|
||||
if len(vertices) < 2:
|
||||
return None
|
||||
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
|
||||
return resolve_primary_region(vertices, route_line, contour_features, stream_features)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StagePreview:
|
||||
"""단계 검증 산출물 묶음. 기능을 붙일 때마다 여기에 항목이 하나씩 늘어난다.
|
||||
|
||||
확장을 거치면 격자와 해석 영역이 1차 영역보다 커진다. 화면·저장은 `region.spec`이
|
||||
아니라 여기 `spec`/`domain`을 봐야 한다.
|
||||
"""
|
||||
|
||||
region: PrimaryRegion
|
||||
spec: GridSpec | None = None
|
||||
domain: np.ndarray | None = None
|
||||
terrain: TerrainGrid | None = None
|
||||
road: RoadRaster | None = None
|
||||
flow: FlowClassification | None = None
|
||||
descent: ContourDescent | None = None
|
||||
expand_rounds: int = 0
|
||||
expand_closed: bool = False
|
||||
expand_added_cells: int = 0
|
||||
# ⑥ 도로 위 흐름 강도 — (누가거리 m, 그 구간으로 모이는 상류 면적 ㎡).
|
||||
strength_profile: list[tuple[float, float]] = field(default_factory=list)
|
||||
# ⑦ 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체를 폴리곤화한 것.
|
||||
basin_boundary_xy: list[tuple[float, float]] = field(default_factory=list)
|
||||
basin_area_m2: float = 0.0
|
||||
# ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점.
|
||||
pipes: list[StructureCandidate] = field(default_factory=list)
|
||||
|
||||
|
||||
def preview_stages(
|
||||
vertices: list[RouteVertex],
|
||||
contour_features: list[dict[str, Any]],
|
||||
stream_features: list[dict[str, Any]],
|
||||
) -> StagePreview | None:
|
||||
"""지금까지 구현·검증된 단계를 순서대로 돌려 결과를 모은다.
|
||||
|
||||
현재 포함: ① 1차 배수유역 ② 격자 생성 ③ **등고선 하강 방향** ④ 도로 도달 판정
|
||||
⑤ **최외곽 적색 셀 주변 확장**.
|
||||
|
||||
③은 보간면(TIN)을 쓰지 않는다. 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을
|
||||
세우므로 가짜 웅덩이·평탄면이 원리적으로 생기지 않는다(2026-07-31 사용자 지시로 방식 교체).
|
||||
|
||||
⑤는 최외곽에 적색이 남아 있으면 그 주변으로 넓혀 다시 분석하고, **새로 추가한 셀에
|
||||
적색이 없으면** 멈춘다.
|
||||
"""
|
||||
if len(vertices) < 2:
|
||||
return None
|
||||
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
|
||||
region = resolve_primary_region(vertices, route_line, contour_features, stream_features)
|
||||
if region is None:
|
||||
return None
|
||||
|
||||
started = time.perf_counter()
|
||||
floor = route_elevation_floor([vertex.z for vertex in vertices])
|
||||
expansion = expand_by_red_boundary(
|
||||
region.spec,
|
||||
region.cell_mask,
|
||||
contour_features,
|
||||
route_line,
|
||||
region.split.upstream,
|
||||
floor,
|
||||
)
|
||||
if expansion is None:
|
||||
logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.")
|
||||
return StagePreview(region=region)
|
||||
|
||||
analysis = expansion.analysis
|
||||
spec = analysis.spec
|
||||
red = analysis.flow.reaches_road & analysis.flow.analyzed
|
||||
|
||||
# ⑥ 흐름 강도 — 셀마다 물이 실제로 들어가는 도로 셀을 구해 도로 셀별로 센다.
|
||||
# 색 판정은 세류 셀에서 멈추지만(거기서 도달이 확정되므로), 강도는 그 물이 세류를 타고
|
||||
# 최종적으로 어느 도로 셀로 들어가는지를 봐야 하므로 도로만 흡수점으로 두고 다시 따라간다.
|
||||
strength_curve = _preview_strength(analysis, red, route_line.length)
|
||||
|
||||
# ⑦ 2차 전체 배수유역 외곽선 = 적색 셀 전체의 외곽.
|
||||
boundary = outer_boundary(spec, red.reshape(spec.n_rows, spec.n_cols))
|
||||
basin_ring = largest_ring(boundary) if boundary is not None else []
|
||||
|
||||
# ⑧ 기본 관 매설 위치 = 도로 × 세류선 교차점(너무 가까운 것은 하나로 합침).
|
||||
pipes = _base_pipes(vertices, stream_features)
|
||||
|
||||
logger.info(
|
||||
"배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — "
|
||||
"2차 유역 %.0f㎡, 기본 관 %d개, 강도 곡선 %d점",
|
||||
time.perf_counter() - started,
|
||||
expansion.rounds,
|
||||
spec.size,
|
||||
int(red.sum()) * spec.cell_area_m2,
|
||||
len(pipes),
|
||||
int((strength_curve > 0).sum()),
|
||||
)
|
||||
return StagePreview(
|
||||
region=region,
|
||||
spec=spec,
|
||||
domain=analysis.domain,
|
||||
terrain=analysis.terrain,
|
||||
road=analysis.road,
|
||||
flow=analysis.flow,
|
||||
descent=analysis.descent,
|
||||
expand_rounds=expansion.rounds,
|
||||
expand_closed=expansion.closed,
|
||||
expand_added_cells=expansion.added_cells,
|
||||
strength_profile=_downsample_strength(strength_curve),
|
||||
basin_boundary_xy=basin_ring,
|
||||
basin_area_m2=int(red.sum()) * spec.cell_area_m2,
|
||||
pipes=pipes,
|
||||
)
|
||||
|
||||
|
||||
def _preview_strength(analysis: Any, red: np.ndarray, route_length_m: float) -> np.ndarray:
|
||||
"""적색 셀이 실제로 들어가는 도로 셀을 세어 누가거리별 유입 면적 곡선을 만든다."""
|
||||
road = analysis.road
|
||||
if road.count == 0:
|
||||
return np.zeros(1)
|
||||
routed = trace_flow(analysis.terrain, road)
|
||||
slots = routed.road_slot
|
||||
counted = red & (slots >= 0)
|
||||
strength = np.bincount(slots[counted], minlength=road.count).astype(np.float64)
|
||||
return _strength_by_chainage(
|
||||
road.chainage, strength * analysis.spec.cell_area_m2, route_length_m
|
||||
)
|
||||
|
||||
|
||||
def _base_pipes(
|
||||
vertices: list[RouteVertex], stream_features: list[dict[str, Any]]
|
||||
) -> list[StructureCandidate]:
|
||||
"""도로 × 세류선 교차점을 기본 관 위치로 삼는다. 300m 보충 배치는 다음 단계다."""
|
||||
pipes: list[StructureCandidate] = []
|
||||
for candidate in find_stream_crossings(vertices, stream_features):
|
||||
if pipes and candidate.chainage_m - pipes[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M:
|
||||
continue
|
||||
pipes.append(candidate)
|
||||
return pipes
|
||||
|
||||
|
||||
# ── ③~④ 격자 해석 (캐시 대상) ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _solve_grid(
|
||||
vertices: list[RouteVertex],
|
||||
route_line: LineString,
|
||||
contour_features: list[dict[str, Any]],
|
||||
stream_features: list[dict[str, Any]],
|
||||
cache_path: Path | None,
|
||||
) -> _GridSolution | None:
|
||||
signature = _signature(vertices, len(contour_features), len(stream_features))
|
||||
cached = _load_cache(cache_path, signature)
|
||||
if cached is not None:
|
||||
logger.info("배수유역: 격자 캐시 재사용 (%s)", cache_path)
|
||||
return cached
|
||||
|
||||
region = resolve_primary_region(vertices, route_line, contour_features, stream_features)
|
||||
if region is None:
|
||||
return None
|
||||
spec = region.spec
|
||||
# TIN용 등고선은 격자가 확장될 여지까지 한 번에 읽어 두고, 회차마다 범위 안쪽만 골라 쓴다.
|
||||
reach = DRAINAGE_EXPAND_STEP_M * DRAINAGE_MAX_EXPAND_ROUNDS
|
||||
x_min, y_min, x_max, y_max = region.area.bounds
|
||||
cloud = build_contour_cloud(
|
||||
contour_features,
|
||||
route_elevation_floor([vertex.z for vertex in vertices]),
|
||||
(x_min - reach, y_min - reach, x_max + reach, y_max + reach),
|
||||
)
|
||||
if cloud.is_empty:
|
||||
logger.warning("배수유역: 1차 영역 안에 등고선이 없어 격자 해석을 건너뜁니다.")
|
||||
return None
|
||||
|
||||
# 확장 루프는 `Watershed_Flow.expand_until_closed()`로 분리했다(2026-07-31 사용자 지시).
|
||||
# 단계 검증 미리보기(`preview_stages`)는 이 경로를 타지 않는다 — 확장 자체가 아직 검증 대상.
|
||||
started = time.perf_counter()
|
||||
expansion = expand_until_closed(spec, cloud, route_line)
|
||||
spec, terrain, road, flow = expansion.spec, expansion.terrain, expansion.road, expansion.flow
|
||||
logger.info(
|
||||
"배수유역: 격자 해석 %.1fs (확장 %d회, %s, 셀 %d개)",
|
||||
time.perf_counter() - started,
|
||||
expansion.rounds,
|
||||
"닫힘" if expansion.closed else "미닫힘",
|
||||
spec.size,
|
||||
)
|
||||
solution = _GridSolution(
|
||||
spec=spec,
|
||||
elevation=terrain.elevation.reshape(-1),
|
||||
road_cell_index=road.cell_index,
|
||||
road_chainage=road.chainage,
|
||||
road_slot=flow.road_slot,
|
||||
path_length=flow.path_length,
|
||||
strength=flow.strength,
|
||||
active=flow.active.reshape(-1),
|
||||
signature=signature,
|
||||
)
|
||||
_save_cache(cache_path, solution)
|
||||
return solution
|
||||
|
||||
|
||||
def _main_boundary(solution: _GridSolution) -> list[tuple[float, float]]:
|
||||
boundary = outer_boundary(
|
||||
solution.spec, solution.active.reshape(solution.spec.n_rows, solution.spec.n_cols)
|
||||
)
|
||||
return largest_ring(boundary) if boundary is not None else []
|
||||
|
||||
|
||||
# ── 흐름 강도 곡선 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _strength_by_chainage(
|
||||
road_chainage: np.ndarray, strength_area: np.ndarray, total_length: float
|
||||
) -> np.ndarray:
|
||||
"""도로 셀 강도를 1m 누가거리 구간으로 합산한 곡선(㎡/m 구간 합)."""
|
||||
bins = max(1, int(np.ceil(total_length)) + 1)
|
||||
if road_chainage.size == 0:
|
||||
return np.zeros(bins)
|
||||
index = np.clip(np.round(road_chainage).astype(np.int64), 0, bins - 1)
|
||||
return np.bincount(index, weights=strength_area, minlength=bins)
|
||||
|
||||
|
||||
def _downsample_strength(curve: np.ndarray) -> list[tuple[float, float]]:
|
||||
"""응답용으로 강도 곡선을 일정 간격으로 줄인다(구간 합 유지).
|
||||
|
||||
끝자락을 잘라내면 종점 부근 유입 면적이 통째로 사라지므로 0으로 채워 맞춘다.
|
||||
"""
|
||||
step = max(1, int(_STRENGTH_OUTPUT_STEP_M))
|
||||
if curve.size == 0:
|
||||
return []
|
||||
padding = (-curve.size) % step
|
||||
padded = np.append(curve, np.zeros(padding)) if padding else curve
|
||||
summed = padded.reshape(-1, step).sum(axis=1)
|
||||
return [
|
||||
(float(position * step), float(value)) for position, value in enumerate(summed) if value > 0
|
||||
]
|
||||
|
||||
|
||||
# ── ⑤ 관 배치 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _place_pipes(
|
||||
vertices: list[RouteVertex],
|
||||
stream_features: list[dict[str, Any]],
|
||||
strength_curve: np.ndarray,
|
||||
) -> list[StructureCandidate]:
|
||||
"""세류 교차점을 기본 관 위치로 두고, 최대 간격을 넘는 구간만 최소 개수로 보충한다.
|
||||
|
||||
교차점은 종단 절·성토를 가리지 않고 모두 관으로 둔다. 하류측 세류선은 이미 격자
|
||||
해석 전에 제거되었으므로, 남은 교차점은 전부 상류에서 물이 실제로 들어오는 지점이다.
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
base: list[StructureCandidate] = []
|
||||
for candidate in find_stream_crossings(vertices, stream_features):
|
||||
if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M:
|
||||
continue
|
||||
base.append(candidate)
|
||||
|
||||
filled: list[StructureCandidate] = []
|
||||
previous = 0.0
|
||||
for candidate in [*base, None]:
|
||||
boundary = candidate.chainage_m if candidate else total_length
|
||||
filled.extend(_fill_gap(vertices, strength_curve, previous, boundary))
|
||||
if candidate:
|
||||
filled.append(candidate)
|
||||
previous = candidate.chainage_m
|
||||
else:
|
||||
previous = boundary
|
||||
filled.sort(key=lambda item: item.chainage_m)
|
||||
return filled
|
||||
|
||||
|
||||
def _fill_gap(
|
||||
vertices: list[RouteVertex],
|
||||
strength_curve: np.ndarray,
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
) -> list[StructureCandidate]:
|
||||
"""[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다.
|
||||
|
||||
필요 개수 n은 구간 길이로 정해지고(ceil(L/max) − 1), 각 관은 등분 위치를 중심으로
|
||||
허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고
|
||||
종단이 낮은 지점으로 붙는다.
|
||||
"""
|
||||
span = end_m - start_m
|
||||
if span <= DRAINAGE_PIPE_MAX_SPACING_M:
|
||||
return []
|
||||
count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1
|
||||
if count <= 0:
|
||||
return []
|
||||
spacing = span / (count + 1)
|
||||
slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0)
|
||||
placed: list[StructureCandidate] = []
|
||||
for order in range(1, count + 1):
|
||||
nominal = start_m + spacing * order
|
||||
low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack)
|
||||
high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack)
|
||||
chosen = _best_position(vertices, strength_curve, low, high, nominal)
|
||||
x, y, _ = _interpolate_vertex(vertices, chosen)
|
||||
placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing"))
|
||||
return placed
|
||||
|
||||
|
||||
def _best_position(
|
||||
vertices: list[RouteVertex],
|
||||
strength_curve: np.ndarray,
|
||||
low_m: float,
|
||||
high_m: float,
|
||||
fallback_m: float,
|
||||
) -> float:
|
||||
"""허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다."""
|
||||
if high_m <= low_m:
|
||||
return fallback_m
|
||||
positions = np.arange(low_m, high_m + 1.0, 1.0)
|
||||
if positions.size == 0:
|
||||
return fallback_m
|
||||
index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1)
|
||||
strength = strength_curve[index]
|
||||
heights = np.array([_interpolate_vertex(vertices, float(p))[2] for p in positions])
|
||||
|
||||
strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength)
|
||||
height_span = float(heights.max() - heights.min())
|
||||
sag_score = (
|
||||
(heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights)
|
||||
)
|
||||
score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score
|
||||
for order, position in enumerate(positions):
|
||||
if not is_uphill_at(vertices, float(position)):
|
||||
score[order] *= _SCORE_FILL_PENALTY
|
||||
return float(positions[int(np.argmax(score))])
|
||||
|
||||
|
||||
def _pipes_from_chainages(
|
||||
vertices: list[RouteVertex], chainages: list[float]
|
||||
) -> list[StructureCandidate]:
|
||||
"""사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다.
|
||||
|
||||
노선 밖 값은 시·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 −50m처럼
|
||||
나와 좌표와 표기가 어긋난다.
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages}
|
||||
pipes: list[StructureCandidate] = []
|
||||
for value in sorted(clamped):
|
||||
x, y, _ = _interpolate_vertex(vertices, value)
|
||||
pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed"))
|
||||
return pipes
|
||||
|
||||
|
||||
# ── ⑥ 측구 흐름으로 도로 셀 → 담당 관 ───────────────────────────────────────
|
||||
|
||||
|
||||
def _assign_road_cells_to_pipes(
|
||||
vertices: list[RouteVertex],
|
||||
pipes: list[StructureCandidate],
|
||||
road_chainage: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다.
|
||||
|
||||
노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고
|
||||
같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은
|
||||
가장 가까운 관이 받는 것으로 본다.
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5)
|
||||
stations = np.arange(0.0, total_length + step, step)
|
||||
heights = np.array([_interpolate_vertex(vertices, float(s))[2] for s in stations])
|
||||
pipe_chainages = np.array([pipe.chainage_m for pipe in pipes])
|
||||
pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1)
|
||||
|
||||
# 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리).
|
||||
back_z = np.full(stations.size, np.inf)
|
||||
back_z[1:] = heights[:-1]
|
||||
forward_z = np.full(stations.size, np.inf)
|
||||
forward_z[:-1] = heights[1:]
|
||||
go_back = (back_z < heights) & (back_z <= forward_z)
|
||||
go_forward = (forward_z < heights) & ~go_back
|
||||
receiver = np.arange(stations.size, dtype=np.int64)
|
||||
receiver[go_back] -= 1
|
||||
receiver[go_forward] += 1
|
||||
receiver[pipe_station] = pipe_station # 관은 물을 흡수한다
|
||||
|
||||
owner = np.full(stations.size, -1, dtype=np.int64)
|
||||
owner[pipe_station] = np.arange(pipe_chainages.size)
|
||||
jump = receiver
|
||||
for _ in range(40):
|
||||
next_jump = jump[jump]
|
||||
if np.array_equal(next_jump, jump):
|
||||
break
|
||||
jump = next_jump
|
||||
resolved = owner[jump]
|
||||
# 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다.
|
||||
orphan = resolved < 0
|
||||
if orphan.any() and pipe_chainages.size:
|
||||
nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1)
|
||||
resolved[orphan] = nearest
|
||||
|
||||
slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1)
|
||||
return resolved[slot_station].astype(np.int32)
|
||||
|
||||
|
||||
# ── 세부유역 조립 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _assemble_basins(
|
||||
solution: _GridSolution,
|
||||
pipes: list[StructureCandidate],
|
||||
pipe_of_slot: np.ndarray,
|
||||
) -> list[WatershedBasin]:
|
||||
"""셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다."""
|
||||
spec = solution.spec
|
||||
labels = np.full(spec.size, -1, dtype=np.int32)
|
||||
reached = solution.road_slot >= 0
|
||||
labels[reached] = pipe_of_slot[solution.road_slot[reached]]
|
||||
|
||||
polygons = polygonize_labels(spec, labels)
|
||||
cell_area = spec.cell_area_m2
|
||||
basins: list[WatershedBasin] = []
|
||||
for order, pipe in enumerate(pipes):
|
||||
member = labels == order
|
||||
count = int(member.sum())
|
||||
if count == 0:
|
||||
continue
|
||||
geometry = polygons.get(order)
|
||||
elevations = solution.elevation[member]
|
||||
highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0
|
||||
outlet_z = _outlet_elevation(solution, order, pipe_of_slot)
|
||||
area = count * cell_area
|
||||
relief = max(0.0, highest - outlet_z)
|
||||
flow_length = float(solution.path_length[member].max())
|
||||
basins.append(
|
||||
WatershedBasin(
|
||||
index=len(basins) + 1,
|
||||
chainage_m=pipe.chainage_m,
|
||||
outlet_x=pipe.x,
|
||||
outlet_y=pipe.y,
|
||||
boundary_xy=largest_ring(geometry) if geometry is not None else [],
|
||||
area_m2=area,
|
||||
relief_m=relief,
|
||||
flow_length_m=flow_length,
|
||||
pipe_diameter_mm=estimate_pipe_diameter_mm(area, relief, flow_length),
|
||||
)
|
||||
)
|
||||
return basins
|
||||
|
||||
|
||||
def _outlet_elevation(solution: _GridSolution, pipe_order: int, pipe_of_slot: np.ndarray) -> float:
|
||||
"""관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고."""
|
||||
slots = np.flatnonzero(pipe_of_slot == pipe_order)
|
||||
if slots.size == 0:
|
||||
return 0.0
|
||||
elevations = solution.elevation[solution.road_cell_index[slots]]
|
||||
finite = elevations[np.isfinite(elevations)]
|
||||
return float(finite.min()) if finite.size else 0.0
|
||||
|
||||
|
||||
# ── 캐시 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _signature(vertices: list[RouteVertex], contour_count: int, stream_count: int) -> str:
|
||||
"""노선 기하와 해석 파라미터가 바뀌면 캐시를 버리도록 하는 지문."""
|
||||
digest = hashlib.sha1()
|
||||
for vertex in vertices:
|
||||
digest.update(f"{vertex.x:.2f},{vertex.y:.2f},{vertex.z:.2f};".encode())
|
||||
digest.update(
|
||||
f"|{contour_count}|{stream_count}|{DRAINAGE_GRID_SIZE_M}|{DRAINAGE_INITIAL_RADIUS_M}"
|
||||
f"|{DRAINAGE_EXPAND_STEP_M}|{DRAINAGE_ROAD_WIDTH_M}".encode()
|
||||
)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _load_cache(cache_path: Path | None, signature: str) -> _GridSolution | None:
|
||||
if cache_path is None or not cache_path.exists():
|
||||
return None
|
||||
try:
|
||||
with np.load(cache_path, allow_pickle=False) as data:
|
||||
if str(data["signature"]) != signature:
|
||||
return None
|
||||
spec = GridSpec(
|
||||
x_min=float(data["x_min"]),
|
||||
y_max=float(data["y_max"]),
|
||||
cell_m=float(data["cell_m"]),
|
||||
n_rows=int(data["n_rows"]),
|
||||
n_cols=int(data["n_cols"]),
|
||||
)
|
||||
return _GridSolution(
|
||||
spec=spec,
|
||||
elevation=data["elevation"],
|
||||
road_cell_index=data["road_cell_index"],
|
||||
road_chainage=data["road_chainage"],
|
||||
road_slot=data["road_slot"],
|
||||
path_length=data["path_length"],
|
||||
strength=data["strength"],
|
||||
active=data["active"],
|
||||
signature=signature,
|
||||
)
|
||||
except (OSError, KeyError, ValueError):
|
||||
logger.warning("배수유역: 격자 캐시를 읽지 못해 다시 계산합니다 (%s).", cache_path)
|
||||
return None
|
||||
|
||||
|
||||
def _save_cache(cache_path: Path | None, solution: _GridSolution) -> None:
|
||||
if cache_path is None:
|
||||
return
|
||||
try:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
np.savez_compressed(
|
||||
cache_path,
|
||||
signature=solution.signature,
|
||||
x_min=solution.spec.x_min,
|
||||
y_max=solution.spec.y_max,
|
||||
cell_m=solution.spec.cell_m,
|
||||
n_rows=solution.spec.n_rows,
|
||||
n_cols=solution.spec.n_cols,
|
||||
elevation=solution.elevation,
|
||||
road_cell_index=solution.road_cell_index,
|
||||
road_chainage=solution.road_chainage,
|
||||
road_slot=solution.road_slot,
|
||||
path_length=solution.path_length,
|
||||
strength=solution.strength,
|
||||
active=solution.active,
|
||||
)
|
||||
except OSError:
|
||||
logger.warning("배수유역: 격자 캐시를 저장하지 못했습니다 (%s).", cache_path)
|
||||
@@ -1,124 +1,36 @@
|
||||
"""배수유역도 API 라우터.
|
||||
"""배수유역 세부 설계 API 라우터 (B05 — 일반 사용자용).
|
||||
|
||||
**분석하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어 관을 보충하고 세부유역만 나눈다.
|
||||
격자 해석은 30초가 걸려 일반 사용자를 붙잡아 두므로 여기서는 아예 돌리지 않는다
|
||||
(2026-07-31 사용자 지시).
|
||||
|
||||
구조물 측점(관 매설) 후보 제안과 배수유역 산정을 제공한다. 지형 근거는 **도엽 등고선과
|
||||
세류선 GeoJSON**뿐이며(표고점은 유효 데이터가 적어 2026-07-31 사용자 지시로 제외),
|
||||
좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pyproj import Transformer
|
||||
from shapely.geometry import LineString, Point, Polygon, box
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import (
|
||||
StructureCandidate,
|
||||
build_route_vertices,
|
||||
propose_structure_stations,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Basin import (
|
||||
build_drainage_watershed,
|
||||
preview_stages,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Export import write_grid_arrays, write_stage
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import (
|
||||
AZIMUTH_INVALID,
|
||||
AZIMUTH_SINK,
|
||||
AZIMUTH_STEPS,
|
||||
mask_row_spans,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Basin import build_drainage_detail
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
get_latest_route,
|
||||
get_route_points,
|
||||
get_surface_crs_epsg,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_route_geometry import StructureCandidate, build_route_vertices
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import DRAINAGE_CACHE_DIRNAME, DRAINAGE_CACHE_FILENAME
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"])
|
||||
|
||||
# 도엽 레이어 파일명 (B04 전처리 산출물과 동일 위치)
|
||||
_CONTOUR_FILE = "도엽_등고선.geojson"
|
||||
_STREAM_FILE = "도엽_하천중심선.geojson"
|
||||
|
||||
|
||||
def _sheet_dir(stored_path: str) -> Path:
|
||||
return Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / "processed"
|
||||
|
||||
|
||||
def _cache_path(stored_path: str) -> Path:
|
||||
"""격자 해석 캐시(.npz) 경로. 관을 옮겨도 격자를 다시 풀지 않게 여기에 남긴다."""
|
||||
root = Path(resolve_stored_project_path(stored_path)) / "B05_wf2_Route"
|
||||
return root / DRAINAGE_CACHE_DIRNAME / DRAINAGE_CACHE_FILENAME
|
||||
|
||||
|
||||
def _load_features(directory: Path, filename: str) -> list[dict[str, Any]]:
|
||||
"""도엽 GeoJSON을 읽어 피처 목록만 돌려준다. 없으면 빈 목록."""
|
||||
path = directory / filename
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("도엽 GeoJSON을 읽지 못했습니다: %s", path)
|
||||
return []
|
||||
features = data.get("features")
|
||||
return features if isinstance(features, list) else []
|
||||
|
||||
|
||||
def _reproject_features(
|
||||
features: list[dict[str, Any]],
|
||||
transformer: Transformer | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""WGS84 도엽 좌표를 사업지 CRS(m)로 바꾼다. 거리·면적을 미터로 계산하기 위함."""
|
||||
if transformer is None:
|
||||
return features
|
||||
converted: list[dict[str, Any]] = []
|
||||
for feature in features:
|
||||
geometry = feature.get("geometry")
|
||||
if not geometry:
|
||||
continue
|
||||
coordinates = _map_coordinates(geometry.get("coordinates"), transformer)
|
||||
if coordinates is None:
|
||||
continue
|
||||
converted.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": feature.get("properties") or {},
|
||||
"geometry": {"type": geometry.get("type"), "coordinates": coordinates},
|
||||
}
|
||||
)
|
||||
return converted
|
||||
|
||||
|
||||
def _map_coordinates(coordinates: Any, transformer: Transformer) -> Any:
|
||||
"""중첩 좌표 배열을 재귀적으로 변환한다."""
|
||||
if not isinstance(coordinates, list) or not coordinates:
|
||||
return None
|
||||
first = coordinates[0]
|
||||
if isinstance(first, (int, float)):
|
||||
x, y = transformer.transform(float(coordinates[0]), float(coordinates[1]))
|
||||
return [x, y]
|
||||
mapped = [_map_coordinates(item, transformer) for item in coordinates]
|
||||
return [item for item in mapped if item is not None]
|
||||
|
||||
|
||||
def _candidate_payload(
|
||||
candidate: StructureCandidate,
|
||||
to_lonlat: Any,
|
||||
) -> dict[str, Any]:
|
||||
def _candidate_payload(candidate: StructureCandidate, to_lonlat: Any) -> dict[str, Any]:
|
||||
lon, lat = to_lonlat(candidate.x, candidate.y)
|
||||
return {
|
||||
"chainage_m": round(candidate.chainage_m, 2),
|
||||
@@ -132,7 +44,7 @@ def _candidate_payload(
|
||||
|
||||
|
||||
async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"""노선 정점·도엽 피처·좌표 변환기를 한 번에 준비한다."""
|
||||
"""확정 노선과 좌표 변환기를 준비한다. 도엽 피처는 읽지 않는다(분석을 안 하므로)."""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
@@ -154,346 +66,67 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "노선 좌표가 부족합니다."},
|
||||
)
|
||||
|
||||
source_crs = f"EPSG:{epsg}" if epsg else "EPSG:5186"
|
||||
to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
|
||||
to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True)
|
||||
|
||||
directory = _sheet_dir(stored_path)
|
||||
streams = _reproject_features(_load_features(directory, _STREAM_FILE), to_metric_transformer)
|
||||
contour_features = _reproject_features(
|
||||
_load_features(directory, _CONTOUR_FILE), to_metric_transformer
|
||||
)
|
||||
transformer = Transformer.from_crs(f"EPSG:{epsg or 5186}", "EPSG:4326", always_xy=True)
|
||||
return {
|
||||
"route_id": int(route["id"]),
|
||||
"vertices": vertices,
|
||||
"route_line": LineString([(vertex.x, vertex.y) for vertex in vertices]),
|
||||
"streams": streams,
|
||||
"contours": contour_features,
|
||||
"stored_path": stored_path,
|
||||
"cache_path": _cache_path(stored_path),
|
||||
"to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y),
|
||||
"to_lonlat": lambda x, y: transformer.transform(x, y),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{project_id}/drainage/candidates", response_model=None)
|
||||
async def get_structure_candidates(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"""관 매설 구조물 측점 후보를 제안한다(세류 교차 + 300m 보충, 성토부 제외)."""
|
||||
prepared = await _prepare(project_id)
|
||||
if isinstance(prepared, JSONResponse):
|
||||
return prepared
|
||||
candidates = propose_structure_stations(prepared["vertices"], prepared["streams"])
|
||||
to_lonlat = prepared["to_lonlat"]
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
"route_id": prepared["route_id"],
|
||||
"candidates": [_candidate_payload(candidate, to_lonlat) for candidate in candidates],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{project_id}/drainage/primary-region", response_model=None)
|
||||
async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"""1차 배수유역 근거를 돌려준다 — 단계 검증용, TIN·흐름 계산은 하지 않는다.
|
||||
|
||||
도로 교차점 상류로 이어진 세류망, 제외된 하류망, 그 상류망을 반경 버퍼한 1차 영역,
|
||||
그 bbox로 잡은 격자 정보를 함께 준다. 같은 내용을 영구저장소에 GeoJSON으로도 남겨
|
||||
QGIS 등으로 직접 열어 대조할 수 있게 한다.
|
||||
"""
|
||||
prepared = await _prepare(project_id)
|
||||
if isinstance(prepared, JSONResponse):
|
||||
return prepared
|
||||
preview = await asyncio.to_thread(
|
||||
preview_stages,
|
||||
prepared["vertices"],
|
||||
prepared["contours"],
|
||||
prepared["streams"],
|
||||
)
|
||||
if preview is None:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."},
|
||||
)
|
||||
region = preview.region
|
||||
to_lonlat = prepared["to_lonlat"]
|
||||
# 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다.
|
||||
spec = preview.spec or region.spec
|
||||
domain = preview.domain if preview.domain is not None else region.cell_mask
|
||||
payload = {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
"route_id": prepared["route_id"],
|
||||
"radius_m": region.radius_m,
|
||||
# 채택된 상류 세류망 = 1차 영역의 기준선.
|
||||
"upstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.upstream],
|
||||
# 도로 아래로 이어진 하류망 — 판정이 맞는지 눈으로 대조하기 위해 함께 준다.
|
||||
"downstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.downstream],
|
||||
"no_contact_count": region.split.no_contact,
|
||||
# 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호.
|
||||
"road_outside_m": round(region.road_outside_m, 1),
|
||||
# 1차 영역(버퍼 합집합) 외곽 링 목록.
|
||||
"region_rings": _polygon_rings(region.area, to_lonlat),
|
||||
"grid": {
|
||||
"cell_m": spec.cell_m,
|
||||
"rows": spec.n_rows,
|
||||
"cols": spec.n_cols,
|
||||
# bbox 전체 셀 수와, 해석 영역에 실제로 생성된 셀 수(확장 반영).
|
||||
"bbox_cells": spec.size,
|
||||
"cells": int(domain.sum()) if domain is not None else 0,
|
||||
"width_m": round(spec.n_cols * spec.cell_m, 1),
|
||||
"height_m": round(spec.n_rows * spec.cell_m, 1),
|
||||
# 격자 bbox 링. 프론트는 이 사각형을 rows×cols로 나눠 행·열 좌표를 얻는다.
|
||||
"bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat),
|
||||
# 실제 생성된 셀을 행별 연속 구간 [행, 시작열, 끝열]으로 압축해 보낸다.
|
||||
# 셀을 낱개로 보내면 수십만 건이라 응답이 감당되지 않는다.
|
||||
"row_spans": [list(span) for span in mask_row_spans(domain)]
|
||||
if domain is not None
|
||||
else [],
|
||||
},
|
||||
# 최외곽 적색 셀 주변 확장 결과.
|
||||
"expansion": {
|
||||
"rounds": preview.expand_rounds,
|
||||
"closed": preview.expand_closed,
|
||||
"added_cells": preview.expand_added_cells,
|
||||
"initial_cells": region.active_cells,
|
||||
},
|
||||
# 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩.
|
||||
"flow": _flow_payload(preview, domain),
|
||||
# ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 면적.
|
||||
"basin_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in preview.basin_boundary_xy],
|
||||
"basin_area_m2": round(preview.basin_area_m2, 1),
|
||||
# ⑥ 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡].
|
||||
"strength_profile": [
|
||||
[round(chainage, 1), round(area, 1)] for chainage, area in preview.strength_profile
|
||||
],
|
||||
# ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점.
|
||||
"pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes],
|
||||
}
|
||||
# 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다.
|
||||
payload["saved_to"] = write_stage(
|
||||
prepared["stored_path"],
|
||||
"primary_region",
|
||||
{
|
||||
"primary_region": _as_polygons(region.area),
|
||||
"upstream": region.split.upstream,
|
||||
"downstream": region.split.downstream,
|
||||
"route": [prepared["route_line"]],
|
||||
"grid_bbox": [_grid_bbox_polygon(spec)],
|
||||
# ⑦ 2차 전체 배수유역 외곽선, ⑧ 기본 관 위치(누가거리·근거 포함).
|
||||
"basin_boundary": _boundary_geometry(preview.basin_boundary_xy),
|
||||
"pipe": [
|
||||
(
|
||||
Point(pipe.x, pipe.y),
|
||||
{
|
||||
"chainage_m": round(pipe.chainage_m, 2),
|
||||
"reason": pipe.reason,
|
||||
"stream_name": pipe.stream_name,
|
||||
},
|
||||
)
|
||||
for pipe in preview.pipes
|
||||
],
|
||||
},
|
||||
{
|
||||
"radius_m": region.radius_m,
|
||||
"road_outside_m": payload["road_outside_m"],
|
||||
"no_contact_count": region.split.no_contact,
|
||||
"basin_area_m2": payload["basin_area_m2"],
|
||||
"pipe_count": len(preview.pipes),
|
||||
# 기하(bbox·셀 구간)는 파일 본문에 있으므로 요약 수치만 남긴다.
|
||||
"grid": {
|
||||
key: value
|
||||
for key, value in payload["grid"].items()
|
||||
if key not in {"bbox_lonlat", "row_spans"}
|
||||
},
|
||||
},
|
||||
to_lonlat,
|
||||
)
|
||||
_write_stage_arrays(prepared["stored_path"], preview, domain, spec)
|
||||
return payload
|
||||
|
||||
|
||||
def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None:
|
||||
"""격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다."""
|
||||
if domain is not None:
|
||||
write_grid_arrays(
|
||||
stored_path,
|
||||
"primary_region",
|
||||
spec,
|
||||
{"mask": domain},
|
||||
{
|
||||
"cells": int(domain.sum()),
|
||||
"bbox_cells": spec.size,
|
||||
"expand_rounds": preview.expand_rounds,
|
||||
"expand_closed": preview.expand_closed,
|
||||
},
|
||||
)
|
||||
flow = preview.flow
|
||||
if flow is None:
|
||||
return
|
||||
arrays = {
|
||||
"direction": flow.direction.reshape(spec.n_rows, spec.n_cols),
|
||||
"reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols),
|
||||
"analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols),
|
||||
# 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다.
|
||||
"receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols),
|
||||
}
|
||||
if flow.burned is not None:
|
||||
arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols)
|
||||
if preview.descent is not None:
|
||||
arrays["band_elevation"] = preview.descent.band_elevation
|
||||
# ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다.
|
||||
if preview.strength_profile:
|
||||
curve = np.asarray(preview.strength_profile, dtype=np.float64)
|
||||
arrays["strength_chainage_m"] = curve[:, 0]
|
||||
arrays["strength_area_m2"] = curve[:, 1]
|
||||
write_grid_arrays(
|
||||
stored_path,
|
||||
"flow_direction",
|
||||
spec,
|
||||
arrays,
|
||||
{
|
||||
"azimuth_steps": AZIMUTH_STEPS,
|
||||
"sink_code": AZIMUTH_SINK,
|
||||
"invalid_code": AZIMUTH_INVALID,
|
||||
"analyzed": int(flow.analyzed.sum()),
|
||||
"reaches_road": int((flow.reaches_road & flow.analyzed).sum()),
|
||||
"no_road": int((~flow.reaches_road & flow.analyzed).sum()),
|
||||
"burned": 0 if flow.burned is None else int(flow.burned.sum()),
|
||||
"outer_seeds": flow.outer_seeds,
|
||||
"interior_seeds": flow.interior_seeds,
|
||||
"strength_points": len(preview.strength_profile),
|
||||
"strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None:
|
||||
"""셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다.
|
||||
|
||||
셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다:
|
||||
하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음
|
||||
최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표).
|
||||
바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다.
|
||||
"""
|
||||
flow = preview.flow
|
||||
if flow is None or domain is None:
|
||||
return None
|
||||
order = np.flatnonzero(domain.reshape(-1))
|
||||
analyzed = flow.analyzed[order]
|
||||
reaches = flow.reaches_road[order]
|
||||
packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8)
|
||||
packed |= np.where(reaches, 0x80, 0).astype(np.uint8)
|
||||
burned = flow.burned
|
||||
return {
|
||||
"encoding": "base64-uint8",
|
||||
"azimuth_steps": AZIMUTH_STEPS,
|
||||
"sink_code": AZIMUTH_SINK,
|
||||
"invalid_code": AZIMUTH_INVALID,
|
||||
"cells": int(order.size),
|
||||
"reaches_road": int((reaches & analyzed).sum()),
|
||||
"no_road": int((~reaches & analyzed).sum()),
|
||||
# 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀.
|
||||
"unanalyzed": int((~analyzed).sum()),
|
||||
# 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수.
|
||||
"burned": 0 if burned is None else int(burned[order].sum()),
|
||||
"outer_seeds": flow.outer_seeds,
|
||||
"interior_seeds": flow.interior_seeds,
|
||||
"data": base64.b64encode(packed.tobytes()).decode("ascii"),
|
||||
}
|
||||
|
||||
|
||||
def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]:
|
||||
"""2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다)."""
|
||||
return [Polygon(ring)] if len(ring) >= 4 else []
|
||||
|
||||
|
||||
def _as_polygons(geometry: Any) -> list[Any]:
|
||||
if geometry is None or geometry.is_empty:
|
||||
return []
|
||||
return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry]
|
||||
|
||||
|
||||
def _grid_bbox_polygon(spec: Any) -> Polygon:
|
||||
x_max = spec.x_min + spec.n_cols * spec.cell_m
|
||||
y_min = spec.y_max - spec.n_rows * spec.cell_m
|
||||
return box(spec.x_min, y_min, x_max, spec.y_max)
|
||||
|
||||
|
||||
def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]:
|
||||
return [list(to_lonlat(x, y)) for x, y in line.coords]
|
||||
|
||||
|
||||
def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]:
|
||||
"""폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다."""
|
||||
if geometry is None or geometry.is_empty:
|
||||
return []
|
||||
parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry]
|
||||
return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts]
|
||||
|
||||
|
||||
def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]:
|
||||
x_min = spec.x_min
|
||||
x_max = spec.x_min + spec.n_cols * spec.cell_m
|
||||
y_max = spec.y_max
|
||||
y_min = spec.y_max - spec.n_rows * spec.cell_m
|
||||
corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min))
|
||||
return [list(to_lonlat(x, y)) for x, y in corners]
|
||||
|
||||
|
||||
@router.post("/{project_id}/drainage/basins", response_model=None)
|
||||
async def post_drainage_basins(
|
||||
project_id: UUID,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""격자 흐름 해석으로 배수유역과 관 배치를 산정한다.
|
||||
"""B04 분석 결과로 관을 보충하고 세부유역을 나눈다.
|
||||
|
||||
payload에 `chainages`(누가거리 목록)를 주면 그 위치로 관을 확정하고, 없으면 세류
|
||||
교차 + 최소 보충으로 자동 배치한다. 격자 해석은 `.npz` 캐시를 재사용하므로 관만
|
||||
옮기는 재요청은 세부유역 분할만 다시 돈다.
|
||||
payload에 `chainages`(누가거리 목록)를 주면 그 위치로 관을 확정하고(사용자 편집),
|
||||
없으면 B04의 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다.
|
||||
"""
|
||||
prepared = await _prepare(project_id)
|
||||
if isinstance(prepared, JSONResponse):
|
||||
return prepared
|
||||
raw_chainages = (payload or {}).get("chainages")
|
||||
confirmed = _parse_chainages(raw_chainages) if isinstance(raw_chainages, list) else []
|
||||
raw = (payload or {}).get("chainages")
|
||||
confirmed = _parse_chainages(raw) if isinstance(raw, list) else []
|
||||
|
||||
# 격자 해석은 수백만 셀 numpy 연산이라 이벤트 루프를 막지 않도록 스레드로 뺀다.
|
||||
result = await asyncio.to_thread(
|
||||
build_drainage_watershed,
|
||||
prepared["vertices"],
|
||||
prepared["contours"],
|
||||
prepared["streams"],
|
||||
confirmed,
|
||||
prepared["cache_path"],
|
||||
detail = await asyncio.to_thread(
|
||||
build_drainage_detail, prepared["stored_path"], prepared["vertices"], confirmed
|
||||
)
|
||||
if detail is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "배수유역 분석 결과가 없습니다. B04에서 먼저 분석을 실행하세요.",
|
||||
},
|
||||
)
|
||||
|
||||
to_lonlat = prepared["to_lonlat"]
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
"route_id": prepared["route_id"],
|
||||
# 계획선 위 배관(관 매설) 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록.
|
||||
"pipes": [_candidate_payload(candidate, to_lonlat) for candidate in result.pipes],
|
||||
# 2차 전체 배수유역 외곽선 = 분수령. 세부유역은 전부 이 안쪽에 들어간다.
|
||||
"main_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in result.main_boundary_xy],
|
||||
# 도로 위 흐름 강도 — [누가거리 m, 그 지점으로 모이는 상류 면적 ㎡].
|
||||
"strength_profile": [
|
||||
[round(chainage, 1), round(area, 1)] for chainage, area in result.strength_profile
|
||||
],
|
||||
"grid_cell_m": result.grid_cell_m,
|
||||
# B04가 남긴 그대로 — 계획도로선과 2차 전체 배수유역 외곽선.
|
||||
"route_lonlat": detail.route_lonlat,
|
||||
"main_polygon_lonlat": detail.basin_lonlat,
|
||||
"grid_cell_m": detail.grid_cell_m,
|
||||
# 계획선 위 배관 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록.
|
||||
"pipes": [_candidate_payload(pipe, to_lonlat) for pipe in detail.pipes],
|
||||
"basins": [
|
||||
{
|
||||
"index": basin.index,
|
||||
"chainage_m": round(basin.chainage_m, 2),
|
||||
# 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용.
|
||||
"outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)),
|
||||
"polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy],
|
||||
"area_m2": round(basin.area_m2, 1),
|
||||
"relief_m": round(basin.relief_m, 2),
|
||||
"flow_length_m": round(basin.flow_length_m, 1),
|
||||
# 관경 수식 미확정 — 산정 함수가 None을 돌려주면 프론트가 "미정"으로 표기한다.
|
||||
# 관경 수식 미확정 — None이면 프론트가 "미정"으로 표기한다.
|
||||
"pipe_diameter_mm": basin.pipe_diameter_mm,
|
||||
}
|
||||
for basin in result.basins
|
||||
for basin in detail.basins
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@ import {
|
||||
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
|
||||
import {
|
||||
fetchDrainageBasins,
|
||||
fetchDrainagePrimaryRegion,
|
||||
type DrainageBasin,
|
||||
type DrainagePrimaryRegion,
|
||||
type RoutePoint,
|
||||
} from "./B05_wf2_Route_Api_Fetch";
|
||||
import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
|
||||
@@ -51,18 +49,6 @@ const LAYER_LABELS: Record<DrainageLayer, string> = {
|
||||
const ROUTE_COLOR = "#f97316";
|
||||
const COLLAPSED_KEY = "b05-route-drainage-collapsed";
|
||||
|
||||
// 해석 격자 셀 선 — 등고선·세류 위에 얹으므로 흰색으로 둔다(2026-07-31 사용자 지시).
|
||||
const GRID_LINE_COLOR = "rgba(255, 255, 255, 0.55)";
|
||||
// 흐름 판정 색 — 도로로 물이 오는 셀은 적색, 오지 않는 셀은 파랑 채움 + 백색 화살표.
|
||||
const FLOW_TO_ROAD_FILL = "rgba(220, 38, 38, 0.28)";
|
||||
const FLOW_TO_ROAD_LINE = "rgba(153, 27, 27, 0.95)";
|
||||
const FLOW_AWAY_FILL = "rgba(37, 99, 235, 0.22)";
|
||||
const FLOW_AWAY_LINE = "rgba(255, 255, 255, 0.95)";
|
||||
/** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */
|
||||
const FLOW_UNKNOWN_FILL = "rgba(120, 113, 108, 0.18)";
|
||||
/** 셀이 이보다 작으면 화살표가 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */
|
||||
const ARROW_MIN_PX = 7;
|
||||
|
||||
/** 유역 오버레이 파스텔 색상. 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤). */
|
||||
const BASIN_COLORS = [
|
||||
"rgba(167, 216, 199, 0.45)",
|
||||
@@ -97,13 +83,15 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
layerButtons.className = "b05-drainage__layers";
|
||||
header.append(title, layerButtons);
|
||||
|
||||
// 배수유역 계산 실행 — 등고선 격자 흐름 해석으로 유역·관 위치를 한 번에 산정한다.
|
||||
// (격자 해석 결과는 백엔드가 캐시하므로 관만 바꾼 재계산은 즉시 끝난다.)
|
||||
// 세부유역 산정 — B04가 미리 분석해 둔 결과를 읽어 관을 보충하고 세부유역만 나눈다.
|
||||
// 격자 해석은 하지 않으므로 즉시 끝난다.
|
||||
const analyzeButton = document.createElement("button");
|
||||
analyzeButton.type = "button";
|
||||
analyzeButton.className = "b05-drainage__analyze";
|
||||
analyzeButton.textContent = "배수유역 계산";
|
||||
analyzeButton.title = "등고선·세류선으로 배수유역과 관 매설 위치를 다시 계산합니다.";
|
||||
analyzeButton.textContent = "세부유역 산정";
|
||||
analyzeButton.title =
|
||||
"B04에서 분석해 둔 배수유역을 불러와 관을 보충하고 세부유역을 나눕니다. " +
|
||||
"분석 결과가 없으면 B04에서 먼저 실행해야 합니다.";
|
||||
// 배관 편집 토글 — 켜면 계획선 클릭으로 배관 추가, 마커 드래그로 이동.
|
||||
const editButton = document.createElement("button");
|
||||
editButton.type = "button";
|
||||
@@ -121,16 +109,7 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
autoButton.type = "button";
|
||||
autoButton.className = "b05-drainage__analyze b05-drainage__tool";
|
||||
autoButton.textContent = "자동 제안";
|
||||
// 1차 영역 확인 — TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 그려 눈으로 검증한다.
|
||||
const regionButton = document.createElement("button");
|
||||
regionButton.type = "button";
|
||||
regionButton.className = "b05-drainage__analyze b05-drainage__tool";
|
||||
regionButton.textContent = "1차 영역";
|
||||
regionButton.title =
|
||||
"도로와 만나는 세류선의 상류측(파랑 굵은 선)·하류측(회색 파선)과 " +
|
||||
"그 반경 버퍼로 잡은 1차 배수유역, 해석 격자 범위를 표시합니다.";
|
||||
regionButton.setAttribute("aria-pressed", "false");
|
||||
header.append(analyzeButton, editButton, deleteButton, autoButton, regionButton);
|
||||
header.append(analyzeButton, editButton, deleteButton, autoButton);
|
||||
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "b05-drainage__viewport";
|
||||
@@ -168,11 +147,6 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
// 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다
|
||||
// (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시).
|
||||
let mainBoundary: Array<[number, number]> = [];
|
||||
// 1차 영역 검증 오버레이. null이면 표시하지 않는다.
|
||||
let primaryRegion: DrainagePrimaryRegion | null = null;
|
||||
let showRegion = false;
|
||||
// 흐름 방향 바이트 디코드 캐시 — 매 프레임 base64를 다시 풀지 않는다.
|
||||
let flowCache: { source: string; bytes: Uint8Array } | null = null;
|
||||
let scale = 1;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
@@ -241,8 +215,6 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
});
|
||||
// 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다.
|
||||
if (mainBoundary.length > 2) drawRidgeRing(context, mainBoundary, normalizer, view);
|
||||
// 1차 영역 검증 오버레이는 채움 위·등고선 아래에 깐다.
|
||||
if (showRegion && primaryRegion) drawPrimaryRegion(context, normalizer, view);
|
||||
}
|
||||
// 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다.
|
||||
DRAINAGE_LAYERS.forEach((layer) => {
|
||||
@@ -258,219 +230,12 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
context.strokeStyle = ROUTE_COLOR;
|
||||
drawPreparedLayer(context, routeLayer, view, "dot");
|
||||
}
|
||||
// 계획선 위 흐름 강도 띠 → 그 위에 배관 마커.
|
||||
pipeEditor.drawStrength(context, view);
|
||||
// 배관(관 매설) 마커 — 계획선 위 최상단.
|
||||
pipeEditor.draw(context, view, pipeColor);
|
||||
updateImageTransform();
|
||||
}
|
||||
|
||||
/** lon/lat 폴리라인을 화면 좌표로 옮겨 한 줄 그린다(1차 영역 오버레이 전용). */
|
||||
function strokeLonLat(
|
||||
context: CanvasRenderingContext2D,
|
||||
line: ReadonlyArray<readonly [number, number]>,
|
||||
map: Normalizer,
|
||||
view: ViewState,
|
||||
): void {
|
||||
if (line.length < 2) return;
|
||||
const ax = view.mapRect.width * view.scale;
|
||||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||||
const ay = view.mapRect.height * view.scale;
|
||||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||||
context.beginPath();
|
||||
line.forEach(([lon, lat], index) => {
|
||||
const x = ((lon - map.lonMin) / map.lonRange) * ax + bx;
|
||||
const y = (1 - (lat - map.latMin) / map.latRange) * ay + by;
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
/** 흐름 방향 바이트를 셀 순서대로 디코드한다(캐시 — 매 프레임 다시 풀지 않는다). */
|
||||
function flowBytes(region: DrainagePrimaryRegion): Uint8Array | null {
|
||||
if (!region.flow) return null;
|
||||
if (flowCache?.source === region.flow.data) return flowCache.bytes;
|
||||
const binary = atob(region.flow.data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
flowCache = { source: region.flow.data, bytes };
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** 1차 영역에 걸쳐 실제로 생성된 셀만 그린다.
|
||||
*
|
||||
* bbox 전체를 채우지 않는다 — 백엔드가 준 행별 구간(row_spans)만 그린다. 흐름 판정이
|
||||
* 있으면 셀마다 방향 화살표를 얹고, 도로에 물이 닿는 셀은 적색·닿지 않으면 파랑으로
|
||||
* 칠한다. 셀이 화면에서 작아지면 화살표가 안 보이므로 채움색만 남긴다. */
|
||||
function drawGridCells(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: Normalizer,
|
||||
view: ViewState,
|
||||
region: DrainagePrimaryRegion,
|
||||
): void {
|
||||
const ring = region.grid.bbox_lonlat;
|
||||
if (ring.length < 4) return;
|
||||
const lons = ring.map(([lon]) => lon);
|
||||
const lats = ring.map(([, lat]) => lat);
|
||||
const ax = view.mapRect.width * view.scale;
|
||||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||||
const ay = view.mapRect.height * view.scale;
|
||||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||||
const left = ((Math.min(...lons) - map.lonMin) / map.lonRange) * ax + bx;
|
||||
const right = ((Math.max(...lons) - map.lonMin) / map.lonRange) * ax + bx;
|
||||
const top = (1 - (Math.max(...lats) - map.latMin) / map.latRange) * ay + by;
|
||||
const bottom = (1 - (Math.min(...lats) - map.latMin) / map.latRange) * ay + by;
|
||||
|
||||
const { rows, cols, row_spans: spans } = region.grid;
|
||||
const cellW = (right - left) / Math.max(cols, 1);
|
||||
const cellH = (bottom - top) / Math.max(rows, 1);
|
||||
const cellPx = Math.min(Math.abs(cellW), Math.abs(cellH));
|
||||
const bytes = flowBytes(region);
|
||||
|
||||
context.save();
|
||||
context.setLineDash([]);
|
||||
context.lineCap = "round";
|
||||
let cursor = 0; // row_spans를 훑은 순서 = 흐름 바이트 순서
|
||||
spans.forEach(([row, colStart, colEnd]) => {
|
||||
const count = colEnd - colStart + 1;
|
||||
const base = cursor;
|
||||
cursor += count;
|
||||
const y = top + cellH * row;
|
||||
if (y + cellH < -40 || y > view.height + 40) return;
|
||||
const x = left + cellW * colStart;
|
||||
const width = cellW * count;
|
||||
if (x + width < -40 || x > view.width + 40) return;
|
||||
|
||||
if (!bytes) {
|
||||
// 흐름 판정 전 — 격자만 흰 선으로 보여 준다.
|
||||
if (cellPx >= 2) {
|
||||
context.strokeStyle = GRID_LINE_COLOR;
|
||||
context.lineWidth = 0.5;
|
||||
context.beginPath();
|
||||
for (let col = colStart; col <= colEnd; col += 1) {
|
||||
context.rect(left + cellW * col, y, cellW, cellH);
|
||||
}
|
||||
context.stroke();
|
||||
} else {
|
||||
context.fillStyle = "rgba(255, 255, 255, 0.2)";
|
||||
context.fillRect(x, y, width, cellH);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sink = region.flow?.sink_code ?? 32;
|
||||
const invalid = region.flow?.invalid_code ?? 33;
|
||||
const steps = region.flow?.azimuth_steps ?? 32;
|
||||
for (let offset = 0; offset < count; offset += 1) {
|
||||
drawFlowCell(
|
||||
context,
|
||||
bytes[base + offset],
|
||||
left + cellW * (colStart + offset),
|
||||
y,
|
||||
cellW,
|
||||
cellH,
|
||||
cellPx,
|
||||
{ sink, invalid, steps },
|
||||
);
|
||||
}
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** 셀 하나 — 도달 여부로 칠하고, 충분히 크면 32방위 흐름 화살표를 얹는다. */
|
||||
function drawFlowCell(
|
||||
context: CanvasRenderingContext2D,
|
||||
code: number,
|
||||
x: number,
|
||||
y: number,
|
||||
cellW: number,
|
||||
cellH: number,
|
||||
cellPx: number,
|
||||
codes: { sink: number; invalid: number; steps: number },
|
||||
): void {
|
||||
const azimuth = code & 0x3f;
|
||||
const reaches = (code & 0x80) !== 0;
|
||||
// 표고가 없어 판정 못한 셀 — 미도달(파랑 채움)과 구분해야 오독이 없다.
|
||||
const unanalyzed = azimuth === codes.invalid;
|
||||
context.fillStyle = unanalyzed
|
||||
? FLOW_UNKNOWN_FILL
|
||||
: reaches
|
||||
? FLOW_TO_ROAD_FILL
|
||||
: FLOW_AWAY_FILL;
|
||||
context.fillRect(x, y, cellW, cellH);
|
||||
if (cellPx >= 2) {
|
||||
context.strokeStyle = GRID_LINE_COLOR;
|
||||
context.lineWidth = 0.5;
|
||||
context.strokeRect(x, y, cellW, cellH);
|
||||
}
|
||||
if (cellPx < ARROW_MIN_PX || unanalyzed) return;
|
||||
const stroke = reaches ? FLOW_TO_ROAD_LINE : FLOW_AWAY_LINE;
|
||||
const midX = x + cellW / 2;
|
||||
const midY = y + cellH / 2;
|
||||
if (azimuth === codes.sink) {
|
||||
// 제자리(싱크) — 방향이 없으므로 점으로 표시한다.
|
||||
context.fillStyle = stroke;
|
||||
context.beginPath();
|
||||
context.arc(midX, midY, Math.max(1, cellPx * 0.12), 0, Math.PI * 2);
|
||||
context.fill();
|
||||
return;
|
||||
}
|
||||
// 코드 0 = 화면 오른쪽(+x), 시계방향(캔버스 y는 아래가 +).
|
||||
const angle = (azimuth * 2 * Math.PI) / codes.steps;
|
||||
const unitX = Math.cos(angle);
|
||||
const unitY = Math.sin(angle);
|
||||
const reach = cellPx * 0.38;
|
||||
const tipX = midX + unitX * reach;
|
||||
const tipY = midY + unitY * reach;
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = Math.max(0.6, cellPx * 0.09);
|
||||
context.beginPath();
|
||||
context.moveTo(midX - unitX * reach, midY - unitY * reach);
|
||||
context.lineTo(tipX, tipY);
|
||||
context.stroke();
|
||||
// 촉 — 진행 방향 기준 좌우로 짧게 접는다.
|
||||
const head = cellPx * 0.18;
|
||||
context.beginPath();
|
||||
context.moveTo(tipX, tipY);
|
||||
context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head);
|
||||
context.moveTo(tipX, tipY);
|
||||
context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
/** 1차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */
|
||||
function drawPrimaryRegion(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: Normalizer,
|
||||
view: ViewState,
|
||||
): void {
|
||||
const region = primaryRegion;
|
||||
if (!region) return;
|
||||
context.save();
|
||||
// ① 해석 격자 — bbox 테두리 + 실제 셀 눈금.
|
||||
drawGridCells(context, map, view, region);
|
||||
// ② 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합.
|
||||
context.setLineDash([]);
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "rgba(5, 150, 105, 0.95)";
|
||||
context.fillStyle = "rgba(16, 185, 129, 0.12)";
|
||||
region.region_rings.forEach((ring) => {
|
||||
strokeLonLat(context, ring, map, view);
|
||||
context.fill();
|
||||
});
|
||||
// ③ 도로 아래로 이어진 하류망 — 판정이 맞는지 대조하도록 회색 파선으로 남긴다.
|
||||
context.setLineDash([6, 5]);
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "rgba(120, 113, 108, 0.85)";
|
||||
region.downstream_lines.forEach((line) => strokeLonLat(context, line, map, view));
|
||||
// ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에.
|
||||
context.setLineDash([]);
|
||||
context.lineWidth = 4;
|
||||
context.strokeStyle = "rgba(29, 78, 216, 0.95)";
|
||||
region.upstream_lines.forEach((line) => strokeLonLat(context, line, map, view));
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 포인터 히트 판정용). */
|
||||
/** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 배관 마커 포인터 히트 판정용). */
|
||||
function currentView(): ViewState {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.floor(rect.width));
|
||||
@@ -552,6 +317,7 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
const response = await fetchDrainageBasins(projectId, chainages);
|
||||
basins = response.basins;
|
||||
mainBoundary = response.main_polygon_lonlat ?? [];
|
||||
// 계획도로선·2차 유역 외곽선은 B04 산출물을 그대로 받는다 — 여기서 다시 계산하지 않는다.
|
||||
// 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함).
|
||||
pipeEditor.setPipes(
|
||||
(response.pipes ?? []).map((pipe) => ({
|
||||
@@ -559,7 +325,6 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
reason: pipe.reason,
|
||||
})),
|
||||
);
|
||||
pipeEditor.setStrength(response.strength_profile ?? []);
|
||||
selectedBasin = null;
|
||||
renderBasinList();
|
||||
syncPipeSelection();
|
||||
@@ -587,87 +352,6 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
void analyze(true);
|
||||
});
|
||||
|
||||
/** 1차 영역 근거를 불러와 겹쳐 그린다.
|
||||
*
|
||||
* 켤 때는 **항상 다시 요청한다** — config(반경·격자)를 바꾸고 서버를 재시작한 뒤
|
||||
* 눌렀는데 캐시된 예전 결과가 나오면 검증이 성립하지 않는다. 끌 때만 요청 없이 숨긴다. */
|
||||
async function toggleRegion(): Promise<void> {
|
||||
if (!projectId) return;
|
||||
if (showRegion) {
|
||||
showRegion = false;
|
||||
regionButton.classList.remove("is-active");
|
||||
regionButton.setAttribute("aria-pressed", "false");
|
||||
status.hidden = true;
|
||||
// 이 버튼이 얹은 2차 유역선·강도 띠·관 마커도 함께 걷는다.
|
||||
mainBoundary = [];
|
||||
pipeEditor.setPipes([]);
|
||||
pipeEditor.setStrength([]);
|
||||
scheduleDraw();
|
||||
return;
|
||||
}
|
||||
regionButton.disabled = true;
|
||||
status.hidden = false;
|
||||
status.textContent = "1차 배수유역을 확인하는 중…";
|
||||
try {
|
||||
primaryRegion = await fetchDrainagePrimaryRegion(projectId);
|
||||
// 2차 유역 외곽선·흐름 강도·기본 관 위치를 기존 렌더 경로에 그대로 태운다.
|
||||
mainBoundary = primaryRegion.basin_polygon_lonlat ?? [];
|
||||
pipeEditor.setPipes(
|
||||
(primaryRegion.pipes ?? []).map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
reason: pipe.reason,
|
||||
})),
|
||||
);
|
||||
pipeEditor.setStrength(primaryRegion.strength_profile ?? []);
|
||||
showRegion = true;
|
||||
regionButton.classList.add("is-active");
|
||||
regionButton.setAttribute("aria-pressed", "true");
|
||||
status.textContent = regionSummary(primaryRegion);
|
||||
} catch (error) {
|
||||
status.textContent =
|
||||
error instanceof Error ? error.message : "1차 배수유역을 확인하지 못했습니다.";
|
||||
} finally {
|
||||
regionButton.disabled = false;
|
||||
scheduleDraw();
|
||||
}
|
||||
}
|
||||
|
||||
/** 상태줄에 띄울 1차 영역 요약 — 격자 셀 수를 보고 격자 크기를 조정할 근거가 된다. */
|
||||
function regionSummary(region: DrainagePrimaryRegion): string {
|
||||
const cells = region.grid.cells.toLocaleString();
|
||||
const outside =
|
||||
region.road_outside_m > 0 ? ` · 노선 이탈 ${Math.round(region.road_outside_m)}m` : "";
|
||||
const unknown =
|
||||
region.flow && region.flow.unanalyzed > 0
|
||||
? ` / 표고없음 ${region.flow.unanalyzed.toLocaleString()}(회)`
|
||||
: "";
|
||||
const burned =
|
||||
region.flow && region.flow.burned > 0
|
||||
? ` · 세류망 새김 ${region.flow.burned.toLocaleString()}셀`
|
||||
: "";
|
||||
const flow = region.flow
|
||||
? ` · 흐름 도로도달 ${region.flow.reaches_road.toLocaleString()}(적) / ` +
|
||||
`미도달 ${region.flow.no_road.toLocaleString()}(청)${unknown}, ` +
|
||||
`최외곽 출발 ${region.flow.outer_seeds.toLocaleString()} + ` +
|
||||
`내부 보충 ${region.flow.interior_seeds.toLocaleString()}${burned}`
|
||||
: " · 흐름 판정 없음";
|
||||
const expansion = region.expansion
|
||||
? ` · 확장 ${region.expansion.rounds}회` +
|
||||
`(${region.expansion.initial_cells.toLocaleString()}→${cells}셀, ` +
|
||||
`${region.expansion.closed ? "닫힘" : "상한 도달"})`
|
||||
: "";
|
||||
const basin = region.basin_area_m2
|
||||
? ` · 2차 유역 ${formatArea(region.basin_area_m2)}, 기본 관 ${region.pipes.length}개`
|
||||
: "";
|
||||
return (
|
||||
`1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` +
|
||||
`하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` +
|
||||
`격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${expansion}${basin}${flow}`
|
||||
);
|
||||
}
|
||||
|
||||
regionButton.addEventListener("click", () => void toggleRegion());
|
||||
|
||||
/** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */
|
||||
function fitToRoute(): void {
|
||||
scale = 1;
|
||||
@@ -705,12 +389,6 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
const sequence = ++loadSequence;
|
||||
meta = null;
|
||||
preparedLayers.clear();
|
||||
// 1차 영역은 프로젝트·노선에 종속이므로 새로 불러올 때 버린다.
|
||||
primaryRegion = null;
|
||||
showRegion = false;
|
||||
regionButton.classList.remove("is-active");
|
||||
regionButton.setAttribute("aria-pressed", "false");
|
||||
routeLayer = null;
|
||||
backgroundImage.removeAttribute("src");
|
||||
status.hidden = false;
|
||||
status.textContent = "배경도를 불러오는 중…";
|
||||
|
||||
@@ -24,8 +24,6 @@ const ADD_SNAP_PX = 14;
|
||||
export interface PipeEditor {
|
||||
setContext(meta: VWorldMeta | null, points: ReadonlyArray<RoutePointLike>): void;
|
||||
setPipes(pipes: ReadonlyArray<PipePoint>): void;
|
||||
/** 도로 위 흐름 강도 [누가거리 m, 상류 면적 ㎡]. 관 추가 판단 근거로 계획선에 덧그린다. */
|
||||
setStrength(profile: ReadonlyArray<readonly [number, number]>): void;
|
||||
pipes(): ReadonlyArray<PipePoint>;
|
||||
chainages(): number[];
|
||||
selected(): number | null;
|
||||
@@ -35,8 +33,6 @@ export interface PipeEditor {
|
||||
handleDown(view: ViewState, screenX: number, screenY: number, editMode: boolean): boolean;
|
||||
handleMove(view: ViewState, screenX: number, screenY: number): boolean;
|
||||
handleUp(): boolean;
|
||||
/** 계획선 위 흐름 강도 띠. 마커보다 아래에 깔아야 하므로 draw()와 따로 호출한다. */
|
||||
drawStrength(context: CanvasRenderingContext2D, view: ViewState): void;
|
||||
draw(
|
||||
context: CanvasRenderingContext2D,
|
||||
view: ViewState,
|
||||
@@ -52,9 +48,6 @@ export function createPipeEditor(onChange: () => void): PipeEditor {
|
||||
let selectedIndex: number | null = null;
|
||||
let draggingIndex: number | null = null;
|
||||
let dragMoved = false;
|
||||
let strength: Array<readonly [number, number]> = [];
|
||||
let strengthPeak = 0;
|
||||
let strengthSpan = 5;
|
||||
|
||||
/** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */
|
||||
function screenToMetric(
|
||||
@@ -153,17 +146,6 @@ export function createPipeEditor(onChange: () => void): PipeEditor {
|
||||
selectedIndex = null;
|
||||
draggingIndex = null;
|
||||
},
|
||||
setStrength(profile) {
|
||||
strength = profile.map((entry) => [entry[0], entry[1]] as const);
|
||||
strengthPeak = strength.reduce((peak, entry) => Math.max(peak, entry[1]), 0);
|
||||
// 표본 간격은 백엔드 출력 간격을 그대로 따른다(값이 0인 구간은 빠져 있으므로 최소 간격 사용).
|
||||
let span = Infinity;
|
||||
for (let i = 1; i < strength.length; i += 1) {
|
||||
const gap = strength[i][0] - strength[i - 1][0];
|
||||
if (gap > 0 && gap < span) span = gap;
|
||||
}
|
||||
strengthSpan = Number.isFinite(span) ? span : 5;
|
||||
},
|
||||
pipes: () => pipeList,
|
||||
chainages: () => pipeList.map((pipe) => Math.round(pipe.chainage_m * 100) / 100),
|
||||
selected: () => selectedIndex,
|
||||
@@ -229,28 +211,6 @@ export function createPipeEditor(onChange: () => void): PipeEditor {
|
||||
}
|
||||
return true;
|
||||
},
|
||||
drawStrength(context, view) {
|
||||
if (strengthPeak <= 0 || route.length < 2) return;
|
||||
context.save();
|
||||
context.lineCap = "butt";
|
||||
strength.forEach(([chainage, area]) => {
|
||||
const from = chainageToXY(chainage);
|
||||
const to = chainageToXY(Math.min(totalChainage, chainage + strengthSpan));
|
||||
if (!from || !to) return;
|
||||
const start = metricToScreen(view, from.x, from.y);
|
||||
const end = metricToScreen(view, to.x, to.y);
|
||||
if (!start || !end) return;
|
||||
// 강도는 편차가 커서(계곡 한 점에 수십 배 집중) 제곱근으로 눌러 표시한다.
|
||||
const intensity = Math.sqrt(area / strengthPeak);
|
||||
context.beginPath();
|
||||
context.moveTo(start.x, start.y);
|
||||
context.lineTo(end.x, end.y);
|
||||
context.lineWidth = 3 + 9 * intensity;
|
||||
context.strokeStyle = `rgba(37, 99, 235, ${(0.15 + 0.5 * intensity).toFixed(3)})`;
|
||||
context.stroke();
|
||||
});
|
||||
context.restore();
|
||||
},
|
||||
draw(context, view, colorOf) {
|
||||
pipeList.forEach((pipe, position) => {
|
||||
const xy = chainageToXY(pipe.chainage_m);
|
||||
|
||||
@@ -1,572 +0,0 @@
|
||||
"""배수유역 산정 엔진 — 세류 기반 등고선 기하 직접 분석 (2026-07-29 합의).
|
||||
|
||||
DEM 보간·D8 전역 흐름분석을 쓰지 않는다. 계산 순서(사용자 정의 7단계):
|
||||
① 도로(노선)가 유역의 하측 경계 ② 도로를 가로지르는 세류 교차점에서 출발
|
||||
③ 세류 상류망을 추적하고 연관 등고선만 분석해 메인 유역 선정(하류 무의미)
|
||||
④ 세류 교차점 = 관매설 지점 ⑤ 300m 초과 구간은 종단 저점에 보충(제안 엔진 담당)
|
||||
⑥ 관 사이 물갈림 고개에서 오르는 분할선(능선 근사)으로 유역을 세분화하고
|
||||
번호·면적·표고차·유하장을 산출 ⑦ 관 추가·경로 변경 시 재호출로 재분석.
|
||||
|
||||
유역 폴리곤 = 도로 구간(하측) + 좌우 분할선 + 최상위 공통 등고선 아크(상측)로 폐합.
|
||||
등고선은 STRtree에서 필요한 것만 꺼내므로 분석량이 유역 크기에 비례한다(도엽 매수 무관).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import LineString, Point, Polygon
|
||||
from shapely.ops import substring, unary_union
|
||||
from shapely.strtree import STRtree
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import (
|
||||
StructureCandidate,
|
||||
_interpolate_vertex,
|
||||
estimate_pipe_diameter_mm,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Assemble import (
|
||||
_assemble_polygon,
|
||||
_road_segment_coords,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Subdivide import subdivide_main_polygon
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import (
|
||||
LOCAL_MAX_STEPS,
|
||||
LOCAL_SEARCH_RADIUS_M,
|
||||
STREAM_JOIN_TOL_M,
|
||||
ContourIndex,
|
||||
DividerStep,
|
||||
_explode_lines,
|
||||
rim_walk,
|
||||
side_sign,
|
||||
trace_divider,
|
||||
trace_ridge_march,
|
||||
trace_upstream_network,
|
||||
valley_region_polygon,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 유효 유역 최소 면적(m²)과 상류망 커버 보정 버퍼(m).
|
||||
MIN_BASIN_AREA_M2 = 100.0
|
||||
STREAM_COVER_BUFFER_M = 20.0
|
||||
# 도로 양끝에서 하류측으로 뻗는 절단 차단선 길이(m).
|
||||
DOWNHILL_BARRIER_M = 800.0
|
||||
# 유역이 상류망을 덮어야 하는 커버리지 목표(미달 시 버퍼 폴백).
|
||||
CLOSING_COVERAGE_GOAL = 0.95
|
||||
# 산측 판정: 도로 접선의 좌우 수직 오프셋(m)과 표고 조회 반경(m).
|
||||
UPHILL_PROBE_OFFSET_M = 40.0
|
||||
UPHILL_PROBE_RADIUS_M = 60.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatershedBasin:
|
||||
"""능선 기반으로 산정된 배수유역 1개."""
|
||||
|
||||
index: int
|
||||
chainage_m: float
|
||||
outlet_x: float
|
||||
outlet_y: float
|
||||
# 유역 경계(사업지 좌표계 m). 외곽선의 산측이 곧 분수령(능선), 하측이 도로선.
|
||||
boundary_xy: list[list[float]] = field(default_factory=list)
|
||||
area_m2: float = 0.0
|
||||
relief_m: float = 0.0
|
||||
flow_length_m: float = 0.0
|
||||
pipe_diameter_mm: float | None = None
|
||||
|
||||
|
||||
def _divide_chainages(vertices: list[Any], ordered: list[StructureCandidate]) -> list[float]:
|
||||
"""유역 분할 누가거리 목록(양끝 포함, 관 개수+1개).
|
||||
|
||||
인접한 두 관 사이 **종단 계획선의 최고점(물갈림 고개)**이 분할점이다 —
|
||||
"도로에 닿은 물은 측구를 타고 내리막의 첫 관으로 들어간다".
|
||||
"""
|
||||
divides = [vertices[0].chainage_m]
|
||||
for left, right in zip(ordered, ordered[1:]):
|
||||
window = [
|
||||
vertex for vertex in vertices if left.chainage_m < vertex.chainage_m < right.chainage_m
|
||||
]
|
||||
if window:
|
||||
divides.append(max(window, key=lambda vertex: vertex.z).chainage_m)
|
||||
else:
|
||||
divides.append((left.chainage_m + right.chainage_m) / 2.0)
|
||||
divides.append(vertices[-1].chainage_m)
|
||||
return divides
|
||||
|
||||
|
||||
def _uphill_sign_at(vertices: list[Any], chainage_m: float, contour_index: ContourIndex) -> int:
|
||||
"""해당 측점의 산측이 도로 진행방향 기준 좌(+1)인지 우(-1)인지. 불명이면 0."""
|
||||
x, y, _ = _interpolate_vertex(vertices, chainage_m)
|
||||
back = _interpolate_vertex(vertices, max(0.0, chainage_m - 10.0))
|
||||
forward = _interpolate_vertex(vertices, chainage_m + 10.0)
|
||||
dx, dy = forward[0] - back[0], forward[1] - back[1]
|
||||
norm = math.hypot(dx, dy)
|
||||
if norm < 1e-6:
|
||||
return 0
|
||||
dx, dy = dx / norm, dy / norm
|
||||
# 좌측 법선 (-dy, dx) 방향 오프셋이 side_sign +1에 대응한다.
|
||||
left_z = contour_index.nearest_elevation(
|
||||
Point(x - dy * UPHILL_PROBE_OFFSET_M, y + dx * UPHILL_PROBE_OFFSET_M),
|
||||
UPHILL_PROBE_RADIUS_M,
|
||||
)
|
||||
right_z = contour_index.nearest_elevation(
|
||||
Point(x + dy * UPHILL_PROBE_OFFSET_M, y - dx * UPHILL_PROBE_OFFSET_M),
|
||||
UPHILL_PROBE_RADIUS_M,
|
||||
)
|
||||
if left_z is None or right_z is None or left_z == right_z:
|
||||
return 0
|
||||
return 1 if left_z > right_z else -1
|
||||
|
||||
|
||||
def _clip_to_uphill(
|
||||
polygon: Polygon,
|
||||
road_line: LineString,
|
||||
uphill_sign: int,
|
||||
contour_index: ContourIndex,
|
||||
keep_geom: Any = None,
|
||||
) -> Polygon | None:
|
||||
"""도로 하류측 조각을 잘라낸다 — 도로가 유역의 한쪽 경계(2026-07-29 사용자 지시).
|
||||
|
||||
절단선 = 실제 도로선 + 양끝에서 **하류측으로 뻗는 수직 차단선** 2개. (후방 접선
|
||||
연장은 곡선 노선에서 계곡 내부를 관통해 오절단을 일으켰다.) 노선 끝을 감아 도는
|
||||
산측 사면은 남고, 도로 하류측 주머니만 분리된다.
|
||||
조각 분류는 표고 기반: 대표점의 등고선 표고 > 최근접 도로 지점 표고 → 산측.
|
||||
keep_geom(세류 상류망)이 실제로 지나가는 조각은 무조건 유지한다.
|
||||
"""
|
||||
length = road_line.length
|
||||
cutters: list[LineString] = [road_line]
|
||||
for t_end, t_inner in ((0.0, min(30.0, length)), (length, max(0.0, length - 30.0))):
|
||||
end = road_line.interpolate(t_end)
|
||||
inner = road_line.interpolate(t_inner)
|
||||
dx, dy = end.x - inner.x, end.y - inner.y
|
||||
norm = math.hypot(dx, dy) or 1.0
|
||||
for nx, ny in ((-dy / norm, dx / norm), (dy / norm, -dx / norm)):
|
||||
probe = Point(end.x + nx * 30.0, end.y + ny * 30.0)
|
||||
if side_sign(road_line, probe) == -uphill_sign:
|
||||
cutters.append(
|
||||
LineString(
|
||||
[
|
||||
(end.x, end.y),
|
||||
(end.x + nx * DOWNHILL_BARRIER_M, end.y + ny * DOWNHILL_BARRIER_M),
|
||||
]
|
||||
)
|
||||
)
|
||||
break
|
||||
# split()은 절단선이 폴리곤 경계와 겹치면(도로 = 유역 하측 경계) 동작하지 않는다.
|
||||
# 얇은 스트립을 차감해 조각을 분리하고, 분류 후 buffer-교집합으로 원형을 복원한다.
|
||||
try:
|
||||
strip = unary_union([cutter.buffer(0.5) for cutter in cutters])
|
||||
separated = polygon.difference(strip)
|
||||
except Exception: # noqa: BLE001 - 절단 실패 시 원본 유지
|
||||
return polygon
|
||||
pieces = [
|
||||
part
|
||||
for part in (separated.geoms if separated.geom_type.startswith("Multi") else [separated])
|
||||
if part.geom_type == "Polygon" and not part.is_empty
|
||||
]
|
||||
kept = []
|
||||
for piece in pieces:
|
||||
if keep_geom is not None and piece.intersection(keep_geom).length > 5.0:
|
||||
kept.append(piece)
|
||||
continue
|
||||
representative = piece.representative_point()
|
||||
piece_z = contour_index.nearest_elevation(representative, 2.0 * UPHILL_PROBE_RADIUS_M)
|
||||
foot = road_line.interpolate(road_line.project(representative))
|
||||
road_z = contour_index.nearest_elevation(foot, 2.0 * UPHILL_PROBE_RADIUS_M)
|
||||
if piece_z is not None and road_z is not None and piece_z != road_z:
|
||||
if piece_z > road_z:
|
||||
kept.append(piece)
|
||||
continue
|
||||
# 표고 판정 불가(등고선 공백·동일 표고) 시에만 좌우 부호로 판정한다.
|
||||
if side_sign(road_line, representative) == uphill_sign:
|
||||
kept.append(piece)
|
||||
if not kept:
|
||||
return None
|
||||
# 스트립 차감으로 깎인 0.5m를 되붙이되 원본 폴리곤 밖으로는 나가지 않는다.
|
||||
merged = unary_union(kept).buffer(0.7).intersection(polygon).buffer(0)
|
||||
if merged.geom_type == "MultiPolygon":
|
||||
merged = max(merged.geoms, key=lambda part: part.area)
|
||||
if merged.is_empty or merged.geom_type != "Polygon":
|
||||
return None
|
||||
return merged
|
||||
|
||||
|
||||
def _assemble_march_polygon(
|
||||
vertices: list[Any],
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
contour_index: ContourIndex,
|
||||
road_line: LineString,
|
||||
uphill_sign: int,
|
||||
network_union: Any,
|
||||
stream_lines: list[Any],
|
||||
valley_top_z: float,
|
||||
) -> Polygon | None:
|
||||
"""개선 2안 — 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 조립한다.
|
||||
|
||||
좌·우 능선 행진 체인(세류 제약이 분수령을 강제) + 발원부 위 공통 등고선 아크로
|
||||
폐합(기존 `_assemble_polygon` 재사용). 상류망 커버리지가 목표 미달이면 None을
|
||||
돌려 1안(등거리+체인) 폴백을 태운다.
|
||||
"""
|
||||
others = [line for line in stream_lines if line.distance(network_union) > STREAM_JOIN_TOL_M]
|
||||
other_tree = STRtree(others) if others else None
|
||||
sx, sy, _ = _interpolate_vertex(vertices, start_m)
|
||||
ex, ey, _ = _interpolate_vertex(vertices, end_m)
|
||||
left = trace_ridge_march(
|
||||
Point(sx, sy), contour_index, network_union, others, other_tree, road_line, uphill_sign
|
||||
)
|
||||
right = trace_ridge_march(
|
||||
Point(ex, ey), contour_index, network_union, others, other_tree, road_line, uphill_sign
|
||||
)
|
||||
if len(left) < 5 or len(right) < 5:
|
||||
return None
|
||||
# 상측 폐합: 우측 정상 → 좌측 정상을 능선마루 보행으로 잇는다.
|
||||
rim = rim_walk(
|
||||
right[-1].point,
|
||||
left[-1].point,
|
||||
contour_index,
|
||||
network_union,
|
||||
others,
|
||||
other_tree,
|
||||
road_line,
|
||||
uphill_sign,
|
||||
)
|
||||
if rim is None:
|
||||
return None
|
||||
ring = _road_segment_coords(vertices, start_m, end_m)
|
||||
ring.extend((step.point.x, step.point.y) for step in right[1:])
|
||||
ring.extend((point.x, point.y) for point in rim)
|
||||
ring.extend((step.point.x, step.point.y) for step in reversed(left[1:]))
|
||||
if len(ring) < 4:
|
||||
return None
|
||||
polygon = Polygon(ring).buffer(0)
|
||||
if polygon.geom_type == "MultiPolygon":
|
||||
polygon = max(polygon.geoms, key=lambda part: part.area)
|
||||
if polygon.is_empty or polygon.geom_type != "Polygon":
|
||||
return None
|
||||
coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0)
|
||||
if coverage < CLOSING_COVERAGE_GOAL:
|
||||
return None
|
||||
return polygon
|
||||
|
||||
|
||||
def _refine_road_edge(polygon: Polygon, road_line: LineString) -> Polygon:
|
||||
"""경계의 도로변 구간을 도로선 원해상도 좌표로 치환한다.
|
||||
|
||||
단순화(simplify)가 도로변 경계를 뭉개 도로를 가로지르는 것을 막는다
|
||||
(2026-07-29 사용자 지시: 경계 참조를 도로선 해상도와 매칭).
|
||||
"""
|
||||
coords = list(polygon.exterior.coords)[:-1]
|
||||
count = len(coords)
|
||||
near = [road_line.distance(Point(c)) < 6.0 for c in coords]
|
||||
if not any(near) or all(near):
|
||||
return polygon
|
||||
start = next(i for i in range(count) if not near[i])
|
||||
coords = coords[start:] + coords[:start]
|
||||
near = near[start:] + near[:start]
|
||||
ring: list[tuple[float, float]] = []
|
||||
i = 0
|
||||
while i < count:
|
||||
if not near[i]:
|
||||
ring.append(coords[i])
|
||||
i += 1
|
||||
continue
|
||||
j = i
|
||||
while j < count and near[j]:
|
||||
j += 1
|
||||
t1 = road_line.project(Point(coords[i]))
|
||||
t2 = road_line.project(Point(coords[j - 1]))
|
||||
segment = substring(road_line, min(t1, t2), max(t1, t2))
|
||||
if segment.geom_type == "LineString" and len(segment.coords) >= 2:
|
||||
segment_coords = list(segment.coords)
|
||||
if t1 > t2:
|
||||
segment_coords.reverse()
|
||||
ring.extend(segment_coords)
|
||||
else:
|
||||
ring.extend(coords[i:j])
|
||||
i = j
|
||||
if len(ring) < 4:
|
||||
return polygon
|
||||
refined = Polygon(ring).buffer(0)
|
||||
if refined.geom_type == "MultiPolygon":
|
||||
refined = max(refined.geoms, key=lambda part: part.area)
|
||||
if refined.is_empty or refined.geom_type != "Polygon":
|
||||
return polygon
|
||||
return refined
|
||||
|
||||
|
||||
def _main_watershed_polygon(
|
||||
vertices: list[Any],
|
||||
divides: list[float],
|
||||
dividers: list[list[DividerStep]],
|
||||
contour_index: ContourIndex,
|
||||
road_line: LineString,
|
||||
uphill_sign: int,
|
||||
network_union: Any,
|
||||
stream_lines: list[Any],
|
||||
stream_features: list[dict[str, Any]],
|
||||
outlet: Point,
|
||||
) -> Polygon | None:
|
||||
"""메인 배수유역 폴리곤 1회 산정 — f72017a 채택 산식 그대로.
|
||||
|
||||
불변 조건(2026-07-30 사용자 지시): 이 함수의 산식은 전체 유역 경계를 결정하므로
|
||||
변경 금지. 세분화는 이 결과를 내부에서만 쪼갠다(`subdivide_main_polygon`).
|
||||
"""
|
||||
valley_top_z = contour_index.max_elevation_within(network_union.buffer(10.0))
|
||||
# 개선 2안: 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 그린다.
|
||||
polygon = None
|
||||
if valley_top_z is not None:
|
||||
polygon = _assemble_march_polygon(
|
||||
vertices,
|
||||
divides[0],
|
||||
divides[-1],
|
||||
contour_index,
|
||||
road_line,
|
||||
uphill_sign,
|
||||
network_union,
|
||||
stream_lines,
|
||||
valley_top_z,
|
||||
)
|
||||
if polygon is None:
|
||||
# 개선 1안(폴백): 도로변 스트립 + 세류 계곡 영역(등거리+등고선 체인) 합집합.
|
||||
base = _assemble_polygon(
|
||||
vertices,
|
||||
divides[0],
|
||||
divides[-1],
|
||||
dividers[0],
|
||||
dividers[-1],
|
||||
contour_index,
|
||||
road_line,
|
||||
network_union,
|
||||
valley_top_z,
|
||||
)
|
||||
valley = valley_region_polygon(network_union, stream_features, outlet, contour_index)
|
||||
if base is None and valley is None:
|
||||
return None
|
||||
if base is not None and valley is not None:
|
||||
merged = base.union(valley).buffer(0)
|
||||
if merged.geom_type == "MultiPolygon":
|
||||
merged = max(merged.geoms, key=lambda part: part.area)
|
||||
polygon = merged if merged.geom_type == "Polygon" and not merged.is_empty else base
|
||||
else:
|
||||
polygon = base if base is not None else valley
|
||||
coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0)
|
||||
if coverage < CLOSING_COVERAGE_GOAL:
|
||||
# 계곡 영역이 못 덮은 상류망만 버퍼로 보정한다(최후 폴백).
|
||||
covered = polygon.union(network_union.buffer(STREAM_COVER_BUFFER_M)).buffer(0)
|
||||
if covered.geom_type == "MultiPolygon":
|
||||
covered = max(covered.geoms, key=lambda part: part.area)
|
||||
if covered.geom_type == "Polygon" and not covered.is_empty:
|
||||
polygon = covered
|
||||
# 마지막에 도로선으로 절단해 하류측을 제거한다 — 도로가 유역의 한쪽 경계.
|
||||
polygon = _clip_to_uphill(polygon, road_line, uphill_sign, contour_index, network_union)
|
||||
if polygon is None or polygon.area < MIN_BASIN_AREA_M2:
|
||||
return None
|
||||
return polygon
|
||||
|
||||
|
||||
def _local_polygon(
|
||||
vertices: list[Any],
|
||||
divides: list[float],
|
||||
dividers: list[list[DividerStep]],
|
||||
position: int,
|
||||
contour_index: ContourIndex,
|
||||
road_line: LineString,
|
||||
uphill_sign: int,
|
||||
) -> Polygon | None:
|
||||
"""세류 없는 관 구간의 소범위 유역 — 도로 상측 첫 능선까지(기존 경로 유지)."""
|
||||
base = _assemble_polygon(
|
||||
vertices,
|
||||
divides[position],
|
||||
divides[position + 1],
|
||||
dividers[position],
|
||||
dividers[position + 1],
|
||||
contour_index,
|
||||
road_line,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
if base is None:
|
||||
return None
|
||||
return _clip_to_uphill(base, road_line, uphill_sign, contour_index, None)
|
||||
|
||||
|
||||
def _basin_from_polygon(
|
||||
polygon: Polygon,
|
||||
candidate: StructureCandidate,
|
||||
index: int,
|
||||
flow_length: float,
|
||||
contour_index: ContourIndex,
|
||||
road_line: LineString,
|
||||
vertices: list[Any],
|
||||
simplify: bool = True,
|
||||
) -> WatershedBasin:
|
||||
"""확정된 유역 폴리곤에서 산출값(면적·표고차·유하장·관경)을 계산한다.
|
||||
|
||||
세분화 조각(simplify=False)은 단순화하지 않는다 — 조각별 독립 단순화는 공유
|
||||
분할선 경계를 어긋나게 해 겹침·틈을 만든다(배타적 타일링 유지).
|
||||
"""
|
||||
outlet = Point(candidate.x, candidate.y)
|
||||
outlet_z = contour_index.nearest_elevation(outlet, UPHILL_PROBE_RADIUS_M)
|
||||
if outlet_z is None:
|
||||
outlet_z = _interpolate_vertex(vertices, candidate.chainage_m)[2]
|
||||
# 표고차는 등고선만으로 계산한다(표고점 미참조 — 사용자 지시).
|
||||
top_z = contour_index.max_elevation_within(polygon) or outlet_z
|
||||
boundary_line = polygon.simplify(5.0, preserve_topology=True) if simplify else polygon
|
||||
if boundary_line.is_empty or boundary_line.geom_type != "Polygon":
|
||||
boundary_line = polygon
|
||||
# 도로변 경계는 단순화 없이 도로선 해상도를 유지한다.
|
||||
boundary_line = _refine_road_edge(boundary_line, road_line)
|
||||
boundary = [[float(x), float(y)] for x, y in boundary_line.exterior.coords]
|
||||
if flow_length <= 0.0:
|
||||
flow_length = max(
|
||||
(math.dist((candidate.x, candidate.y), point) for point in boundary),
|
||||
default=0.0,
|
||||
)
|
||||
basin = WatershedBasin(
|
||||
index=index,
|
||||
chainage_m=candidate.chainage_m,
|
||||
outlet_x=candidate.x,
|
||||
outlet_y=candidate.y,
|
||||
boundary_xy=boundary,
|
||||
area_m2=float(polygon.area),
|
||||
relief_m=max(0.0, float(top_z) - float(outlet_z)),
|
||||
flow_length_m=float(flow_length),
|
||||
)
|
||||
basin.pipe_diameter_mm = estimate_pipe_diameter_mm(
|
||||
basin.area_m2, basin.relief_m, basin.flow_length_m
|
||||
)
|
||||
return basin
|
||||
|
||||
|
||||
def build_watershed_basins(
|
||||
vertices: list[Any],
|
||||
candidates: list[StructureCandidate],
|
||||
contour_features: list[dict[str, Any]],
|
||||
spot_features: list[dict[str, Any]],
|
||||
elevation_keys: tuple[str, ...],
|
||||
stream_features: list[dict[str, Any]] | None = None,
|
||||
) -> list[WatershedBasin]:
|
||||
"""메인 배수유역을 1회 산정하고 관 지점 기준으로 내부 세분화한다.
|
||||
|
||||
메인 유역 경계는 관 개수와 무관하게 항상 동일하다(불변 조건 — 2026-07-30 사용자
|
||||
지시). 세부유역 = 메인 폴리곤을 관 사이 분할선으로 쪼갠 조각(배타적, 합집합 =
|
||||
메인). 세류 없는 관이 메인 범위 밖이면 소범위 유역을 별도 생성(기존 동작).
|
||||
번호는 노선 시점에 가까운 순.
|
||||
"""
|
||||
if not candidates or len(vertices) < 2:
|
||||
return []
|
||||
contour_index = ContourIndex(contour_features, elevation_keys)
|
||||
if contour_index.tree is None:
|
||||
logger.warning("표고 속성이 있는 등고선이 없어 유역을 산정하지 못했습니다.")
|
||||
return []
|
||||
# 표고점은 참조하지 않는다(2026-07-29 사용자 지시: 계측 측점 데이터라 오류 유입).
|
||||
_ = spot_features
|
||||
road_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
|
||||
|
||||
ordered = sorted(candidates, key=lambda item: item.chainage_m)
|
||||
divides = _divide_chainages(vertices, ordered)
|
||||
signs = [_uphill_sign_at(vertices, item.chainage_m, contour_index) for item in ordered]
|
||||
majority = 1 if sum(signs) >= 0 else -1
|
||||
signs = [sign or majority for sign in signs]
|
||||
# 사용자 확정("confirmed") 측점도 세류에 닿아 있으면 세류 유역으로 취급한다.
|
||||
stream_lines = _explode_lines(stream_features) if stream_features else []
|
||||
is_stream = [
|
||||
item.reason == "stream"
|
||||
or any(line.distance(Point(item.x, item.y)) <= STREAM_JOIN_TOL_M for line in stream_lines)
|
||||
for item in ordered
|
||||
]
|
||||
|
||||
# 분할선은 인접 유역과 공유하므로 분할점마다 1회만 추적한다.
|
||||
dividers: list[list[DividerStep]] = []
|
||||
for position, chainage in enumerate(divides):
|
||||
neighbor_streams = []
|
||||
if position > 0:
|
||||
neighbor_streams.append(is_stream[position - 1])
|
||||
if position < len(ordered):
|
||||
neighbor_streams.append(is_stream[position])
|
||||
wide = any(neighbor_streams)
|
||||
sign = signs[position - 1] if position > 0 else signs[0]
|
||||
x, y, _ = _interpolate_vertex(vertices, chainage)
|
||||
if wide:
|
||||
steps = trace_divider(Point(x, y), contour_index, road_line, sign)
|
||||
else:
|
||||
steps = trace_divider(
|
||||
Point(x, y),
|
||||
contour_index,
|
||||
road_line,
|
||||
sign,
|
||||
radius_m=LOCAL_SEARCH_RADIUS_M,
|
||||
max_steps=LOCAL_MAX_STEPS,
|
||||
)
|
||||
dividers.append(steps)
|
||||
|
||||
# 관별 상류망은 1회만 추적한다(유하장 계산에도 사용).
|
||||
networks: list[list[Any]] = []
|
||||
flows: list[float] = []
|
||||
for position, candidate in enumerate(ordered):
|
||||
network: list[Any] = []
|
||||
flow_length = 0.0
|
||||
if is_stream[position] and stream_features:
|
||||
network, flow_length = trace_upstream_network(
|
||||
Point(candidate.x, candidate.y), stream_features, road_line, signs[position]
|
||||
)
|
||||
networks.append(network)
|
||||
flows.append(flow_length)
|
||||
|
||||
main_polygon = None
|
||||
combined = [line for network in networks for line in network]
|
||||
first_stream = next((position for position in range(len(ordered)) if networks[position]), None)
|
||||
if combined and first_stream is not None:
|
||||
main_polygon = _main_watershed_polygon(
|
||||
vertices,
|
||||
divides,
|
||||
dividers,
|
||||
contour_index,
|
||||
road_line,
|
||||
majority,
|
||||
unary_union(combined),
|
||||
stream_lines,
|
||||
stream_features or [],
|
||||
Point(ordered[first_stream].x, ordered[first_stream].y),
|
||||
)
|
||||
|
||||
pieces: list[Polygon | None] = [None] * len(ordered)
|
||||
if main_polygon is not None:
|
||||
divide_points = [
|
||||
Point(*_interpolate_vertex(vertices, chainage)[:2]) for chainage in divides
|
||||
]
|
||||
pieces = subdivide_main_polygon(main_polygon, divide_points, dividers, road_line)
|
||||
|
||||
basins: list[WatershedBasin] = []
|
||||
for position, candidate in enumerate(ordered):
|
||||
polygon = pieces[position] if position < len(pieces) else None
|
||||
from_subdivision = polygon is not None and len(ordered) > 1
|
||||
if polygon is None:
|
||||
if networks[position] and main_polygon is not None:
|
||||
logger.warning(
|
||||
"세류 관(%.0fm) 구간에 세부유역 조각이 없습니다 — 건너뜁니다.",
|
||||
candidate.chainage_m,
|
||||
)
|
||||
continue
|
||||
# 메인 유역 밖(또는 상류망 없음) 관은 소범위 유역을 별도 생성한다.
|
||||
polygon = _local_polygon(
|
||||
vertices, divides, dividers, position, contour_index, road_line, signs[position]
|
||||
)
|
||||
if polygon is None or polygon.area < MIN_BASIN_AREA_M2:
|
||||
continue
|
||||
basins.append(
|
||||
_basin_from_polygon(
|
||||
polygon,
|
||||
candidate,
|
||||
len(basins) + 1,
|
||||
flows[position],
|
||||
contour_index,
|
||||
road_line,
|
||||
vertices,
|
||||
simplify=not from_subdivision,
|
||||
)
|
||||
)
|
||||
return basins
|
||||
@@ -1,205 +0,0 @@
|
||||
"""배수유역 폴리곤 폐합 조립 — 도로 구간 + 분할선 + 등고선 아크 (700줄 분리, 2026-07-30).
|
||||
|
||||
`B05_wf2_Route_Engine_Drainage_Watershed.py`에서 산식 변경 없이 그대로 옮겨온
|
||||
개선 1안(등거리+체인) 폐합 헬퍼 모음이다. 불변 조건: 산식 수정 금지(메인 유역
|
||||
경계가 바뀐다 — 2026-07-30 사용자 지시).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import LineString, Point, Polygon
|
||||
from shapely.ops import nearest_points, substring
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import _interpolate_vertex
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import ContourIndex, DividerStep
|
||||
|
||||
# 폐합 등고선 기하 탐색: 분할선 경로와 등고선의 근접 허용 거리(m).
|
||||
CLOSING_SEARCH_M = 30.0
|
||||
|
||||
|
||||
def _road_segment_coords(
|
||||
vertices: list[Any], start_m: float, end_m: float
|
||||
) -> list[tuple[float, float]]:
|
||||
"""분할점 사이 도로 구간의 평면 좌표열(유역 폴리곤의 하측 경계)."""
|
||||
sx, sy, _ = _interpolate_vertex(vertices, start_m)
|
||||
ex, ey, _ = _interpolate_vertex(vertices, end_m)
|
||||
coords = [(sx, sy)]
|
||||
coords.extend(
|
||||
(vertex.x, vertex.y) for vertex in vertices if start_m < vertex.chainage_m < end_m
|
||||
)
|
||||
coords.append((ex, ey))
|
||||
return coords
|
||||
|
||||
|
||||
def _contour_arc(
|
||||
line: Any,
|
||||
p_from: Point,
|
||||
p_to: Point,
|
||||
road_line: LineString,
|
||||
network_union: Any = None,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""등고선에서 두 분할선 접점 사이 아크(상측 경계)를 뽑는다.
|
||||
|
||||
폐합 등고선은 두 방향 아크가 생기므로, 세류 상류망을 가로지르지 않고(계곡을
|
||||
자르지 않고) 도로와도 교차하지 않는(=산측) 쪽을 고른다.
|
||||
"""
|
||||
t1, t2 = sorted((line.project(p_from), line.project(p_to)))
|
||||
arcs = []
|
||||
inner = substring(line, t1, t2)
|
||||
if inner.geom_type == "LineString" and len(inner.coords) >= 2:
|
||||
arcs.append(inner)
|
||||
if getattr(line, "is_closed", False):
|
||||
head = substring(line, t2, line.length)
|
||||
tail = substring(line, 0.0, t1)
|
||||
coords = list(head.coords) + list(tail.coords)[1:]
|
||||
if len(coords) >= 2:
|
||||
arcs.append(LineString(coords))
|
||||
if not arcs:
|
||||
return []
|
||||
scored = []
|
||||
for arc in arcs:
|
||||
crosses_stream = bool(network_union is not None and arc.crosses(network_union))
|
||||
crosses_road = arc.crosses(road_line)
|
||||
midpoint = arc.interpolate(0.5, normalized=True)
|
||||
scored.append((crosses_stream, crosses_road, -road_line.distance(midpoint), arc))
|
||||
scored.sort(key=lambda item: (item[0], item[1], item[2]))
|
||||
arc = scored[0][3]
|
||||
coords = list(arc.coords)
|
||||
if Point(coords[0]).distance(p_from) > Point(coords[-1]).distance(p_from):
|
||||
coords.reverse()
|
||||
return [(float(x), float(y)) for x, y in coords]
|
||||
|
||||
|
||||
def _junction(
|
||||
left: list[DividerStep],
|
||||
right: list[DividerStep],
|
||||
min_z: float | None = None,
|
||||
) -> tuple[int, int, int] | None:
|
||||
"""두 분할선이 같은 등고선 지오메트리를 밟은 폐합 지점(좌 idx, 우 idx, geom idx).
|
||||
|
||||
min_z(세류 상류망 최고 표고)가 있으면 그 **이상인 가장 낮은** 공통 등고선을 고른다 —
|
||||
"세류로 영역을 지정한 뒤 가까운 등고선으로 바로 올려치면 안 된다"(2026-07-29 사용자
|
||||
지시). 계곡 발원부를 넘긴 첫 등고선이 유역 상측 경계가 된다. 없으면 최고 공통 등고선.
|
||||
"""
|
||||
left_keys = {
|
||||
(step.z, step.geom_index): position
|
||||
for position, step in enumerate(left)
|
||||
if step.geom_index >= 0
|
||||
}
|
||||
matches: list[tuple[float, int, int, int]] = []
|
||||
for position, step in enumerate(right):
|
||||
if step.geom_index < 0:
|
||||
continue
|
||||
left_position = left_keys.get((step.z, step.geom_index))
|
||||
if left_position is None:
|
||||
continue
|
||||
matches.append((step.z, left_position, position, step.geom_index))
|
||||
if not matches:
|
||||
return None
|
||||
if min_z is not None:
|
||||
above = [match for match in matches if match[0] >= min_z]
|
||||
if above:
|
||||
best = min(above)
|
||||
return best[1], best[2], best[3]
|
||||
best = max(matches)
|
||||
return best[1], best[2], best[3]
|
||||
|
||||
|
||||
def _closing_contour(
|
||||
left: list[DividerStep],
|
||||
right: list[DividerStep],
|
||||
contour_index: ContourIndex,
|
||||
min_z: float,
|
||||
) -> tuple[int, int, int, Point, Point] | None:
|
||||
"""두 분할선 경로에 모두 근접한 등고선 중 min_z 이상 최저를 찾는다.
|
||||
|
||||
분할선이 같은 스텝에서 같은 지오메트리를 밟지 못해도(도엽 분할 등) 계곡 발원부
|
||||
위를 지나는 폐합 등고선을 기하적으로 찾아낸다.
|
||||
반환: (좌 절단 idx, 우 절단 idx, 등고선 geom idx, 좌 접점, 우 접점).
|
||||
"""
|
||||
if len(left) < 2 or len(right) < 2:
|
||||
return None
|
||||
left_line = LineString([step.point for step in left])
|
||||
right_line = LineString([step.point for step in right])
|
||||
shared = set(contour_index.query(left_line.buffer(CLOSING_SEARCH_M))) & set(
|
||||
contour_index.query(right_line.buffer(CLOSING_SEARCH_M))
|
||||
)
|
||||
best: tuple[float, int] | None = None
|
||||
for index in shared:
|
||||
z = contour_index.zs[index]
|
||||
if z < min_z:
|
||||
continue
|
||||
geom = contour_index.geoms[index]
|
||||
if (
|
||||
geom.distance(left_line) > CLOSING_SEARCH_M
|
||||
or geom.distance(right_line) > CLOSING_SEARCH_M
|
||||
):
|
||||
continue
|
||||
if best is None or z < best[0]:
|
||||
best = (z, index)
|
||||
if best is None:
|
||||
return None
|
||||
geom = contour_index.geoms[best[1]]
|
||||
left_touch = nearest_points(geom, left_line)[0]
|
||||
right_touch = nearest_points(geom, right_line)[0]
|
||||
left_position = min(range(len(left)), key=lambda i: left[i].point.distance(left_touch))
|
||||
right_position = min(range(len(right)), key=lambda i: right[i].point.distance(right_touch))
|
||||
return left_position, right_position, best[1], left_touch, right_touch
|
||||
|
||||
|
||||
def _assemble_polygon(
|
||||
vertices: list[Any],
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
left: list[DividerStep],
|
||||
right: list[DividerStep],
|
||||
contour_index: ContourIndex,
|
||||
road_line: LineString,
|
||||
network_union: Any = None,
|
||||
valley_top_z: float | None = None,
|
||||
) -> Polygon | None:
|
||||
"""도로 구간 + 우측 분할선 + 상측 등고선 아크 + 좌측 분할선으로 폴리곤을 폐합한다.
|
||||
|
||||
세류 유역(valley_top_z 지정)은 발원부 위를 지나는 폐합 등고선을 기하 탐색으로
|
||||
먼저 찾고, 실패 시 같은 스텝 매칭(_junction)으로 폐합한다.
|
||||
"""
|
||||
ring = _road_segment_coords(vertices, start_m, end_m)
|
||||
left_used, right_used, arc = left, right, []
|
||||
closure = (
|
||||
_closing_contour(left, right, contour_index, valley_top_z)
|
||||
if valley_top_z is not None
|
||||
else None
|
||||
)
|
||||
if closure is not None:
|
||||
left_position, right_position, geom_index, left_touch, right_touch = closure
|
||||
left_used = left[: left_position + 1]
|
||||
right_used = right[: right_position + 1]
|
||||
arc = _contour_arc(
|
||||
contour_index.geoms[geom_index], right_touch, left_touch, road_line, network_union
|
||||
)
|
||||
else:
|
||||
junction = _junction(left, right, min_z=valley_top_z)
|
||||
if junction is not None:
|
||||
left_position, right_position, geom_index = junction
|
||||
left_used = left[: left_position + 1]
|
||||
right_used = right[: right_position + 1]
|
||||
arc = _contour_arc(
|
||||
contour_index.geoms[geom_index],
|
||||
right_used[-1].point,
|
||||
left_used[-1].point,
|
||||
road_line,
|
||||
network_union,
|
||||
)
|
||||
ring.extend((step.point.x, step.point.y) for step in right_used[1:])
|
||||
ring.extend(arc)
|
||||
ring.extend((step.point.x, step.point.y) for step in reversed(left_used[1:]))
|
||||
if len(ring) < 4:
|
||||
return None
|
||||
polygon = Polygon(ring).buffer(0)
|
||||
if polygon.geom_type == "MultiPolygon":
|
||||
polygon = max(polygon.geoms, key=lambda part: part.area)
|
||||
if polygon.is_empty or polygon.geom_type != "Polygon":
|
||||
return None
|
||||
return polygon
|
||||
@@ -1,171 +0,0 @@
|
||||
"""메인 배수유역 내부 세분화 — 분할선으로 폴리곤을 쪼갠다 (2026-07-30).
|
||||
|
||||
불변 조건(사용자 지시): 전체(메인) 배수유역 경계는 절대 변경하지 않는다.
|
||||
세분화는 확정된 메인 유역 폴리곤을 관 사이 분할선(물갈림 고개에서 오르는
|
||||
능선 근사선)으로 **내부에서만** 쪼개는 방식이다 — 외곽 재추적 금지.
|
||||
따라서 세부유역은 서로 배타적이고 합집합은 항상 메인 유역과 동일하다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from shapely.geometry import LineString, Point, Polygon
|
||||
from shapely.ops import split as shapely_split
|
||||
from shapely.ops import substring, unary_union
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import DividerStep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 분할 절단선 연장: 도로 하류측(m)과 능선 너머(폴리곤 대각선 배수).
|
||||
CUT_ROAD_TAIL_M = 40.0
|
||||
CUT_RIDGE_EXTEND_RATIO = 1.5
|
||||
|
||||
|
||||
def _cut_line(
|
||||
road_point: Point,
|
||||
steps: list[DividerStep],
|
||||
road_line: LineString,
|
||||
main_polygon: Polygon,
|
||||
) -> LineString | None:
|
||||
"""분할선 스텝을 절단선으로 확장한다 — 도로 하류측과 능선 너머까지 관통.
|
||||
|
||||
split()은 절단선이 폴리곤 경계를 완전히 넘어야 동작하므로 양끝을 연장한다.
|
||||
스텝이 없으면(등고선 공백) 도로 법선 직선으로 폴백한다.
|
||||
"""
|
||||
bounds = main_polygon.bounds
|
||||
reach = CUT_RIDGE_EXTEND_RATIO * math.hypot(bounds[2] - bounds[0], bounds[3] - bounds[1])
|
||||
points = [(road_point.x, road_point.y)]
|
||||
points.extend((step.point.x, step.point.y) for step in steps if step.geom_index >= 0)
|
||||
if len(points) < 2:
|
||||
# 폴백: 도로 접선의 법선 방향으로 폴리곤을 관통하는 직선.
|
||||
t = road_line.project(road_point)
|
||||
a = road_line.interpolate(max(0.0, t - 5.0))
|
||||
b = road_line.interpolate(min(road_line.length, t + 5.0))
|
||||
dx, dy = b.x - a.x, b.y - a.y
|
||||
norm = math.hypot(dx, dy)
|
||||
if norm < 1e-6:
|
||||
return None
|
||||
nx, ny = -dy / norm, dx / norm
|
||||
head = (road_point.x + nx * reach, road_point.y + ny * reach)
|
||||
tail = (road_point.x - nx * reach, road_point.y - ny * reach)
|
||||
return LineString([tail, (road_point.x, road_point.y), head])
|
||||
# 능선 너머 연장: 마지막 진행 방향 유지.
|
||||
(px, py), (qx, qy) = points[-2], points[-1]
|
||||
dx, dy = qx - px, qy - py
|
||||
norm = math.hypot(dx, dy)
|
||||
if norm >= 1e-6:
|
||||
points.append((qx + dx / norm * reach, qy + dy / norm * reach))
|
||||
# 도로 하류측 연장: 첫 스텝 → 도로점 방향을 그대로 지나쳐 내려간다.
|
||||
(fx, fy) = points[1]
|
||||
dx, dy = road_point.x - fx, road_point.y - fy
|
||||
norm = math.hypot(dx, dy)
|
||||
if norm >= 1e-6:
|
||||
points.insert(
|
||||
0,
|
||||
(
|
||||
road_point.x + dx / norm * CUT_ROAD_TAIL_M,
|
||||
road_point.y + dy / norm * CUT_ROAD_TAIL_M,
|
||||
),
|
||||
)
|
||||
return LineString(points)
|
||||
|
||||
|
||||
def _interval_index(piece: Polygon, road_line: LineString, divide_ts: list[float]) -> int:
|
||||
"""조각이 어느 관 구간(k)에 속하는지 — 구간 도로와 맞닿는 길이가 최대인 곳.
|
||||
|
||||
대표점 투영은 대형 계곡 조각에서 오판한다(상류로 길게 뻗은 조각의 대표점이
|
||||
엉뚱한 구간에 떨어짐). 도로 접촉이 전혀 없는 조각만 대표점 투영으로 폴백.
|
||||
"""
|
||||
strip = piece.buffer(1.0)
|
||||
best_k, best_length = -1, 0.0
|
||||
for k in range(len(divide_ts) - 1):
|
||||
segment = substring(road_line, divide_ts[k], divide_ts[k + 1])
|
||||
if segment.is_empty:
|
||||
continue
|
||||
length = segment.intersection(strip).length
|
||||
if length > best_length:
|
||||
best_k, best_length = k, length
|
||||
if best_k >= 0:
|
||||
return best_k
|
||||
t = road_line.project(piece.representative_point())
|
||||
for k in range(len(divide_ts) - 1):
|
||||
if divide_ts[k] <= t <= divide_ts[k + 1]:
|
||||
return k
|
||||
return 0 if t < divide_ts[0] else len(divide_ts) - 2
|
||||
|
||||
|
||||
def subdivide_main_polygon(
|
||||
main_polygon: Polygon,
|
||||
divide_points: list[Point],
|
||||
dividers: list[list[DividerStep]],
|
||||
road_line: LineString,
|
||||
) -> list[Polygon | None]:
|
||||
"""메인 유역 폴리곤을 내부 분할선으로 쪼개 관 구간별 조각을 돌려준다.
|
||||
|
||||
반환 길이 = 관 개수(구간 수). 조각이 없는 구간은 None.
|
||||
split() 기반이므로 조각들은 배타적이고 합집합 == 메인 폴리곤이 보장된다.
|
||||
구간에 여러 조각이 잡히면(절단선 재진입) 모두 합쳐 가장 큰 폴리곤을 쓴다.
|
||||
"""
|
||||
interval_count = len(divide_points) - 1
|
||||
if interval_count <= 1:
|
||||
return [main_polygon]
|
||||
pieces: list[Polygon] = [main_polygon]
|
||||
for position in range(1, interval_count):
|
||||
cut = _cut_line(divide_points[position], dividers[position], road_line, main_polygon)
|
||||
if cut is None:
|
||||
logger.warning("분할 절단선 생성 실패(구간 %d) — 해당 분할을 건너뜁니다.", position)
|
||||
continue
|
||||
next_pieces: list[Polygon] = []
|
||||
for piece in pieces:
|
||||
try:
|
||||
parts = shapely_split(piece, cut)
|
||||
except Exception: # noqa: BLE001 - 절단 실패 시 조각 유지
|
||||
next_pieces.append(piece)
|
||||
continue
|
||||
split_parts = [
|
||||
part
|
||||
for part in getattr(parts, "geoms", [parts])
|
||||
if part.geom_type == "Polygon" and not part.is_empty
|
||||
]
|
||||
next_pieces.extend(split_parts if split_parts else [piece])
|
||||
pieces = next_pieces
|
||||
divide_ts = [road_line.project(point) for point in divide_points]
|
||||
assigned: list[list[Polygon]] = [[] for _ in range(interval_count)]
|
||||
for piece in pieces:
|
||||
assigned[_interval_index(piece, road_line, divide_ts)].append(piece)
|
||||
# 구간별 대표 조각 = 최대 조각과 그에 붙는 조각들. 비연결 잔여 조각은 버리지
|
||||
# 않고(합집합 불변 조건) 맞닿는 인접 구간으로 재배정한다.
|
||||
result: list[Polygon | None] = []
|
||||
leftovers: list[Polygon] = []
|
||||
for group in assigned:
|
||||
merged = _merge_touching(group)
|
||||
result.append(merged[0] if merged else None)
|
||||
leftovers.extend(merged[1:])
|
||||
for extra in leftovers:
|
||||
for position in sorted(
|
||||
range(interval_count),
|
||||
key=lambda k: extra.distance(result[k]) if result[k] is not None else math.inf,
|
||||
):
|
||||
base = result[position]
|
||||
if base is None or not extra.touches(base):
|
||||
continue
|
||||
candidate = base.union(extra).buffer(0)
|
||||
if candidate.geom_type == "Polygon":
|
||||
result[position] = candidate
|
||||
break
|
||||
else:
|
||||
logger.warning("세분화 잔여 조각(%.0f m²)을 재배정하지 못해 제외합니다.", extra.area)
|
||||
return result
|
||||
|
||||
|
||||
def _merge_touching(group: list[Polygon]) -> list[Polygon]:
|
||||
"""조각 묶음을 서로 맞닿는 것끼리 합쳐 면적 내림차순으로 돌려준다."""
|
||||
if not group:
|
||||
return []
|
||||
merged = unary_union(group).buffer(0)
|
||||
parts = list(merged.geoms) if merged.geom_type == "MultiPolygon" else [merged]
|
||||
parts = [part for part in parts if part.geom_type == "Polygon" and not part.is_empty]
|
||||
return sorted(parts, key=lambda part: part.area, reverse=True)
|
||||
@@ -1,657 +0,0 @@
|
||||
"""배수유역 추적 유틸 — 등고선 공간 인덱스·세류 상류망·분수령(능선) 추적.
|
||||
|
||||
등고선 기하 직접 분석(2026-07-29 합의)의 하위 도구 모음. DEM 보간 없이:
|
||||
- `ContourIndex`: 등고선(선)·표고점(점)을 STRtree에 1회 적재하고 필요한 것만 꺼낸다.
|
||||
- `trace_upstream_network`: 세류 교차점에서 도로 산측 상류망만 추적한다(하류 무시).
|
||||
- `trace_divider`: 물갈림 지점에서 상향 등고선을 한 겹씩 따라 오르는 유역 분할선(능선 근사).
|
||||
|
||||
전체 등고선을 순회하는 연산을 두지 않아 분석량이 유역 크기에 비례한다(도엽 매수와 무관).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import LineString, Point, Polygon, box, shape
|
||||
from shapely.ops import nearest_points, substring, unary_union
|
||||
from shapely.strtree import STRtree
|
||||
|
||||
# 분할선(능선 근사) 추적: 다음 상위 등고선을 찾는 탐색 반경(m)과 최대 단계 수.
|
||||
DIVIDER_SEARCH_RADIUS_M = 120.0
|
||||
DIVIDER_MAX_STEPS = 60
|
||||
# 세류 없는 소규모 유역: 도로 상측 첫 능선까지만 오르도록 좁힌 한계(영역 선정 주의).
|
||||
LOCAL_SEARCH_RADIUS_M = 80.0
|
||||
LOCAL_MAX_STEPS = 12
|
||||
# 세류 연결 판정 이격(m)과 상류망 총연장 상한(m).
|
||||
STREAM_JOIN_TOL_M = 15.0
|
||||
MAX_UPSTREAM_TOTAL_M = 5000.0
|
||||
# 계곡 유역 근사 격자: 셀 크기(m)와 상류망에서의 최대 이격(m).
|
||||
VALLEY_CELL_M = 12.0
|
||||
VALLEY_CAP_M = 350.0
|
||||
# 능선 스냅: 경계 정점에서 등고선 탐색 반경(m)과 등고선 위 능선 꼭짓점 탐색 폭(m).
|
||||
RIDGE_SNAP_M = 40.0
|
||||
RIDGE_WALK_M = 80.0
|
||||
# 능선 행진(개선 2안): 다음 상위 등고선 탐색 반경(m)·등고선 위 꼭짓점 탐색 폭(m)·최대 단계.
|
||||
# 탐색 폭을 좁게 유지해야 체인이 자기 능선을 국소 추종한다(넓으면 이웃 능선으로 가로 이탈).
|
||||
MARCH_RADIUS_M = 120.0
|
||||
MARCH_WALK_M = 40.0
|
||||
MARCH_MAX_STEPS = 150
|
||||
# 첫 스텝(시드)만 넓게 탐색 — 물갈림점이 능선 위가 아닐 수 있어 국소 분수령을 먼저 찾는다.
|
||||
MARCH_SEED_WALK_M = 250.0
|
||||
# 이탈 제약 완화 비율 — 등거리선(dn=do) 부근에서 체인이 멈추지 않게 소폭 허용.
|
||||
MARCH_OTHER_RATIO = 0.7
|
||||
|
||||
|
||||
@dataclass
|
||||
class DividerStep:
|
||||
"""분할선의 한 단계 — 어느 등고선(geom_index)의 어느 지점을 밟았는지."""
|
||||
|
||||
point: Point
|
||||
z: float
|
||||
geom_index: int
|
||||
|
||||
|
||||
class ContourIndex:
|
||||
"""표고 속성이 있는 등고선·표고점 피처의 STRtree 래퍼."""
|
||||
|
||||
def __init__(self, features: list[dict[str, Any]], elevation_keys: tuple[str, ...]) -> None:
|
||||
self.geoms: list[Any] = []
|
||||
self.zs: list[float] = []
|
||||
for feature in features:
|
||||
elevation = _feature_elevation(feature, elevation_keys)
|
||||
if elevation is None:
|
||||
continue
|
||||
geometry = feature.get("geometry") or {}
|
||||
try:
|
||||
geom = shape(geometry)
|
||||
except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다
|
||||
continue
|
||||
if geom.is_empty:
|
||||
continue
|
||||
parts = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom]
|
||||
for part in parts:
|
||||
self.geoms.append(part)
|
||||
self.zs.append(elevation)
|
||||
self.tree = STRtree(self.geoms) if self.geoms else None
|
||||
|
||||
def query(self, geometry: Any) -> list[int]:
|
||||
"""geometry 근방(bbox 교차) 피처의 인덱스만 돌려준다."""
|
||||
if self.tree is None:
|
||||
return []
|
||||
return [int(i) for i in self.tree.query(geometry)]
|
||||
|
||||
def nearest_elevation(self, point: Point, radius_m: float) -> float | None:
|
||||
"""point에서 radius 안 가장 가까운 피처의 표고. 없으면 None."""
|
||||
best_z: float | None = None
|
||||
best_distance = radius_m
|
||||
for index in self.query(point.buffer(radius_m)):
|
||||
distance = self.geoms[index].distance(point)
|
||||
if distance <= best_distance:
|
||||
best_distance = distance
|
||||
best_z = self.zs[index]
|
||||
return best_z
|
||||
|
||||
def max_elevation_within(self, polygon: Any) -> float | None:
|
||||
"""polygon과 실제로 교차하는 피처들의 최고 표고."""
|
||||
best: float | None = None
|
||||
for index in self.query(polygon):
|
||||
if not polygon.intersects(self.geoms[index]):
|
||||
continue
|
||||
if best is None or self.zs[index] > best:
|
||||
best = self.zs[index]
|
||||
return best
|
||||
|
||||
|
||||
def _feature_elevation(feature: dict[str, Any], elevation_keys: tuple[str, ...]) -> float | None:
|
||||
properties = feature.get("properties") or {}
|
||||
for key in elevation_keys:
|
||||
value = properties.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def side_sign(road_line: LineString, point: Point) -> int:
|
||||
"""도로선 기준 point가 어느 쪽인지(+1/-1, 선상이면 0). 국소 접선과의 외적 부호."""
|
||||
t = road_line.project(point)
|
||||
a = road_line.interpolate(max(0.0, t - 5.0))
|
||||
b = road_line.interpolate(min(road_line.length, t + 5.0))
|
||||
cross = (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x)
|
||||
if cross > 0:
|
||||
return 1
|
||||
if cross < 0:
|
||||
return -1
|
||||
return 0
|
||||
|
||||
|
||||
def trace_divider(
|
||||
start: Point,
|
||||
contour_index: ContourIndex,
|
||||
road_line: LineString,
|
||||
uphill_sign: int,
|
||||
radius_m: float = DIVIDER_SEARCH_RADIUS_M,
|
||||
max_steps: int = DIVIDER_MAX_STEPS,
|
||||
) -> list[DividerStep]:
|
||||
"""물갈림 지점에서 상향 등고선을 한 겹씩 밟아 오르는 분할선을 만든다.
|
||||
|
||||
각 단계에서 반경 안의 "현재보다 높은 등고선 중 가장 낮은 것"의 최근접점으로 이동한다.
|
||||
도로 산측(uphill_sign)을 벗어나거나 도로 쪽으로 되돌아가는 이동은 막는다.
|
||||
더 높은 등고선이 반경 안에 없으면 능선(분수령)에 닿은 것으로 보고 멈춘다.
|
||||
"""
|
||||
z = contour_index.nearest_elevation(start, radius_m)
|
||||
if z is None:
|
||||
return []
|
||||
steps = [DividerStep(point=start, z=z, geom_index=-1)]
|
||||
current = start
|
||||
road_distance = road_line.distance(start)
|
||||
for _ in range(max_steps):
|
||||
best: tuple[float, float, Point, int] | None = None
|
||||
for index in contour_index.query(current.buffer(radius_m)):
|
||||
candidate_z = contour_index.zs[index]
|
||||
if candidate_z <= z + 0.01:
|
||||
continue
|
||||
if best is not None and candidate_z > best[0]:
|
||||
continue
|
||||
point = nearest_points(contour_index.geoms[index], current)[0]
|
||||
distance = current.distance(point)
|
||||
if distance > radius_m:
|
||||
continue
|
||||
# 도로 반대편·도로 방향 후퇴 금지 — 분할선은 산측으로만 오른다.
|
||||
if side_sign(road_line, point) == -uphill_sign:
|
||||
continue
|
||||
if road_line.distance(point) + 1.0 < road_distance:
|
||||
continue
|
||||
if (
|
||||
best is None
|
||||
or candidate_z < best[0]
|
||||
or (candidate_z == best[0] and distance < best[1])
|
||||
):
|
||||
best = (candidate_z, distance, point, index)
|
||||
if best is None:
|
||||
break
|
||||
z, _, current, geom_index = best
|
||||
road_distance = max(road_distance, road_line.distance(current))
|
||||
steps.append(DividerStep(point=current, z=z, geom_index=geom_index))
|
||||
return steps
|
||||
|
||||
|
||||
def _explode_lines(stream_features: list[dict[str, Any]]) -> list[LineString]:
|
||||
lines: list[LineString] = []
|
||||
for feature in stream_features:
|
||||
geometry = feature.get("geometry") or {}
|
||||
try:
|
||||
geom = shape(geometry)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if geom.is_empty:
|
||||
continue
|
||||
parts = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom]
|
||||
lines.extend(part for part in parts if part.geom_type == "LineString")
|
||||
return lines
|
||||
|
||||
|
||||
def _oriented_from(line: LineString, origin: Point) -> LineString:
|
||||
"""origin에 가까운 끝이 시작점이 되도록 방향을 맞춘다."""
|
||||
if Point(line.coords[0]).distance(origin) <= Point(line.coords[-1]).distance(origin):
|
||||
return line
|
||||
return LineString(list(line.coords)[::-1])
|
||||
|
||||
|
||||
def _clip_uphill(line: LineString, road_line: LineString, origin: Point) -> LineString | None:
|
||||
"""도로를 다시 가로지르면 교차 지점에서 잘라 origin 쪽 조각만 남긴다."""
|
||||
if not line.crosses(road_line):
|
||||
return line
|
||||
t = line.project(nearest_points(line.intersection(road_line), origin)[0])
|
||||
piece = substring(line, 0.0, t) if line.project(origin) < t else substring(line, t, line.length)
|
||||
if piece.geom_type != "LineString" or piece.length < 1.0:
|
||||
return None
|
||||
return piece
|
||||
|
||||
|
||||
def valley_region_polygon(
|
||||
network_union: Any,
|
||||
stream_features: list[dict[str, Any]],
|
||||
crossing: Point,
|
||||
contour_index: Any = None,
|
||||
) -> Any | None:
|
||||
"""상류망 계곡의 유역 영역 — 경계는 등고선을 참고한 능선(분수령)으로 긋는다.
|
||||
|
||||
① 등거리 1차 근사: 격자 셀 중심이 인접 계곡 세류보다 우리 상류망에 가깝고 상한
|
||||
거리 이내이면 유역 소속. 지류 사이 사면(지능선) 포함, 인접 계곡 자동 제외.
|
||||
② 등고선 스냅(2026-07-29 사용자 지시): 경계는 세류들 사이 중간 어딘가가 아니라
|
||||
**등고선을 참고해** 그어야 한다 — 각 경계 정점을 근처 등고선 위에서 두 세류
|
||||
모두로부터 가장 먼 지점(능선 꼭짓점)으로 이동. 단 우리 세류를 침범하거나
|
||||
인접 세류 너머로 나가지 않는다.
|
||||
반환: 폴리곤(간략화됨) 또는 None.
|
||||
"""
|
||||
lines = _explode_lines(stream_features)
|
||||
others = [line for line in lines if line.distance(network_union) > STREAM_JOIN_TOL_M]
|
||||
other_tree = STRtree(others) if others else None
|
||||
min_x, min_y, max_x, max_y = network_union.buffer(VALLEY_CAP_M).bounds
|
||||
cells = []
|
||||
y = min_y
|
||||
while y < max_y:
|
||||
x = min_x
|
||||
while x < max_x:
|
||||
center = Point(x + VALLEY_CELL_M / 2.0, y + VALLEY_CELL_M / 2.0)
|
||||
distance = network_union.distance(center)
|
||||
if distance <= VALLEY_CAP_M:
|
||||
if other_tree is not None:
|
||||
nearest = others[int(other_tree.nearest(center))]
|
||||
if nearest.distance(center) < distance:
|
||||
x += VALLEY_CELL_M
|
||||
continue
|
||||
cells.append(box(x, y, x + VALLEY_CELL_M, y + VALLEY_CELL_M))
|
||||
x += VALLEY_CELL_M
|
||||
y += VALLEY_CELL_M
|
||||
if not cells:
|
||||
return None
|
||||
region = unary_union(cells).buffer(0)
|
||||
if region.geom_type == "MultiPolygon":
|
||||
touching = [
|
||||
part
|
||||
for part in region.geoms
|
||||
if part.intersects(network_union) or part.distance(crossing) < VALLEY_CELL_M * 2
|
||||
]
|
||||
region = unary_union(touching) if touching else max(region.geoms, key=lambda p: p.area)
|
||||
if region.geom_type == "MultiPolygon":
|
||||
region = max(region.geoms, key=lambda p: p.area)
|
||||
region = region.simplify(VALLEY_CELL_M, preserve_topology=True)
|
||||
if region.is_empty or region.geom_type != "Polygon":
|
||||
return None
|
||||
if contour_index is not None:
|
||||
region = _contour_chain_boundary(region, contour_index, network_union, others, other_tree)
|
||||
return region
|
||||
|
||||
|
||||
def _collect_intersection_points(geometry: Any) -> list[Point]:
|
||||
"""교차 결과에서 대표 점들을 뽑는다."""
|
||||
if geometry.is_empty:
|
||||
return []
|
||||
if geometry.geom_type == "Point":
|
||||
return [geometry]
|
||||
if geometry.geom_type in {"MultiPoint", "GeometryCollection"}:
|
||||
points: list[Point] = []
|
||||
for part in geometry.geoms:
|
||||
points.extend(_collect_intersection_points(part))
|
||||
return points
|
||||
if geometry.geom_type in {"LineString", "MultiLineString"}:
|
||||
return [geometry.interpolate(0.5, normalized=True)]
|
||||
return []
|
||||
|
||||
|
||||
def _contour_chain_boundary(
|
||||
region: Any,
|
||||
contour_index: Any,
|
||||
network_union: Any,
|
||||
others: list[LineString],
|
||||
other_tree: STRtree | None,
|
||||
) -> Any:
|
||||
"""유역 경계를 **등고선마다 능선 포인트 1개씩 찍어 연결**한 체인으로 재구성한다.
|
||||
|
||||
(2026-07-29 사용자 지시: 정점 스냅은 점이 듬성듬성해 등고선을 건너뛴다.)
|
||||
등거리 1차 경계 링은 순서 뼈대로만 쓴다: 링을 가로지르는 모든 등고선 교차점마다
|
||||
그 등고선 위에서 두 세류(우리 상류망·인접 세류) 모두로부터 가장 먼 지점(능선
|
||||
꼭짓점)을 정제해 포인트를 얻고, 링 위 위치 순으로 연결한다. 등고선이 없는 구간은
|
||||
원래 링 정점으로 메운다. 제약: 우리 세류 침범·인접 세류 이탈 금지.
|
||||
|
||||
2차 보정(2026-07-29 사용자 채택, "2번 방식"): 능선은 등고선의 **직교 궤적**이므로,
|
||||
각 포인트를 등고선 위에서 미세 이동해 경계선이 그 등고선과 수직으로 교차하도록
|
||||
반복 조정한다(TOPOG/TAPES-C 계열 개념). 능선 점수(세류 최소거리)가 꼭짓점 대비
|
||||
크게 떨어지는 이동은 막는다.
|
||||
"""
|
||||
ring = LineString(region.exterior.coords)
|
||||
|
||||
def _score(point: Point) -> tuple[float, float]:
|
||||
to_network = network_union.distance(point)
|
||||
to_other = (
|
||||
others[int(other_tree.nearest(point))].distance(point)
|
||||
if other_tree is not None
|
||||
else float("inf")
|
||||
)
|
||||
return to_network, to_other
|
||||
|
||||
# ① 링을 가로지르는 등고선 교차점마다 능선 꼭짓점 1개.
|
||||
# entry = [링 위치 s, Point, geom_index(-1=링 정점), 등고선 파라미터 t, 꼭짓점 점수]
|
||||
entries: list[list[Any]] = []
|
||||
for index in contour_index.query(ring.buffer(1.0)):
|
||||
geom = contour_index.geoms[index]
|
||||
try:
|
||||
crossings = _collect_intersection_points(geom.intersection(ring))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for crossing in crossings:
|
||||
s = ring.project(crossing)
|
||||
t0 = geom.project(crossing)
|
||||
best = None
|
||||
best_t = t0
|
||||
best_value = -1.0
|
||||
steps = int(RIDGE_WALK_M / 10.0)
|
||||
for offset in [0.0] + [
|
||||
sign * k * 10.0 for k in range(1, steps + 1) for sign in (1, -1)
|
||||
]:
|
||||
t = min(max(t0 + offset, 0.0), geom.length)
|
||||
candidate = geom.interpolate(t)
|
||||
if candidate.distance(crossing) > RIDGE_WALK_M + RIDGE_SNAP_M:
|
||||
continue
|
||||
to_network, to_other = _score(candidate)
|
||||
if to_network < STREAM_JOIN_TOL_M:
|
||||
continue # 우리 세류 침범 금지
|
||||
if to_other < to_network:
|
||||
continue # 인접 세류 쪽으로 이탈 금지
|
||||
if min(to_network, to_other) > best_value:
|
||||
best_value = min(to_network, to_other)
|
||||
best = candidate
|
||||
best_t = t
|
||||
if best is not None:
|
||||
entries.append([s, best, index, best_t, best_value])
|
||||
if len(entries) < 4:
|
||||
return region
|
||||
entries.sort(key=lambda entry: entry[0])
|
||||
# ② 등고선 공백 구간(교차점 사이가 먼 곳)은 원래 링 정점으로 메운다.
|
||||
positions = [entry[0] for entry in entries]
|
||||
for x, y in list(region.exterior.coords)[:-1]:
|
||||
s = ring.project(Point(x, y))
|
||||
slot = bisect.bisect_left(positions, s)
|
||||
before = positions[slot - 1] if slot > 0 else positions[-1] - ring.length
|
||||
after = positions[slot] if slot < len(positions) else positions[0] + ring.length
|
||||
if min(s - before, after - s) > VALLEY_CELL_M * 2.5:
|
||||
entries.append([s, Point(x, y), -1, 0.0, 0.0])
|
||||
entries.sort(key=lambda entry: entry[0])
|
||||
# ③ 직교 보정: 경계 진행방향과 등고선 접선이 수직이 되도록 포인트를 미세 이동.
|
||||
entries = _orthogonalize_chain(entries, contour_index, _score)
|
||||
polygon = Polygon([(entry[1].x, entry[1].y) for entry in entries]).buffer(0)
|
||||
if polygon.geom_type == "MultiPolygon":
|
||||
polygon = max(polygon.geoms, key=lambda part: part.area)
|
||||
if polygon.is_empty or polygon.geom_type != "Polygon":
|
||||
return region
|
||||
return polygon
|
||||
|
||||
|
||||
def _orthogonalize_chain(
|
||||
entries: list[list[Any]],
|
||||
contour_index: Any,
|
||||
score: Any,
|
||||
) -> list[list[Any]]:
|
||||
"""체인 포인트를 등고선 위에서 이동해 경계가 등고선과 직교하게 만든다.
|
||||
|
||||
각 포인트에서 |등고선 접선 · 체인 진행방향| (수직이면 0)을 최소화한다. 이동 허용
|
||||
조건: 세류 침범·이탈 금지 + 능선 점수(두 세류 최소거리)가 꼭짓점 값의 70% 이상.
|
||||
2회 반복으로 이웃 이동의 영향을 수렴시킨다.
|
||||
"""
|
||||
count = len(entries)
|
||||
for _ in range(2):
|
||||
for i, entry in enumerate(entries):
|
||||
geom_index = entry[2]
|
||||
if geom_index < 0:
|
||||
continue
|
||||
geom = contour_index.geoms[geom_index]
|
||||
previous = entries[i - 1][1]
|
||||
following = entries[(i + 1) % count][1]
|
||||
dx, dy = following.x - previous.x, following.y - previous.y
|
||||
norm = (dx * dx + dy * dy) ** 0.5
|
||||
if norm < 1.0:
|
||||
continue
|
||||
dx, dy = dx / norm, dy / norm
|
||||
floor = 0.7 * entry[4]
|
||||
best_t = entry[3]
|
||||
best_point = entry[1]
|
||||
best_dot = None
|
||||
for offset in range(-int(RIDGE_SNAP_M), int(RIDGE_SNAP_M) + 1, 5):
|
||||
t = min(max(entry[3] + float(offset), 0.0), geom.length)
|
||||
candidate = geom.interpolate(t)
|
||||
ahead = geom.interpolate(min(t + 4.0, geom.length))
|
||||
behind = geom.interpolate(max(t - 4.0, 0.0))
|
||||
tx, ty = ahead.x - behind.x, ahead.y - behind.y
|
||||
tangent_norm = (tx * tx + ty * ty) ** 0.5
|
||||
if tangent_norm < 0.5:
|
||||
continue
|
||||
to_network, to_other = score(candidate)
|
||||
if to_network < STREAM_JOIN_TOL_M or to_other < to_network:
|
||||
continue
|
||||
if min(to_network, to_other) < floor:
|
||||
continue
|
||||
dot = abs((tx * dx + ty * dy) / tangent_norm)
|
||||
if best_dot is None or dot < best_dot:
|
||||
best_dot = dot
|
||||
best_t = t
|
||||
best_point = candidate
|
||||
entry[1] = best_point
|
||||
entry[3] = best_t
|
||||
return entries
|
||||
|
||||
|
||||
def trace_ridge_march(
|
||||
start: Point,
|
||||
contour_index: Any,
|
||||
network_union: Any,
|
||||
others: list[LineString],
|
||||
other_tree: STRtree | None,
|
||||
road_line: LineString,
|
||||
uphill_sign: int,
|
||||
) -> list[DividerStep]:
|
||||
"""능선 행진(개선 2안) — 상위 등고선마다 능선 꼭짓점을 한 칸씩 밟아 오른다.
|
||||
|
||||
수작업 유역도 작도법의 자동화: 물갈림점에서 출발해 매 단계 "반경 안 현재보다 높은
|
||||
등고선 중 가장 낮은 것" 위에서 **|우리 상류망까지 거리 − 인접 세류까지 거리|가
|
||||
최소인 지점**(분수령 = 두 세류망 등거리점)으로 이동한다. 두 세류에서 가장 먼 점을
|
||||
고르면 우리 지류들 사이 내부 지능선으로 새므로, 등거리 조건이 바깥 분수령을 강제
|
||||
한다. 꼭짓점 연결선은 등고선과 자연히 직교한다.
|
||||
제약: 도로 산측 유지, 우리 세류 침범(15m)·인접 세류 과이탈 금지. 더 높은 등고선이
|
||||
없으면(능선 정상) 자연 종료. 반환은 DividerStep 목록 — 기존 폐합 로직과 호환.
|
||||
"""
|
||||
|
||||
def _score(point: Point) -> tuple[float, float]:
|
||||
to_network = network_union.distance(point)
|
||||
to_other = (
|
||||
others[int(other_tree.nearest(point))].distance(point)
|
||||
if other_tree is not None
|
||||
else float("inf")
|
||||
)
|
||||
return to_network, to_other
|
||||
|
||||
z = contour_index.nearest_elevation(start, MARCH_RADIUS_M)
|
||||
if z is None:
|
||||
return []
|
||||
steps_out = [DividerStep(point=start, z=z, geom_index=-1)]
|
||||
current = start
|
||||
for step_no in range(MARCH_MAX_STEPS):
|
||||
walk_m = MARCH_SEED_WALK_M if step_no == 0 else MARCH_WALK_M
|
||||
reach_m = max(MARCH_RADIUS_M, walk_m)
|
||||
best: tuple[float, float, Point, int] | None = None # (레벨, |dn-do|, 지점, geom idx)
|
||||
for index in contour_index.query(current.buffer(reach_m)):
|
||||
level = contour_index.zs[index]
|
||||
if level <= z + 0.01:
|
||||
continue
|
||||
if best is not None and level > best[0]:
|
||||
continue
|
||||
geom = contour_index.geoms[index]
|
||||
if geom.distance(current) > reach_m:
|
||||
continue
|
||||
t0 = geom.project(current)
|
||||
walk = int(walk_m / 10.0)
|
||||
for offset in [0.0] + [sign * k * 10.0 for k in range(1, walk + 1) for sign in (1, -1)]:
|
||||
t = min(max(t0 + offset, 0.0), geom.length)
|
||||
candidate = geom.interpolate(t)
|
||||
if candidate.distance(current) > reach_m:
|
||||
continue
|
||||
# 도로 하류측 이탈 금지 — 단, 노선 끝 너머(투영이 끝점에 걸림)는 좌우
|
||||
# 부호가 무의미하므로 세류 제약에만 맡긴다(끝을 감아 도는 분수령 허용).
|
||||
projection = road_line.project(candidate)
|
||||
if (
|
||||
5.0 < projection < road_line.length - 5.0
|
||||
and side_sign(road_line, candidate) == -uphill_sign
|
||||
):
|
||||
continue
|
||||
to_network, to_other = _score(candidate)
|
||||
if to_network < STREAM_JOIN_TOL_M:
|
||||
continue # 우리 세류 침범 금지
|
||||
if to_other < to_network * MARCH_OTHER_RATIO:
|
||||
continue # 인접 세류 쪽 과이탈 금지(등거리선 부근 소폭 허용)
|
||||
balance = abs(to_network - to_other)
|
||||
if best is None or level < best[0] or (level == best[0] and balance < best[1]):
|
||||
best = (level, balance, candidate, index)
|
||||
if best is None:
|
||||
break
|
||||
z = best[0]
|
||||
current = best[2]
|
||||
steps_out.append(DividerStep(point=current, z=z, geom_index=best[3]))
|
||||
return steps_out
|
||||
|
||||
|
||||
def rim_walk(
|
||||
start: Point,
|
||||
target: Point,
|
||||
contour_index: Any,
|
||||
network_union: Any,
|
||||
others: list[LineString],
|
||||
other_tree: STRtree | None,
|
||||
road_line: LineString,
|
||||
uphill_sign: int,
|
||||
) -> list[Point] | None:
|
||||
"""능선마루를 따라 두 행진 정상을 잇는다(개선 2안 상측 폐합).
|
||||
|
||||
좌·우 능선 정상 높이가 달라 단일 등고선 아크로 못 닫는 경우, 매 단계 target에
|
||||
가까워지는 등고선 위 지점 중 |우리 세류 거리 − 인접 세류 거리|가 최소인 곳
|
||||
(분수령)으로 이동한다. 레벨 제한 없음(마루는 오르내린다). 막히면 None.
|
||||
"""
|
||||
|
||||
def _score(point: Point) -> tuple[float, float]:
|
||||
to_network = network_union.distance(point)
|
||||
to_other = (
|
||||
others[int(other_tree.nearest(point))].distance(point)
|
||||
if other_tree is not None
|
||||
else float("inf")
|
||||
)
|
||||
return to_network, to_other
|
||||
|
||||
points: list[Point] = []
|
||||
current = start
|
||||
remaining = current.distance(target)
|
||||
for _ in range(MARCH_MAX_STEPS):
|
||||
if remaining <= MARCH_RADIUS_M:
|
||||
return points
|
||||
best: tuple[float, Point] | None = None # (|dn-do|, 지점)
|
||||
for index in contour_index.query(current.buffer(MARCH_RADIUS_M)):
|
||||
geom = contour_index.geoms[index]
|
||||
if geom.distance(current) > MARCH_RADIUS_M:
|
||||
continue
|
||||
t0 = geom.project(current)
|
||||
walk = int(MARCH_WALK_M / 10.0)
|
||||
for offset in [0.0] + [sign * k * 10.0 for k in range(1, walk + 1) for sign in (1, -1)]:
|
||||
t = min(max(t0 + offset, 0.0), geom.length)
|
||||
candidate = geom.interpolate(t)
|
||||
if candidate.distance(current) > MARCH_RADIUS_M:
|
||||
continue
|
||||
if candidate.distance(target) > remaining - 5.0:
|
||||
continue # target에 실질적으로 가까워지는 이동만 허용
|
||||
projection = road_line.project(candidate)
|
||||
if (
|
||||
5.0 < projection < road_line.length - 5.0
|
||||
and side_sign(road_line, candidate) == -uphill_sign
|
||||
):
|
||||
continue
|
||||
to_network, to_other = _score(candidate)
|
||||
if to_network < STREAM_JOIN_TOL_M:
|
||||
continue
|
||||
if to_other < to_network * MARCH_OTHER_RATIO:
|
||||
continue
|
||||
balance = abs(to_network - to_other)
|
||||
if best is None or balance < best[0]:
|
||||
best = (balance, candidate)
|
||||
if best is None:
|
||||
return None
|
||||
current = best[1]
|
||||
remaining = current.distance(target)
|
||||
points.append(current)
|
||||
return None
|
||||
|
||||
|
||||
def trace_upstream_network(
|
||||
crossing: Point,
|
||||
stream_features: list[dict[str, Any]],
|
||||
road_line: LineString,
|
||||
uphill_sign: int,
|
||||
) -> tuple[list[LineString], float]:
|
||||
"""세류 교차점에서 도로 산측으로 뻗는 상류망을 추적한다.
|
||||
|
||||
① 교차한 세류를 교차점에서 잘라 산측 조각을 뿌리로 삼는다.
|
||||
② 끝점이 기존 망에 근접(STREAM_JOIN_TOL_M)한 세류를 반복 편입한다(분기 포함).
|
||||
도로를 다시 가로지르는 조각은 절단하고, 총연장 상한을 두어 폭주를 막는다.
|
||||
반환: (상류망 폴리라인 목록, 최장 유하 경로 길이 m).
|
||||
"""
|
||||
lines = _explode_lines(stream_features)
|
||||
network: list[tuple[LineString, float]] = [] # (폴리라인, 뿌리에서 시작점까지 누적거리)
|
||||
used: set[int] = set()
|
||||
total = 0.0
|
||||
|
||||
# ① 뿌리: 교차점을 지나는 세류의 산측 조각. 도엽 세류는 교차점 부근에서 별도
|
||||
# 피처로 조각나 있는 경우가 많아, 근접(STREAM_JOIN_TOL_M) 조각도 뿌리로 받는다.
|
||||
for index, line in enumerate(lines):
|
||||
distance = line.distance(crossing)
|
||||
if distance > STREAM_JOIN_TOL_M:
|
||||
continue
|
||||
used.add(index)
|
||||
if distance <= 1.0:
|
||||
t = line.project(crossing)
|
||||
pieces = [substring(line, 0.0, t), substring(line, t, line.length)]
|
||||
else:
|
||||
pieces = [line]
|
||||
for piece in pieces:
|
||||
if piece.geom_type != "LineString" or piece.length < 1.0:
|
||||
continue
|
||||
oriented = _oriented_from(piece, crossing)
|
||||
probe = oriented.interpolate(min(10.0, oriented.length))
|
||||
if side_sign(road_line, probe) != uphill_sign:
|
||||
continue
|
||||
clipped = _clip_uphill(oriented, road_line, crossing)
|
||||
if clipped is None:
|
||||
continue
|
||||
network.append((clipped, 0.0))
|
||||
total += clipped.length
|
||||
if not network:
|
||||
return [], 0.0
|
||||
|
||||
# ② 편입 반복: 끝점이 망에 닿는 세류를 상류로 붙인다.
|
||||
grew = True
|
||||
while grew and total < MAX_UPSTREAM_TOTAL_M:
|
||||
grew = False
|
||||
for index, line in enumerate(lines):
|
||||
if index in used:
|
||||
continue
|
||||
attach: tuple[float, Point, LineString, float] | None = None
|
||||
for endpoint in (Point(line.coords[0]), Point(line.coords[-1])):
|
||||
for parent, parent_cum in network:
|
||||
distance = parent.distance(endpoint)
|
||||
if distance > STREAM_JOIN_TOL_M:
|
||||
continue
|
||||
cum = parent_cum + parent.project(endpoint)
|
||||
if attach is None or distance < attach[0]:
|
||||
attach = (distance, endpoint, parent, cum)
|
||||
if attach is None:
|
||||
continue
|
||||
used.add(index)
|
||||
_, endpoint, _, cum = attach
|
||||
# 좌우(산측) 판정은 도로 근처에서만 신뢰 — 노선에서 먼 상류는 투영 기준이
|
||||
# 뒤틀려 부호가 뒤집히므로 연결성과 도로 재교차 절단만으로 판단한다.
|
||||
near_road = road_line.distance(endpoint) < 2.0 * STREAM_JOIN_TOL_M
|
||||
if near_road and side_sign(road_line, line.interpolate(0.5, normalized=True)) == (
|
||||
-uphill_sign
|
||||
):
|
||||
continue
|
||||
oriented = _oriented_from(line, endpoint)
|
||||
clipped = _clip_uphill(oriented, road_line, endpoint)
|
||||
if clipped is None:
|
||||
continue
|
||||
network.append((clipped, cum))
|
||||
total += clipped.length
|
||||
grew = True
|
||||
|
||||
flow_length = max((cum + line.length for line, cum in network), default=0.0)
|
||||
return [line for line, _ in network], flow_length
|
||||
@@ -1,16 +0,0 @@
|
||||
# _legacy_watershed (보관용, 실행 경로 아님)
|
||||
|
||||
2026-07-31 배수유역 전면 재설계로 폐기된 **등고선 아크 추적 + 능선 행진** 방식 엔진 4종이다.
|
||||
능선/계곡 분리가 안정적이지 않아 격자 흐름(D8 + 상류 BFS) 방식으로 교체되었다.
|
||||
|
||||
| 파일 | 폐기 당시 역할 |
|
||||
|---|---|
|
||||
| `B05_wf2_Route_Engine_Drainage_Watershed.py` | 유역 산정 오케스트레이터 (`build_watershed_basins`) |
|
||||
| `B05_wf2_Route_Engine_Watershed_Trace.py` | 등고선 아크 인덱싱·분수계 행진 |
|
||||
| `B05_wf2_Route_Engine_Watershed_Assemble.py` | 아크+능선+도로선 폐합 폴리곤 조립 |
|
||||
| `B05_wf2_Route_Engine_Watershed_Subdivide.py` | 메인 유역 내부 세부유역 분할 |
|
||||
|
||||
**주의**
|
||||
- 내용은 이동 당시 그대로이며 수정하지 않는다. 서로를 `B05_wf2_Route.B05_wf2_Route_Engine_Watershed_*`
|
||||
경로로 import하므로 이 폴더에서는 그대로 실행되지 않는다(의도된 상태 — 참고용 보관).
|
||||
- 현행 엔진: `B05_wf2_Route_Engine_Watershed_Grid.py` / `_Flow.py` / `_Basin.py`.
|
||||
@@ -0,0 +1,255 @@
|
||||
"""계획 노선 기하 공용 유틸 — 정점·누가거리·세류 교차점.
|
||||
|
||||
배수유역 분석(B04)과 관 편집·세부유역(B05)이 같은 노선 표현을 써야 하므로 여기 한 곳에만
|
||||
정의한다. 어느 한쪽 페이지 폴더에 두면 반대 방향 import가 생긴다.
|
||||
|
||||
노선 원천은 두 가지다.
|
||||
· B03에 업로드된 **계획 노선 파일**(CSV) — 배수유역 분석의 입력
|
||||
· DB `route_points` — B05에서 탐색·확정한 노선
|
||||
둘 다 같은 `RouteVertex` 목록으로 바꿔 아래 함수들이 그대로 받는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import LineString, Point, shape
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 계획 노선 CSV 열 이름 후보. B03이 여러 형식을 받게 되므로 흔한 표기를 모두 받아 준다.
|
||||
_X_KEYS = ("x", "X", "동", "easting", "EASTING")
|
||||
_Y_KEYS = ("y", "Y", "북", "northing", "NORTHING")
|
||||
_Z_KEYS = ("z", "Z", "표고", "elevation", "ELEV")
|
||||
_ORDER_KEYS = ("sequence", "order", "seq", "no", "번호")
|
||||
_EPSG_KEYS = ("crs_epsg", "epsg", "EPSG")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteVertex:
|
||||
"""노선 폴리라인의 한 점. chainage는 시점 기준 누가거리(m)."""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
chainage_m: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class StructureCandidate:
|
||||
"""관 매설 구조물 측점 후보."""
|
||||
|
||||
chainage_m: float
|
||||
x: float
|
||||
y: float
|
||||
# "stream"=세류 교차, "spacing"=최대 간격 규칙 보충, "confirmed"=사용자 확정
|
||||
reason: str
|
||||
stream_name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlannedRoute:
|
||||
"""계획 노선 파일에서 읽은 노선."""
|
||||
|
||||
vertices: list[RouteVertex]
|
||||
epsg: int | None
|
||||
name: str | None
|
||||
source: Path
|
||||
|
||||
@property
|
||||
def line(self) -> LineString:
|
||||
return LineString([(vertex.x, vertex.y) for vertex in self.vertices])
|
||||
|
||||
|
||||
def read_planned_route_csv(path: Path) -> PlannedRoute | None:
|
||||
"""계획 노선 CSV를 읽어 정점 목록으로 바꾼다.
|
||||
|
||||
열 이름은 대소문자·한글 표기를 함께 받아 준다(B03이 여러 형식을 수용할 예정).
|
||||
`sequence`가 있으면 그 순서로 정렬하고, 없으면 파일에 적힌 순서를 그대로 쓴다.
|
||||
"""
|
||||
try:
|
||||
with path.open("r", encoding="utf-8-sig", newline="") as file:
|
||||
rows = list(csv.DictReader(file))
|
||||
except (OSError, csv.Error, UnicodeDecodeError):
|
||||
logger.warning("계획 노선 CSV를 읽지 못했습니다: %s", path)
|
||||
return None
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
epsg = _first_int(rows[0], _EPSG_KEYS)
|
||||
name = _first_text(rows[0], ("route_name", "name", "노선명"))
|
||||
parsed: list[tuple[float, float, float, float]] = [] # (정렬키, x, y, z)
|
||||
for index, row in enumerate(rows):
|
||||
x = _first_float(row, _X_KEYS)
|
||||
y = _first_float(row, _Y_KEYS)
|
||||
if x is None or y is None:
|
||||
continue
|
||||
order = _first_float(row, _ORDER_KEYS)
|
||||
parsed.append(
|
||||
(float(index) if order is None else order, x, y, _first_float(row, _Z_KEYS) or 0.0)
|
||||
)
|
||||
if len(parsed) < 2:
|
||||
logger.warning("계획 노선 CSV에 좌표가 2점 미만입니다: %s", path)
|
||||
return None
|
||||
|
||||
parsed.sort(key=lambda item: item[0])
|
||||
vertices: list[RouteVertex] = []
|
||||
cumulative = 0.0
|
||||
previous: tuple[float, float] | None = None
|
||||
for _, x, y, z in parsed:
|
||||
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)
|
||||
logger.info(
|
||||
"계획 노선 %s: 정점 %d개, 연장 %.0fm, EPSG %s", path.name, len(vertices), cumulative, epsg
|
||||
)
|
||||
return PlannedRoute(vertices=vertices, epsg=epsg, name=name, source=path)
|
||||
|
||||
|
||||
def find_planned_route_file(input_dir: Path) -> Path | None:
|
||||
"""B03 입력 폴더에서 계획 노선 파일을 찾는다. 여러 개면 가장 최근 것."""
|
||||
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
|
||||
|
||||
|
||||
def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]:
|
||||
"""DB route_points 행을 누가거리가 채워진 정점 목록으로 바꾼다."""
|
||||
vertices: list[RouteVertex] = []
|
||||
cumulative = 0.0
|
||||
previous: tuple[float, float] | None = None
|
||||
for row in points:
|
||||
x = float(row["x"])
|
||||
y = float(row["y"])
|
||||
z = float(row.get("z") or 0.0)
|
||||
if previous is not None:
|
||||
cumulative += math.dist(previous, (x, y))
|
||||
chainage = row.get("chainage_m")
|
||||
vertices.append(
|
||||
RouteVertex(
|
||||
x=x, y=y, z=z, chainage_m=float(chainage) if chainage is not None else cumulative
|
||||
)
|
||||
)
|
||||
previous = (x, y)
|
||||
return vertices
|
||||
|
||||
|
||||
def interpolate_vertex(
|
||||
vertices: list[RouteVertex], chainage_m: float
|
||||
) -> tuple[float, float, float]:
|
||||
"""누가거리 위치의 (x, y, z)를 선형 보간한다. 범위 밖은 끝점으로 당긴다."""
|
||||
if not vertices:
|
||||
return (0.0, 0.0, 0.0)
|
||||
if chainage_m <= vertices[0].chainage_m:
|
||||
return (vertices[0].x, vertices[0].y, vertices[0].z)
|
||||
for previous, current in zip(vertices, vertices[1:]):
|
||||
if chainage_m <= current.chainage_m:
|
||||
span = current.chainage_m - previous.chainage_m
|
||||
ratio = 0.0 if span <= 0 else (chainage_m - previous.chainage_m) / span
|
||||
return (
|
||||
previous.x + (current.x - previous.x) * ratio,
|
||||
previous.y + (current.y - previous.y) * ratio,
|
||||
previous.z + (current.z - previous.z) * ratio,
|
||||
)
|
||||
last = vertices[-1]
|
||||
return (last.x, last.y, last.z)
|
||||
|
||||
|
||||
def is_uphill_at(vertices: list[RouteVertex], chainage_m: float, window_m: float = 20.0) -> bool:
|
||||
"""해당 위치가 오르막(절토부)인지 종단 계획선의 국소 기울기 부호로 판정한다."""
|
||||
_, _, back_z = interpolate_vertex(vertices, max(0.0, chainage_m - window_m))
|
||||
_, _, forward_z = interpolate_vertex(vertices, chainage_m + window_m)
|
||||
return forward_z >= back_z
|
||||
|
||||
|
||||
def find_stream_crossings(
|
||||
vertices: list[RouteVertex],
|
||||
stream_features: list[dict[str, Any]],
|
||||
) -> list[StructureCandidate]:
|
||||
"""노선 평면 선형과 세류선의 교차 지점을 누가거리 순으로 찾는다."""
|
||||
if len(vertices) < 2:
|
||||
return []
|
||||
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
|
||||
candidates: list[StructureCandidate] = []
|
||||
for feature in stream_features:
|
||||
geometry = feature.get("geometry")
|
||||
if not geometry:
|
||||
continue
|
||||
try:
|
||||
stream = shape(geometry)
|
||||
except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다
|
||||
continue
|
||||
if stream.is_empty:
|
||||
continue
|
||||
intersection = route_line.intersection(stream)
|
||||
if intersection.is_empty:
|
||||
continue
|
||||
name = _stream_name(feature)
|
||||
for point in _collect_points(intersection):
|
||||
candidates.append(
|
||||
StructureCandidate(
|
||||
chainage_m=route_line.project(point),
|
||||
x=point.x,
|
||||
y=point.y,
|
||||
reason="stream",
|
||||
stream_name=name,
|
||||
)
|
||||
)
|
||||
candidates.sort(key=lambda item: item.chainage_m)
|
||||
return candidates
|
||||
|
||||
|
||||
def _stream_name(feature: dict[str, Any]) -> str | None:
|
||||
properties = feature.get("properties") or {}
|
||||
for key in ("명칭", "하천명", "NAME", "name"):
|
||||
value = properties.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
def _collect_points(geometry: Any) -> list[Point]:
|
||||
"""교차 결과(Point/MultiPoint/LineString 등)에서 대표 점들을 뽑는다."""
|
||||
if geometry.geom_type == "Point":
|
||||
return [geometry]
|
||||
if geometry.geom_type in {"MultiPoint", "GeometryCollection"}:
|
||||
points: list[Point] = []
|
||||
for part in geometry.geoms:
|
||||
points.extend(_collect_points(part))
|
||||
return points
|
||||
# 선분끼리 겹쳐 선으로 나온 경우는 중점을 대표로 쓴다.
|
||||
if geometry.geom_type in {"LineString", "MultiLineString"}:
|
||||
return [geometry.interpolate(0.5, normalized=True)]
|
||||
return []
|
||||
|
||||
|
||||
def _first_text(row: dict[str, Any], keys: tuple[str, ...]) -> str | None:
|
||||
for key in keys:
|
||||
value = row.get(key)
|
||||
if value not in (None, ""):
|
||||
return str(value).strip()
|
||||
return None
|
||||
|
||||
|
||||
def _first_float(row: dict[str, Any], keys: tuple[str, ...]) -> float | None:
|
||||
text = _first_text(row, keys)
|
||||
if text is None:
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _first_int(row: dict[str, Any], keys: tuple[str, ...]) -> int | None:
|
||||
value = _first_float(row, keys)
|
||||
return None if value is None else int(value)
|
||||
@@ -41,8 +41,8 @@ export const PROGRESS_UPDATE_INTERVAL_MS = 10_000;
|
||||
/** B03 업로드 Service Worker 번들 경로 */
|
||||
export const SERVICE_WORKER_PATH = "/assets/B03_FileInput_ServiceWorker.js";
|
||||
|
||||
/** 허용 확장자 (지형/포인트클라우드/도면) */
|
||||
export const UPLOAD_ALLOWED_EXT = [".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] as const;
|
||||
/** 허용 확장자 (계획노선/지형/포인트클라우드/도면) */
|
||||
export const UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] as const;
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 3. WebCAD / 3D 렌더링 옵션
|
||||
|
||||
@@ -48,7 +48,7 @@ DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20"))
|
||||
UPLOAD_MAX_MB = int(os.getenv("UPLOAD_MAX_MB", str(30 * 1024)))
|
||||
UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "5"))
|
||||
UPLOAD_CHUNK_SIZE_BYTES = int(os.getenv("UPLOAD_CHUNK_SIZE_BYTES", str(1024 * 1024 * 1024)))
|
||||
UPLOAD_ALLOWED_EXT = [".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"]
|
||||
UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"]
|
||||
CHUNK_TEMP_DIR = os.getenv("CHUNK_TEMP_DIR", "B03_FileInput/chunks_temp")
|
||||
CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24"))
|
||||
MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600"))
|
||||
|
||||
@@ -31,6 +31,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router_Contour import router as b04_surface_contour_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router_Watershed import router as b04_watershed_router
|
||||
from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router
|
||||
from B05_wf2_Route.B05_wf2_Route_Router_Drainage import router as b05_drainage_router
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router
|
||||
@@ -273,6 +274,7 @@ app.include_router(b03_file_input_router, dependencies=protected_with_company)
|
||||
app.include_router(b04_surface_router, dependencies=protected_with_company)
|
||||
app.include_router(b04_surface_contour_router, dependencies=protected_with_company)
|
||||
app.include_router(b04_surface_gis_router, dependencies=protected_with_company)
|
||||
app.include_router(b04_watershed_router, dependencies=protected_with_company)
|
||||
app.include_router(tiles_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_drainage_router, dependencies=protected_with_company)
|
||||
|
||||
@@ -527,13 +527,13 @@ export const ui_locales = {
|
||||
/* --- B03_FileInput 파일 입력 --- */
|
||||
B03_File_Title: ["파일 입력", "File Input"],
|
||||
B03_File_Subtitle: [
|
||||
"지형·포인트클라우드·도면 파일을 업로드하세요.",
|
||||
"Upload terrain, point cloud, and drawing files.",
|
||||
"필수 계획노선과 지형·포인트클라우드 파일을 업로드하세요.",
|
||||
"Upload the required planned route, terrain, and point cloud files.",
|
||||
],
|
||||
B03_File_Select_Label: ["입력 파일 선택", "Select input files"],
|
||||
B03_File_Select_Hint: [
|
||||
"LAS/LAZ 1개를 포함해 관련 PRJ, TFW, TIF 또는 도면 파일을 선택하세요.",
|
||||
"Select exactly one LAS/LAZ file with related PRJ, TFW, TIF, or drawing files.",
|
||||
"계획노선 CSV, LAS/LAZ 1개, PRJ, TFW를 선택하세요. TIF는 선택 사항입니다.",
|
||||
"Select a planned-route CSV, one LAS/LAZ, PRJ, and TFW. TIF is optional.",
|
||||
],
|
||||
B03_File_Selected_Title: ["선택한 파일", "Selected files"],
|
||||
B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."],
|
||||
@@ -566,6 +566,9 @@ export const ui_locales = {
|
||||
B03_File_Result_Path: ["저장 경로", "Stored path"],
|
||||
B03_File_Group_Required: ["필수 파일", "Required files"],
|
||||
B03_File_Group_Optional: ["선택 파일", "Optional files"],
|
||||
B03_File_Group_Route: ["원청 계획노선 (필수)", "Client Planned Route (Required)"],
|
||||
B03_File_Group_Terrain: ["지형 분석자료", "Terrain Analysis Files"],
|
||||
B03_File_Slot_PlannedRoute: ["계획노선 좌표", "Planned Route Coordinates"],
|
||||
B03_File_Slot_PointCloud: ["포인트클라우드", "Point Cloud"],
|
||||
B03_File_Slot_Projection: ["좌표계 정의", "Projection"],
|
||||
B03_File_Slot_RasterCoord: ["래스터 좌표", "Raster Coord 1"],
|
||||
@@ -578,8 +581,8 @@ export const ui_locales = {
|
||||
"A file for this slot is already selected.",
|
||||
],
|
||||
B03_File_Error_RequiredSlots: [
|
||||
"필수 파일(LAS/LAZ, PRJ, TFW)을 모두 선택하세요.",
|
||||
"Select all required files: LAS/LAZ, PRJ, and TFW.",
|
||||
"필수 파일(계획노선 CSV, LAS/LAZ, PRJ, TFW)을 모두 선택하세요.",
|
||||
"Select all required files: planned-route CSV, LAS/LAZ, PRJ, and TFW.",
|
||||
],
|
||||
B03_File_Error_SlotType: [
|
||||
"선택한 파일 유형이 이 카드와 맞지 않습니다.",
|
||||
|
||||
Reference in New Issue
Block a user