Merge remote-tracking branch 'origin/dev' into sub_laptop_8
This commit is contained in:
@@ -22,6 +22,8 @@ from B04_PreProcess.B04_PreProcess_Engine_Ground import (
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_models
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Structurize import structurize_las
|
||||
from common_util.common_util_atomic import atomic_write_npz
|
||||
from common_util.common_util_derived_store import file_stamp
|
||||
from common_util.common_util_derived_store import key_matches as _key_matches
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from config.config_system import (
|
||||
SHEET_SURFACE_AUTO_METHODS,
|
||||
@@ -128,23 +130,6 @@ def _structured_stats(structured: Any, structured_path: Path) -> tuple[dict[str,
|
||||
return stats, xyz
|
||||
|
||||
|
||||
def file_stamp(path: Path | None) -> str | None:
|
||||
"""결과 파일 지문 = 수정시각 ns : 크기. 없으면 None."""
|
||||
try:
|
||||
stat = path.stat() if path is not None else None
|
||||
except OSError:
|
||||
return None
|
||||
return f"{stat.st_mtime_ns}:{stat.st_size}" if stat else None
|
||||
|
||||
|
||||
def _key_matches(key_path: Path, key: Any) -> bool:
|
||||
"""결과 옆 열쇠 파일이 지금 입력과 같은지."""
|
||||
try:
|
||||
return json.loads(key_path.read_text(encoding="utf-8")) == key
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _relative_to_project(project_root: Path, path: Path) -> str:
|
||||
"""프로젝트 루트 기준 posix 상대 경로 문자열."""
|
||||
return path.relative_to(project_root).as_posix()
|
||||
|
||||
@@ -25,7 +25,6 @@ from typing import Any
|
||||
import numpy as np
|
||||
from pyproj import Transformer
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine import file_stamp
|
||||
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
|
||||
atomic_npz,
|
||||
clip_and_compact_mesh,
|
||||
@@ -38,6 +37,7 @@ from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import (
|
||||
SHEET_METHOD_LABELS,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import grid_spec_from_bounds
|
||||
from common_util.common_util_derived_store import file_stamp
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from config.config_system import (
|
||||
SHEET_SURFACE_GRID_M,
|
||||
|
||||
@@ -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
|
||||
@@ -10,10 +10,15 @@
|
||||
*
|
||||
* 저장하지 않는 것: 패널 접힘·높이·표시 토글 같은 **화면 배치 값**. 조작 이력이 아니라
|
||||
* 보기 설정이라 되돌릴 대상이 아니다(`isLayoutKey`).
|
||||
*
|
||||
* 대상 = 초안 통(`aislo:draft:` 전부 — 노선 초안 · 계획고 · 관 · 구조물 · 횡단) + 옛 b05 · b06 키(346-4).
|
||||
* 끌기 · 누르고 있기 중간은 안 찍음 — 누름이 남아 있는 동안 record() 는 미루고 놓을 때 한 번.
|
||||
* 사진끼리 안 바뀐 값은 앞 사진 글을 같이 씀 — 칸마다 수백 KB × 100단이어도 바뀐 칸만 늘어남.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */
|
||||
const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-", "aislo:draft:alignment-draft:"];
|
||||
const DRAFT_PREFIX = "aislo:draft:";
|
||||
const LEGACY_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"];
|
||||
|
||||
/** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */
|
||||
function isLayoutKey(key: string): boolean {
|
||||
@@ -27,20 +32,22 @@ function isLayoutKey(key: string): boolean {
|
||||
}
|
||||
|
||||
function isDataKey(key: string): boolean {
|
||||
return DATA_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key);
|
||||
if (key.startsWith(DRAFT_PREFIX)) return true;
|
||||
return LEGACY_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key);
|
||||
}
|
||||
|
||||
/** 한 시점의 조작값 — 키 → 값(JSON 문자열). */
|
||||
export type HistorySnapshot = Record<string, string>;
|
||||
|
||||
function takeSnapshot(): HistorySnapshot {
|
||||
/** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). */
|
||||
function takeSnapshot(previous?: HistorySnapshot): HistorySnapshot {
|
||||
const snapshot: HistorySnapshot = {};
|
||||
try {
|
||||
for (let index = 0; index < sessionStorage.length; index += 1) {
|
||||
const key = sessionStorage.key(index);
|
||||
if (!key || !isDataKey(key)) continue;
|
||||
const value = sessionStorage.getItem(key);
|
||||
if (value !== null) snapshot[key] = value;
|
||||
if (value !== null) snapshot[key] = previous?.[key] === value ? previous[key] : value;
|
||||
}
|
||||
} catch {
|
||||
// 세션 저장소를 못 쓰는 환경에서는 되돌리기만 비활성이 된다.
|
||||
@@ -55,6 +62,39 @@ function sameSnapshot(left: HistorySnapshot, right: HistorySnapshot): boolean {
|
||||
return leftKeys.every((key) => left[key] === right[key]);
|
||||
}
|
||||
|
||||
/** 누름(마우스 · 터치 · 펜)이 남아 있나 — 창 하나에 한 번 듣고 놓으면 미룬 기록을 부름. */
|
||||
let pointerHeld = false;
|
||||
const releaseWaiters = new Set<() => void>();
|
||||
let pointerWatched = false;
|
||||
|
||||
function watchPointer(): void {
|
||||
if (pointerWatched || typeof window === "undefined") return;
|
||||
if (typeof window.addEventListener !== "function") return;
|
||||
pointerWatched = true;
|
||||
window.addEventListener("pointerdown", () => (pointerHeld = true), true);
|
||||
const release = (): void => {
|
||||
if (!pointerHeld) return;
|
||||
// 놓을 때 부르는 쪽(끌기 마무리 · 세션 쓰기)이 먼저 끝나게 한 틱 뒤.
|
||||
window.setTimeout(() => {
|
||||
pointerHeld = false;
|
||||
const waiters = [...releaseWaiters];
|
||||
releaseWaiters.clear();
|
||||
waiters.forEach((fn) => fn());
|
||||
}, 0);
|
||||
};
|
||||
window.addEventListener("pointerup", release, true);
|
||||
window.addEventListener("pointercancel", release, true);
|
||||
window.addEventListener("blur", release);
|
||||
// 창 밖에서 놓아 pointerup 을 못 받은 때 — 단추 없이 움직이면 놓은 것.
|
||||
window.addEventListener(
|
||||
"pointermove",
|
||||
(event) => {
|
||||
if (pointerHeld && event.buttons === 0) release();
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 조작 키는 지운다. */
|
||||
function restoreSnapshot(snapshot: HistorySnapshot): void {
|
||||
try {
|
||||
@@ -91,7 +131,8 @@ export interface ProfileHistory {
|
||||
* `onRestore`는 세션 값이 바뀐 뒤 화면을 다시 세우는 콜백이다 — 세션이 정본이므로
|
||||
* 여기서 각 패널이 자기 값을 다시 읽어 그린다.
|
||||
*/
|
||||
export function createProfileHistory(onRestore: () => void, limit = 50): ProfileHistory {
|
||||
export function createProfileHistory(onRestore: () => void, limit = 100): ProfileHistory {
|
||||
watchPointer();
|
||||
const stack: HistorySnapshot[] = [takeSnapshot()];
|
||||
let cursor = 0;
|
||||
/** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */
|
||||
@@ -112,10 +153,14 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
const history: ProfileHistory = {
|
||||
record() {
|
||||
if (restoring || paused) return;
|
||||
const snapshot = takeSnapshot();
|
||||
if (pointerHeld) {
|
||||
releaseWaiters.add(history.record);
|
||||
return;
|
||||
}
|
||||
const snapshot = takeSnapshot(stack[cursor]);
|
||||
if (sameSnapshot(snapshot, stack[cursor])) return;
|
||||
stack.splice(cursor + 1);
|
||||
stack.push(snapshot);
|
||||
@@ -128,11 +173,12 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile
|
||||
resume() {
|
||||
if (!paused) return;
|
||||
paused = false;
|
||||
this.record();
|
||||
history.record();
|
||||
},
|
||||
undo: () => apply(cursor - 1),
|
||||
redo: () => apply(cursor + 1),
|
||||
canUndo: () => cursor > 0,
|
||||
canRedo: () => cursor < stack.length - 1,
|
||||
};
|
||||
return history;
|
||||
}
|
||||
|
||||
@@ -19,12 +19,14 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import (
|
||||
BUNDLE,
|
||||
_mass_haul_context,
|
||||
@@ -32,7 +34,10 @@ from B06_Section.B06_Section_Server_Calc_Prebuild import (
|
||||
haul_inputs_for,
|
||||
haul_limits_for,
|
||||
)
|
||||
from common_util.common_util_derived_store import read_derived, write_derived
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import run_with_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
||||
@@ -41,10 +46,11 @@ _NPM_SCRIPT = "build:server-calc"
|
||||
# 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다.
|
||||
_MAX_POINTS = 5000
|
||||
# 열쇠(누가토량 결과 + 계수 · 장비 경계) → 배분 — 같은 열쇠면 Node 를 다시 안 띄움
|
||||
# (342-1 · 부록 4 R25). ponytail: 프로세스 메모리 · 최근 몇 벌만 — 재시작 뒤 첫 열기는
|
||||
# 다시 셈(_derived 저장본은 343 틀 뒤).
|
||||
# (342-1 · 부록 4 R25). 프로세스 메모리 먼저 · 다음 = 프로젝트 `_derived/haul/<열쇠>.json`
|
||||
# (재시작 뒤 첫 열기도 셈 없음 · 종류당 최근 2벌).
|
||||
_KEPT: OrderedDict[str, Any] = OrderedDict()
|
||||
_KEPT_MAX = 32
|
||||
_DERIVED_KIND = "haul"
|
||||
|
||||
|
||||
def _plan_key(result: dict[str, Any], context: Any) -> str:
|
||||
@@ -52,6 +58,12 @@ def _plan_key(result: dict[str, Any], context: Any) -> str:
|
||||
return hashlib.sha1(body.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _keep(key: str, plan: Any) -> None:
|
||||
_KEPT[key] = plan
|
||||
if len(_KEPT) > _KEPT_MAX:
|
||||
_KEPT.popitem(last=False)
|
||||
|
||||
|
||||
@router.post("/{project_id}/sections/{route_id}/haul-plan", response_model=None)
|
||||
async def compute_haul_plan(
|
||||
project_id: UUID,
|
||||
@@ -87,6 +99,20 @@ async def compute_haul_plan(
|
||||
if key in _KEPT:
|
||||
_KEPT.move_to_end(key)
|
||||
return JSONResponse(content={"status": "success", "haul_plan": _KEPT[key]})
|
||||
project_root: Path | None = None
|
||||
try:
|
||||
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
except Exception: # noqa: BLE001 — 저장소를 못 찾아도 셈은 함(디스크 저장본만 건너뜀)
|
||||
logger.warning("유토 배분 저장소 못 찾음: project_id=%s", project_id)
|
||||
stored = (
|
||||
None
|
||||
if project_root is None
|
||||
else await asyncio.to_thread(read_derived, project_root, _DERIVED_KIND, key)
|
||||
)
|
||||
if stored is not None:
|
||||
_keep(key, stored["value"])
|
||||
return JSONResponse(content={"status": "success", "haul_plan": stored["value"]})
|
||||
try:
|
||||
output = await asyncio.to_thread(
|
||||
run_bundle_json,
|
||||
@@ -107,7 +133,10 @@ async def compute_haul_plan(
|
||||
content={"status": "error", "message": "유토 배분 계산에 실패했습니다."},
|
||||
)
|
||||
plan = output.get("haul_plan")
|
||||
_KEPT[key] = plan
|
||||
if len(_KEPT) > _KEPT_MAX:
|
||||
_KEPT.popitem(last=False)
|
||||
_keep(key, plan)
|
||||
try:
|
||||
if project_root is not None:
|
||||
await asyncio.to_thread(write_derived, project_root, _DERIVED_KIND, key, plan)
|
||||
except OSError: # 못 적어도 응답은 그대로 — 다음에 다시 셈
|
||||
logger.warning("유토 배분 저장본 쓰기 실패: project_id=%s", project_id, exc_info=True)
|
||||
return JSONResponse(content={"status": "success", "haul_plan": plan})
|
||||
|
||||
@@ -9,7 +9,7 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections import cross_station, prune_stale_cross_files
|
||||
from B05_Profile.B05_Profile_Engine_Sections import cross_station, valid_cross_names
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +39,8 @@ def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[
|
||||
if not cross_dir.is_dir():
|
||||
raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.")
|
||||
stations = longitudinal.get("stations")
|
||||
valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else [])
|
||||
# 읽기는 지우지 않음 — 잔재는 걸러 읽기만(지우기는 측점을 쓰는 쪽 몫 · R26)
|
||||
valid_names = valid_cross_names(cross_dir, stations if isinstance(stations, list) else [])
|
||||
files = sorted(cross_dir.glob("cross_*.json"))
|
||||
if valid_names:
|
||||
files = [path for path in files if path.name in valid_names]
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""파생 저장본 — 입력 지문 열쇠 + 결과 한 쌍(343 · 341 부록 4 2-1).
|
||||
|
||||
자리 = 프로젝트 `_derived/<종류>/<열쇠>.json` · 작업본에서 다시 셀 수 있는 값만(정본 아님) ·
|
||||
통째로 지워도 다시 셈으로 돌아옴 · 종류마다 최근 `KEEP` 벌(꺼내면 최근으로).
|
||||
결과 옆 열쇠(`<이름>.key.json` — 결과가 곧 산출물인 도엽 3D 면 등)는 `key_matches` · `file_stamp`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
DERIVED_DIR = "_derived"
|
||||
KEEP = 2
|
||||
|
||||
|
||||
def derived_key(*parts: Any) -> str:
|
||||
"""입력 지문 — 작은 입력은 내용 그대로 · 파일은 `file_stamp` 를 넣는다."""
|
||||
body = json.dumps(parts, sort_keys=True, ensure_ascii=False, default=str)
|
||||
return hashlib.sha1(body.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def file_stamp(path: Path | None) -> str | None:
|
||||
"""파일 지문 = 수정시각 ns : 크기. 없으면 None."""
|
||||
try:
|
||||
stat = path.stat() if path is not None else None
|
||||
except OSError:
|
||||
return None
|
||||
return f"{stat.st_mtime_ns}:{stat.st_size}" if stat else None
|
||||
|
||||
|
||||
def key_matches(key_path: Path, key: Any) -> bool:
|
||||
"""결과 옆 열쇠 파일이 지금 입력과 같은지."""
|
||||
try:
|
||||
return json.loads(key_path.read_text(encoding="utf-8")) == key
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def derived_dir(project_root: Path, kind: str) -> Path:
|
||||
return Path(project_root) / DERIVED_DIR / kind
|
||||
|
||||
|
||||
def read_derived(project_root: Path, kind: str, key: str) -> dict[str, Any] | None:
|
||||
"""저장본 `{"value": …}` — 없거나 못 읽으면 None(값이 null 이어도 맞음과 구별)."""
|
||||
path = derived_dir(project_root, kind) / f"{key}.json"
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(data, dict) or data.get("key") != key or "value" not in data:
|
||||
return None
|
||||
try:
|
||||
path.touch() # 최근에 쓴 것 = 정리 때 남김
|
||||
except OSError:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def write_derived(project_root: Path, kind: str, key: str, value: Any, keep: int = KEEP) -> None:
|
||||
"""원자 쓰기 뒤 같은 종류 옛 열쇠를 정리한다."""
|
||||
atomic_write_json(derived_dir(project_root, kind) / f"{key}.json", {"key": key, "value": value})
|
||||
prune_derived(project_root, kind, keep)
|
||||
|
||||
|
||||
def prune_derived(project_root: Path, kind: str, keep: int = KEEP) -> int:
|
||||
"""종류 하나를 최근 `keep` 벌만 남기고 지운다. 지운 수."""
|
||||
folder = derived_dir(project_root, kind)
|
||||
files = sorted(
|
||||
((path.stat().st_mtime_ns, path) for path in folder.glob("*.json")), reverse=True
|
||||
)
|
||||
for _, path in files[keep:]:
|
||||
path.unlink(missing_ok=True)
|
||||
return max(0, len(files) - keep)
|
||||
@@ -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
|
||||
@@ -13,6 +13,7 @@ import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -48,15 +49,34 @@ def node_env() -> dict[str, str]:
|
||||
return env
|
||||
|
||||
|
||||
# 「낡지 않음」 판정 기억 — 묶음마다 (묶음 mtime_ns, 잰 때). 같은 묶음이면 `RECHECK_S` 안에는
|
||||
# TS 330여 개를 다시 stat 하지 않는다(R24 · 부름마다 수십 ms). 낡음은 기억하지 않는다.
|
||||
RECHECK_S = 30.0
|
||||
_FRESH: dict[str, tuple[int, float]] = {}
|
||||
|
||||
|
||||
def bundle_stale(bundle: Path) -> bool:
|
||||
"""번들이 없거나 TS 원본보다 오래됐으면 참.
|
||||
|
||||
번들이 낡으면 서버와 화면이 **다른 값**을 만든다 — 이 판정이 그것을 막는 유일한
|
||||
장치다. 개발 중에는 `npm run build`를 따로 돌리지 않으므로 여기서 스스로 갱신한다.
|
||||
「낡지 않음」은 `RECHECK_S` 동안 기억 — TS 를 고치면 늦어도 그만큼 뒤에 다시 짓는다.
|
||||
"""
|
||||
if not bundle.is_file():
|
||||
return True
|
||||
built_at = bundle.stat().st_mtime
|
||||
stamp = bundle.stat().st_mtime_ns
|
||||
remembered = _FRESH.get(str(bundle))
|
||||
if remembered and remembered[0] == stamp and time.monotonic() - remembered[1] < RECHECK_S:
|
||||
return False
|
||||
if _sources_newer(bundle.stat().st_mtime):
|
||||
_FRESH.pop(str(bundle), None)
|
||||
return True
|
||||
_FRESH[str(bundle)] = (stamp, time.monotonic())
|
||||
return False
|
||||
|
||||
|
||||
def _sources_newer(built_at: float) -> bool:
|
||||
"""묶음이 끌어가는 TS · 자료 중 하나라도 `built_at` 보다 새로우면 참."""
|
||||
for directory in SOURCE_DIRS:
|
||||
for path in (ROOT / directory).rglob("*.ts"):
|
||||
if "node_modules" not in path.parts and path.stat().st_mtime > built_at:
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""343 덧 — 파생 저장본 도우미 · 유토 배분 저장본 · B07 읽기 안 지움 · 묶음 낡음 검사 기억."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from B06_Section import B06_Section_Router_HaulPlan as haul
|
||||
from common_util import common_util_derived_store as store
|
||||
from common_util import common_util_node_bundle as nb
|
||||
|
||||
# ── 도우미 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_열쇠_같은_입력_같은_값() -> None:
|
||||
assert store.derived_key({"a": 1, "b": 2}, 3) == store.derived_key({"b": 2, "a": 1}, 3)
|
||||
assert store.derived_key({"a": 1}) != store.derived_key({"a": 2})
|
||||
|
||||
|
||||
def test_읽기_쓰기_null_도_맞음(tmp_path: Path) -> None:
|
||||
assert store.read_derived(tmp_path, "haul", "k") is None
|
||||
store.write_derived(tmp_path, "haul", "k", None)
|
||||
assert store.read_derived(tmp_path, "haul", "k") == {"key": "k", "value": None}
|
||||
assert (tmp_path / "_derived" / "haul" / "k.json").is_file()
|
||||
|
||||
|
||||
def test_종류마다_최근_두_벌_꺼내면_최근(tmp_path: Path) -> None:
|
||||
store.write_derived(tmp_path, "haul", "a", 1)
|
||||
time.sleep(0.02)
|
||||
store.write_derived(tmp_path, "haul", "b", 2)
|
||||
store.write_derived(tmp_path, "other", "x", 0)
|
||||
time.sleep(0.02)
|
||||
assert store.read_derived(tmp_path, "haul", "a")["value"] == 1
|
||||
time.sleep(0.02)
|
||||
store.write_derived(tmp_path, "haul", "c", 3)
|
||||
names = sorted(path.stem for path in (tmp_path / "_derived" / "haul").glob("*.json"))
|
||||
assert names == ["a", "c"]
|
||||
assert (tmp_path / "_derived" / "other" / "x.json").is_file()
|
||||
|
||||
|
||||
def test_결과_옆_열쇠와_파일_지문(tmp_path: Path) -> None:
|
||||
path = tmp_path / "m.key.json"
|
||||
assert not store.key_matches(path, {"a": 1})
|
||||
path.write_text(json.dumps({"a": 1}), encoding="utf-8")
|
||||
assert store.key_matches(path, {"a": 1}) and not store.key_matches(path, {"a": 2})
|
||||
assert store.file_stamp(tmp_path / "없음") is None and store.file_stamp(path)
|
||||
|
||||
|
||||
# ── 유토 배분 디스크 저장본 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _wire(monkeypatch: pytest.MonkeyPatch, root: Path, outputs: list) -> list:
|
||||
calls: list = []
|
||||
|
||||
async def _none(_project_id): # type: ignore[no-untyped-def]
|
||||
return {}
|
||||
|
||||
async def _stored(_fn, _project_id): # type: ignore[no-untyped-def]
|
||||
return "p"
|
||||
|
||||
def _run(_bundle, _script, payload): # type: ignore[no-untyped-def]
|
||||
calls.append(payload)
|
||||
return outputs.pop(0)
|
||||
|
||||
monkeypatch.setattr(haul, "haul_inputs_for", _none)
|
||||
monkeypatch.setattr(haul, "conversion_factors_for", _none)
|
||||
monkeypatch.setattr(haul, "haul_limits_for", _none)
|
||||
monkeypatch.setattr(haul, "_mass_haul_context", lambda *_: {"limits": []})
|
||||
monkeypatch.setattr(haul, "run_bundle_json", _run)
|
||||
monkeypatch.setattr(haul, "run_with_connection", _stored)
|
||||
monkeypatch.setattr(haul, "resolve_stored_project_path", lambda _p: str(root))
|
||||
haul._KEPT.clear()
|
||||
return calls
|
||||
|
||||
|
||||
def _post(result: dict) -> dict:
|
||||
response = asyncio.run(haul.compute_haul_plan(uuid4(), 7, {"result": result}))
|
||||
return json.loads(response.body)
|
||||
|
||||
|
||||
def test_유토_배분_재시작_뒤에도_셈_없음(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = _wire(monkeypatch, tmp_path, [{"haul_plan": {"tag": "a"}}, {"haul_plan": {"tag": "b"}}])
|
||||
a = {"points": [{"cumulative_volume_m3": 1.0}]}
|
||||
assert _post(a)["haul_plan"] == {"tag": "a"} and len(calls) == 1
|
||||
key = haul._plan_key(a, {"limits": []}) # 화면 · 서버 메모리와 같은 열쇠
|
||||
assert (tmp_path / "_derived" / "haul" / f"{key}.json").is_file()
|
||||
haul._KEPT.clear() # 재시작
|
||||
assert _post(a)["haul_plan"] == {"tag": "a"} and len(calls) == 1
|
||||
b = {"points": [{"cumulative_volume_m3": 2.0}]}
|
||||
assert _post(b)["haul_plan"] == {"tag": "b"} and len(calls) == 2
|
||||
|
||||
|
||||
def test_유토_배분_저장소_못_찾아도_셈(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = _wire(monkeypatch, tmp_path, [{"haul_plan": {"tag": "a"}}])
|
||||
|
||||
async def _missing(_fn, _project_id): # type: ignore[no-untyped-def]
|
||||
raise LookupError("없음")
|
||||
|
||||
monkeypatch.setattr(haul, "run_with_connection", _missing)
|
||||
assert _post({"points": [{"cumulative_volume_m3": 1.0}]})["haul_plan"] == {"tag": "a"}
|
||||
assert len(calls) == 1 and not (tmp_path / "_derived").exists()
|
||||
|
||||
|
||||
# ── B07 읽기는 측점 파일을 안 지움 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_B07_읽기는_잔재를_걸러만_읽음(tmp_path: Path) -> None:
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _cross_files
|
||||
|
||||
cross_dir = tmp_path / "B05_Profile" / "cross_sections"
|
||||
cross_dir.mkdir(parents=True)
|
||||
for name in ("cross_00000m.json", "cross_00020m.json", "cross_00040m.json"):
|
||||
(cross_dir / name).write_text("{}", encoding="utf-8")
|
||||
longitudinal_path = tmp_path / "B05_Profile" / "longitudinal" / "longitudinal.json"
|
||||
files = _cross_files(longitudinal_path, {"stations": [{"chainage_m": 0}, {"chainage_m": 20}]})
|
||||
assert [path.name for path in files] == ["cross_00000m.json", "cross_00020m.json"]
|
||||
assert (cross_dir / "cross_00040m.json").is_file()
|
||||
|
||||
|
||||
# ── 묶음 낡음 검사 기억 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bundle(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
monkeypatch.setattr(nb, "ROOT", tmp_path)
|
||||
monkeypatch.setattr(nb, "_FRESH", {})
|
||||
path = tmp_path / "dist" / "bundle.mjs"
|
||||
path.parent.mkdir()
|
||||
path.write_text("", encoding="utf-8")
|
||||
os.utime(path, (1_000_000_000, 1_000_000_000))
|
||||
return path
|
||||
|
||||
|
||||
def _counting(monkeypatch: pytest.MonkeyPatch) -> list:
|
||||
scans: list = []
|
||||
real = nb._sources_newer
|
||||
|
||||
def counting(built_at: float) -> bool:
|
||||
scans.append(built_at)
|
||||
return real(built_at)
|
||||
|
||||
monkeypatch.setattr(nb, "_sources_newer", counting)
|
||||
return scans
|
||||
|
||||
|
||||
def test_낡지_않음은_잠시_기억(bundle: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
scans = _counting(monkeypatch)
|
||||
assert not nb.bundle_stale(bundle) and not nb.bundle_stale(bundle)
|
||||
assert len(scans) == 1
|
||||
# 묶음을 새로 지으면(mtime 바뀜) 다시 잼
|
||||
os.utime(bundle, (1_000_000_100, 1_000_000_100))
|
||||
assert not nb.bundle_stale(bundle) and len(scans) == 2
|
||||
# 기억 시간이 지나면 다시 잼
|
||||
monkeypatch.setattr(nb, "RECHECK_S", 0.0)
|
||||
assert not nb.bundle_stale(bundle) and len(scans) == 3
|
||||
|
||||
|
||||
def test_낡음은_기억하지_않음(bundle: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
source = nb.ROOT / "B05_Profile" / "x.ts"
|
||||
source.parent.mkdir()
|
||||
source.write_text("", encoding="utf-8")
|
||||
scans = _counting(monkeypatch)
|
||||
assert nb.bundle_stale(bundle) and nb.bundle_stale(bundle)
|
||||
assert len(scans) == 2 and str(bundle) not in nb._FRESH
|
||||
@@ -0,0 +1,128 @@
|
||||
"""346-4 되돌리기 앞일 — 사진 대상 = aislo:draft: 전부 · 누름 중간은 안 찍고 놓을 때 한 번 · 크기.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_346_4_profile_history.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_Profile_History.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
DRIVER = r"""
|
||||
import { writeFileSync } from "node:fs";
|
||||
// 세션 흉내 — getItem 은 진짜처럼 매번 새 글을 돌려줌(사진끼리 글을 같이 쓰는지 재려고).
|
||||
const data = new Map();
|
||||
globalThis.sessionStorage = {
|
||||
get length() { return data.size; },
|
||||
key: (i) => [...data.keys()][i] ?? null,
|
||||
getItem: (k) => (data.has(k) ? Buffer.from(data.get(k)).toString() : null),
|
||||
setItem: (k, v) => data.set(k, String(v)),
|
||||
removeItem: (k) => data.delete(k),
|
||||
};
|
||||
globalThis.window = new EventTarget();
|
||||
window.setTimeout = setTimeout;
|
||||
const { createProfileHistory } = await import("./B05_Profile_UI_Profile_History.js");
|
||||
const tick = () => new Promise((r) => setTimeout(r, 5));
|
||||
const out = {};
|
||||
|
||||
// 1. 대상 — 초안 통 전부 + 옛 키 · 보기 설정 · pref · result 는 뺌
|
||||
const K = {
|
||||
route: "aislo:draft:route-draft:p1",
|
||||
cross: "aislo:draft:crossdesign:p1:7",
|
||||
pipes: "aislo:draft:pipes:p1",
|
||||
legacy: "b05:uphill:p1",
|
||||
layout: "b05-panel-collapsed",
|
||||
pref: "aislo:pref:table-open",
|
||||
result: "aislo:result:latest:p1",
|
||||
};
|
||||
for (const k of Object.values(K)) sessionStorage.setItem(k, "0");
|
||||
let restored = 0;
|
||||
const h = createProfileHistory(() => (restored += 1));
|
||||
for (const k of Object.values(K)) sessionStorage.setItem(k, "1");
|
||||
h.record();
|
||||
out.undo = h.undo();
|
||||
out.after = Object.fromEntries(Object.entries(K).map(([n, k]) => [n, sessionStorage.getItem(k)]));
|
||||
out.restored = restored;
|
||||
|
||||
// 2. 누르고 있는 동안 기록 미룸 · 놓으면 한 번
|
||||
data.clear();
|
||||
sessionStorage.setItem(K.cross, "a");
|
||||
const h2 = createProfileHistory(() => {});
|
||||
window.dispatchEvent(new Event("pointerdown"));
|
||||
for (const v of ["b", "c", "d", "e"]) {
|
||||
sessionStorage.setItem(K.cross, v);
|
||||
h2.record();
|
||||
}
|
||||
out.heldCanUndo = h2.canUndo();
|
||||
window.dispatchEvent(new Event("pointerup"));
|
||||
await tick();
|
||||
out.releasedCanUndo = h2.canUndo();
|
||||
h2.undo();
|
||||
out.oneUndo = sessionStorage.getItem(K.cross);
|
||||
out.noMoreUndo = !h2.canUndo();
|
||||
|
||||
// 3. 크기 — 300 KB 칸 셋 · 작은 칸만 바꾸며 100단
|
||||
data.clear();
|
||||
const big = (c) => c.repeat(300 * 1024);
|
||||
sessionStorage.setItem(K.route, big("r"));
|
||||
sessionStorage.setItem(K.cross, big("x"));
|
||||
sessionStorage.setItem(K.pipes, big("p"));
|
||||
globalThis.gc();
|
||||
const before = process.memoryUsage().heapUsed;
|
||||
const h3 = createProfileHistory(() => {});
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
sessionStorage.setItem("aislo:draft:alignment-draft:p1:7", JSON.stringify({ i }));
|
||||
h3.record();
|
||||
}
|
||||
globalThis.gc();
|
||||
out.heapMb = (process.memoryUsage().heapUsed - before) / 1024 / 1024;
|
||||
let steps = 0;
|
||||
while (h3.undo()) steps += 1;
|
||||
out.steps = steps;
|
||||
writeFileSync(process.argv[2], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def result(tmp_path_factory: pytest.TempPathFactory) -> dict:
|
||||
out = tmp_path_factory.mktemp("ts_history")
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext",
|
||||
"--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck",
|
||||
"--noResolve"],
|
||||
cwd=str(ROOT), check=True, capture_output=True,
|
||||
) # fmt: skip
|
||||
(out / "driver.mjs").write_text(DRIVER, encoding="utf-8")
|
||||
run = subprocess.run( # noqa: S603
|
||||
["node", "--expose-gc", str(out / "driver.mjs"), str(out / "out.json")],
|
||||
capture_output=True, text=True, cwd=str(out),
|
||||
) # fmt: skip
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads((out / "out.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_초안_통_전부를_되돌림(result) -> None:
|
||||
after = result["after"]
|
||||
assert result["undo"] and result["restored"] == 1
|
||||
assert after["route"] == after["cross"] == after["pipes"] == after["legacy"] == "0"
|
||||
# 보기 설정 · pref · result 는 사진 밖 — 되돌리기가 안 건드림
|
||||
assert after["layout"] == after["pref"] == after["result"] == "1"
|
||||
|
||||
|
||||
def test_누름_중간은_안_찍고_놓을_때_한_번(result) -> None:
|
||||
assert result["heldCanUndo"] is False
|
||||
assert result["releasedCanUndo"] is True
|
||||
assert result["oneUndo"] == "a" and result["noMoreUndo"]
|
||||
|
||||
|
||||
def test_큰_칸_100단도_바뀐_칸만_늚(result) -> None:
|
||||
assert result["steps"] == 99 # 한도 100(첫 사진 포함)
|
||||
# 같은 글을 같이 쓰지 않으면 900 KB × 100 ≈ 90 MB(UTF-8 1바이트 글 기준 · 실제는 더)
|
||||
assert result["heapMb"] < 10, result["heapMb"]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user