feat(B05): 단계 검증에 흐름 강도·2차 유역 외곽선·기본 관 위치 추가

본 계산(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 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 19:58:54 +09:00
co-authored by Claude Fable 5
parent 2dcafcd286
commit baef88dd50
4 changed files with 104 additions and 5 deletions
+7
View File
@@ -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;
}
@@ -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
# ── ③~④ 격자 해석 (캐시 대상) ───────────────────────────────────────────────
+20 -1
View File
@@ -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 []
@@ -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}`
);
}