fix(B05): 분석 요청 타임아웃 분리 + 확장 회차마다 방향장 재계산 제거
"signal is aborted without reason" 오류. 프론트 공통 타임아웃이 30초인데 등고선 하강 + 확장 루프가 33초 걸려 계산 도중 abort 됐다. - config_frontend 에 API_ANALYSIS_TIMEOUT_MS(60초) 추가. 공통 30초는 그대로 두고 분석 엔드포인트(primary-region, basins)에만 적용한다. 일반 요청까지 늘리면 장애 시 화면이 오래 멈춘다. - requestJson 에 timeoutMs 인자 추가. AbortError 원문은 원인을 알 수 없으므로 "요청이 N초 안에 끝나지 않았습니다"로 바꿔 던진다. 같이 속도도 줄였다 (33.3s -> 26.2s, 결과 동일) - 하강 방향장은 해석 영역과 무관하다 — 등고선 기하만으로 정해진다. 확장 회차마다 다시 만들 이유가 없어, 격자가 커졌을 때만 새로 만들고 아니면 재사용한다. 해석 영역은 마지막에 마스크로만 씌운다. - 1차 영역 bbox 는 영역에 딱 붙어 있어 첫 회차부터 격자를 넓혀야 했다. 시작할 때 사방에 확장폭 2배 여유를 둬 몇 회차는 격자를 안 넓히고 돈다. 검증: 적색 457,404 로 이전과 동일. 확장 3회 닫힘, 방향장 계산 4회 -> 2회. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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,
|
||||
@@ -160,6 +167,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);
|
||||
}
|
||||
@@ -365,9 +378,12 @@ export interface DrainagePrimaryRegion {
|
||||
export async function fetchDrainagePrimaryRegion(
|
||||
projectId: string,
|
||||
): Promise<DrainagePrimaryRegion> {
|
||||
return requestJson<DrainagePrimaryRegion>(`/projects/${projectId}/drainage/primary-region`, {
|
||||
method: "GET",
|
||||
});
|
||||
// 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다.
|
||||
return requestJson<DrainagePrimaryRegion>(
|
||||
`/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<DrainageBasinResponse> {
|
||||
return requestJson<DrainageBasinResponse>(`/projects/${projectId}/drainage/basins`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ chainages: chainages ?? [] }),
|
||||
});
|
||||
// 격자 해석이 포함된 요청이라 캐시가 없으면 수십 초가 걸린다.
|
||||
return requestJson<DrainageBasinResponse>(
|
||||
`/projects/${projectId}/drainage/basins`,
|
||||
{ method: "POST", body: JSON.stringify({ chainages: chainages ?? [] }) },
|
||||
API_ANALYSIS_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user