Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -107,6 +107,8 @@ export const STATE_REGISTRY = {
|
||||
"input-prefs": { bucket: "pref", scope: "global" },
|
||||
/** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */
|
||||
"b05-3d-projection": { bucket: "pref", scope: "global" },
|
||||
/** 배수유역 계산 `manual · auto`(347-2) · 없으면 수동. */
|
||||
"drainage-mode": { bucket: "pref", scope: "global" },
|
||||
"section-panel-height": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
|
||||
@@ -591,6 +591,8 @@ export interface DetailBasinResponse {
|
||||
pipe_signature?: string;
|
||||
/** 분석이 낡음(노선이 분석 때와 다름) — 까닭 글 · 세부 유역 없음 · 관 자리만. */
|
||||
stale?: string | null;
|
||||
/** 관 자리 후보(347-3) — 제안만 · [적용] 때 관 초안에. */
|
||||
pipe_candidates?: Array<{ chainage_m: number; lonlat: [number, number] }>;
|
||||
}
|
||||
|
||||
let lastNote = { text: "", at: 0 };
|
||||
@@ -640,10 +642,13 @@ export async function resetDetailPipePoints(projectId: string): Promise<DetailBa
|
||||
export async function saveDetailPipePoints(
|
||||
projectId: string,
|
||||
points: DetailPipeInput[],
|
||||
recompute = false,
|
||||
): Promise<DetailBasinResponse> {
|
||||
// [확정](recompute) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈(347-2) — 오래 걸림.
|
||||
const saved = await requestJson<DetailBasinResponse>(
|
||||
`/projects/${projectId}/drainage/pipe-points`,
|
||||
`/projects/${projectId}/drainage/pipe-points${recompute ? "?recompute=true" : ""}`,
|
||||
{ method: "PUT", body: JSON.stringify({ points }) },
|
||||
recompute ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS,
|
||||
);
|
||||
noteSnapshotFailed(saved.snapshot_failed);
|
||||
return saved;
|
||||
|
||||
@@ -23,6 +23,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -31,7 +32,10 @@ import numpy as np
|
||||
from shapely.geometry import LineString
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Descent import ContourDescent
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Expand import expand_by_red_boundary
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Expand import (
|
||||
AnalysisStopped,
|
||||
expand_by_red_boundary,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import (
|
||||
FlowClassification,
|
||||
RoadRaster,
|
||||
@@ -154,6 +158,7 @@ def preview_stages(
|
||||
contour_features: list[dict[str, Any]],
|
||||
stream_features: list[dict[str, Any]],
|
||||
descent_store: tuple[Path, str] | None = None,
|
||||
should_stop: Callable[[], bool] | None = None,
|
||||
) -> StagePreview | None:
|
||||
"""지금까지 구현·검증된 단계를 순서대로 돌려 결과를 모은다.
|
||||
|
||||
@@ -176,6 +181,8 @@ def preview_stages(
|
||||
marks.append(("1차 영역(resolve_primary_region)", time.perf_counter()))
|
||||
if region is None:
|
||||
return None
|
||||
if should_stop is not None and should_stop():
|
||||
raise AnalysisStopped
|
||||
|
||||
started = time.perf_counter()
|
||||
floor = route_elevation_floor([vertex.z for vertex in vertices])
|
||||
@@ -187,6 +194,7 @@ def preview_stages(
|
||||
region.split.upstream,
|
||||
floor,
|
||||
descent_store=descent_store,
|
||||
should_stop=should_stop,
|
||||
)
|
||||
if expansion is None:
|
||||
logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.")
|
||||
|
||||
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -58,6 +59,10 @@ logger = logging.getLogger(__name__)
|
||||
DESCENT_SPARE_M = 400.0
|
||||
|
||||
|
||||
class AnalysisStopped(Exception):
|
||||
"""새 차례가 와서 계산을 그만둠(「지금 노선으로 셈」 · 347-2) — 단계 사이에서만 확인."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GridAnalysis:
|
||||
"""한 회차 분석 결과 — 격자·해석 영역·방향·색까지 한 묶음."""
|
||||
@@ -142,6 +147,7 @@ def expand_by_red_boundary(
|
||||
band_m: float = DRAINAGE_RED_EXPAND_BAND_M,
|
||||
max_rounds: int = DRAINAGE_RED_EXPAND_MAX_ROUNDS,
|
||||
descent_store: tuple[Path, str] | None = None,
|
||||
should_stop: Callable[[], bool] | None = None,
|
||||
) -> RedExpansion | None:
|
||||
"""최외곽 적색 셀 주변으로 넓히며, 새로 추가한 셀에 적색이 없을 때까지 반복한다."""
|
||||
_expand_started = time.perf_counter()
|
||||
@@ -194,6 +200,8 @@ def expand_by_red_boundary(
|
||||
rounds = 0
|
||||
closed = False
|
||||
for attempt in range(max_rounds):
|
||||
if should_stop is not None and should_stop():
|
||||
raise AnalysisStopped
|
||||
_round_started = time.perf_counter()
|
||||
current = analysis.spec
|
||||
reaches = analysis.flow.reaches_road.reshape(current.n_rows, current.n_cols)
|
||||
|
||||
@@ -73,7 +73,11 @@ STALE_MESSAGE = (
|
||||
|
||||
|
||||
def _build(
|
||||
stored_path: str, context: DrainageContext, points: list[PipePoint] | None
|
||||
stored_path: str,
|
||||
context: DrainageContext,
|
||||
points: list[PipePoint] | None,
|
||||
routing: Any = None,
|
||||
upstream_lonlat: list | None = None,
|
||||
) -> tuple[DrainageDetail | None, list[PipePoint]]:
|
||||
"""세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다.
|
||||
|
||||
@@ -83,7 +87,7 @@ def _build(
|
||||
"""
|
||||
directory = drainage_dir(stored_path)
|
||||
chainages = [point.chainage_m for point in points] if points is not None else None
|
||||
detail = build_detail(directory, context.vertices, chainages)
|
||||
detail = build_detail(directory, context.vertices, chainages, routing, upstream_lonlat)
|
||||
if detail is None:
|
||||
return None, []
|
||||
if points is not None:
|
||||
@@ -377,6 +381,11 @@ def _raw_payload(
|
||||
for basin in detail.basins
|
||||
],
|
||||
"pipe_count": len(points),
|
||||
# 관 자리 후보(347-3) — 제안만 · [적용] 때 화면이 관 초안에 더함.
|
||||
"pipe_candidates": [
|
||||
{"chainage_m": round(pipe.chainage_m, 2), "lonlat": list(to_lonlat(pipe.x, pipe.y))}
|
||||
for pipe in detail.pipe_candidates
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -479,6 +488,13 @@ async def _resolve(
|
||||
return context, detail, resolved, saved, False
|
||||
|
||||
|
||||
async def _analysis_stale(project_id: UUID) -> bool:
|
||||
context, _ = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return False
|
||||
return analysis_route_signature(context.stored_path) != route_signature(context.vertices)
|
||||
|
||||
|
||||
@router.get("/{project_id}/drainage/pipe-points", response_model=None)
|
||||
async def get_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"""저장된 관 지점과 그 세부유역을 돌려준다. 저장분이 없으면 자동 생성한 목록."""
|
||||
@@ -529,8 +545,17 @@ async def put_pipe_points(
|
||||
project_id: UUID,
|
||||
payload: dict[str, Any] | None = None,
|
||||
session: dict[str, Any] | None = Depends(get_optional_session), # 가져온이(142-2)
|
||||
recompute: bool = False,
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""관 지점을 정본으로 확정하고 세부유역 산출물까지 함께 남긴다(모델 확정 시점)."""
|
||||
# [확정](recompute) = 낡았으면 지금 노선으로 배수유역부터 다시 셈(정본 · 작업본에 씀 · 347-2) ·
|
||||
# [저장] = 낡음 표시 달고 관만 저장.
|
||||
if recompute and await _analysis_stale(project_id):
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import get_primary_region
|
||||
|
||||
region = await get_primary_region(project_id, refresh=True)
|
||||
if isinstance(region, JSONResponse):
|
||||
return region
|
||||
resolved = await _resolve(project_id, payload, use_stored=True, fresh=True)
|
||||
if isinstance(resolved, JSONResponse):
|
||||
return resolved
|
||||
|
||||
@@ -569,3 +569,7 @@ async def get_primary_region(
|
||||
_log_steps("배수유역 분석 내부", marks)
|
||||
_save_response(prepared["stored_path"], payload)
|
||||
return payload
|
||||
|
||||
|
||||
# 「지금 노선으로 셈」(347-2) — 같은 router 에 붙임(그 파일이 이 router 를 읽음).
|
||||
from B04_PreProcess import B04_PreProcess_Router_Watershed_Live # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""「지금 노선으로 셈」(347-2) — 노선 초안 + 관 자리로 배수유역 · 세부 유역을 한 요청에 셈.
|
||||
|
||||
서버 단독(③) · **작업본에 안 씀** — `00_watershed_response.json` · 격자 배열 · 관 지점은 그대로이고
|
||||
답으로만 돌려준다(파생 저장본 = 흐름 방향 · 도엽 좌표만 · 347-1). 세부 유역 나눔까지 같은 요청에서
|
||||
메모리 격자로 해 격자를 화면에 넘기지 않는다.
|
||||
|
||||
프로젝트마다 「마지막 차례」를 적어 두고 단계 사이(1차 영역 뒤 · 확장 회차마다 · 세부 유역 앞)에서
|
||||
차례가 바뀌었으면 그만둔다(`superseded`). 화면은 늦은 답을 차례 · 노선 지문으로 한 번 더 거른다.
|
||||
라우터는 `B04_PreProcess_Router_Watershed.router` 에 붙는다(그 파일 끝에서 읽음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import preview_stages
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Expand import AnalysisStopped
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import _build, _payload
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare, router
|
||||
from common_util.common_util_drainage_context import _open_sampler, load_drainage_context
|
||||
from common_util.common_util_drainage_detail import RoadRouting
|
||||
from common_util.common_util_drainage_pipes import parse_pipe_points
|
||||
from common_util.common_util_route_geometry import RouteVertex, build_route_vertices
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
from common_util.common_util_route_profile import resolve_route_profile
|
||||
from config.config_system import DRAINAGE_ARROW_SPACING_M
|
||||
|
||||
# 프로젝트 → 마지막으로 받은 차례 번호. ponytail: 서버 프로세스 하나 전제(여럿이면 DB · 파일로).
|
||||
_LATEST_GENERATION: dict[str, int] = {}
|
||||
SUPERSEDED = {"status": "superseded"}
|
||||
|
||||
|
||||
def draft_vertices(draft: dict[str, Any], min_radius_m: float) -> list[RouteVertex]:
|
||||
"""노선 초안(RouteDraft) → 그린 노선 정점(z 없음) — 노선 [확인]과 같은 폴리라인 규칙.
|
||||
|
||||
ponytail: 붙든 곡선 길이(curveLock · curveArc) · 측점 스냅은 안 봄 — 곡선 중점이 조금 다를 뿐
|
||||
배수유역 결과엔 셀 몇 개 차이. 필요하면 노선 [확인] 길과 같이 넘김.
|
||||
"""
|
||||
nodes = [(float(x), float(y)) for x, y in draft["nodes"]]
|
||||
flags = [bool(flag) for flag in draft.get("curveOn") or [True] * len(nodes)]
|
||||
radii = [None if r is None else float(r) for r in draft.get("curveRadius") or []]
|
||||
polyline = build_planned_polyline(
|
||||
nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii or None
|
||||
)
|
||||
return build_route_vertices([{"x": x, "y": y} for x, y in polyline.vertices])
|
||||
|
||||
|
||||
def live_routing(preview: Any, route_xy: list[tuple[float, float]], to_lonlat: Any) -> RoadRouting:
|
||||
"""분석 결과(메모리) → 세부 유역 나눔 입력 — 파일 길(`_write_road_routing` → 읽기)과 같은 값."""
|
||||
routing, road = preview.routing, preview.road
|
||||
return RoadRouting(
|
||||
spec=preview.spec,
|
||||
road_slot=routing.road_slot,
|
||||
path_length=routing.path_length,
|
||||
elevation=preview.terrain.elevation.reshape(-1),
|
||||
road_cell_index=road.cell_index,
|
||||
road_chainage=road.chainage,
|
||||
strength=routing.strength,
|
||||
route_lonlat=[list(to_lonlat(x, y)) for x, y in route_xy],
|
||||
basin_lonlat=[list(to_lonlat(x, y)) for x, y in preview.basin_boundary_xy],
|
||||
base_pipes=list(preview.pipes),
|
||||
flow_arrows=[
|
||||
[round(x, 2), round(y, 2), round(math.degrees(angle), 1), reaches, cells]
|
||||
for x, y, angle, reaches, cells in preview.flow_arrows
|
||||
],
|
||||
arrow_spacing_m=DRAINAGE_ARROW_SPACING_M,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/drainage/live", response_model=None)
|
||||
async def post_drainage_live(
|
||||
project_id: UUID, payload: dict[str, Any] | None = None
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""몸통 = {generation, route: RouteDraft | null(저장 노선), points: 관 목록 | null(자동)}."""
|
||||
body = payload or {}
|
||||
generation = int(body.get("generation") or 0)
|
||||
key = str(project_id)
|
||||
_LATEST_GENERATION[key] = generation
|
||||
|
||||
def superseded() -> bool:
|
||||
return _LATEST_GENERATION.get(key) != generation
|
||||
|
||||
try:
|
||||
raw = body.get("points")
|
||||
points = None if raw is None else parse_pipe_points(raw)
|
||||
except ValueError as error:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
|
||||
prepared = await _prepare(project_id)
|
||||
if isinstance(prepared, JSONResponse):
|
||||
return prepared
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
|
||||
vertices = context.vertices
|
||||
draft = body.get("route")
|
||||
if draft and len(draft.get("nodes") or []) >= 2:
|
||||
from B05_Profile.B05_Profile_Router_Replan import _plan_criteria
|
||||
|
||||
radius_m, *_ = await _plan_criteria(project_id)
|
||||
drawn = await asyncio.to_thread(draft_vertices, draft, radius_m)
|
||||
sampler = await asyncio.to_thread(
|
||||
_open_sampler, context.project_root, context.surface_params
|
||||
)
|
||||
vertices, _ = await asyncio.to_thread(resolve_route_profile, drawn, sampler=sampler)
|
||||
if superseded():
|
||||
return SUPERSEDED
|
||||
|
||||
try:
|
||||
preview = await asyncio.to_thread(
|
||||
preview_stages,
|
||||
vertices,
|
||||
prepared["contours"],
|
||||
prepared["streams"],
|
||||
(drainage_dir(context.stored_path), prepared["contour_key"]),
|
||||
superseded,
|
||||
)
|
||||
except AnalysisStopped:
|
||||
return SUPERSEDED
|
||||
if preview is None or preview.routing is None:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "지금 노선으로 배수유역을 정하지 못했습니다."},
|
||||
)
|
||||
if superseded():
|
||||
return SUPERSEDED
|
||||
|
||||
live = replace(context, vertices=vertices)
|
||||
to_lonlat = context.to_lonlat
|
||||
routing = live_routing(preview, [(v.x, v.y) for v in vertices], to_lonlat)
|
||||
upstream = [
|
||||
[list(to_lonlat(x, y)) for x, y in line.coords] for line in preview.region.split.upstream
|
||||
]
|
||||
detail, carried = await asyncio.to_thread(
|
||||
_build, context.stored_path, live, points, routing, upstream
|
||||
)
|
||||
if detail is None:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "세부 유역을 나누지 못했습니다."},
|
||||
)
|
||||
result = _payload(project_id, live, detail, carried, False)
|
||||
result["generation"] = generation
|
||||
result["live"] = True
|
||||
return result
|
||||
@@ -0,0 +1,225 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Live.ts
|
||||
* 배수유역 자동/수동 다시 셈(347-2) — 묶음 drainage 단계 · ⚙ 설정 창 · 흐림 + [업데이트] · 구석
|
||||
* 「계산 중」 · 「지금 노선으로 셈」 요청(차례 번호 · 끊기). 규칙은 `logic/B05_Profile_Drainage_Live.ts`.
|
||||
*
|
||||
* - 단계(`stage`)는 계산 묶음(`design_pipeline` · 붙이기 = sub7)이 노선 초안 · 관이 바뀔 때 부름 —
|
||||
* 기다리지 않고 null 을 돌려줌(배수유역은 뒤 단계 · 출력 없음).
|
||||
* - 노선 초안이 있으면 「낡음」 — 자동 = 3 s 조용한 뒤 셈 · 수동 = 칸만 흐림 + 가운데 [업데이트].
|
||||
* - 다시 조작하면 처음으로 — 떠 있는 요청을 끊고(AbortController) 다시 흐림 또는 다시 3 s.
|
||||
* - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림.
|
||||
* - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import type { StageFn } from "../A00_Common/design/design_pipeline_types";
|
||||
import {
|
||||
computeDetailBasins,
|
||||
type DetailBasinResponse,
|
||||
type DetailPipeInput,
|
||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
acceptLive,
|
||||
draftMakesStale,
|
||||
liveRequestBody,
|
||||
parseDrainageMode,
|
||||
QUIET_MS,
|
||||
type DrainageMode,
|
||||
} from "./logic/B05_Profile_Drainage_Live";
|
||||
import type { DetailRunner } from "./B05_Profile_UI_Drainage_Run";
|
||||
import { createDrainageSuggest, type DrainageSuggest } from "./B05_Profile_UI_Drainage_Suggest";
|
||||
|
||||
const MODE_KEY = "drainage-mode";
|
||||
|
||||
export interface DrainageLiveDeps {
|
||||
/** 패널 뿌리 — 머리(⚙) · 지도(흐림 · 구석 「계산 중」)를 이 안에서 찾음. */
|
||||
root: HTMLElement;
|
||||
/** 제안 목록을 이 앞에 둠. */
|
||||
basinList: HTMLElement;
|
||||
pipeRequest: () => DetailPipeInput[];
|
||||
runner: DetailRunner;
|
||||
/** [적용] 답이 화면에 오른 뒤 — 세션 초안에 담음. */
|
||||
rememberPipes: () => void;
|
||||
}
|
||||
|
||||
export interface DrainageLive {
|
||||
/** 계산 묶음 drainage 단계 — 노선 초안 · 관이 바뀐 차례에 부름(출력 없음). */
|
||||
stage: StageFn<"drainage">;
|
||||
/** 낡음을 지금 노선으로 다시 셈(자동 · [업데이트]와 같은 길). */
|
||||
refresh(): void;
|
||||
/** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청 · 기다림도 끊음). */
|
||||
markFresh(): void;
|
||||
readonly stale: boolean;
|
||||
suggest: DrainageSuggest;
|
||||
}
|
||||
|
||||
/** 「지금 노선으로 셈」 — 서버 `post_drainage_live` · 작업본에 안 씀. */
|
||||
async function fetchLive(
|
||||
projectId: string,
|
||||
body: ReturnType<typeof liveRequestBody>,
|
||||
signal: AbortSignal,
|
||||
): Promise<DetailBasinResponse & { status?: string; generation?: number }> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drainage/live`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function readDrainageMode(): DrainageMode {
|
||||
return parseDrainageMode(readStateRaw(MODE_KEY));
|
||||
}
|
||||
|
||||
export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
const viewport = deps.root.querySelector<HTMLElement>(".b05-drainage__viewport") as HTMLElement;
|
||||
const header = deps.root.querySelector<HTMLElement>(".b05-drainage__header") as HTMLElement;
|
||||
const suggest = createDrainageSuggest(deps.pipeRequest, (points) => {
|
||||
const projectId = deps.runner.projectId();
|
||||
if (!projectId) return;
|
||||
void deps.runner
|
||||
.run(() => computeDetailBasins(projectId, points))
|
||||
.then((ok) => ok && deps.rememberPipes());
|
||||
});
|
||||
deps.basinList.before(suggest.root);
|
||||
let mode = readDrainageMode();
|
||||
let route: unknown = null;
|
||||
let stale = false;
|
||||
let generation = 0;
|
||||
let timer = 0;
|
||||
let inflight: AbortController | null = null;
|
||||
|
||||
const veil = document.createElement("div");
|
||||
veil.className = "b05-drainage__veil";
|
||||
veil.hidden = true;
|
||||
const update = document.createElement("button");
|
||||
update.type = "button";
|
||||
update.className = "b05-drainage__analyze b05-drainage__update";
|
||||
update.textContent = "업데이트";
|
||||
update.title = "노선 · 관 자리가 바뀌어 배수유역이 낡았습니다 — 지금 노선으로 다시 셉니다.";
|
||||
veil.append(update);
|
||||
const busy = createProgressCircle({ size: 22, label: "계산 중" });
|
||||
busy.root.classList.add("b05-drainage__busy");
|
||||
busy.root.hidden = true;
|
||||
viewport.append(veil, busy.root);
|
||||
|
||||
const gear = document.createElement("button");
|
||||
gear.type = "button";
|
||||
gear.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__gear";
|
||||
gear.textContent = "⚙";
|
||||
gear.title = "배수유역 계산 설정 — 수동(기본) / 자동";
|
||||
header.append(gear);
|
||||
|
||||
const paint = (): void => {
|
||||
deps.root.classList.toggle("is-stale", stale);
|
||||
veil.hidden = !stale || mode === "auto" || inflight !== null;
|
||||
busy.root.hidden = inflight === null;
|
||||
};
|
||||
|
||||
function cancel(): void {
|
||||
window.clearTimeout(timer);
|
||||
timer = 0;
|
||||
inflight?.abort();
|
||||
inflight = null;
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
const projectId = deps.runner.projectId();
|
||||
if (!projectId) return;
|
||||
cancel();
|
||||
const sent = generation;
|
||||
const controller = new AbortController();
|
||||
inflight = controller;
|
||||
paint();
|
||||
const body = liveRequestBody(sent, route, deps.pipeRequest());
|
||||
void deps.runner
|
||||
.run(async () => {
|
||||
const response = await fetchLive(projectId, body, controller.signal);
|
||||
// 옛 차례 · 서버가 그만둔 답 — 감싸기가 조용히 버리게 끊김으로 돌려줌.
|
||||
if (!acceptLive(response, sent, generation))
|
||||
throw new DOMException("superseded", "AbortError");
|
||||
return response;
|
||||
}, true)
|
||||
.then((applied) => {
|
||||
if (inflight !== controller) return;
|
||||
inflight = null;
|
||||
if (applied) stale = false;
|
||||
paint();
|
||||
});
|
||||
}
|
||||
|
||||
/** 평면 · 관 자리 조작 — 처음으로(요청 끊기 · 다시 흐림 또는 다시 3 s). */
|
||||
function invalidate(): void {
|
||||
generation += 1;
|
||||
cancel();
|
||||
stale = true;
|
||||
if (mode === "auto") timer = window.setTimeout(refresh, QUIET_MS);
|
||||
paint();
|
||||
}
|
||||
|
||||
function openSettings(): void {
|
||||
openModal({
|
||||
title: "배수유역 계산",
|
||||
closeLabel: "닫기",
|
||||
mount(body, close) {
|
||||
const choices: Array<[DrainageMode, string, string]> = [
|
||||
["manual", "수동(기본)", "노선 · 관 자리가 바뀌면 칸만 흐려지고 [업데이트]를 누를 때 셈"],
|
||||
["auto", "자동", "조작이 3초 멈추면 뒤에서 조용히 셈(화면을 막지 않음)"],
|
||||
];
|
||||
choices.forEach(([value, label, hint]) => {
|
||||
const row = document.createElement("label");
|
||||
row.className = "b05-drainage__mode";
|
||||
const input = document.createElement("input");
|
||||
input.type = "radio";
|
||||
input.name = "b05-drainage-mode";
|
||||
input.checked = mode === value;
|
||||
input.addEventListener("change", () => {
|
||||
setMode(value);
|
||||
close();
|
||||
});
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${label} — ${hint}`;
|
||||
row.append(input, text);
|
||||
body.append(row);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setMode(next: DrainageMode): void {
|
||||
mode = next;
|
||||
writeStateRaw(MODE_KEY, next);
|
||||
window.clearTimeout(timer);
|
||||
timer = 0;
|
||||
if (stale && next === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS);
|
||||
paint();
|
||||
}
|
||||
|
||||
update.addEventListener("click", refresh);
|
||||
gear.addEventListener("click", openSettings);
|
||||
|
||||
return {
|
||||
stage(ctx) {
|
||||
route = ctx.inputs.route;
|
||||
if (draftMakesStale(route) || stale) invalidate();
|
||||
return null;
|
||||
},
|
||||
refresh,
|
||||
suggest,
|
||||
markFresh() {
|
||||
cancel();
|
||||
stale = false;
|
||||
paint();
|
||||
},
|
||||
get stale() {
|
||||
return stale;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Dra
|
||||
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
|
||||
import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill";
|
||||
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
|
||||
import { createDetailRunner } from "./B05_Profile_UI_Drainage_Run";
|
||||
import { createDrainageLive } from "./B05_Profile_UI_Drainage_Live";
|
||||
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
|
||||
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
|
||||
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
|
||||
@@ -47,7 +49,6 @@ import {
|
||||
DRAINAGE_LAYERS,
|
||||
fetchDrainageLayers,
|
||||
createProgressReporter,
|
||||
analyzeFailed,
|
||||
fitCanvasToViewport,
|
||||
fitViewToRoute,
|
||||
observeViewportSize,
|
||||
@@ -180,7 +181,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
/** 화면 틀은 판이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 틀은 멈춤(230-2). */
|
||||
let frameView: Window = window;
|
||||
let loadSequence = 0;
|
||||
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
|
||||
/** 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1 · B06 [추가]도 298-1). */
|
||||
const fordWaits = () => fordWaitsOf(projectId);
|
||||
let canvasWidth = 0;
|
||||
@@ -276,6 +276,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
markedChainage,
|
||||
stationIntervalM: MAP_STATION_INTERVAL_M,
|
||||
intervalSpans,
|
||||
candidates: live.suggest.candidates(),
|
||||
});
|
||||
updateImageTransform();
|
||||
}
|
||||
@@ -338,6 +339,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
* — 두 화면이 다른 결과를 보이면 안 되기 때문이다(2026-08-01 일원화). */
|
||||
function apply(response: DetailBasinResponse): void {
|
||||
basins = response.basins;
|
||||
live.suggest.update(response);
|
||||
mainBoundary = response.main_polygon_lonlat ?? [];
|
||||
upstreamLines = (response.upstream_lonlat ?? []) as Array<Array<[number, number]>>;
|
||||
flowArrows = (response.flow_arrows ?? []) as FlowArrow[];
|
||||
@@ -374,32 +376,18 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
syncPipeSelection();
|
||||
}
|
||||
|
||||
/** 세부유역 요청 한 번을 감싼다 — 버튼 잠금·진행 문구·오류 표기를 한 자리에 모은다. */
|
||||
async function run(request: () => Promise<DetailBasinResponse>): Promise<void> {
|
||||
if (!projectId) return;
|
||||
analyzeButton.disabled = true;
|
||||
status.hidden = false;
|
||||
status.textContent = L("B05_Drainage_Status_Analyzing");
|
||||
showProgress(null, L("B05_Drainage_Status_Analyzing"));
|
||||
const seq = ++requestSeq;
|
||||
try {
|
||||
const response = await request();
|
||||
if (seq !== requestSeq) return; // 뒤에 보낸 요청이 있음 — 그 응답을 기다림
|
||||
apply(response);
|
||||
status.hidden = basins.length > 0;
|
||||
if (!basins.length) status.textContent = response.stale ?? L("B05_Drainage_Status_NoBasin");
|
||||
scheduleDraw();
|
||||
} catch (error) {
|
||||
if (seq !== requestSeq) return;
|
||||
status.hidden = false;
|
||||
status.textContent = analyzeFailed(error, L("B05_Drainage_Status_AnalyzeFailed"));
|
||||
} finally {
|
||||
if (seq === requestSeq) {
|
||||
analyzeButton.disabled = false;
|
||||
showProgress(null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 세부유역 요청 한 번 — 버튼 잠금 · 진행 문구 · 오류 · 최신 응답만(347-2 에 나눔). */
|
||||
const runner = createDetailRunner({
|
||||
projectId: () => projectId,
|
||||
analyzeButton,
|
||||
status,
|
||||
showProgress,
|
||||
apply,
|
||||
basinCount: () => basins.length,
|
||||
scheduleDraw,
|
||||
label: L,
|
||||
});
|
||||
const { run } = runner;
|
||||
|
||||
/** 지금 관 목록 + 시설 정보 — 저장 · 재계산 · 세션 초안 요청 몸통이 모두 이것이다. */
|
||||
const pipeRequest = () =>
|
||||
@@ -418,7 +406,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
|
||||
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이
|
||||
* 있으면 그 관 목록으로 세운다 — B06 을 다녀와도 지정 · 옵션이 옛값으로 안 돌아감(21-10 흠 3). */
|
||||
const loadSaved = (): Promise<void> =>
|
||||
const loadSaved = (): Promise<boolean> =>
|
||||
run(() => {
|
||||
const pending = readPendingPipes(projectId as string);
|
||||
return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감
|
||||
@@ -428,12 +416,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
|
||||
/** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때
|
||||
* 옛 관이 살아난다(2026-08-02 사용자 보고). */
|
||||
const resetPipes = (): Promise<void> => run(() => resetDetailPipePoints(projectId as string));
|
||||
const resetPipes = (): Promise<boolean> => run(() => resetDetailPipePoints(projectId as string));
|
||||
|
||||
/** 세부유역을 다시 나눈다(격자 해석 없음) — 같은 틱의 여러 부름은 한 번(342-3 · 옛 소수 관 옮김).
|
||||
* 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */
|
||||
const analyze = batched(() => run(() => computeDetailBasins(projectId as string, pipeRequest())));
|
||||
|
||||
// 자동/수동 다시 셈(347-2) · 제안 목록(347-3 · [적용] = 관 초안으로 세부 유역 다시 나눔).
|
||||
const live = createDrainageLive({ root, basinList, pipeRequest, runner, rememberPipes });
|
||||
analyzeButton.addEventListener("click", () => void analyze());
|
||||
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
|
||||
autoButton.addEventListener("click", () => void resetPipes());
|
||||
@@ -668,10 +658,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
syncPipeSelection(notify);
|
||||
scheduleDraw();
|
||||
},
|
||||
async savePipes() {
|
||||
async savePipes(confirm = false) {
|
||||
if (!projectId) return 0;
|
||||
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
|
||||
const response = await saveDetailPipePoints(projectId, pipeRequest());
|
||||
const response = await saveDetailPipePoints(projectId, pipeRequest(), confirm);
|
||||
if (confirm && !response.stale) live.markFresh();
|
||||
apply(response);
|
||||
scheduleDraw();
|
||||
return response.pipe_count;
|
||||
@@ -695,5 +686,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
widthResizer.dispose();
|
||||
},
|
||||
rehome: () => resizeObserver.rehome(),
|
||||
pipelineStage: live.stage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
|
||||
import type { StageFn } from "../A00_Common/design/design_pipeline_types";
|
||||
|
||||
export interface DrainagePanel {
|
||||
root: HTMLElement;
|
||||
@@ -40,7 +41,10 @@ export interface DrainagePanel {
|
||||
) => void;
|
||||
removePipe: (chainageM: number) => void;
|
||||
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
|
||||
savePipes: () => Promise<number>;
|
||||
/** [확정](confirm) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈 · [저장] = 낡음 달고 저장. */
|
||||
savePipes: (confirm?: boolean) => Promise<number>;
|
||||
/** 계산 묶음 drainage 단계(347-2) — 붙이기 = 묶음 쪽. */
|
||||
pipelineStage: StageFn<"drainage">;
|
||||
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
|
||||
selectBasinByChainage: (chainageM: number | null) => void;
|
||||
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
|
||||
|
||||
@@ -61,8 +61,8 @@ export interface PipeEditor {
|
||||
* 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시).
|
||||
* `onMove`는 끌기를 마친 관의 옛 · 새 측점 — `onCommit` 앞에 불러 시설 속성이 관을 따라감. */
|
||||
/** 같은 틱에 여러 번 불러도 한 번만 돈다 — 옛 소수 관을 한꺼번에 옮겨도 세부유역 요청은 한 번(342-3). */
|
||||
export function batched(work: () => Promise<void>): () => Promise<void> {
|
||||
let queued: Promise<void> | null = null;
|
||||
export function batched<T>(work: () => Promise<T>): () => Promise<T> {
|
||||
let queued: Promise<T> | null = null;
|
||||
return () =>
|
||||
(queued ??= Promise.resolve().then(() => {
|
||||
queued = null;
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface DrainageScene {
|
||||
stationIntervalM: number;
|
||||
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */
|
||||
intervalSpans: ReadonlyArray<RouteSpanBand>;
|
||||
/** 관 자리 후보 누가거리(347-3 · 제안만) — 계획선 위 점선 원. */
|
||||
candidates: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
export function drawDrainageScene(
|
||||
@@ -168,6 +170,20 @@ export function drawDrainageScene(
|
||||
scene.maxHotspotArea,
|
||||
);
|
||||
}
|
||||
// 관 자리 후보 — 점선 원(배관 마커 아래 · 제안만이라 채우지 않음).
|
||||
scene.candidates.forEach((chainage) => {
|
||||
const sample = scene.strengthSamples[Math.round(chainage)];
|
||||
if (!sample) return;
|
||||
const [x, y] = projector.toScreen(sample.x, sample.y);
|
||||
context.save();
|
||||
context.setLineDash([3, 3]);
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "#1e88e5";
|
||||
context.beginPath();
|
||||
context.arc(x, y, 9, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
});
|
||||
// 배관(관 매설) 마커 — 계획선 위.
|
||||
scene.pipeEditor.draw(context, view, scene.pipeColor);
|
||||
// 측점 선택 마킹 — 계획선 위 다이아몬드 표식(2026-08-05 사용자 지시. 3자 선택 동기화의
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Run.ts
|
||||
* 세부유역 요청 한 번 감싸기 — 버튼 잠금 · 진행 문구 · 오류 표기 · 최신 응답만 채택(차례 번호).
|
||||
*
|
||||
* 패널 본체(B05_Profile_UI_Drainage_Panel)가 700줄이라 나눔(347-2). 「지금 노선으로 셈」 답도
|
||||
* 같은 차례 번호를 타서, 늦게 온 세부유역 답이 새 답을 덮지 않음 — `quiet` 이면 버튼 · 가운데 진행
|
||||
* 원 · 상태 글을 안 건드림(칸 구석 「계산 중」은 부르는 쪽 · 조작을 막지 않음).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DetailBasinResponse } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { analyzeFailed } from "./B05_Profile_UI_Drainage_Parts";
|
||||
|
||||
type StatusKey =
|
||||
| "B05_Drainage_Status_Analyzing"
|
||||
| "B05_Drainage_Status_NoBasin"
|
||||
| "B05_Drainage_Status_AnalyzeFailed";
|
||||
|
||||
export interface DetailRunnerDeps {
|
||||
projectId: () => string | null;
|
||||
analyzeButton: HTMLButtonElement;
|
||||
status: HTMLElement;
|
||||
showProgress: (ratio: number | null, label: string | null) => void;
|
||||
apply: (response: DetailBasinResponse) => void;
|
||||
basinCount: () => number;
|
||||
scheduleDraw: () => void;
|
||||
label: (key: StatusKey) => string;
|
||||
}
|
||||
|
||||
export interface DetailRunner {
|
||||
projectId: () => string | null;
|
||||
/** 요청 하나 — 답을 화면에 올렸으면 true(뒤에 보낸 요청이 있으면 false). */
|
||||
run(request: () => Promise<DetailBasinResponse>, quiet?: boolean): Promise<boolean>;
|
||||
}
|
||||
|
||||
export function createDetailRunner(deps: DetailRunnerDeps): DetailRunner {
|
||||
const { analyzeButton, status, showProgress, label } = deps;
|
||||
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
|
||||
return {
|
||||
projectId: deps.projectId,
|
||||
async run(request, quiet = false) {
|
||||
if (!deps.projectId()) return false;
|
||||
if (!quiet) {
|
||||
analyzeButton.disabled = true;
|
||||
status.hidden = false;
|
||||
status.textContent = label("B05_Drainage_Status_Analyzing");
|
||||
showProgress(null, label("B05_Drainage_Status_Analyzing"));
|
||||
}
|
||||
const seq = ++requestSeq;
|
||||
try {
|
||||
const response = await request();
|
||||
if (seq !== requestSeq) return false; // 뒤에 보낸 요청이 있음 — 그 응답을 기다림
|
||||
deps.apply(response);
|
||||
status.hidden = deps.basinCount() > 0;
|
||||
if (!deps.basinCount())
|
||||
status.textContent = response.stale ?? label("B05_Drainage_Status_NoBasin");
|
||||
deps.scheduleDraw();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (seq !== requestSeq) return false;
|
||||
if (error instanceof DOMException && error.name === "AbortError") return false;
|
||||
status.hidden = false;
|
||||
status.textContent = analyzeFailed(error, label("B05_Drainage_Status_AnalyzeFailed"));
|
||||
return false;
|
||||
} finally {
|
||||
if (seq === requestSeq) {
|
||||
analyzeButton.disabled = false;
|
||||
showProgress(null, null);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Suggest.ts
|
||||
* 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류 · 관 자리 후보를 보여 주고 [적용] 한 번에 관 초안으로.
|
||||
* 모으기 · 얹기 규칙은 `logic/B05_Profile_Drainage_Suggest.ts` · 자동 바꿈 없음.
|
||||
* 관 자리 후보는 배수유역도에도 점선 원으로 그림(`candidates()` → 그리기 판).
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
DetailBasinResponse,
|
||||
DetailPipeInput,
|
||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
applySuggestions,
|
||||
collectSuggestions,
|
||||
type Suggestion,
|
||||
} from "./logic/B05_Profile_Drainage_Suggest";
|
||||
|
||||
const FACILITY_NAME: Record<string, string> = {
|
||||
pipe: "배관",
|
||||
box_culvert: "BOX암거",
|
||||
ford_bridge: "세월교",
|
||||
};
|
||||
|
||||
const facilityName = (id: string): string => FACILITY_NAME[id] ?? id;
|
||||
|
||||
/** 제안 한 줄 글. */
|
||||
export function suggestionText(item: Suggestion): string {
|
||||
const at = `측점 ${Math.round(item.chainageM)} m`;
|
||||
if (item.kind === "position") return `${at} — 관 자리 후보(흐름이 모이는 오목 자리)`;
|
||||
if (item.kind === "facility")
|
||||
return `${at} — ${facilityName(item.from)} → ${facilityName(item.to)} 검토`;
|
||||
return `${at} — 관경 ${item.from === null ? "미정" : `Ø${item.from}`} → Ø${item.to}`;
|
||||
}
|
||||
|
||||
export interface DrainageSuggest {
|
||||
root: HTMLElement;
|
||||
update(response: DetailBasinResponse): void;
|
||||
/** 관 자리 후보 누가거리 — 배수유역도 점선 원. */
|
||||
candidates(): number[];
|
||||
}
|
||||
|
||||
export function createDrainageSuggest(
|
||||
pipeRequest: () => DetailPipeInput[],
|
||||
applyPoints: (points: DetailPipeInput[]) => void,
|
||||
): DrainageSuggest {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-drainage__suggest";
|
||||
root.hidden = true;
|
||||
const head = document.createElement("div");
|
||||
head.className = "b05-drainage__suggest-head";
|
||||
const title = document.createElement("span");
|
||||
const apply = document.createElement("button");
|
||||
apply.type = "button";
|
||||
apply.className = "b05-drainage__analyze";
|
||||
apply.textContent = "적용";
|
||||
apply.title =
|
||||
"제안을 관 초안에 한 번에 얹습니다(관 더함 · 관경 · 시설 바꿈) — [저장] 때 남습니다.";
|
||||
head.append(title, apply);
|
||||
const list = document.createElement("ul");
|
||||
list.className = "b05-drainage__suggest-list";
|
||||
root.append(head, list);
|
||||
let suggestions: Suggestion[] = [];
|
||||
|
||||
apply.addEventListener("click", () => {
|
||||
if (suggestions.length) applyPoints(applySuggestions(pipeRequest(), suggestions));
|
||||
});
|
||||
|
||||
return {
|
||||
root,
|
||||
update(response) {
|
||||
suggestions = response.stale
|
||||
? []
|
||||
: collectSuggestions(response.pipe_points, response.basins, response.pipe_candidates ?? []);
|
||||
root.hidden = suggestions.length === 0;
|
||||
title.textContent = `제안 ${suggestions.length}건`;
|
||||
list.replaceChildren(
|
||||
...suggestions.map((item) => {
|
||||
const row = document.createElement("li");
|
||||
row.textContent = suggestionText(item);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
},
|
||||
candidates: () =>
|
||||
suggestions.filter((item) => item.kind === "position").map((item) => item.chainageM),
|
||||
};
|
||||
}
|
||||
@@ -266,3 +266,70 @@
|
||||
.b05-drainage[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 배수유역 낡음(347-2) — 칸만 흐림 · 가운데 [업데이트] · 구석 「계산 중」(조작은 막지 않음). */
|
||||
.b05-drainage.is-stale .b05-drainage__canvas,
|
||||
.b05-drainage.is-stale .b05-drainage__image,
|
||||
.b05-drainage.is-stale .b05-drainage__summary,
|
||||
.b05-drainage.is-stale .b05-drainage__basins,
|
||||
.b05-drainage.is-stale .b05-drainage__suggest {
|
||||
filter: blur(2px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.b05-drainage__veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-drainage__veil[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-drainage__veil .b05-drainage__update {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.b05-drainage__busy {
|
||||
position: absolute;
|
||||
right: var(--spacing-8);
|
||||
bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-drainage__busy[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-drainage__mode {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
padding: var(--spacing-8) 0;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
/* 제안 목록(347-3) — 유역 목록 위 · [적용] 한 번. */
|
||||
.b05-drainage__suggest {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: var(--spacing-8);
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b05-drainage__suggest-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.b05-drainage__suggest-list {
|
||||
max-height: 6em;
|
||||
margin: var(--spacing-8) 0 0;
|
||||
padding-left: 1.2em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Drainage_Live.ts
|
||||
* 배수유역 자동/수동 다시 셈(347-2) — 순수 규칙만. 화면 · fetch 는 `B05_Profile_UI_Drainage_Live.ts`.
|
||||
*
|
||||
* - 대상 = 평면(노선 초안) · 관 자리만 — 종단 · 횡단 조작은 배수유역 결과를 안 바꿈(묶음 deps 와 같음).
|
||||
* - 노선 초안이 없으면(저장 노선 그대로) 낡음 아님 — 관 조작은 세부 유역 나눔(0.2 s)이 바로 맞춤.
|
||||
* - 자동 = 조용한 뒤 `QUIET_MS` · 수동 = 흐림 + [업데이트] · 기본 수동.
|
||||
* - 답 채택 = 보낸 차례가 지금 차례와 같고 서버가 그만두지 않았을 때만(늦은 답 버림).
|
||||
* ========================================================================== */
|
||||
|
||||
export type DrainageMode = "manual" | "auto";
|
||||
|
||||
/** 자동 모드 — 마지막 조작 뒤 이만큼 조용하면 셈(ms · 341 결정 거리 6 · 3 초). */
|
||||
export const QUIET_MS = 3000;
|
||||
|
||||
/** 계정 화면 설정 글자 → 모드(없거나 모르는 값 = 수동). */
|
||||
export const parseDrainageMode = (raw: string | null | undefined): DrainageMode =>
|
||||
raw === "auto" ? "auto" : "manual";
|
||||
|
||||
/** 묶음 drainage 단계 입력 → 낡았는가 — 노선 초안이 있을 때만. */
|
||||
export const draftMakesStale = (route: unknown): boolean => route !== null && route !== undefined;
|
||||
|
||||
/** 「지금 노선으로 셈」 몸통 — 서버 `post_drainage_live`. */
|
||||
export function liveRequestBody<P>(
|
||||
generation: number,
|
||||
route: unknown,
|
||||
points: P[],
|
||||
): { generation: number; route: unknown; points: P[] } {
|
||||
return { generation, route: route ?? null, points };
|
||||
}
|
||||
|
||||
/** 답을 화면에 올리는가 — 서버가 그만둔 답 · 옛 차례 답은 버림. */
|
||||
export function acceptLive(
|
||||
response: { status?: string; generation?: number },
|
||||
sentGeneration: number,
|
||||
currentGeneration: number,
|
||||
): boolean {
|
||||
return (
|
||||
response.status !== "superseded" &&
|
||||
sentGeneration === currentGeneration &&
|
||||
(response.generation === undefined || response.generation === sentGeneration)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Drainage_Suggest.ts
|
||||
* 배수유역 제안(347-3) — 관경 · 시설 종류 · 관 자리 후보를 모으고 [적용] 때 관 초안에 한 번에 얹음.
|
||||
* 순수 함수만(화면 = `B05_Profile_UI_Drainage_Suggest.ts`).
|
||||
*
|
||||
* - 관경 = 유역 추천이 배관이고 추천 관경이 지금 관경과 다를 때(「Ø→Ø 배관 제안」).
|
||||
* - 시설 = 유역 추천이 BOX암거 · 세월교이고 지금 관이 그 종류가 아닐 때.
|
||||
* - 관 자리 = 서버 `suggest_pipe_positions`(간격 보충 규칙 · 기존 관에서 먼 곳).
|
||||
* - 자동 바꿈 없음 — 모으기만 하고 바꾸는 것은 `applySuggestions`([적용])뿐.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 관 자리가 같은 유역으로 볼 거리(m) — 화면 `PIPE_MATCH_M` 과 같은 값. */
|
||||
const SAME_PIPE_M = 0.5;
|
||||
|
||||
interface PipeLike {
|
||||
chainage_m: number;
|
||||
source?: string;
|
||||
facility?: string;
|
||||
options?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
interface BasinLike {
|
||||
chainage_m: number;
|
||||
recommended_facility?: string;
|
||||
recommended_diameter_mm?: number | null;
|
||||
}
|
||||
|
||||
export type Suggestion =
|
||||
| { kind: "diameter"; chainageM: number; from: number | null; to: number }
|
||||
| { kind: "facility"; chainageM: number; from: string; to: string }
|
||||
| { kind: "position"; chainageM: number };
|
||||
|
||||
const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_PIPE_M;
|
||||
|
||||
/** 응답(유역 · 관 자리 후보) + 지금 관 목록 → 제안 목록(관 자리 차례). */
|
||||
export function collectSuggestions(
|
||||
pipes: ReadonlyArray<PipeLike>,
|
||||
basins: ReadonlyArray<BasinLike>,
|
||||
candidates: ReadonlyArray<{ chainage_m: number }>,
|
||||
): Suggestion[] {
|
||||
const found: Suggestion[] = [];
|
||||
basins.forEach((basin) => {
|
||||
const pipe = pipes.find((item) => near(item.chainage_m, basin.chainage_m));
|
||||
if (!pipe) return;
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
const want = basin.recommended_facility ?? "pipe";
|
||||
if (want !== "pipe" && facility !== want) {
|
||||
found.push({ kind: "facility", chainageM: pipe.chainage_m, from: facility, to: want });
|
||||
return;
|
||||
}
|
||||
const to = basin.recommended_diameter_mm;
|
||||
const raw = pipe.options?.pipe_diameter_mm;
|
||||
const from = raw === undefined || raw === "" ? null : Number(raw);
|
||||
if (want === "pipe" && facility === "pipe" && to != null && from !== to)
|
||||
found.push({ kind: "diameter", chainageM: pipe.chainage_m, from, to });
|
||||
});
|
||||
candidates
|
||||
.filter((item) => !pipes.some((pipe) => near(pipe.chainage_m, item.chainage_m)))
|
||||
.forEach((item) => found.push({ kind: "position", chainageM: item.chainage_m }));
|
||||
return found.sort((a, b) => a.chainageM - b.chainageM);
|
||||
}
|
||||
|
||||
/** [적용] — 제안을 관 초안에 한 번에(관 더함 · 관경 · 시설 바꿈). 입력 목록은 안 고침. */
|
||||
export function applySuggestions<P extends PipeLike>(
|
||||
pipes: ReadonlyArray<P>,
|
||||
suggestions: ReadonlyArray<Suggestion>,
|
||||
): P[] {
|
||||
const next = pipes.map(
|
||||
(pipe) => ({ ...pipe, options: pipe.options && { ...pipe.options } }) as P,
|
||||
);
|
||||
suggestions.forEach((item) => {
|
||||
if (item.kind === "position") {
|
||||
if (!next.some((pipe) => near(pipe.chainage_m, item.chainageM)))
|
||||
next.push({ chainage_m: item.chainageM, source: "spacing" } as P);
|
||||
return;
|
||||
}
|
||||
const pipe = next.find((candidate) => near(candidate.chainage_m, item.chainageM));
|
||||
if (!pipe) return;
|
||||
if (item.kind === "facility") pipe.facility = item.to;
|
||||
else pipe.options = { ...(pipe.options ?? {}), pipe_diameter_mm: item.to };
|
||||
});
|
||||
return next.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -36,10 +35,11 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import find_inflow_h
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import polygon_parts, polygonize_labels
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PIPE_FACILITY_BOX,
|
||||
PIPE_FACILITY_FORD_BRIDGE,
|
||||
PIPE_FACILITY_PIPE,
|
||||
from common_util.common_util_drainage_sizing import ( # noqa: F401 — 옛 자리에서 읽는 곳
|
||||
estimate_pipe_diameter_mm,
|
||||
load_rainfall_idf,
|
||||
recommend_structure,
|
||||
size_pipe,
|
||||
)
|
||||
from common_util.common_util_route_geometry import (
|
||||
RouteVertex,
|
||||
@@ -48,24 +48,11 @@ from common_util.common_util_route_geometry import (
|
||||
is_uphill_at,
|
||||
)
|
||||
from common_util.common_util_structure_station import snap_candidates
|
||||
from common_util.common_util_wamis_rainfall import idf_intensity
|
||||
from config.config_system import (
|
||||
DRAINAGE_BOX_THRESHOLD_MM,
|
||||
DRAINAGE_BRIDGE_THRESHOLD_MM,
|
||||
DRAINAGE_DESIGN_FLOW_FACTOR,
|
||||
DRAINAGE_DITCH_SAMPLE_M,
|
||||
DRAINAGE_FLOW_AREA_RATIO,
|
||||
DRAINAGE_MANNING_N,
|
||||
DRAINAGE_PIPE_MAX_SPACING_M,
|
||||
DRAINAGE_PIPE_MIN_SPACING_M,
|
||||
DRAINAGE_PIPE_SLOPE_DEG,
|
||||
DRAINAGE_RAINFALL_FILENAME,
|
||||
DRAINAGE_RECOMMEND_DIAMETERS_MM,
|
||||
DRAINAGE_RUNOFF_COEFFICIENT,
|
||||
DRAINAGE_TC_MIN_MINUTES,
|
||||
DRAINAGE_VELOCITY_MAX_MS,
|
||||
DRAINAGE_VELOCITY_MIN_MS,
|
||||
PIPE_DEFAULT_DIAMETER_MM,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -98,6 +85,8 @@ class DrainageDetail:
|
||||
strength_profile: list[list[float]] = field(default_factory=list)
|
||||
# 유입 집중점 — [누가거리, 유입면적, 구역번호, 구역 내 순위]. 관 자리를 판단하는 근거.
|
||||
inflow_hotspots: list[list[float]] = field(default_factory=list)
|
||||
# 관 자리 후보(347-3) — 관 사이가 최대 간격을 넘는 구간의 흐름 세기 + 오목 자리 · 제안만.
|
||||
pipe_candidates: list[StructureCandidate] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -176,18 +165,23 @@ def build_detail(
|
||||
directory: Path,
|
||||
vertices: list[RouteVertex],
|
||||
confirmed_chainages: list[float] | None = None,
|
||||
routing: RoadRouting | None = None,
|
||||
upstream_lonlat: list[list[list[float]]] | None = None,
|
||||
) -> DrainageDetail | None:
|
||||
"""B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다.
|
||||
|
||||
`confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집 · 빈 목록 = 관 0개), None 이면
|
||||
B04의 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다.
|
||||
`routing` · `upstream_lonlat` 을 주면 파일 대신 그것(「지금 노선으로 셈」 메모리 결과 · 347-2).
|
||||
"""
|
||||
routing = read_road_routing(directory)
|
||||
routing = routing or read_road_routing(directory)
|
||||
if routing is None or len(vertices) < 2:
|
||||
return None
|
||||
|
||||
candidates: list[StructureCandidate] = []
|
||||
if confirmed_chainages is not None:
|
||||
pipes = pipes_from_chainages(vertices, confirmed_chainages)
|
||||
candidates = suggest_pipe_positions(vertices, pipes, routing.strength_curve)
|
||||
else:
|
||||
# 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다.
|
||||
base = [
|
||||
@@ -209,7 +203,9 @@ def build_detail(
|
||||
grid_cell_m=routing.spec.cell_m,
|
||||
flow_arrows=routing.flow_arrows,
|
||||
arrow_spacing_m=routing.arrow_spacing_m,
|
||||
upstream_lonlat=read_upstream_lines(directory),
|
||||
upstream_lonlat=read_upstream_lines(directory)
|
||||
if upstream_lonlat is None
|
||||
else upstream_lonlat,
|
||||
strength_profile=build_strength_profile(routing),
|
||||
inflow_hotspots=[
|
||||
[chainage, area, float(zone), float(rank)]
|
||||
@@ -219,6 +215,7 @@ def build_detail(
|
||||
vertices[-1].chainage_m,
|
||||
)
|
||||
],
|
||||
pipe_candidates=candidates,
|
||||
)
|
||||
if not pipes:
|
||||
return detail
|
||||
@@ -475,6 +472,24 @@ def _best_position(
|
||||
return float(positions[int(np.argmax(score))])
|
||||
|
||||
|
||||
def suggest_pipe_positions(
|
||||
vertices: list[RouteVertex],
|
||||
pipes: list[StructureCandidate],
|
||||
strength_curve: np.ndarray,
|
||||
) -> list[StructureCandidate]:
|
||||
"""관 자리 후보(347-3) — 지금 관 사이가 최대 간격을 넘는 구간만 간격 보충 규칙으로 고른 자리.
|
||||
|
||||
`_best_position`(흐름 세기 + 오목 · 성토 덜 선호) · 정수 m · 관 목록엔 안 넣음(제안).
|
||||
"""
|
||||
taken = [pipe.chainage_m for pipe in pipes]
|
||||
filled = snap_candidates(place_pipes(vertices, pipes, strength_curve), vertices)
|
||||
return [
|
||||
pipe
|
||||
for pipe in filled
|
||||
if pipe.reason == "spacing" and all(abs(pipe.chainage_m - other) > 0.5 for other in taken)
|
||||
]
|
||||
|
||||
|
||||
def pipes_from_chainages(
|
||||
vertices: list[RouteVertex], chainages: list[float]
|
||||
) -> list[StructureCandidate]:
|
||||
@@ -610,112 +625,3 @@ def _outlet_elevation(routing: RoadRouting, pipe_order: int, pipe_of_slot: np.nd
|
||||
elevations = routing.elevation[routing.road_cell_index[slots]]
|
||||
finite = elevations[np.isfinite(elevations)]
|
||||
return float(finite.min()) if finite.size else 0.0
|
||||
|
||||
|
||||
def load_rainfall_idf(directory: Path) -> dict[str, float] | None:
|
||||
"""배수유역 폴더의 rainfall_table.json에서 설계빈도 IDF 적합계수를 읽는다.
|
||||
|
||||
파일은 B04 전처리(`/drainage/rainfall`)가 만든다. 없으면 None — 유효직경은
|
||||
"미정"으로 남고, 강우량표가 생기는 순간 다음 세부유역 계산부터 채워진다.
|
||||
"""
|
||||
path = Path(directory) / DRAINAGE_RAINFALL_FILENAME
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
table = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("강우량표를 읽지 못했습니다 (%s) — 유효직경 미산출.", path)
|
||||
return None
|
||||
idf = table.get("idf_design")
|
||||
if not isinstance(idf, dict) or not all(k in idf for k in ("a", "b", "n")):
|
||||
return None
|
||||
return idf
|
||||
|
||||
|
||||
def size_pipe(
|
||||
area_m2: float,
|
||||
relief_m: float,
|
||||
flow_length_m: float,
|
||||
idf: dict[str, float] | None,
|
||||
) -> dict[str, float] | None:
|
||||
"""유역 제원 + 설계빈도 IDF로 배수 유효직경(mm)과 산출 근거를 계산한다.
|
||||
|
||||
시행규칙 별표2 (가) 1호 방법: 100년빈도 확률강우량 + 홍수도달시간 → 합리식.
|
||||
전 과정·계수 근거: docs/raw/guidelines/2026-08-05_임도_배수설계_규정_조사.md 4·6절.
|
||||
|
||||
① 도달시간 tc = 0.0663·L^0.77·(H/L)^-0.385 [Kirpich, hr] — 하한 5분
|
||||
② 강우강도 I = General형 적합식 I(tc) [mm/hr]
|
||||
③ 합리식 Q = (1/3.6)·C·I·A[km²], 설계유량 = 2.0·Q (별표2)
|
||||
④ 유속 V = Manning(경사 10도 고정, n=0.024) → 0.8~3.0m/s 클램프
|
||||
⑤ 통수단면 70%만 유효 → D = √(4·(Q설계/V)/(0.7π))
|
||||
"""
|
||||
if idf is None or area_m2 <= 0:
|
||||
return None
|
||||
length_km = max(flow_length_m, 1.0) / 1000.0
|
||||
slope = max(relief_m, 0.1) / max(flow_length_m, 1.0)
|
||||
tc_hr = 0.0663 * (length_km**0.77) * (slope**-0.385)
|
||||
tc_min = max(DRAINAGE_TC_MIN_MINUTES, tc_hr * 60.0)
|
||||
intensity = idf_intensity(idf, tc_min)
|
||||
flow = DRAINAGE_RUNOFF_COEFFICIENT * intensity * (area_m2 / 1e6) / 3.6
|
||||
design_flow = DRAINAGE_DESIGN_FLOW_FACTOR * flow
|
||||
if design_flow <= 0:
|
||||
return None
|
||||
pipe_slope = math.tan(math.radians(DRAINAGE_PIPE_SLOPE_DEG))
|
||||
diameter = 0.5
|
||||
for _ in range(20):
|
||||
velocity = (
|
||||
(1.0 / DRAINAGE_MANNING_N) * (diameter / 4.0) ** (2.0 / 3.0) * math.sqrt(pipe_slope)
|
||||
)
|
||||
velocity = min(DRAINAGE_VELOCITY_MAX_MS, max(DRAINAGE_VELOCITY_MIN_MS, velocity))
|
||||
required_area = design_flow / velocity
|
||||
updated = math.sqrt(4.0 * required_area / (DRAINAGE_FLOW_AREA_RATIO * math.pi))
|
||||
if abs(updated - diameter) < 1e-4:
|
||||
diameter = updated
|
||||
break
|
||||
diameter = updated
|
||||
return {
|
||||
"tc_minutes": round(tc_min, 1),
|
||||
"intensity_mm_hr": round(intensity, 1),
|
||||
"design_flow_m3s": round(design_flow, 4),
|
||||
"diameter_mm": round(diameter * 1000.0, 1),
|
||||
# 물넘이·세월교는 관이 아니라 개수로라 직경 대신 이 단면적에서 출발한다.
|
||||
"required_area_m2": round(required_area, 4),
|
||||
}
|
||||
|
||||
|
||||
def recommend_structure(diameter_mm: float | None) -> tuple[str, int | None]:
|
||||
"""유효직경으로 추천 구조물과 관경을 고른다 (2026-08-17 사용자 확정: 유량 근거만).
|
||||
|
||||
지식DB 선정 트리(개거_세월시설 §5·횡단배수관_암거 §5)에서 유량 조건만 취했다 —
|
||||
계곡 횡단경사·하천 차수는 지형 계산이 필요해 화면이 "현장 확인"으로 안내한다.
|
||||
|
||||
D ≤ 1,500㎜ 배관 (레지스트리 선택지로 스냅, 하한 800㎜)
|
||||
1,500 < D ≤ 2,000 BOX암거 후보
|
||||
D > 2,000㎜ 세월교·물넘이 검토 (관 최대 규격 초과)
|
||||
|
||||
유효직경을 못 구한 유역(강우량표 없음)은 배관·관경 미정으로 둔다.
|
||||
"""
|
||||
if diameter_mm is None:
|
||||
return PIPE_FACILITY_PIPE, None
|
||||
if diameter_mm > DRAINAGE_BRIDGE_THRESHOLD_MM:
|
||||
return PIPE_FACILITY_FORD_BRIDGE, None
|
||||
if diameter_mm > DRAINAGE_BOX_THRESHOLD_MM:
|
||||
return PIPE_FACILITY_BOX, None
|
||||
# 별표2 (나) 예외 하한 800㎜ — 계산값이 더 작아도 그 아래로는 내리지 않는다.
|
||||
need = max(float(PIPE_DEFAULT_DIAMETER_MM), diameter_mm)
|
||||
size = next(
|
||||
(value for value in DRAINAGE_RECOMMEND_DIAMETERS_MM if value >= need),
|
||||
DRAINAGE_RECOMMEND_DIAMETERS_MM[-1],
|
||||
)
|
||||
return PIPE_FACILITY_PIPE, int(size)
|
||||
|
||||
|
||||
def estimate_pipe_diameter_mm(
|
||||
area_m2: float,
|
||||
relief_m: float,
|
||||
flow_length_m: float,
|
||||
idf: dict[str, float] | None = None,
|
||||
) -> float | None:
|
||||
"""유역 제원으로 배수 유효직경(mm)만 돌려주는 축약형 — 상세는 size_pipe()."""
|
||||
sizing = size_pipe(area_m2, relief_m, flow_length_m, idf)
|
||||
return sizing["diameter_mm"] if sizing else None
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""배수 관경 산정 — 강우량표 읽기 · 합리식 유효직경 · 추천 구조물.
|
||||
|
||||
세부 유역 엔진(`common_util_drainage_detail` · 700줄로 나눔)이 유역 제원으로 부름 ·
|
||||
이름은 그 파일에서도 그대로 읽힘.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PIPE_FACILITY_BOX,
|
||||
PIPE_FACILITY_FORD_BRIDGE,
|
||||
PIPE_FACILITY_PIPE,
|
||||
)
|
||||
from common_util.common_util_wamis_rainfall import idf_intensity
|
||||
from config.config_system import (
|
||||
DRAINAGE_BOX_THRESHOLD_MM,
|
||||
DRAINAGE_BRIDGE_THRESHOLD_MM,
|
||||
DRAINAGE_DESIGN_FLOW_FACTOR,
|
||||
DRAINAGE_FLOW_AREA_RATIO,
|
||||
DRAINAGE_MANNING_N,
|
||||
DRAINAGE_PIPE_SLOPE_DEG,
|
||||
DRAINAGE_RAINFALL_FILENAME,
|
||||
DRAINAGE_RECOMMEND_DIAMETERS_MM,
|
||||
DRAINAGE_RUNOFF_COEFFICIENT,
|
||||
DRAINAGE_TC_MIN_MINUTES,
|
||||
DRAINAGE_VELOCITY_MAX_MS,
|
||||
DRAINAGE_VELOCITY_MIN_MS,
|
||||
PIPE_DEFAULT_DIAMETER_MM,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_rainfall_idf(directory: Path) -> dict[str, float] | None:
|
||||
"""배수유역 폴더의 rainfall_table.json에서 설계빈도 IDF 적합계수를 읽는다.
|
||||
|
||||
파일은 B04 전처리(`/drainage/rainfall`)가 만든다. 없으면 None — 유효직경은
|
||||
"미정"으로 남고, 강우량표가 생기는 순간 다음 세부유역 계산부터 채워진다.
|
||||
"""
|
||||
path = Path(directory) / DRAINAGE_RAINFALL_FILENAME
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
table = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("강우량표를 읽지 못했습니다 (%s) — 유효직경 미산출.", path)
|
||||
return None
|
||||
idf = table.get("idf_design")
|
||||
if not isinstance(idf, dict) or not all(k in idf for k in ("a", "b", "n")):
|
||||
return None
|
||||
return idf
|
||||
|
||||
|
||||
def size_pipe(
|
||||
area_m2: float,
|
||||
relief_m: float,
|
||||
flow_length_m: float,
|
||||
idf: dict[str, float] | None,
|
||||
) -> dict[str, float] | None:
|
||||
"""유역 제원 + 설계빈도 IDF로 배수 유효직경(mm)과 산출 근거를 계산한다.
|
||||
|
||||
시행규칙 별표2 (가) 1호 방법: 100년빈도 확률강우량 + 홍수도달시간 → 합리식.
|
||||
전 과정·계수 근거: docs/raw/guidelines/2026-08-05_임도_배수설계_규정_조사.md 4·6절.
|
||||
|
||||
① 도달시간 tc = 0.0663·L^0.77·(H/L)^-0.385 [Kirpich, hr] — 하한 5분
|
||||
② 강우강도 I = General형 적합식 I(tc) [mm/hr]
|
||||
③ 합리식 Q = (1/3.6)·C·I·A[km²], 설계유량 = 2.0·Q (별표2)
|
||||
④ 유속 V = Manning(경사 10도 고정, n=0.024) → 0.8~3.0m/s 클램프
|
||||
⑤ 통수단면 70%만 유효 → D = √(4·(Q설계/V)/(0.7π))
|
||||
"""
|
||||
if idf is None or area_m2 <= 0:
|
||||
return None
|
||||
length_km = max(flow_length_m, 1.0) / 1000.0
|
||||
slope = max(relief_m, 0.1) / max(flow_length_m, 1.0)
|
||||
tc_hr = 0.0663 * (length_km**0.77) * (slope**-0.385)
|
||||
tc_min = max(DRAINAGE_TC_MIN_MINUTES, tc_hr * 60.0)
|
||||
intensity = idf_intensity(idf, tc_min)
|
||||
flow = DRAINAGE_RUNOFF_COEFFICIENT * intensity * (area_m2 / 1e6) / 3.6
|
||||
design_flow = DRAINAGE_DESIGN_FLOW_FACTOR * flow
|
||||
if design_flow <= 0:
|
||||
return None
|
||||
pipe_slope = math.tan(math.radians(DRAINAGE_PIPE_SLOPE_DEG))
|
||||
diameter = 0.5
|
||||
for _ in range(20):
|
||||
velocity = (
|
||||
(1.0 / DRAINAGE_MANNING_N) * (diameter / 4.0) ** (2.0 / 3.0) * math.sqrt(pipe_slope)
|
||||
)
|
||||
velocity = min(DRAINAGE_VELOCITY_MAX_MS, max(DRAINAGE_VELOCITY_MIN_MS, velocity))
|
||||
required_area = design_flow / velocity
|
||||
updated = math.sqrt(4.0 * required_area / (DRAINAGE_FLOW_AREA_RATIO * math.pi))
|
||||
if abs(updated - diameter) < 1e-4:
|
||||
diameter = updated
|
||||
break
|
||||
diameter = updated
|
||||
return {
|
||||
"tc_minutes": round(tc_min, 1),
|
||||
"intensity_mm_hr": round(intensity, 1),
|
||||
"design_flow_m3s": round(design_flow, 4),
|
||||
"diameter_mm": round(diameter * 1000.0, 1),
|
||||
# 물넘이·세월교는 관이 아니라 개수로라 직경 대신 이 단면적에서 출발한다.
|
||||
"required_area_m2": round(required_area, 4),
|
||||
}
|
||||
|
||||
|
||||
def recommend_structure(diameter_mm: float | None) -> tuple[str, int | None]:
|
||||
"""유효직경으로 추천 구조물과 관경을 고른다 (2026-08-17 사용자 확정: 유량 근거만).
|
||||
|
||||
지식DB 선정 트리(개거_세월시설 §5·횡단배수관_암거 §5)에서 유량 조건만 취했다 —
|
||||
계곡 횡단경사·하천 차수는 지형 계산이 필요해 화면이 "현장 확인"으로 안내한다.
|
||||
|
||||
D ≤ 1,500㎜ 배관 (레지스트리 선택지로 스냅, 하한 800㎜)
|
||||
1,500 < D ≤ 2,000 BOX암거 후보
|
||||
D > 2,000㎜ 세월교·물넘이 검토 (관 최대 규격 초과)
|
||||
|
||||
유효직경을 못 구한 유역(강우량표 없음)은 배관·관경 미정으로 둔다.
|
||||
"""
|
||||
if diameter_mm is None:
|
||||
return PIPE_FACILITY_PIPE, None
|
||||
if diameter_mm > DRAINAGE_BRIDGE_THRESHOLD_MM:
|
||||
return PIPE_FACILITY_FORD_BRIDGE, None
|
||||
if diameter_mm > DRAINAGE_BOX_THRESHOLD_MM:
|
||||
return PIPE_FACILITY_BOX, None
|
||||
# 별표2 (나) 예외 하한 800㎜ — 계산값이 더 작아도 그 아래로는 내리지 않는다.
|
||||
need = max(float(PIPE_DEFAULT_DIAMETER_MM), diameter_mm)
|
||||
size = next(
|
||||
(value for value in DRAINAGE_RECOMMEND_DIAMETERS_MM if value >= need),
|
||||
DRAINAGE_RECOMMEND_DIAMETERS_MM[-1],
|
||||
)
|
||||
return PIPE_FACILITY_PIPE, int(size)
|
||||
|
||||
|
||||
def estimate_pipe_diameter_mm(
|
||||
area_m2: float,
|
||||
relief_m: float,
|
||||
flow_length_m: float,
|
||||
idf: dict[str, float] | None = None,
|
||||
) -> float | None:
|
||||
"""유역 제원으로 배수 유효직경(mm)만 돌려주는 축약형 — 상세는 size_pipe()."""
|
||||
sizing = size_pipe(area_m2, relief_m, flow_length_m, idf)
|
||||
return sizing["diameter_mm"] if sizing else None
|
||||
@@ -0,0 +1,57 @@
|
||||
/* 347-2 · 347-3 배수유역 자동/수동 · 제안 헬퍼 — logic 두 파일을 그 자리에서 트랜스파일해
|
||||
* Node 로 돌리고 값을 JSON 으로 낸다(틀 = helper_281_1).
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
function loadTs(file) {
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const live = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Drainage_Live.ts"));
|
||||
const sug = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Drainage_Suggest.ts"));
|
||||
|
||||
const pipes = [
|
||||
{ chainage_m: 100, source: "stream", options: { pipe_diameter_mm: 1000 } },
|
||||
{ chainage_m: 300, source: "user", facility: "pipe" },
|
||||
{ chainage_m: 500, source: "stream", facility: "box_culvert" },
|
||||
];
|
||||
const basins = [
|
||||
{ chainage_m: 100, recommended_facility: "pipe", recommended_diameter_mm: 1200 },
|
||||
{ chainage_m: 300, recommended_facility: "ford_bridge", recommended_diameter_mm: null },
|
||||
{ chainage_m: 500, recommended_facility: "box_culvert", recommended_diameter_mm: null },
|
||||
];
|
||||
const candidates = [{ chainage_m: 200 }, { chainage_m: 500.2 }];
|
||||
const before = JSON.stringify(pipes);
|
||||
const suggestions = sug.collectSuggestions(pipes, basins, candidates);
|
||||
const applied = sug.applySuggestions(pipes, suggestions);
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
quietMs: live.QUIET_MS,
|
||||
modes: [
|
||||
live.parseDrainageMode(null),
|
||||
live.parseDrainageMode("auto"),
|
||||
live.parseDrainageMode("x"),
|
||||
],
|
||||
stale: [live.draftMakesStale(null), live.draftMakesStale({ rev: 1 })],
|
||||
accept: [
|
||||
live.acceptLive({ status: "success", generation: 3 }, 3, 3),
|
||||
live.acceptLive({ status: "success", generation: 3 }, 3, 4),
|
||||
live.acceptLive({ status: "superseded" }, 3, 3),
|
||||
live.acceptLive({ status: "success", generation: 2 }, 3, 3),
|
||||
],
|
||||
body: live.liveRequestBody(5, undefined, [{ chainage_m: 1 }]),
|
||||
suggestions,
|
||||
applied,
|
||||
untouched: JSON.stringify(pipes) === before,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,169 @@
|
||||
"""347-2 「지금 노선으로 셈」 서버 길 · 347-3 관 자리 후보.
|
||||
|
||||
- 늦은 차례: 계산 중 새 차례가 오면 단계 사이에서 그만둠(superseded) · 작업본에 안 씀.
|
||||
- 메모리 격자로 세부 유역(파일 안 읽음) · 노선 초안 → 그린 노선.
|
||||
- 관 자리 후보: 최대 간격(100 m)을 넘는 구간에만 · 기존 관 자리엔 안 냄 · 관 목록엔 안 넣음.
|
||||
- [확정](recompute) = 낡았으면 배수유역부터 다시 셈 · [저장] = 안 셈.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from shapely.geometry import LineString
|
||||
|
||||
from B04_PreProcess import B04_PreProcess_Engine_Watershed_Expand as expand
|
||||
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
|
||||
from B04_PreProcess import B04_PreProcess_Router_Watershed_Live as live
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec
|
||||
from common_util.common_util_drainage_context import DrainageContext
|
||||
from common_util.common_util_drainage_detail import (
|
||||
RoadRouting,
|
||||
build_detail,
|
||||
pipes_from_chainages,
|
||||
suggest_pipe_positions,
|
||||
)
|
||||
from common_util.common_util_route_geometry import RouteVertex
|
||||
|
||||
VERTICES = [RouteVertex(0.0, 0.0, 100.0, 0.0), RouteVertex(300.0, 0.0, 90.0, 300.0)]
|
||||
|
||||
|
||||
def test_draft_vertices_follow_nodes():
|
||||
draft = {"nodes": [[0, 0], [100, 0], [100, 100]], "curveOn": [False, False, False]}
|
||||
vertices = live.draft_vertices(draft, 30.0)
|
||||
assert vertices[0].x == 0 and vertices[-1].y == 100
|
||||
assert math.isclose(vertices[-1].chainage_m, 200.0)
|
||||
|
||||
|
||||
def test_candidates_only_in_long_gaps():
|
||||
strength = np.zeros(301)
|
||||
strength[150] = 50.0 # 흐름이 모이는 자리
|
||||
pipes = pipes_from_chainages(VERTICES, [0.0, 300.0])
|
||||
picked = suggest_pipe_positions(VERTICES, pipes, strength)
|
||||
assert picked and all(0 < pipe.chainage_m < 300 for pipe in picked)
|
||||
assert all(pipe.chainage_m == round(pipe.chainage_m) for pipe in picked) # 정수 m
|
||||
dense = pipes_from_chainages(VERTICES, [0.0, 90.0, 180.0, 270.0, 300.0])
|
||||
assert suggest_pipe_positions(VERTICES, dense, strength) == []
|
||||
|
||||
|
||||
def _routing() -> RoadRouting:
|
||||
spec = GridSpec(x_min=-5.0, y_max=5.0, cell_m=1.0, n_rows=10, n_cols=310)
|
||||
road_index = np.arange(5 * 310 + 5, 5 * 310 + 305, dtype=np.int32)
|
||||
road_slot = np.full(spec.size, -1, dtype=np.int32)
|
||||
road_slot[road_index] = np.arange(road_index.size)
|
||||
return RoadRouting(
|
||||
spec=spec,
|
||||
road_slot=road_slot,
|
||||
path_length=np.zeros(spec.size, np.float32),
|
||||
elevation=np.full(spec.size, 95.0, np.float32),
|
||||
road_cell_index=road_index,
|
||||
road_chainage=np.arange(road_index.size, dtype=np.float64),
|
||||
strength=np.ones(road_index.size, np.int64),
|
||||
)
|
||||
|
||||
|
||||
def test_build_detail_uses_memory_routing(tmp_path):
|
||||
missing = tmp_path / "없는 폴더" # 파일을 읽으면 None 이 됨
|
||||
detail = build_detail(missing, VERTICES, [0.0, 300.0], _routing(), [])
|
||||
assert detail is not None and detail.pipe_candidates
|
||||
assert build_detail(missing, VERTICES, [0.0, 300.0]) is None
|
||||
|
||||
|
||||
def _patch_live(monkeypatch, preview_fn):
|
||||
async def prepare(project_id):
|
||||
return {"contours": [], "streams": [], "contour_key": "k"}
|
||||
|
||||
async def load(project_id):
|
||||
return DrainageContext(stored_path="x", project_root=Path("."), vertices=VERTICES), ""
|
||||
|
||||
monkeypatch.setattr(live, "_prepare", prepare)
|
||||
monkeypatch.setattr(live, "load_drainage_context", load)
|
||||
monkeypatch.setattr(live, "drainage_dir", lambda stored: Path("."))
|
||||
monkeypatch.setattr(live, "preview_stages", preview_fn)
|
||||
calls = SimpleNamespace(build=0)
|
||||
|
||||
def build(*args):
|
||||
calls.build += 1
|
||||
return None, []
|
||||
|
||||
monkeypatch.setattr(live, "_build", build)
|
||||
return calls
|
||||
|
||||
|
||||
def test_newer_generation_stops_between_stages(monkeypatch):
|
||||
pid = uuid4()
|
||||
|
||||
def preview(vertices, contours, streams, store, should_stop):
|
||||
live._LATEST_GENERATION[str(pid)] = 8 # 계산 중 새 조작 → 새 요청이 차례를 올림
|
||||
if should_stop():
|
||||
raise expand.AnalysisStopped
|
||||
|
||||
calls = _patch_live(monkeypatch, preview)
|
||||
result = asyncio.run(live.post_drainage_live(pid, {"generation": 7, "points": []}))
|
||||
assert result == live.SUPERSEDED and calls.build == 0
|
||||
|
||||
|
||||
def test_newer_generation_after_preview_skips_split(monkeypatch):
|
||||
pid = uuid4()
|
||||
|
||||
def preview(vertices, contours, streams, store, should_stop):
|
||||
assert not should_stop()
|
||||
live._LATEST_GENERATION[str(pid)] = 3
|
||||
return SimpleNamespace(routing=object())
|
||||
|
||||
calls = _patch_live(monkeypatch, preview)
|
||||
result = asyncio.run(live.post_drainage_live(pid, {"generation": 2}))
|
||||
assert result == live.SUPERSEDED and calls.build == 0
|
||||
|
||||
|
||||
def test_engine_stops_in_expansion_round():
|
||||
contours = [
|
||||
{
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [
|
||||
[150 + r * math.cos(t), -150 + r * math.sin(t)]
|
||||
for t in np.linspace(0, 2 * math.pi, 121)
|
||||
],
|
||||
},
|
||||
"properties": {"elevation": 300 - r},
|
||||
}
|
||||
for r in range(10, 200, 10)
|
||||
]
|
||||
spec = GridSpec(x_min=100.0, y_max=-100.0, cell_m=1.0, n_rows=100, n_cols=100)
|
||||
domain = np.zeros((100, 100), bool)
|
||||
domain[20:80, 20:80] = True
|
||||
route = LineString([(110.0, -150.0), (190.0, -150.0)])
|
||||
with pytest.raises(expand.AnalysisStopped):
|
||||
expand.expand_by_red_boundary(
|
||||
spec, domain, contours, route, [], None, should_stop=lambda: True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"recompute, stale, expected", [(True, True, 1), (True, False, 0), (False, True, 0)]
|
||||
)
|
||||
def test_confirm_recomputes_only_when_stale(monkeypatch, recompute, stale, expected):
|
||||
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed
|
||||
|
||||
calls = []
|
||||
|
||||
async def is_stale(project_id):
|
||||
return stale
|
||||
|
||||
async def region(project_id, refresh=False):
|
||||
calls.append(refresh)
|
||||
return {"status": "success"}
|
||||
|
||||
async def resolve(*args, **kwargs):
|
||||
return basins.JSONResponse(status_code=404, content={})
|
||||
|
||||
monkeypatch.setattr(basins, "_analysis_stale", is_stale)
|
||||
monkeypatch.setattr(watershed, "get_primary_region", region)
|
||||
monkeypatch.setattr(basins, "_resolve", resolve)
|
||||
asyncio.run(basins.put_pipe_points(uuid4(), {"points": []}, None, recompute))
|
||||
assert len(calls) == expected and all(calls)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""347-2 · 347-3 배수유역 자동/수동 · 제안 — 화면 쪽 순수 규칙(logic/ 두 파일).
|
||||
|
||||
- 기본 수동 · 자동 = 3 초 조용한 뒤 · 노선 초안이 있을 때만 낡음.
|
||||
- 늦은 답 버림: 옛 차례 · 서버가 그만둔 답 · 답 차례가 다른 것은 안 올림.
|
||||
- 제안: 관경(배관 · 추천 ≠ 지금) · 시설(BOX암거 · 세월교) · 관 자리(기존 관 근처 뺌) ·
|
||||
[적용] = 한 번에 얹음 · 입력 목록은 안 고침(자동 바꿈 없음).
|
||||
Node 헬퍼(`helper_347_2_drainage_live.cjs`)가 TS 를 그 자리에서 돌린다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_347_2_drainage_live.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_mode_and_stale(r):
|
||||
assert r["quietMs"] == 3000
|
||||
assert r["modes"] == ["manual", "auto", "manual"]
|
||||
assert r["stale"] == [False, True]
|
||||
|
||||
|
||||
def test_late_answers_dropped(r):
|
||||
assert r["accept"] == [True, False, False, False]
|
||||
assert r["body"] == {"generation": 5, "route": None, "points": [{"chainage_m": 1}]}
|
||||
|
||||
|
||||
def test_suggestions_collected(r):
|
||||
assert r["suggestions"] == [
|
||||
{"kind": "diameter", "chainageM": 100, "from": 1000, "to": 1200},
|
||||
{"kind": "position", "chainageM": 200},
|
||||
{"kind": "facility", "chainageM": 300, "from": "pipe", "to": "ford_bridge"},
|
||||
]
|
||||
|
||||
|
||||
def test_apply_once_without_touching_input(r):
|
||||
assert r["untouched"]
|
||||
applied = {pipe["chainage_m"]: pipe for pipe in r["applied"]}
|
||||
assert sorted(applied) == [100, 200, 300, 500]
|
||||
assert applied[100]["options"]["pipe_diameter_mm"] == 1200
|
||||
assert applied[200]["source"] == "spacing"
|
||||
assert applied[300]["facility"] == "ford_bridge"
|
||||
assert applied[500]["facility"] == "box_culvert" # 이미 그 종류 — 그대로
|
||||
Reference in New Issue
Block a user