diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 579e79bc..e66b6c25 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -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"; /** 경로 제어점 (BP/EP/CP) */ export interface RoutePoint { @@ -141,10 +141,17 @@ export interface RouteLatestResponse { } | null; } -/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */ -async function requestJson(path: string, init: RequestInit): Promise { +/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. + * + * `timeoutMs`를 주면 그 값으로 끊는다. 격자 해석처럼 오래 걸리는 요청은 + * `API_ANALYSIS_TIMEOUT_MS`를 넘긴다 — 기본값으로 두면 계산 도중 abort 된다. */ +async function requestJson( + path: string, + init: RequestInit, + timeoutMs: number = API_TIMEOUT_MS, +): Promise { 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, @@ -160,6 +167,12 @@ async function requestJson(path: string, init: RequestInit): Promise { 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); } @@ -365,9 +378,12 @@ export interface DrainagePrimaryRegion { export async function fetchDrainagePrimaryRegion( projectId: string, ): Promise { - return requestJson(`/projects/${projectId}/drainage/primary-region`, { - method: "GET", - }); + // 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다. + return requestJson( + `/projects/${projectId}/drainage/primary-region`, + { method: "GET" }, + API_ANALYSIS_TIMEOUT_MS, + ); } export async function fetchDrainageCandidates( @@ -383,8 +399,10 @@ export async function fetchDrainageBasins( projectId: string, chainages?: number[], ): Promise { - return requestJson(`/projects/${projectId}/drainage/basins`, { - method: "POST", - body: JSON.stringify({ chainages: chainages ?? [] }), - }); + // 격자 해석이 포함된 요청이라 캐시가 없으면 수십 초가 걸린다. + return requestJson( + `/projects/${projectId}/drainage/basins`, + { method: "POST", body: JSON.stringify({ chainages: chainages ?? [] }) }, + API_ANALYSIS_TIMEOUT_MS, + ); } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py index a4de669f..54017008 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py @@ -72,15 +72,23 @@ def analyze_domain( route_line: LineString, upstream_streams: list[LineString], elevation_floor_m: float | None = None, + descent: ContourDescent | None = None, ) -> GridAnalysis | None: - """주어진 격자·해석 영역에 대해 등고선 하강 방향과 도로 도달 색을 한 번 계산한다.""" - descent = build_contour_descent(spec, contour_features, domain, elevation_floor_m) - if not descent.valid.any(): + """주어진 격자·해석 영역에 대해 등고선 하강 방향과 도로 도달 색을 한 번 계산한다. + + **하강 방향장은 해석 영역과 무관하다** — 등고선 기하만으로 정해진다. 그래서 확장 + 회차마다 다시 계산하지 않고, 격자가 커졌을 때만 새로 만들어 넘겨받는다(`descent`). + 해석 영역은 마지막에 마스크로만 씌운다. + """ + if descent is None or descent.spec != spec: + descent = build_contour_descent(spec, contour_features, None, elevation_floor_m) + valid = descent.valid & domain + if not valid.any(): return None terrain = TerrainGrid( spec=spec, - elevation=descent.band_elevation, - valid=descent.valid, + elevation=np.where(valid, descent.band_elevation, np.nan).astype(np.float32), + valid=valid, receiver=descent.receiver, step_length=descent.step_length, ) @@ -103,14 +111,17 @@ def expand_by_red_boundary( max_rounds: int = DRAINAGE_RED_EXPAND_MAX_ROUNDS, ) -> RedExpansion | None: """최외곽 적색 셀 주변으로 넓히며, 새로 추가한 셀에 적색이 없을 때까지 반복한다.""" + band_cells = max(1, int(round(band_m / spec.cell_m))) + # 1차 영역의 bbox는 영역에 딱 붙어 있어 첫 회차부터 격자를 넓혀야 한다. 미리 여유를 + # 두면 방향장을 다시 만들지 않고 해석 영역만 넓히며 몇 회차를 돌 수 있다. + spec, domain = _pad_spec(spec, domain, band_cells * 2) + started_cells = int(domain.sum()) analysis = analyze_domain( spec, domain, contour_features, route_line, upstream_streams, elevation_floor_m ) if analysis is None: return None - band_cells = max(1, int(round(band_m / spec.cell_m))) - started_cells = int(domain.sum()) rounds = 0 closed = False for attempt in range(max_rounds): @@ -138,7 +149,14 @@ def expand_by_red_boundary( int(added_mask.sum()), ) widened_analysis = analyze_domain( - grown_spec, widened, contour_features, route_line, upstream_streams, elevation_floor_m + grown_spec, + widened, + contour_features, + route_line, + upstream_streams, + elevation_floor_m, + # 격자가 그대로면 방향장을 재사용한다 — 등고선 기하가 안 바뀌었으므로 결과는 같다. + descent=analysis.descent if grown_spec == current else None, ) if widened_analysis is None: break @@ -182,6 +200,22 @@ def _dilate_by(mask: np.ndarray, steps: int) -> np.ndarray: return result +def _pad_spec(spec: GridSpec, domain: np.ndarray, cells: int) -> tuple[GridSpec, np.ndarray]: + """격자에 사방 여유를 두고 해석 영역 마스크를 그 안으로 옮겨 담는다.""" + if cells <= 0: + return spec, domain + padded_spec = GridSpec( + x_min=spec.x_min - cells * spec.cell_m, + y_max=spec.y_max + cells * spec.cell_m, + cell_m=spec.cell_m, + n_rows=spec.n_rows + 2 * cells, + n_cols=spec.n_cols + 2 * cells, + ) + padded = np.zeros((padded_spec.n_rows, padded_spec.n_cols), dtype=bool) + padded[cells : cells + spec.n_rows, cells : cells + spec.n_cols] = domain + return padded_spec, padded + + def _grow_for_rim( spec: GridSpec, domain: np.ndarray, rim: np.ndarray, band_cells: int ) -> tuple[GridSpec, np.ndarray, np.ndarray]: diff --git a/config/config_frontend.ts b/config/config_frontend.ts index bd7bd651..3e0f6e56 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -16,6 +16,10 @@ export const API_BASE_URL = "/api"; /** API 요청 타임아웃 (ms) */ export const API_TIMEOUT_MS = 30_000; +/** 격자 해석처럼 수십 초가 걸리는 분석 요청용 타임아웃 (ms). + * 일반 요청에 이 값을 쓰면 장애 시 화면이 오래 멈추므로 분석 엔드포인트에만 쓴다. */ +export const API_ANALYSIS_TIMEOUT_MS = 60_000; + /** B03~B09 워크플로우에서 사용할 현재 프로젝트 UUID 저장 키 */ export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id";