diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index 7650e13f..f85dc842 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -330,15 +330,26 @@ export interface WatershedAnalysis { strength_profile: Array<[number, number]>; /** 기본 관 매설 위치 — 도로 × 세류선 교차점. */ pipes: WatershedPipe[]; + /** B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수]. + * 세류·도로 셀을 뺀 10m 블록 평균이라 사면 경향만 남는다. */ + flow_arrows: Array<[number, number, number, boolean, number]>; + /** 계산하지 않고 저장분을 그대로 돌려준 응답인지. */ + from_cache: boolean; /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ saved_to: string | null; } -export async function fetchWatershedAnalysis(projectId: string): Promise { - // 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다. +/** 배수유역 분석 결과를 받는다. + * + * `refresh`를 주지 않으면 영구저장소에 남은 결과를 그대로 받아 즉시 끝난다. + * `refresh=true`면 처음부터 다시 계산하므로 수십 초가 걸린다. */ +export async function fetchWatershedAnalysis( + projectId: string, + refresh = false, +): Promise { return requestJson( - `/projects/${projectId}/drainage/primary-region`, + `/projects/${projectId}/drainage/primary-region?refresh=${refresh}`, { method: "GET" }, - API_ANALYSIS_TIMEOUT_MS, + refresh ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS, ); } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py index 7fcc071d..8955406c 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py @@ -21,6 +21,7 @@ B05에 남긴다. from __future__ import annotations import logging +import math import time from dataclasses import dataclass, field from typing import Any @@ -38,6 +39,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import ( trace_flow, ) from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( + AZIMUTH_STEPS, GridSpec, TerrainGrid, build_contour_cloud, @@ -53,6 +55,10 @@ from common_util.common_util_route_geometry import ( find_stream_crossings, ) from config.config_system import ( + DRAINAGE_ARROW_BLOCK_M, + DRAINAGE_ARROW_MIN_AGREEMENT, + DRAINAGE_ARROW_MIN_COVERAGE, + DRAINAGE_ARROW_SPACING_M, DRAINAGE_GRID_SIZE_M, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_PIPE_MIN_SPACING_M, @@ -126,6 +132,8 @@ class StagePreview: basin_area_m2: float = 0.0 # 셀 → 도로 셀 귀속. B05가 세부유역을 나눌 때 이 배열이 있어야 한다. routing: Any = None + # B05용 평균 흐름 화살표 — (x, y, 방위 라디안, 도로 도달, 셀 수). + flow_arrows: list[tuple[float, float, float, bool, int]] = field(default_factory=list) # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. pipes: list[StructureCandidate] = field(default_factory=list) @@ -184,6 +192,9 @@ def preview_stages( # ⑧ 기본 관 매설 위치 = 도로 × 세류선 교차점(너무 가까운 것은 하나로 합침). pipes = _base_pipes(vertices, stream_features) + # B05에 얹을 평균 흐름 화살표 — 셀 화살표는 도면 배율에서 안 보인다. + flow_arrows = build_flow_arrows(analysis, analysis.flow) + logger.info( "배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — " "2차 유역 %.0f㎡, 기본 관 %d개, 강도 곡선 %d점", @@ -210,6 +221,7 @@ def preview_stages( basin_area_m2=int(red.sum()) * spec.cell_area_m2, routing=routing, pipes=pipes, + flow_arrows=flow_arrows, ) @@ -228,6 +240,70 @@ def _preview_strength( ) +def build_flow_arrows(analysis: Any, flow: Any) -> list[tuple[float, float, float, bool, int]]: + """셀 흐름을 블록 단위로 평균해 B05에 얹을 화살표를 뽑는다. + + 셀 화살표는 1m라 도면 배율에서 경향이 안 보인다. 겹치지 않는 블록으로 나눠 방향을 + 평균하고, 화살표끼리 최소 간격을 두어 솎아낸다(2026-07-31 사용자 지시). + + **세류선 셀과 도로 셀은 뺀다.** 그 자리 흐름은 지형 경사가 아니라 확정된 물길·노면을 + 따르는 값이라 사면 경향을 왜곡한다. + + 방향 평균은 산술평균이 아니라 **원형 평균**으로 낸다(0°와 359°의 평균은 180°가 아니라 + 0°다). 평균 벡터 길이가 일치도이므로, 블록 안 방향이 제각각이면 그 블록은 버린다. + """ + spec = analysis.spec + rows, cols = spec.n_rows, spec.n_cols + direction = flow.direction.reshape(rows, cols) + usable = ( + analysis.domain + & flow.analyzed.reshape(rows, cols) + & (direction < AZIMUTH_STEPS) # 싱크·무효 제외 + & ~analysis.road.mask + ) + if flow.burned is not None: + usable &= ~flow.burned.reshape(rows, cols) + if not usable.any(): + return [] + + block = max(1, int(round(DRAINAGE_ARROW_BLOCK_M / spec.cell_m))) + stride = max(1, int(round(DRAINAGE_ARROW_SPACING_M / (block * spec.cell_m)))) + angle = direction.astype(np.float64) * (2.0 * math.pi / AZIMUTH_STEPS) + reaches = flow.reaches_road.reshape(rows, cols) + + arrows: list[tuple[float, float, float, bool, int]] = [] + for row0 in range(0, rows - block + 1, block * stride): + for col0 in range(0, cols - block + 1, block * stride): + window = usable[row0 : row0 + block, col0 : col0 + block] + count = int(window.sum()) + if count < DRAINAGE_ARROW_MIN_COVERAGE * block * block: + continue + local = angle[row0 : row0 + block, col0 : col0 + block][window] + mean_x = float(np.cos(local).mean()) + mean_y = float(np.sin(local).mean()) + agreement = math.hypot(mean_x, mean_y) + if agreement < DRAINAGE_ARROW_MIN_AGREEMENT: + continue # 방향이 제각각인 블록 — 평균이 경향을 대표하지 못한다 + centre_row = row0 + block / 2.0 + centre_col = col0 + block / 2.0 + arrows.append( + ( + spec.x_min + centre_col * spec.cell_m, + spec.y_max - centre_row * spec.cell_m, + math.atan2(mean_y, mean_x), + bool(reaches[row0 : row0 + block, col0 : col0 + block][window].mean() >= 0.5), + count, + ) + ) + logger.info( + "배수유역: 평균 흐름 화살표 %d개 (블록 %.0fm, 간격 %.0fm, 세류·도로 셀 제외)", + len(arrows), + block * spec.cell_m, + block * stride * spec.cell_m, + ) + return arrows + + def _base_pipes( vertices: list[RouteVertex], stream_features: list[dict[str, Any]] ) -> list[StructureCandidate]: diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py index 248a69f1..757a0e0d 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py @@ -11,6 +11,7 @@ import asyncio import base64 import json import logging +import math from pathlib import Path from typing import Any from uuid import UUID @@ -23,7 +24,11 @@ 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_Export import ( + drainage_dir, + write_grid_arrays, + write_stage, +) from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( AZIMUTH_INVALID, AZIMUTH_SINK, @@ -38,6 +43,7 @@ from common_util.common_util_route_geometry import ( ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool +from config.config_system import DRAINAGE_RESPONSE_FILENAME logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Watershed"]) @@ -172,14 +178,52 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: } -@router.get("/{project_id}/drainage/primary-region", response_model=None) -async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: - """1차 배수유역 근거를 돌려준다 — 단계 검증용, TIN·흐름 계산은 하지 않는다. +def _response_path(stored_path: str) -> Path: + """분석 응답 캐시 경로. 재산정하지 않는 한 이 파일을 그대로 돌려준다.""" + return drainage_dir(stored_path) / DRAINAGE_RESPONSE_FILENAME - 도로 교차점 상류로 이어진 세류망, 제외된 하류망, 그 상류망을 반경 버퍼한 1차 영역, - 그 bbox로 잡은 격자 정보를 함께 준다. 같은 내용을 영구저장소에 GeoJSON으로도 남겨 - QGIS 등으로 직접 열어 대조할 수 있게 한다. + +def _load_saved_response(stored_path: str) -> dict[str, Any] | None: + path = _response_path(stored_path) + if not path.exists(): + return None + try: + with path.open("r", encoding="utf-8") as file: + return json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: 저장된 분석 응답을 읽지 못했습니다 (%s).", path) + return None + + +def _save_response(stored_path: str, payload: dict[str, Any]) -> None: + path = _response_path(stored_path) + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as file: + json.dump(payload, file, ensure_ascii=False) + except OSError: + logger.warning("배수유역: 분석 응답을 저장하지 못했습니다 (%s).", path) + + +@router.get("/{project_id}/drainage/primary-region", response_model=None) +async def get_primary_region( + project_id: UUID, refresh: bool = False +) -> dict[str, Any] | JSONResponse: + """배수유역 분석 결과를 돌려준다. + + 기본은 **영구저장소에 남은 결과를 그대로** 준다 — 분석이 30초 걸리므로 화면을 열 + 때마다 다시 돌릴 이유가 없다. `refresh=true`면 처음부터 다시 계산하고 덮어쓴다 + (2026-07-31 사용자 지시). """ + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + if not refresh: + saved = _load_saved_response(stored_path) + if saved is not None: + logger.info("배수유역: 저장된 분석 결과를 그대로 돌려줍니다 (%s).", stored_path) + return {**saved, "from_cache": True} + prepared = await _prepare(project_id) if isinstance(prepared, JSONResponse): return prepared @@ -248,6 +292,13 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: ], # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes], + # B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수]. + # 세류·도로 셀을 뺀 블록 평균이라 사면 경향만 남는다. + "flow_arrows": [ + [*to_lonlat(x, y), round(math.degrees(angle), 1), reaches, cells] + for x, y, angle, reaches, cells in preview.flow_arrows + ], + "from_cache": False, } # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. payload["saved_to"] = write_stage( diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index 8950ac7f..7add560a 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -32,12 +32,18 @@ const ARROW_SPACING_PX = 22; const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)"; /** 기본 관 마커. */ const PIPE_COLOR = "rgba(249, 115, 22, 0.95)"; +/** 평균 흐름 화살표 — 10m 블록 평균. 셀 화살표보다 크게 그려 경향을 읽는다. */ +const MEAN_ARROW_TO_ROAD = "rgba(153, 27, 27, 0.95)"; +const MEAN_ARROW_AWAY = "rgba(30, 64, 175, 0.95)"; +const MEAN_ARROW_PX = 14; +const MEAN_ARROW_MAX_PX = 46; /** 개별로 켜고 끌 수 있는 오버레이 갈래. */ const PARTS = [ { key: "primary", label: "1차 유역", color: "#059669" }, { key: "basin", label: "2차 유역", color: "#92400e" }, { key: "flow", label: "유역 방향", color: "#2563eb" }, + { key: "arrows", label: "평균 흐름", color: "#7c3aed" }, ] as const; type PartKey = (typeof PARTS)[number]["key"]; @@ -62,19 +68,26 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { let shown = false; let statusText = ""; let flowCache: { source: string; bytes: Uint8Array } | null = null; + let busy = false; const button = document.createElement("button"); button.type = "button"; button.className = "b04-map__layer-button b04-map__layer-button--gis"; - button.textContent = "배수유역"; + button.textContent = "배수유역 재산정"; button.style.setProperty("--b04-layer-color", "#dc2626"); button.setAttribute("aria-pressed", "false"); button.title = - "계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 분석합니다. " + - "30초 안팎이 걸리며 결과는 영구저장소에 남습니다."; + "계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 처음부터 다시 분석합니다. " + + "30초 안팎이 걸리며 결과는 영구저장소에 남습니다. " + + "저장된 결과는 지도를 열 때 자동으로 표시되므로, 조건을 바꿨을 때만 누르면 됩니다."; // 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다. - const shownParts: Record = { primary: true, basin: true, flow: true }; + const shownParts: Record = { + primary: true, + basin: true, + flow: true, + arrows: true, + }; const partButtons = PARTS.map((part) => { const element = document.createElement("button"); element.type = "button"; @@ -314,6 +327,60 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { context.restore(); } + /** B05에 얹을 평균 흐름 화살표 — 10m 블록 평균이라 축소해도 경향이 읽힌다. */ + function drawFlowArrows( + context: CanvasRenderingContext2D, + map: Normalizer, + view: ViewState, + region: WatershedAnalysis, + ): void { + const arrows = region.flow_arrows ?? []; + if (arrows.length === 0) 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; + // 블록 간격(m)이 화면에서 몇 px인지로 화살표 크기를 정한다 — 확대하면 같이 커진다. + const pxPerLon = ax / map.lonRange; + const spacingPx = + arrows.length > 1 + ? Math.abs(arrows[1][0] - arrows[0][0]) * pxPerLon || MEAN_ARROW_PX + : MEAN_ARROW_PX; + const size = Math.max(MEAN_ARROW_PX, Math.min(spacingPx * 0.8, MEAN_ARROW_MAX_PX)); + + context.save(); + context.lineCap = "round"; + arrows.forEach(([lon, lat, degrees, reaches]) => { + const x = ((lon - map.lonMin) / map.lonRange) * ax + bx; + const y = (1 - (lat - map.latMin) / map.latRange) * ay + by; + if (x < -size || x > view.width + size || y < -size || y > view.height + size) return; + const angle = (degrees * Math.PI) / 180; + const unitX = Math.cos(angle); + const unitY = Math.sin(angle); + const reach = size / 2; + const tipX = x + unitX * reach; + const tipY = y + unitY * reach; + const head = size * 0.32; + // 배경 대비를 위해 흰 테두리를 깔고 그 위에 색을 얹는다. + for (const [color, lineWidth] of [ + ["rgba(255, 255, 255, 0.9)", size * 0.18 + 2] as const, + [reaches ? MEAN_ARROW_TO_ROAD : MEAN_ARROW_AWAY, size * 0.18] as const, + ]) { + context.strokeStyle = color; + context.lineWidth = lineWidth; + context.beginPath(); + context.moveTo(x - unitX * reach, y - unitY * reach); + context.lineTo(tipX, tipY); + 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(); + } + }); + context.restore(); + } + /** ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 ⑧ 기본 관 위치. */ function drawBasinAndPipes( context: CanvasRenderingContext2D, @@ -389,10 +456,38 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`; } - /** 켤 때마다 다시 요청한다 — config를 바꾸고 재시작했는데 캐시된 옛 결과가 나오면 - * 검증이 성립하지 않는다. 끌 때만 요청 없이 숨긴다. */ - async function toggle(): Promise { - if (!projectId) return; + /** 분석 결과를 받아 화면에 올린다. + * + * `refresh=false`면 영구저장소에 남은 결과를 그대로 받아 즉시 끝나므로 지도를 열 때 + * 자동으로 부른다. `refresh=true`(재산정 버튼)면 처음부터 다시 계산한다. */ + async function loadAnalysis(refresh: boolean): Promise { + if (!projectId || busy) return; + busy = true; + button.disabled = true; + statusText = refresh + ? "배수유역을 다시 분석하는 중… (30초 안팎)" + : "저장된 배수유역을 불러오는 중…"; + onChange(); + try { + analysis = await fetchWatershedAnalysis(projectId, refresh); + shown = true; + button.classList.add("is-active"); + button.setAttribute("aria-pressed", "true"); + statusText = regionSummary(analysis); + } catch (error) { + // 저장분이 없어 자동 조회가 실패한 경우는 오류가 아니다 — 재산정하면 된다. + const message = error instanceof Error ? error.message : "배수유역을 불러오지 못했습니다."; + statusText = refresh ? message : ""; + if (!refresh) analysis = null; + } finally { + busy = false; + button.disabled = false; + onChange(); + } + } + + // 재산정 버튼: 켜져 있으면 끄고, 꺼져 있으면 처음부터 다시 분석한다. + button.addEventListener("click", () => { if (shown) { shown = false; button.classList.remove("is-active"); @@ -401,24 +496,8 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { 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()); + void loadAnalysis(true); + }); return { button, @@ -435,12 +514,15 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { }, setProject(next: string) { projectId = next; + // 저장분이 있으면 즉시 올린다 — 없으면 조용히 넘어가고, 재산정 버튼을 누르면 계산한다. + void loadAnalysis(false); }, draw(context, map, view) { if (!shown || !analysis) return; // 격자·화살표(유역 방향) → 1차 영역 → 2차 유역·관 순으로 아래에서 위로 쌓는다. if (shownParts.flow) drawGridCells(context, map, view, analysis); if (shownParts.primary) drawPrimaryRegion(context, map, view, analysis); + if (shownParts.arrows) drawFlowArrows(context, map, view, analysis); if (shownParts.basin) drawBasinAndPipes(context, map, view, analysis); }, }; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 1527252a..5349c7a4 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -121,7 +121,7 @@ export function createDrainagePanel(): DrainagePanel { canvas.className = "b05-drainage__canvas"; const status = document.createElement("span"); status.className = "b05-drainage__status"; - status.textContent = "노선을 확정하면 배수유역 배경도가 표시됩니다."; + status.textContent = "노선을 확정하면 배수유역도가 표시됩니다."; viewport.append(backgroundImage, canvas, status); // 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다. const basinList = document.createElement("div"); @@ -311,7 +311,7 @@ export function createDrainagePanel(): DrainagePanel { if (!projectId) return; analyzeButton.disabled = true; status.hidden = false; - status.textContent = "배수유역을 산정하는 중…"; + status.textContent = "세부유역을 산정하는 중…"; try { const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined; const response = await fetchDrainageBasins(projectId, chainages); @@ -333,7 +333,7 @@ export function createDrainagePanel(): DrainagePanel { scheduleDraw(); } catch (error) { status.hidden = false; - status.textContent = error instanceof Error ? error.message : "배수유역 산정에 실패했습니다."; + status.textContent = error instanceof Error ? error.message : "세부유역 산정에 실패했습니다."; } finally { analyzeButton.disabled = false; } @@ -420,6 +420,8 @@ export function createDrainagePanel(): DrainagePanel { if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요."; fitToRoute(); scheduleDraw(); + // B04 분석 결과를 읽어 오는 것뿐이라 즉시 끝난다 — 페이지에 들어오면 바로 보여 준다. + void analyze(true); } catch (error) { if (sequence !== loadSequence) return; status.hidden = false; diff --git a/config/config_system.py b/config/config_system.py index 771fedcc..9679bc92 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -281,9 +281,22 @@ DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0")) DRAINAGE_POLYGON_SIMPLIFY_M = float(os.getenv("DRAINAGE_POLYGON_SIMPLIFY_M", "2.0")) # 이 면적(㎡) 미만의 유역 조각은 버린다(격자 노이즈 제거). DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100.0")) -# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B05_wf2_Route/drainage/ 아래에 놓인다. +# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B04_wf1_Surface/drainage/ 아래에 놓인다. DRAINAGE_CACHE_DIRNAME = "drainage" DRAINAGE_CACHE_FILENAME = "watershed_grid.npz" +# 분석 응답 자체를 그대로 담아 두는 파일. 재산정하지 않는 한 이걸 그대로 돌려준다 — +# 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시). +DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json" + +# ── B05용 평균 흐름 화살표 ── +# 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다. +DRAINAGE_ARROW_BLOCK_M = float(os.getenv("DRAINAGE_ARROW_BLOCK_M", "10.0")) +# 화살표끼리 최소 이 간격을 두고 솎아낸다. 촘촘하면 도면이 지저분해진다. +DRAINAGE_ARROW_SPACING_M = float(os.getenv("DRAINAGE_ARROW_SPACING_M", "40.0")) +# 블록 안에서 화살표를 낼 수 있는 셀이 이 비율 미만이면 건너뛴다(가장자리 조각 방지). +DRAINAGE_ARROW_MIN_COVERAGE = float(os.getenv("DRAINAGE_ARROW_MIN_COVERAGE", "0.5")) +# 방향 일치도 하한(원형 평균 결과 길이 0~1). 블록 안 방향이 제각각이면 평균이 무의미하므로 버린다. +DRAINAGE_ARROW_MIN_AGREEMENT = float(os.getenv("DRAINAGE_ARROW_MIN_AGREEMENT", "0.7")) # 단계별 검증 산출물은 같은 폴더에 `{번호}_{단계}.geojson` + `manifest.json`으로 쌓인다. # 파일명 규칙은 B05_wf2_Route_Engine_Watershed_Export.STAGES가 유일한 정의처다.