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:
@@ -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
|
||||
|
||||
|
||||
# ── ③~④ 격자 해석 (캐시 대상) ───────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user