From baef88dd50697e7b52eef05595929ae46388ae39 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 19:58:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EB=8B=A8=EA=B3=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=97=90=20=ED=9D=90=EB=A6=84=20=EA=B0=95=EB=8F=84?= =?UTF-8?q?=C2=B72=EC=B0=A8=20=EC=9C=A0=EC=97=AD=20=EC=99=B8=EA=B3=BD?= =?UTF-8?q?=EC=84=A0=C2=B7=EA=B8=B0=EB=B3=B8=20=EA=B4=80=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 본 계산(POST /basins)은 손대지 않았다. 검증 경로에만 6/7/8 단계를 얹는다. 6. 흐름 강도 색 판정은 세류 셀에서 멈춘다(거기서 도로 도달이 확정되므로). 강도는 그 물이 세류를 타고 최종적으로 어느 도로 셀로 들어가는지를 봐야 하므로, 도로만 흡수점으로 두고 한 번 더 따라가 도로 셀별로 센다. -> 누가거리 5m 구간별 유입 면적 곡선 7. 2차 전체 배수유역 외곽선 적색 셀 전체를 폴리곤화한 외곽 링 + 면적 8. 기본 관 매설 위치 도로 x 세류선 교차점. 20m 이내는 하나로 병합. 300m 보충 배치는 다음 단계. 프론트는 기존 렌더 경로를 그대로 쓴다 — 2차 유역선은 갈색 파선, 강도는 계획선 위 파란 띠, 관은 번호 마커. 버튼을 끄면 이 셋도 같이 걷는다. 실데이터 검증 (27.8s) 적색 457,404셀 = 457,404m2 7. 폴리곤 면적 457,374m2 (셀 면적의 100.0%) 6. 강도 합계 457,404m2 (적색 면적의 100.0% - 귀속 누락 0) 최대 지점 150m 에 409,244m2(89.5%) 집중 8. 기본 관 1개 chainage 149.7m -> 강도 최대 지점과 정확히 일치. 세류가 도로를 건너는 그 자리다. 교차 노드는 2개였으나 13.3m 간격이라 20m 병합 규칙으로 1개가 됐다. Co-Authored-By: Claude Fable 5 --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 7 +++ .../B05_wf2_Route_Engine_Watershed_Basin.py | 63 ++++++++++++++++++- .../B05_wf2_Route_Router_Drainage.py | 21 ++++++- .../B05_wf2_Route_UI_Drainage_Panel.ts | 18 +++++- 4 files changed, 104 insertions(+), 5 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index e66b6c25..c22140e9 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -371,6 +371,13 @@ export interface DrainagePrimaryRegion { * 순서는 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; } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py index 27382d51..128ca2d4 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py @@ -44,6 +44,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( largest_ring, outer_boundary, polygonize_labels, + trace_flow, ) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( GridSpec, @@ -222,6 +223,13 @@ class StagePreview: 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( @@ -262,15 +270,34 @@ def preview_stages( 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개)", + "배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — " + "2차 유역 %.0f㎡, 기본 관 %d개, 강도 곡선 %d점", time.perf_counter() - started, expansion.rounds, - analysis.spec.size, + spec.size, + int(red.sum()) * spec.cell_area_m2, + len(pipes), + int((strength_curve > 0).sum()), ) return StagePreview( region=region, - spec=analysis.spec, + spec=spec, domain=analysis.domain, terrain=analysis.terrain, road=analysis.road, @@ -279,9 +306,39 @@ def preview_stages( 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 + + # ── ③~④ 격자 해석 (캐시 대상) ─────────────────────────────────────────────── diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index c15f44b7..7a5c0345 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -17,7 +17,7 @@ import numpy as np from fastapi import APIRouter from fastapi.responses import JSONResponse from pyproj import Transformer -from shapely.geometry import LineString, Polygon, box +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 ( @@ -259,6 +259,15 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: }, # 셀별 흐름 방향과 도로 도달 여부. 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( @@ -270,11 +279,16 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: "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) 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 @@ -371,6 +385,11 @@ def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: } +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 [] 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 9e6f72a4..a26e9c40 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -598,6 +598,10 @@ export function createDrainagePanel(): DrainagePanel { regionButton.classList.remove("is-active"); regionButton.setAttribute("aria-pressed", "false"); status.hidden = true; + // 이 버튼이 얹은 2차 유역선·강도 띠·관 마커도 함께 걷는다. + mainBoundary = []; + pipeEditor.setPipes([]); + pipeEditor.setStrength([]); scheduleDraw(); return; } @@ -606,6 +610,15 @@ export function createDrainagePanel(): DrainagePanel { 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"); @@ -643,10 +656,13 @@ export function createDrainagePanel(): DrainagePanel { `(${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}${flow}` + `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${expansion}${basin}${flow}` ); }