From 0c463bcc6470fab8bdf3af193d9f626c8c91a36e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:45:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0):?= =?UTF-8?q?=20=EC=A7=80=ED=98=95=20=EB=B0=9B=EA=B8=B0=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=E2=80=94=20=EC=A7=80=EC=A0=81=20=EB=BA=8C=20=C2=B7=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=A1=B0=EA=B0=81=EB=A7=8C=20=C2=B7=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=B0=94=EA=BF=88=20=EB=A9=80=EB=8B=A4=20?= =?UTF-8?q?=C2=B7=20=EB=9D=A0=20=C2=B7=20reset=20=C2=B7=20=EC=B9=B8=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=20=C2=B7=20=EB=81=8A=EA=B9=80=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=EA=B8=80=20=C2=B7=20=EC=9E=84=EB=8F=84?= =?UTF-8?q?=EB=A7=9D=20=C2=B15=20km=20(PLAN=20595=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20463)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지적도를 지형 일에서 뺌(겹 GET 이 받음) · 지적 실패 조각만 다시(열쇠 failed_tiles) - 받기 몸 center · reset — 5 km 넘음 far · 다른 TM 띠 band 물음 · reset = 옛 범위 · 병합 도엽 · 위성 버림 - 받을 칸 500 만 넘음 413 · extent max_cells · lon/lat - 진행 기록 파일 — 서버 다시 켜짐 = interrupted(percent < 100 · message 없음) · 받은 적 없음 percent 100 - 진행 단계 글 n/5 · 받은 범위에 epsg · center 기록 - 임도망 자르기 = 기준점 ±5 km ∪ 받은 지형 · 지형 전에도 올림 · bounds · distance_m · 밖뿐 = 거리 글 - 시험 530_1 · 530_2 · 569_1 · 584_3 덧 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 35 +++- .../D03_PlanAnalysis_ForestRoad.py | 90 ++++++--- D03_PlanAnalysis/D03_PlanAnalysis_Layers.py | 28 +-- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 186 +++++++++++++++--- D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py | 102 ++++++---- resources/tester/test_530_1_plan_terrain.py | 39 +++- resources/tester/test_530_2_plan_router.py | 163 ++++++++++++++- resources/tester/test_569_1_plan_layers.py | 26 ++- .../test_584_3_plan_forest_road_zones.py | 27 ++- 9 files changed, 578 insertions(+), 118 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 1e7cb00e4..acaf8b07a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -27,11 +27,26 @@ export interface PlanBaseView { site: { lon: number; lat: number; address: string | null } | null; } +/** 진행(595-3) — 끊김 = running 거짓 · percent < 100 · message 없음 · interrupted(서버가 다시 켜짐) · + * 받은 적 없음 = percent 100 · message = 「n/5 …」 단계 글. */ export interface PlanJob { running: boolean; percent: number; message: string | null; error: string | null; + interrupted?: boolean; +} + +/** 받기 답(595-2) — 시작 · 멀다(받은 범위에서 5 km 넘음) · 다른 TM 띠 — 둘은 확인 뒤 `reset`. */ +export type TerrainStart = + | ({ status: "accepted" } & PlanJob) + | { status: "far"; distance_m: number; message: string } + | { status: "band"; epsg: number; center_epsg: number; message: string }; + +/** 위치 칸 기준점(경위도 · 캐시) · 새로 받기(옛 받은 범위 버림). */ +export interface TerrainAim { + center?: { lon: number; lat: number } | null; + reset?: boolean; } /** 받기 전 범위(561-2) — 상자(작업 좌표계 m · 경위도 네 귀) · 받은 범위 · 도엽 · 칸 수 어림. */ @@ -45,6 +60,8 @@ export interface TerrainExtent { covered: boolean; sheets: Array<{ no: string; ring: Array<[number, number]>; stored: boolean }>; cells: number; + /** 받기 칸 상한(넘으면 받기 413). */ + max_cells: number; } /** 그은 선 서버 꼴(562-2) — 마크 = 조각 경계 · 조각 없는 구역 = 연도 없음. */ @@ -89,26 +106,33 @@ async function request(path: string, init: RequestInit = {}): Promise { const base = (projectId: string): string => `/projects/${projectId}/plan`; -/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점). */ +/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점) · `center` = 위치 칸 기준점(둘레 ±1 km). */ export const fetchTerrainExtent = ( projectId: string, marginM: number | null, extra: Vertex[], + center: { lon: number; lat: number } | null = null, ): Promise => { const query = new URLSearchParams(); if (marginM !== null) query.set("margin_m", String(marginM)); if (extra.length) query.set("extra", JSON.stringify(extra)); + if (center) { + query.set("lon", String(center.lon)); + query.set("lat", String(center.lat)); + } return request(`${base(projectId)}/terrain/extent?${query}`); }; +/** 받기 — 칸 상한 넘음 = 오류(413 글). */ export const startTerrain = ( projectId: string, marginM: number | null, extra: Vertex[], -): Promise => + aim: TerrainAim = {}, +): Promise => request(`${base(projectId)}/terrain`, { method: "POST", - body: JSON.stringify({ margin_m: marginM, extra }), + body: JSON.stringify({ margin_m: marginM, extra, ...aim }), }); export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> => @@ -206,12 +230,15 @@ export const fetchPlanZeroOrder = (projectId: string): Promise => export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> => request(`${base(projectId)}/layers/bands`); -/** 올린 임도망도(584-3) — 받은 범위로 자른 선 + 가닥 · 길이 m · 원본 선 수 · 좌표계 · 올린 파일. */ +/** 올린 임도망도(584-3 · 595-4) — 기준점 ±5 km ∪ 받은 지형으로 자른 선 + 가닥 · 길이 m · 원본 선 수 · + * 좌표계 · 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 올린 파일. */ export interface PlanForestRoads { count: number; length_m: number; total: number; crs: string[]; + bounds: [number, number, number, number] | null; + distance_m: number | null; source: { files: string[]; bytes: number; uploaded_at: string } | null; collection: GeoJsonCollection; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py b/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py index 052a24828..d18a2b9bd 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py @@ -1,12 +1,13 @@ -"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 요구 443). +"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 595-4 · 요구 443 · 463). - 출처 = 사용자가 받은 산림청 임도망도 SHP(FGIS 무료신청 · 산림빅데이터거래소 · 시군구 단위 · 선) -- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 받은 범위 안 - 선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError`) +- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 자르는 범위 + 안 선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError` + 기준점 ~ 가장 가까운 임도 거리) +- 자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형(`clip_box` · 지형 받기 전에도 기준점만으로) - 원본 = 프로젝트 `D03_PlanAnalysis/임도망도.zip`(낱 파일은 zip 으로 묶음) + `임도망도.json`(파일 이름 · 올린 때) -- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 받은 범위로 잘라 가닥 · 길이 → - 파생 저장본(열쇠 = 원본 지문 · 범위) +- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 자르는 범위로 잘라 가닥 · 길이 · + 잘린 상자(`bounds` 경위도) · 기준점 거리 → 파생 저장본(열쇠 = 원본 지문 · 범위 · 기준점) 계산 자리 = ③ 서버 단독. """ @@ -32,6 +33,7 @@ from common_util.common_util_json import atomic_write_json from common_util.common_util_time import utc_now_text Box = tuple[float, float, float, float] +XY = tuple[float, float] FOLDER = "D03_PlanAnalysis" SOURCE_ZIP = "임도망도.zip" @@ -41,7 +43,17 @@ DBF_ENCODING = "cp949" MAX_BYTES = 100 * 1024 * 1024 SHP_PARTS = (".shp", ".shx", ".dbf", ".prj", ".cpg") ROAD_KIND = "plan_forest_road" -ROAD_VERSION = 1 +ROAD_VERSION = 2 # 잘린 상자 · 기준점 거리 +RADIUS_M = 5000.0 # 기준점 둘레(요구서_463 3장 18) + + +def clip_box(origin: XY, have: Box | None) -> Box: + """자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형.""" + x, y = origin + box = (x - RADIUS_M, y - RADIUS_M, x + RADIUS_M, y + RADIUS_M) + if have is None: + return box + return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3]) def source_dir(project_root: Path) -> Path: @@ -100,18 +112,22 @@ def _plain(value: Any) -> Any: return value if isinstance(value, (str, int, float, bool)) or value is None else str(value) -def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]: - """SHP 선 → 받은 범위로 잘라 경위도 선(원 속성 + `length_m`) · 가닥 · 길이 · 원본 선 수 · - 좌표계.""" +def clip_roads(zip_path: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]: + """SHP 선 → 범위로 잘라 경위도 선(원 속성 + `length_m`) · 가닥 · 길이 · 원본 선 수 · 좌표계 · + 잘린 상자(`bounds` [서, 남, 동, 북] · 없으면 None) · 기준점 ~ 가장 가까운 임도(`distance_m` · + 자르기 전 모든 선).""" from pyproj import Transformer + from shapely.geometry import Point, mapping from shapely.geometry import box as rect - from shapely.geometry import mapping from shapely.ops import transform frame = rect(*box) + here = Point(origin) if origin else None to_ll = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True).transform features: list[dict[str, Any]] = [] total, length, crs = 0, 0.0, [] + near: float | None = None + edges: list[tuple[float, float, float, float]] = [] for shapes in read_shapes(zip_path): crs.append(shapes.crs.to_string()) shapes = shapes[shapes.geometry.notna()] @@ -121,19 +137,18 @@ def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]: for (_, row), geometry in zip( shapes.drop(columns=shapes.geometry.name).iterrows(), metric.geometry ): + if here is not None: + gap = geometry.distance(here) + near = gap if near is None else min(near, gap) part = geometry.intersection(frame) if part.is_empty or part.length <= 0: continue length += part.length props = {str(k): _plain(v) for k, v in row.items()} props["length_m"] = round(part.length, 1) - features.append( - { - "type": "Feature", - "geometry": mapping(transform(to_ll, part)), - "properties": props, - } - ) + lonlat = transform(to_ll, part) + edges.append(lonlat.bounds) + features.append({"type": "Feature", "geometry": mapping(lonlat), "properties": props}) if not total: raise ValueError("SHP 에 선 지물이 없습니다 — 임도망도(선) 파일인지 보세요.") return { @@ -141,23 +156,34 @@ def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]: "length_m": round(length, 1), "total": total, "crs": sorted(set(crs)), + "bounds": [ + min(e[0] for e in edges), + min(e[1] for e in edges), + max(e[2] for e in edges), + max(e[3] for e in edges), + ] + if edges + else None, + "distance_m": None if near is None else round(near, 1), "collection": {"type": "FeatureCollection", "features": features}, } -def road_layer(project_root: Path, box: Box, epsg: int) -> dict[str, Any]: - """올린 임도망도 — 원본 지문 · 범위 같으면 저장본 · 올린 적 없으면 `FileNotFoundError`.""" +def road_layer(project_root: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]: + """올린 임도망도 — 원본 지문 · 범위 · 기준점 같으면 저장본 · 올린 적 없으면 + `FileNotFoundError`.""" path = source_dir(project_root) / SOURCE_ZIP stamp = file_stamp(path) if stamp is None: raise FileNotFoundError( - "올린 임도망도가 없습니다 — 옆 올리기 단추로 시군구 SHP 를 올리세요." + "올린 임도망도가 없습니다 — [임도망 올리기]로 시군구 SHP 를 올리세요." ) - key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box]) + at = [round(v, 1) for v in origin] if origin else None + key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box], at) saved = read_derived(project_root, ROAD_KIND, key) if saved is not None: return saved["value"] - value = clip_roads(path, box, epsg) + value = clip_roads(path, box, epsg, origin) try: value["source"] = json.loads((source_dir(project_root) / SOURCE_META).read_text("utf-8")) except (OSError, ValueError): @@ -167,10 +193,14 @@ def road_layer(project_root: Path, box: Box, epsg: int) -> dict[str, Any]: def save_roads( - project_root: Path, files: list[tuple[str, bytes]], box: Box, epsg: int + project_root: Path, + files: list[tuple[str, bytes]], + box: Box, + epsg: int, + origin: XY | None = None, ) -> dict[str, Any]: - """올림 — 묶어 읽어 보고 받은 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError` - (옛 것 그대로).""" + """올림 — 묶어 읽어 보고 자르는 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError` + (옛 것 그대로 · 기준점 ~ 가장 가까운 임도 거리).""" data = pack(files) folder = source_dir(project_root) folder.mkdir(parents=True, exist_ok=True) @@ -178,11 +208,13 @@ def save_roads( temporary.write(data) trial = Path(temporary.name) try: - got = clip_roads(trial, box, epsg) + got = clip_roads(trial, box, epsg, origin) if not got["count"]: + gap = got["distance_m"] + where = f" — 가장 가까운 임도 {gap / 1000:.1f} km" if gap is not None else "" raise ValueError( - f"받은 범위 안에 임도가 없습니다(파일 선 {got['total']}가닥) — " - "사업지 시군구 파일인지 보세요." + f"임도가 기준점 ±{RADIUS_M / 1000:.0f} km · 받은 지형 밖뿐입니다{where}" + f"(파일 선 {got['total']}가닥) — 사업지 시군구 파일인지 보세요." ) os.replace(trial, folder / SOURCE_ZIP) finally: @@ -195,4 +227,4 @@ def save_roads( "uploaded_at": utc_now_text(), }, ) - return road_layer(project_root, box, epsg) + return road_layer(project_root, box, epsg, origin) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py index 534b74e5e..1d365152e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py @@ -179,31 +179,37 @@ def _covers(have: Any, box: Box) -> bool: def ensure_cadastral(processed: Path, box: Box, epsg: int) -> dict[str, Any]: - """지적도 — 열쇠 상자가 덮고 실패 조각 없으면 그대로 · 아니면 상자 통째로 다시 받음.""" + """지적도 — 열쇠 상자가 덮고 실패 조각 없으면 그대로 · 덮는데 실패 조각이 남았으면 그 조각만 + 다시(PLAN 595-1) · 못 덮으면 상자 통째로 다시 받음.""" path, key_path = processed / CADASTRAL_FILE, processed / CADASTRAL_KEY key = _read_json(key_path) or {} - if path.is_file() and not key.get("failed") and _covers(key.get("box"), box): + covered = path.is_file() and _covers(key.get("box"), box) + if covered and not key.get("failed"): return {"path": path, "fetched": False, "count": key.get("count"), "failed": 0} - parts = tiles(box) + retry = key.get("failed_tiles") if covered else None # 옛 열쇠(조각 기록 없음) = 통째로 + old = ((_read_json(path) or {}).get("features") or []) if retry else [] + frame = key["box"] if retry else [float(v) for v in box] + parts = [tuple(t) for t in retry] if retry else tiles(box) with ThreadPoolExecutor(max_workers=WFS_WORKERS) as pool: results = list(pool.map(lambda part: fetch_tile(part, epsg), parts)) - failed = sum(bad for _, bad in results) - if failed == len(parts): - raise RuntimeError(f"지적도를 받지 못했습니다 — 브이월드 WFS 조각 {failed}개 실패") - features = dedup_parcels([f for got, _ in results for f in got]) + bad = [list(part) for part, (_, n) in zip(parts, results) if n] + if len(bad) == len(parts) and not old: + raise RuntimeError(f"지적도를 받지 못했습니다 — 브이월드 WFS 조각 {len(bad)}개 실패") + features = dedup_parcels([*old, *(f for got, _ in results for f in got)]) processed.mkdir(parents=True, exist_ok=True) atomic_write_json(path, {"type": "FeatureCollection", "features": features}) atomic_write_json( key_path, { - "box": [float(v) for v in box], + "box": frame, "epsg": int(epsg), "count": len(features), - "failed": failed, + "failed": len(bad), + "failed_tiles": bad, }, ) - logger.info("D 지적도 받기: 조각 %d · 실패 %d · 필지 %d", len(parts), failed, len(features)) - return {"path": path, "fetched": True, "count": len(features), "failed": failed} + logger.info("D 지적도 받기: 조각 %d · 실패 %d · 필지 %d", len(parts), len(bad), len(features)) + return {"path": path, "fetched": True, "count": len(features), "failed": len(bad)} # ── 급경사 ────────────────────────────────────────────── diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 6059b375e..3b430b71b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -11,6 +11,10 @@ 범위 = 기준점 둘레(`SITE_RADIUS_M`) + 지점 + 그은 점(`extra` · 지도 보기 귀) 상자 + 여유 ∪ 받은 범위 (PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐). +위치 바꿈(PLAN 595 · 꼴 = PLAN 595 절) = 몸 `center`(위치 칸 캐시 기준점) 둘레를 더함 · +받은 범위에서 `FAR_M` 넘음 = 「far」 물음 · 다른 TM 띠 = 「band」 물음 → `reset` = 옛 범위 버리고 +center 둘레만 · 칸 `MAX_CELLS` 넘음 = 413 · 진행은 `models_plan/plan_job_<일>.json` 에도 적어 +서버가 다시 켜지면 「끊김」(interrupted). 노선 정본(손 그은 선)은 `D03_PlanAnalysis_Network`(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음). """ @@ -28,8 +32,9 @@ from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFil from pydantic import BaseModel from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from common_util.common_util_crs import lonlat_to_tm +from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon from common_util.common_util_http_cache import cached_file_response +from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from D03_PlanAnalysis import D03_PlanAnalysis_ForestRoad as forest_road @@ -50,26 +55,53 @@ _TASKS: set[asyncio.Task] = set() KINDS = ("terrain", "analyze") +class Center(BaseModel): + lon: float + lat: float + + class TerrainBody(BaseModel): - """지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m).""" + """지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m) · + 위치 칸 가운데(경위도 · 595) · 새로(옛 받은 범위 버림).""" margin_m: float | None = None extra: list[tuple[float, float]] = [] + center: Center | None = None + reset: bool = False -async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, float]]]: - """(실경로, 좌표계, [지점들]) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점 둘레 두 귀.""" +Ends = tuple[Path, int, list[tuple[float, float]], int | None] + + +async def _plan_ends( + project_id: UUID, center: tuple[float, float] | None = None, reset: bool = False +) -> Ends: + """(실경로, 좌표계, [지점들], 띠 넘음 epsg) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점 + 둘레 두 귀 · `center`(위치 칸) = 그 둘레를 더함(`reset` = 둘레만) · 다른 TM 띠 = 확인 전이면 + 넷째 값에 그 띠(셋은 `center` 없이) · 확인 뒤(받은 지형이 그 띠) = 그 띠 · 둘레만.""" root, plan = await store.load_plan(project_id) # 없음 404 · 예정노선 아님 400 site = (await network.project_info(project_id))["site"] epsg = network.work_epsg(plan, site) + meta = terrain.received_meta(root) + if center is None and meta.get("center") and meta.get("epsg") not in (None, epsg): + center = tuple(meta["center"]) # 띠 넘어 받은 뒤 [저장] 전 — 받은 지형 띠 · 가운데 + ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]] + if center is not None: + band = tm_epsg_for_lon(center[0]) + if epsg is not None and band != epsg: + if not (reset or meta.get("epsg") == band): + return (*(await _plan_ends(project_id))[:3], band) + epsg, ends, reset = band, [], True # 옛 띠 지점 · 선 = 「노선 지움」 확인으로 빠짐 + epsg = epsg or band + corners = terrain.site_corners(*lonlat_to_tm(center[0], center[1], epsg)) + return root, epsg, corners if reset else [*ends, *corners], None if epsg is None: raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.") - ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]] if site and not ends and not plan["lines"]: # 처음 = 기준점 둘레 · 그 뒤는 받은 범위가 덮음 ends = terrain.site_corners(*lonlat_to_tm(site["lon"], site["lat"], epsg)) if not ends: raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.") - return root, epsg, ends + return root, epsg, ends, None def _query_xy(extra: str | None) -> list[tuple[float, float]]: @@ -81,15 +113,49 @@ def _query_xy(extra: str | None) -> list[tuple[float, float]]: raise HTTPException(status_code=400, detail="extra 꼴이 다릅니다.") from error -def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]: +IDLE = {"running": False, "percent": 100, "message": None, "error": None, "interrupted": False} + + +def _job_path(root: Path, kind: str) -> Path: + return terrain.models_dir_of(root) / f"plan_job_{kind}.json" + + +def _note_job(path: Path | None, job: dict[str, Any]) -> None: + """진행 기록 — 서버가 다시 켜져도 「돌던 일」을 알게(실패해도 일은 계속).""" + if path is None: + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(path, job) + except OSError as error: + logger.warning("D 진행 기록 실패: %s", error) + + +def _saved_job(root: Path, kind: str) -> dict[str, Any]: + """기억에 없는 일 — 기록이 「도는 중」이면 서버가 다시 켜져 끊김(percent < 100 · + message 없음).""" + try: + saved = json.loads(_job_path(root, kind).read_text(encoding="utf-8")) + except (OSError, ValueError): + return dict(IDLE) + if not (isinstance(saved, dict) and saved.get("running")): + return dict(IDLE) + percent = min(99, int(saved.get("percent") or 0)) + return {**IDLE, "percent": percent, "interrupted": True} + + +def _start_job(project_id: UUID, kind: str, work: Any, root: Path | None = None) -> dict[str, Any]: key = (str(project_id), kind) if (_JOBS.get(key) or {}).get("running"): raise HTTPException(status_code=409, detail="이미 돌고 있습니다.") - job = {"running": True, "percent": 0, "message": "시작", "error": None} + job = {"running": True, "percent": 0, "message": "시작", "error": None, "interrupted": False} _JOBS[key] = job + path = _job_path(root, kind) if root is not None else None + _note_job(path, job) def report(percent: int, message: str) -> None: job.update(percent=int(percent), message=message) + _note_job(path, job) async def run() -> None: try: @@ -100,6 +166,7 @@ def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]: job["error"] = str(getattr(error, "detail", None) or error) finally: job["running"] = False + _note_job(path, job) task = asyncio.create_task(run(), name=f"D {kind} {project_id}") _TASKS.add(task) @@ -133,10 +200,16 @@ async def _save_models(project_id: UUID, models: list[dict[str, Any]], epsg: int @router.get("/{project_id}/plan/terrain/extent", response_model=None) async def plan_terrain_extent( - project_id: UUID, margin_m: float | None = None, extra: str | None = None + project_id: UUID, + margin_m: float | None = None, + extra: str | None = None, + lon: float | None = None, + lat: float | None = None, ) -> dict[str, Any]: - """받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]`) 상자 + 여유 ∪ 받은 범위.""" - root, epsg, ends = await _plan_ends(project_id) + """받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]` · `lon` `lat` 위치 칸 둘레) 상자 + 여유 ∪ + 받은 범위.""" + center = (lon, lat) if lon is not None and lat is not None else None + root, epsg, ends, _ = await _plan_ends(project_id, center) view = await asyncio.to_thread( terrain.plan_extent, root, [*ends, *_query_xy(extra)], epsg, margin_m ) @@ -145,34 +218,73 @@ async def plan_terrain_extent( @router.post("/{project_id}/plan/terrain", response_model=None) async def plan_terrain(project_id: UUID, body: TerrainBody | None = None) -> dict[str, Any]: - """사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`.""" + """사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress` · + 위치 칸 가운데가 멀거나 다른 띠 = 물음(`far` · `band`) · 칸 상한 넘음 = 413.""" body = body or TerrainBody() - root, epsg, ends = await _plan_ends(project_id) + center = (body.center.lon, body.center.lat) if body.center else None + root, epsg, ends, band = await _plan_ends(project_id, center, body.reset) + if band is not None: + return { + "status": "band", + "epsg": epsg, + "center_epsg": band, + "message": "다른 좌표 띠로 옮깁니다 — 그은 지점 · 선을 지우고 새로 받을까요?", + } + have = None if body.reset else terrain.received_box(root, epsg) + if center is not None and have is not None: + far = terrain.distance_to_box(lonlat_to_tm(center[0], center[1], epsg), have) + if far > terrain.FAR_M: + return { + "status": "far", + "distance_m": round(far), + "message": f"받은 지형에서 {far / 1000:.1f} km 떨어졌습니다 — " + "옛 받은 범위를 버리고 새로 받을까요?", + } points = [*ends, *body.extra] + cells = terrain.cells_of(terrain.wanted_box(points, terrain.clamp_margin(body.margin_m), have)) + if cells > terrain.MAX_CELLS: + raise HTTPException( + status_code=413, + detail=f"받을 범위가 너무 큽니다 — 칸 {cells / 1e4:.0f}만 · " + f"상한 {terrain.MAX_CELLS / 1e4:.0f}만(지점 · 선을 모으거나 위치를 옮겨 새로 받으세요)", + ) async def work(report: Any) -> None: got = await asyncio.to_thread( - terrain.ensure_plan_terrain, root, points, epsg, body.margin_m, report + terrain.ensure_plan_terrain, + root, + points, + epsg, + body.margin_m, + report, + reset=have is None, + center=center, ) await _save_models(project_id, got["models"], epsg) report(100, f"지형 받기 끝 — 새 도엽 {len(got['sheets']['downloaded'])}매") - return _start_job(project_id, "terrain", work) + return _start_job(project_id, "terrain", work, root) @router.get("/{project_id}/plan/progress", response_model=None) async def plan_progress(project_id: UUID) -> dict[str, Any]: - idle = {"running": False, "percent": 0, "message": None, "error": None} - return { - "status": "success", - **{kind: dict(_JOBS.get((str(project_id), kind)) or idle) for kind in KINDS}, - } + """일마다 진행 — 기억에 없으면 기록을 봄(돌던 기록 = 끊김 · 없으면 percent 100).""" + jobs: dict[str, Any] = {} + for kind in KINDS: + job = _JOBS.get((str(project_id), kind)) + if job is None: + try: + job = await asyncio.to_thread(_saved_job, await _tile_root(project_id), kind) + except HTTPException: + job = IDLE + jobs[kind] = dict(job) + return {"status": "success", **jobs} async def _layer_root(project_id: UUID) -> tuple[Path, int, terrain.Box]: """(실경로, 좌표계, 받은 범위) — 지형 받기 전이면 404.""" - root, epsg, _ = await _plan_ends(project_id) - box = terrain.received_box(root) + root, epsg, _, _ = await _plan_ends(project_id) + box = terrain.received_box(root, epsg) if box is None: raise HTTPException(status_code=404, detail="지형 받기 뒤 보입니다.") return root, epsg, box @@ -301,12 +413,30 @@ async def plan_layer_zero_order( return {"status": "success", **value} +async def _road_frame(project_id: UUID) -> tuple[Path, int, terrain.Box, tuple[float, float]]: + """(실경로, 좌표계, 자르는 범위, 기준점 m) — 기준점 = 위치 칸으로 받은 가운데 · 등록 기준점 · + 지점 가운데 차례 · 지형 받기 전에도(595-4).""" + root, epsg, ends, _ = await _plan_ends(project_id) + center = terrain.received_meta(root).get("center") + site = (await network.project_info(project_id))["site"] + if center: + origin = lonlat_to_tm(float(center[0]), float(center[1]), epsg) + elif site: + origin = lonlat_to_tm(site["lon"], site["lat"], epsg) + else: + xs, ys = [p[0] for p in ends], [p[1] for p in ends] + origin = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2) + box = forest_road.clip_box(origin, terrain.received_box(root, epsg)) + return root, epsg, box, origin + + @router.get("/{project_id}/plan/layers/forest-roads", response_model=None) async def plan_layer_forest_roads(project_id: UUID) -> dict[str, Any]: - """올린 임도망도(받은 범위로 자른 선 · 가닥 · 길이) — 올린 적 없으면 404.""" - root, epsg, box = await _layer_root(project_id) + """올린 임도망도(기준점 ± 5 km ∪ 받은 지형으로 자른 선 · 가닥 · 길이 · 잘린 상자 · 기준점 + 거리) — 올린 적 없으면 404.""" + root, epsg, box, origin = await _road_frame(project_id) try: - value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg) + value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg, origin) except FileNotFoundError as error: raise HTTPException(status_code=404, detail=str(error)) from error return {"status": "success", **value} @@ -317,8 +447,8 @@ async def plan_layer_forest_roads_upload( project_id: UUID, files: list[UploadFile] = File(...) ) -> dict[str, Any]: """임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음 · 합계 100 MB) — 못 읽거나 - 받은 범위 안 선이 없으면 400(옛 것 그대로) · 넘치면 413.""" - root, epsg, box = await _layer_root(project_id) + 자르는 범위 안 선이 없으면 400(옛 것 그대로 · 가장 가까운 임도 거리) · 넘치면 413.""" + root, epsg, box, origin = await _road_frame(project_id) got: list[tuple[str, bytes]] = [] total = 0 for upload in files: @@ -335,7 +465,7 @@ async def plan_layer_forest_roads_upload( if not total: raise HTTPException(status_code=400, detail="빈 파일입니다.") try: - value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg) + value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg, origin) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error return {"status": "success", **value} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py index 866937b52..6827181ef 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py @@ -1,13 +1,17 @@ -"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · PLAN 530-1 · 561-2). +"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · 463 · PLAN 530-1 · +561-2 · 595). 지형은 D 프로젝트 storage 안 `B04_PreProcess/` 배치 그대로(지형 코드 주인 = B04 · 고칠 곳 0): -- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음) +- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음) · + 새로(`reset` · 위치를 멀리 옮김) = 옛 받은 범위 · 병합 도엽 · 위성 범위를 버리고 새 상자만 · + 칸 상한 `MAX_CELLS`(넘으면 라우터가 거절) - 도엽 = 범위 상자에 닿는 도엽 모두 → `ensure_sheets`(없는 것만 · prune 없음) - 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀) - 위성 = 범위 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀) -- 지적도 = 범위 상자 · 브이월드 WFS 1 km 조각(`D03_PlanAnalysis_Layers` · 실패해도 계속) +- 지적도 = 지형 일에서 뺌 — 겹을 켤 때 GET 이 받음(`D03_PlanAnalysis_Layers.ensure_cadastral`) - DEM = 한 벌 `models_plan/`(연도는 D03 나눔 뒤에 정해짐) · 받은 범위 = `plan_terrain.json` + (상자 · 여유 · 좌표계 · 위치 칸 가운데) - 작업 좌표계 = 지점 epsg · `B03_FileInput/input/prj/plan.prj` 한 장(B04 · B05 좌표계 창구가 읽음) - 받기 전 범위 보기 = `plan_extent`(내려받기 없음) · 받기는 사용자가 누를 때만 @@ -43,7 +47,6 @@ from config.config_system_terrain import ( SHEET_SURFACE_DEFAULT_METHOD, SHEET_SURFACE_MARGIN_M, ) -from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers logger = logging.getLogger(__name__) @@ -52,6 +55,9 @@ DEFAULT_MARGIN_M = 350.0 MAX_MARGIN_M = 5000.0 # 처음 받는 범위 = 등록 기준점 둘레 ± 1 km(요구 453 · PLAN 591-2) SITE_RADIUS_M = 1000.0 +# 위치 바꿈이 받은 범위에서 이만큼 넘게 멀면 묻고 새로 · 받을 DEM 칸 상한(요구서_463 3장 4) +FAR_M = 5000.0 +MAX_CELLS = 5_000_000 STAGE = Path("B04_PreProcess") PLAN_MODELS = "models_plan" TERRAIN_META = "plan_terrain.json" @@ -74,9 +80,16 @@ def clamp_margin(margin_m: float | None) -> float: return min(MAX_MARGIN_M, max(SHEET_SURFACE_MARGIN_M, float(margin_m))) -def received_box(project_root: Path) -> Box | None: - """받은 DEM 범위 — 받은 적 없거나 읽지 못하면 None.""" - meta = _read_json(models_dir_of(project_root) / TERRAIN_META) or {} +def received_meta(project_root: Path) -> dict[str, Any]: + """받은 범위 기록(`box` · `margin_m` · `epsg` · `center`) — 없으면 빈 dict.""" + return _read_json(models_dir_of(project_root) / TERRAIN_META) or {} + + +def received_box(project_root: Path, epsg: int | None = None) -> Box | None: + """받은 DEM 범위 — 받은 적 없거나 읽지 못하거나 다른 좌표계(`epsg` 줄 때)면 None.""" + meta = received_meta(project_root) + if epsg is not None and meta.get("epsg") not in (None, int(epsg)): + return None try: x0, y0, x1, y1 = (float(v) for v in meta["box"]) except (KeyError, TypeError, ValueError): @@ -99,6 +112,18 @@ def wanted_box(points: list[XY], margin_m: float, have: Box | None) -> Box: return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3]) +def cells_of(box: Box) -> int: + """DEM 칸 수 어림 — 받기 시간 · 메모리 가늠 · 상한 판정.""" + return int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2) + + +def distance_to_box(xy: XY, box: Box) -> float: + """점 ~ 상자 거리(m · 안이면 0).""" + dx = max(box[0] - xy[0], 0.0, xy[0] - box[2]) + dy = max(box[1] - xy[1], 0.0, xy[1] - box[3]) + return math.hypot(dx, dy) + + def covers(have: Box | None, box: Box) -> bool: return ( have is not None @@ -135,7 +160,7 @@ def plan_extent( ) -> dict[str, Any]: """받기 전 범위 보기 — 내려받기 없음. `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m).""" margin = clamp_margin(margin_m) - have = received_box(project_root) + have = received_box(project_root, epsg) box = wanted_box(points, margin, have) store = get_project_map_sheets_dir(project_root) sheets = [] @@ -157,8 +182,8 @@ def plan_extent( "have": box_ring(have, epsg) if have else None, "covered": covers(have, box), "sheets": sheets, - # DEM 칸 수 어림 — 받기 시간 · 메모리 가늠 - "cells": int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2), + "cells": cells_of(box), + "max_cells": MAX_CELLS, } @@ -181,10 +206,12 @@ def _read_json(path: Path) -> dict[str, Any] | None: return None -def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[str]: - """병합 = 지난번 병합 도엽(zip 남은 것) ∪ 이번 도엽 · 열쇠 같으면 건너뜀.""" +def _merge_sheets( + processed: Path, store: Path, available: list[str], keep_old: bool = True +) -> list[str]: + """병합 = 지난번 병합 도엽(zip 남은 것 · 새로면 뺌) ∪ 이번 도엽 · 열쇠 같으면 건너뜀.""" key_path = processed / "sheet_merge.key.json" - old = (_read_json(key_path) or {}).get("sheets") or [] + old = ((_read_json(key_path) or {}).get("sheets") or []) if keep_old else [] sheets = sorted({*available, *(s for s in old if get_sheet_path(store, s) is not None)}) key = {"sheets": sheets, "parser": PARSER_VERSION} if sheets and (_read_json(key_path) != key or not any(processed.glob("도엽_*.geojson"))): @@ -193,8 +220,15 @@ def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[st return sheets -def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], epsg: int) -> str: - """위성 = 기준 도엽 도곽 ∪ 옛 범위 · 이미 덮으면 건너뜀. 돌려줌 = skip · done · failed.""" +def _satellite( + processed: Path, + prj: Path, + points: list[tuple[float, float]], + epsg: int, + keep_old: bool = True, +) -> str: + """위성 = 기준 도엽 도곽 ∪ 옛 범위(새로면 뺌) · 이미 덮으면 건너뜀. 돌려줌 = skip · done · + failed.""" from pyproj import Transformer to_metric = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True) @@ -207,7 +241,7 @@ def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], ep xs.append(x) ys.append(y) need = {"x": [min(xs), max(xs)], "y": [min(ys), max(ys)]} - meta = _read_json(processed / "vworld_satellite_meta.json") + meta = _read_json(processed / "vworld_satellite_meta.json") if keep_old else None try: old = [float(meta[k]) for k in ("x_min", "x_max", "y_min", "y_max")] if meta else None except (KeyError, TypeError, ValueError): @@ -234,8 +268,11 @@ def ensure_plan_terrain( epsg: int, margin_m: float | None = None, on_progress: Progress | None = None, + reset: bool = False, + center: tuple[float, float] | None = None, ) -> dict[str, Any]: - """사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m). + """사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m) · + `reset` = 옛 받은 범위 버리고 새로 · `center` = 위치 칸 가운데(경위도 · 기록만). 돌려줌 = 도엽(받은 · 실패 · 병합) · 위성 상태 · 받은 범위 · 모델 등록용 dict 목록. """ @@ -250,28 +287,24 @@ def ensure_plan_terrain( processed.mkdir(parents=True, exist_ok=True) prj = write_plan_prj(project_root, epsg) margin = clamp_margin(margin_m) - box = wanted_box(points, margin, received_box(project_root)) + have = None if reset else received_box(project_root, epsg) + box = wanted_box(points, margin, have) - report(5, "수치지형도 도엽 고르는 중") + report(5, "1/5 수치지형도 도엽 고르는 중") wanted = sheets_for_box(box, epsg) store = get_project_map_sheets_dir(project_root) before = {s for s in wanted if get_sheet_path(store, s) is not None} - report(10, f"수치지형도 도엽 {len(wanted) - len(before)}매 받는 중") + report(10, f"2/5 수치지형도 도엽 {len(wanted) - len(before)}매 받는 중") got = ensure_sheets(store, wanted) if not got["available"]: raise ValueError(f"수치지형도 도엽을 하나도 받지 못했습니다: {got['failed']}") - report(45, "도엽 레이어 병합 중") - merged = _merge_sheets(processed, store, list(got["available"])) - report(60, "위성 지도 받는 중") - satellite = _satellite(processed, prj, [(p[1], p[0]) for p in box_ring(box, epsg)], epsg) - report(62, "지적도 받는 중") - try: - cadastral = layers.ensure_cadastral(processed, box, epsg)["count"] - except Exception as exc: # noqa: BLE001 — 지적도는 겹 · 지형 받기를 막지 않음(켤 때 다시) - logger.warning("D 지적도 받기 실패: %s", exc) - cadastral = None + report(45, "3/5 도엽 레이어 병합 중") + merged = _merge_sheets(processed, store, list(got["available"]), keep_old=have is not None) + report(60, "4/5 위성 지도 받는 중") + ring = [(p[1], p[0]) for p in box_ring(box, epsg)] + satellite = _satellite(processed, prj, ring, epsg, keep_old=have is not None) - report(65, "사업 지형면 만드는 중") + report(65, f"5/5 사업 지형면 만드는 중 — 칸 {cells_of(box) / 1e4:.0f}만 · 수 분 걸릴 수 있음") models_dir = models_dir_of(project_root) models_dir.mkdir(parents=True, exist_ok=True) # B 절취 = 넘긴 꼭짓점 상자 + `SHEET_SURFACE_MARGIN_M` → 그만큼 안쪽으로 넘김(여유 ≥ 그 값) @@ -293,7 +326,11 @@ def ensure_plan_terrain( "사업 지형면을 만들지 못했습니다 — 범위 안 도엽 등고선 높이 확인" f"(병합 도엽 {len(merged)}매)" ) - atomic_write_json(models_dir / TERRAIN_META, {"box": list(box), "margin_m": margin}) + meta: dict[str, Any] = {"box": list(box), "margin_m": margin, "epsg": int(epsg)} + kept = None if reset else received_meta(project_root).get("center") + if center or kept: + meta["center"] = list(center or kept) + atomic_write_json(models_dir / TERRAIN_META, meta) report(100, "지형 받기 끝") return { "sheets": { @@ -303,7 +340,6 @@ def ensure_plan_terrain( "merged": merged, }, "satellite": satellite, - "cadastral": cadastral, "box_m": list(box), "models": models, } diff --git a/resources/tester/test_530_1_plan_terrain.py b/resources/tester/test_530_1_plan_terrain.py index 082c77c00..74af4f6a1 100644 --- a/resources/tester/test_530_1_plan_terrain.py +++ b/resources/tester/test_530_1_plan_terrain.py @@ -1,5 +1,6 @@ -"""D 예정노선 지형 받기(PLAN 530-1 · 561-2) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 · -옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음. +"""D 예정노선 지형 받기(PLAN 530-1 · 561-2 · 595) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 · +옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음 · 지적도 뺌 · 새로(reset) = 옛 범위 버림 · +받은 범위 좌표계 · 칸 상한 · 거리. ./venv/Scripts/python.exe -m pytest resources/tester/test_530_1_plan_terrain.py -q """ @@ -54,7 +55,6 @@ def fake(tmp_path, monkeypatch): monkeypatch.setattr(terrain, "parse_and_merge_sheets", merge) monkeypatch.setattr(terrain, "download_vworld_satellite_map", satellite) monkeypatch.setattr(terrain, "build_sheet_surface_model", dem) - monkeypatch.setattr(terrain.layers, "ensure_cadastral", lambda *a: {"count": 0}) # 569-1 return tmp_path, seen @@ -104,6 +104,39 @@ def test_one_set_and_extend_keeps_old(fake) -> None: assert len(seen["merge"]) == merges and third["box_m"] == grown +def test_reset_drops_old_box_sheets_satellite(fake) -> None: + """595-2 — 새로 = 옛 받은 범위 · 병합 도엽 · 위성 범위 버림 · 좌표계 · 가운데 기록 · + 다른 좌표계로 물으면 받은 범위 없음 · 지적도는 지형 일에 없음.""" + root, seen = fake + terrain.ensure_plan_terrain(root, [A, B], EPSG, center=(127.5, 36.0)) + old = set(seen["merge"][-1]) + far = [(A[0] + 40000.0, A[1]), (A[0] + 41000.0, A[1] + 1000.0)] + got = terrain.ensure_plan_terrain(root, far, EPSG, reset=True, center=(128.0, 36.0)) + m = terrain.DEFAULT_MARGIN_M + assert got["box_m"] == pytest.approx( + [far[0][0] - m, far[0][1] - m, far[1][0] + m, far[1][1] + m] + ) + assert not old & set(seen["merge"][-1]) # 옛 도엽 병합에서 빠짐(zip 은 남음) + assert seen["satellite"][-1]["x"][0] > A[0] + 30000 # 옛 위성 범위와 안 합침 + assert "cadastral" not in got + meta = terrain.received_meta(root) + assert meta["epsg"] == EPSG and meta["center"] == [128.0, 36.0] + assert terrain.received_box(root, EPSG) == pytest.approx(tuple(got["box_m"])) + assert terrain.received_box(root, 5187) is None # 다른 띠 = 받은 범위 아님 + # 새로 아닌 이어 받기 = 가운데 기록 그대로 + terrain.ensure_plan_terrain(root, far, EPSG) + assert terrain.received_meta(root)["center"] == [128.0, 36.0] + + +def test_cells_distance_and_limit(fake) -> None: + root, _ = fake + assert terrain.cells_of((0.0, 0.0, 2000.0, 2000.0)) == 1_000_000 # 2 m 격자 + assert terrain.distance_to_box((5.0, 5.0), (0.0, 0.0, 10.0, 10.0)) == 0 + assert terrain.distance_to_box((13.0, 14.0), (0.0, 0.0, 10.0, 10.0)) == 5 + assert terrain.MAX_CELLS == 5_000_000 and terrain.FAR_M == 5000 + assert terrain.plan_extent(root, [A, B], EPSG)["max_cells"] == terrain.MAX_CELLS + + def test_extent_no_download_and_covered(fake) -> None: root, seen = fake before = terrain.plan_extent(root, [A, B], EPSG) diff --git a/resources/tester/test_530_2_plan_router.py b/resources/tester/test_530_2_plan_router.py index 483c6932e..10bfe6970 100644 --- a/resources/tester/test_530_2_plan_router.py +++ b/resources/tester/test_530_2_plan_router.py @@ -1,5 +1,6 @@ -"""D 지형 API(PLAN 530-1 · 561-2 · 591-2) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 · 선이 -없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌. +"""D 지형 API(PLAN 530-1 · 561-2 · 591-2 · 595) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 · +선이 없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌 · 위치 칸 가운데 = 둘레 덧 · 멀다 · 다른 띠 +물음 · 새로(reset) · 칸 상한 413 · 서버 다시 켜짐 = 끊김. 옛 분석 API 시험(GET · PUT analysis · 꺾임점 고침)은 옛 추천 엔진과 함께 걷음(PLAN 562-2) · 노선 정본 시험 = `test_530_2_plan_analyze.py` · 지형 셈 시험 = `test_530_1_plan_terrain.py`. @@ -20,6 +21,8 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2" P0, P2 = (0.0, 0.0), (900.0, 400.0) URL = f"/api/projects/{PID}/plan/terrain" +OX, OY = 200000.0, 500000.0 # 595 시험 = 중부 띠 안(경도 127°) +Q0, Q2 = (OX + P0[0], OY + P0[1]), (OX + P2[0], OY + P2[1]) class _Pool: @@ -51,6 +54,7 @@ def client(tmp_path, monkeypatch): test = TestClient(app) test.plan = plan test.info = info + test.root = tmp_path return test @@ -90,14 +94,14 @@ def test_post_runs_one_set(client, monkeypatch) -> None: saved: list = [] works: list = [] - def ensure(root, points, epsg, margin_m, report): + def ensure(root, points, epsg, margin_m, report, **kw): calls.append((points, epsg, margin_m)) return {"sheets": {"downloaded": ["1"]}, "models": [{"model_file_path": "m"}]} async def save(project_id, models, epsg): saved.append((models, epsg)) - def start(project_id, kind, work): + def start(project_id, kind, work, root=None): works.append(work) return {"status": "accepted", "kind": kind} @@ -116,3 +120,154 @@ def test_post_runs_one_set(client, monkeypatch) -> None: # 옛 화면 꼴(points 몸) — 여유 처음 값 · 전체 시점 · 종점 post({"points": []}) assert calls[-1] == ([P0, P2], 5186, None) + + +# ── 595 위치 바꿈 · 상한 · 끊김 ── + + +@pytest.fixture +def posting(client, monkeypatch): + """POST 받기 — 일은 바로 돌림 · ensure 부름(점 · 좌표계 · reset · center) 기록.""" + calls: list = [] + + def ensure(root, points, epsg, margin_m, report, reset=False, center=None): + calls.append({"points": points, "epsg": epsg, "reset": reset, "center": center}) + return {"sheets": {"downloaded": []}, "models": []} + + async def save(project_id, models, epsg): + return None + + works: list = [] + + def start(project_id, kind, work, root=None): + works.append(work) + return {"status": "accepted", "kind": kind} + + def post(body): + res = client.post(URL, json=body) + while works: # 일은 응답 뒤 돌림(앱 고리 밖) + asyncio.run(works.pop()(lambda percent, message: None)) + return res + + monkeypatch.setattr(router_mod.terrain, "ensure_plan_terrain", ensure) + monkeypatch.setattr(router_mod, "_save_models", save) + monkeypatch.setattr(router_mod, "_start_job", start) + client.plan["points"] = [{"x": Q0[0], "y": Q0[1]}, {"x": Q2[0], "y": Q2[1]}] + client.calls = calls + client.run_post = post + return client + + +def _have(client, box, epsg=5186, center=None): + meta = {"box": list(box), "margin_m": 350.0, "epsg": epsg} + if center: + meta["center"] = list(center) + folder = router_mod.terrain.models_dir_of(client.root) + folder.mkdir(parents=True, exist_ok=True) + (folder / router_mod.terrain.TERRAIN_META).write_text(json.dumps(meta), "utf-8") + + +def test_center_near_adds_site_ring(posting) -> None: + """가까운 위치 바꿈 = 지점 + 새 가운데 둘레 · 받은 범위와 합침.""" + from common_util.common_util_crs import lonlat_to_tm, tm_to_lonlat + + _have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0)) + lon, lat = tm_to_lonlat(OX + 3000.0, OY, 5186) # 받은 범위에서 1.5 km + res = posting.run_post({"center": {"lon": lon, "lat": lat}}).json() + assert res["status"] == "accepted" + call = posting.calls[-1] + x, y = lonlat_to_tm(lon, lat, 5186) + r = router_mod.terrain.SITE_RADIUS_M - router_mod.terrain.DEFAULT_MARGIN_M + assert call["points"][:2] == [Q0, Q2] and call["points"][2] == pytest.approx((x - r, y - r)) + assert call["reset"] is False and call["center"] == (lon, lat) + # extent 에도 캐시 가운데 = 둘레가 상자에 들어감 + got = posting.get(f"{URL}/extent", params={"lon": lon, "lat": lat}).json() + assert got["box_m"][2] == pytest.approx(x + router_mod.terrain.SITE_RADIUS_M) + + +def test_center_far_asks_then_reset(posting) -> None: + """5 km 넘음 = 「far」(받지 않음) · reset = 옛 범위 버리고 새 ±1 km(지점 안 넣음).""" + from common_util.common_util_crs import tm_to_lonlat + + _have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0)) + lon, lat = tm_to_lonlat(OX + 8000.0, OY, 5186) # 6.5 km + body = {"center": {"lon": lon, "lat": lat}} + far = posting.run_post(body).json() + assert far["status"] == "far" and far["distance_m"] == pytest.approx(6500, abs=2) + assert "km" in far["message"] and posting.calls == [] + res = posting.run_post({**body, "reset": True}).json() + assert res["status"] == "accepted" + call = posting.calls[-1] + assert call["reset"] is True and len(call["points"]) == 2 # 둘레 두 귀만 + assert call["points"][0][0] == pytest.approx(OX + 8000 - 650, abs=1) + + +def test_center_other_band_asks_then_moves(posting) -> None: + """다른 TM 띠 = 「band」 · 확인(reset) 뒤 그 띠 · 그 뒤엔 받은 지형 띠를 따름.""" + posting.plan["lines"] = [{"id": "a"}] + body = {"center": {"lon": 129.1017, "lat": 36.8128}} # 용화(동부 5187) + ask = posting.run_post(body).json() + assert ask == { + "status": "band", + "epsg": 5186, + "center_epsg": 5187, + "message": ask["message"], + } + assert posting.calls == [] + # 확인 전 extent = 가운데 무시(옛 띠 그대로) + assert posting.get(f"{URL}/extent", params={"lon": 129.1, "lat": 36.8}).json()["epsg"] == 5186 + posting.run_post({**body, "reset": True}) + call = posting.calls[-1] + assert call["epsg"] == 5187 and call["reset"] is True and len(call["points"]) == 2 + # 받은 뒤([저장] 전) — 가운데 없이 물어도 받은 지형 띠 · 가운데 둘레 + (x0, y0), (x1, y1) = call["points"] + box = [x0 - 400, y0 - 400, x1 + 400, y1 + 400] + _have(posting, box, epsg=5187, center=(129.1017, 36.8128)) + got = posting.get(f"{URL}/extent").json() + assert got["epsg"] == 5187 and got["covered"] is True + + +def test_cells_limit_413(posting) -> None: + posting.plan["points"].append( + {"x": OX + 6000.0, "y": OY + 6000.0} + ) # 6.7 × 6.7 km = 1,100 만 칸 + res = posting.run_post({}) + assert res.status_code == 413 and "상한 500만" in res.json()["detail"] + assert posting.calls == [] + + +def test_progress_interrupted_after_restart(client, monkeypatch) -> None: + """도는 기록이 남았는데 기억에 없음(서버 다시 켜짐) = 끊김 · 받은 적 없음 = percent 100.""" + root = client.root + + async def tile_root(project_id): + return root + + monkeypatch.setattr(router_mod, "_tile_root", tile_root) + url = f"/api/projects/{PID}/plan/progress" + router_mod._JOBS.clear() + idle = client.get(url).json()["terrain"] + assert idle == {**router_mod.IDLE} + + path = router_mod._job_path(root, "terrain") + seen = [] + + async def work(report): + report(65, "5/5 사업 지형면 만드는 중") + seen.append(json.loads(path.read_text("utf-8"))) + + async def run(): + router_mod._start_job(PID, "terrain", work, root) + await asyncio.sleep(0.05) + + asyncio.run(run()) + assert seen[0]["running"] is True and seen[0]["percent"] == 65 # 도는 동안 기록 + assert json.loads(path.read_text("utf-8"))["running"] is False # 끝 기록 + path.write_text(json.dumps(seen[0]), "utf-8") # 도는 중 꺼짐 + router_mod._JOBS.clear() # 다시 켜짐 = 기억 빔 + got = client.get(url).json()["terrain"] + assert got["running"] is False and got["interrupted"] is True + assert got["percent"] == 65 and got["message"] is None and got["error"] is None + # 끝난 기록 = 끊김 아님 + path.write_text(json.dumps({**seen[0], "running": False}), "utf-8") + assert client.get(url).json()["terrain"] == {**router_mod.IDLE} diff --git a/resources/tester/test_569_1_plan_layers.py b/resources/tester/test_569_1_plan_layers.py index 56fdf0019..60c745a83 100644 --- a/resources/tester/test_569_1_plan_layers.py +++ b/resources/tester/test_569_1_plan_layers.py @@ -1,5 +1,5 @@ -"""D 노선분석 지도 겹(PLAN 569-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 · -겹친 필지 거름 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈. +"""D 노선분석 지도 겹(PLAN 569-1 · 595-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 · +겹친 필지 거름 · 실패 조각만 다시 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈. ./venv/Scripts/python.exe -m pytest resources/tester/test_569_1_plan_layers.py -q """ @@ -127,9 +127,29 @@ def test_cadastral_failures(tmp_path, monkeypatch) -> None: monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=1)) first = layers.ensure_cadastral(tmp_path, BOX, EPSG) assert first["failed"] == 1 and (tmp_path / layers.CADASTRAL_FILE).exists() + assert ( + len(json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8"))["failed_tiles"]) == 1 + ) + # 595-1 — 실패 조각만 다시 · 필지는 옛 것 + 그 조각 = 통째로 받은 것과 같음 again = FakeWfs() monkeypatch.setattr(layers, "request_vworld_wfs", again) - assert layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 15 + retry = layers.ensure_cadastral(tmp_path, BOX, EPSG) + assert retry["fetched"] and again.calls == 1 and retry["failed"] == 0 + assert retry["count"] == 15 * 2 + 1 + assert not layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 1 + # 옛 열쇠(실패 조각 기록 없음) = 통째로 + key = json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8")) + key.update(failed=1) + key.pop("failed_tiles") + (tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8") + layers.ensure_cadastral(tmp_path, BOX, EPSG) + assert again.calls == 1 + 15 + # 다시 받기도 모두 실패 = 옛 필지 그대로 · 실패 조각 남김(오류 아님) + key.update(failed=1, failed_tiles=[list(layers.tiles(BOX)[0])]) + (tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8") + monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=99)) + kept = layers.ensure_cadastral(tmp_path, BOX, EPSG) + assert kept["failed"] == 1 and kept["count"] == 15 * 2 + 1 # ── 급경사 ── diff --git a/resources/tester/test_584_3_plan_forest_road_zones.py b/resources/tester/test_584_3_plan_forest_road_zones.py index 87ad0b83e..f5e43748c 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -1,5 +1,6 @@ -"""D 노선분석 GIS 겹 더함 3(PLAN 584-3) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면 5179 · -cp949 · 받은 범위로 자름 · 범위 밖 = 받지 않음 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 · +"""D 노선분석 GIS 겹 더함 3(PLAN 584-3 · 595-4) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면 +5179 · cp949 · 기준점 ±5 km ∪ 받은 지형으로 자름 · 잘린 상자 · 기준점 거리 · 범위 밖 = 받지 않음 + +거리 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 · 도분초 · 지번) · 점 · 지적 필지 맞춤 · 표 없는 시도 · 화면 겹 정의. ./venv/Scripts/python.exe -m pytest resources/tester/test_584_3_plan_forest_road_zones.py -q @@ -81,7 +82,7 @@ def test_bad_uploads_keep_old(tmp_path) -> None: ([("a.zip", b"not a zip")], "zip 파일을 열지"), ([(n, d) for n, d in files if not n.endswith(".dbf")], ".dbf"), ([("a.zip", _zip({"readme.txt": b"x"}))], ".shp 파일이 없습니다"), - (_shp_files(tmp_path / "far", [LINES[2]]), "받은 범위 안에 임도가 없습니다"), + (_shp_files(tmp_path / "far", [LINES[2]]), "받은 지형 밖뿐입니다"), ] for upload, text in bad: with pytest.raises(ValueError, match=re.escape(text)): @@ -91,6 +92,26 @@ def test_bad_uploads_keep_old(tmp_path) -> None: assert not list((tmp_path / fr.FOLDER).glob("*.new")) +def test_clip_box_site_5km_and_distance(tmp_path) -> None: + """595-4 — 자르는 범위 = 기준점 ±5 km ∪ 받은 지형 · 잘린 상자 · 가장 가까운 임도 거리 · + 밖뿐 = 거절 + 거리.""" + site = (231000.0, 450600.0) + frame = fr.clip_box(site, BOX) + assert frame == (226000.0, 445600.0, 236000.0, 455600.0) # 5 km 상자가 받은 지형을 덮음 + assert fr.clip_box(site, (220000.0, 450000.0, 232000.0, 451000.0))[0] == 220000.0 # ∪ + assert fr.clip_box(site, None) == frame + got = fr.save_roads(tmp_path, _shp_files(tmp_path / "a", LINES), frame, EPSG, site) + assert (got["count"], got["total"]) == (2, 3) # 범위 밖 한 가닥(25 km)만 빠짐 + assert got["length_m"] == pytest.approx(2000 + 300, abs=1) # 받은 지형 밖 반도 들어옴 + assert got["distance_m"] == pytest.approx(100, abs=0.5) # 450600 ~ 450500 + w, s, e, n = got["bounds"] + assert (w, s) == pytest.approx(tm_to_lonlat(229000, 450100, EPSG), abs=1e-4) + assert (e, n) == pytest.approx(tm_to_lonlat(231500, 450500, EPSG), abs=1e-4) + far = _shp_files(tmp_path / "far", [LINES[2]]) + with pytest.raises(ValueError, match=r"가장 가까운 임도 2[0-9]\.[0-9] km\(파일 선 1가닥\)"): + fr.save_roads(tmp_path, far, frame, EPSG, site) + + def test_no_upload_is_not_found(tmp_path) -> None: with pytest.raises(FileNotFoundError): fr.road_layer(tmp_path, BOX, EPSG)