Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

- 충돌 D03_PlanAnalysis_Api.ts: 지형 받기 꼴 이름(TerrainCenter · TerrainAim) 같은 뜻 둘 → sub2(595 몫) TerrainAim 쪽 살림 · 쓰는 곳 없음
- PlanForestRoads bounds · distance_m 두 벌 → sub2 한 벌 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 14:49:10 +09:00
co-authored by Claude Opus 5.5
9 changed files with 564 additions and 128 deletions
+17 -14
View File
@@ -30,23 +30,24 @@ 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;
/** 서버 다시 켜짐 등으로 끊김(595-3). */
interrupted?: boolean;
}
/** 받기 시작 답(595-2) — 받음 · 멂(받은 범위에서 5 km 넘음 → 묻고 `reset`) · 다른 TM 띠(노선 지움 확인 뒤 `reset`). */
/** 받기 답(595-2) — 시작 · 멀다(받은 범위에서 5 km 넘음) · 다른 TM 띠 — 둘은 확인 뒤 `reset`. */
export type TerrainStart =
| (PlanJob & { status?: "accepted" })
| ({ status: "accepted" } & PlanJob)
| { status: "far"; distance_m: number; message: string }
| { status: "band"; epsg: number; center_epsg: number; message: string };
/** 위치 칸 기준점(캐시 · [저장] 전) — 서버가 둘레 ±1 km 를 더 덮음 · `reset` = 옛 받은 범위 버림. */
export interface TerrainCenter {
/** 위치 칸 기준점(경위도 · 캐시) · 새로 받기(옛 받은 범위 버림). */
export interface TerrainAim {
center?: { lon: number; lat: number } | null;
reset?: boolean;
}
@@ -62,6 +63,8 @@ export interface TerrainExtent {
covered: boolean;
sheets: Array<{ no: string; ring: Array<[number, number]>; stored: boolean }>;
cells: number;
/** 받기 칸 상한(넘으면 받기 413). */
max_cells: number;
}
/** 그은 선 서버 꼴(562-2) — 마크 = 조각 경계 · 조각 없는 구역 = 연도 없음. */
@@ -110,12 +113,12 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const base = (projectId: string): string => `/projects/${projectId}/plan`;
/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점). */
/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점) · `center` = 위치 칸 기준점(둘레 ±1 km). */
export const fetchTerrainExtent = (
projectId: string,
marginM: number | null,
extra: Vertex[],
center: TerrainCenter["center"] = null,
center: { lon: number; lat: number } | null = null,
): Promise<TerrainExtent> => {
const query = new URLSearchParams();
if (marginM !== null) query.set("margin_m", String(marginM));
@@ -127,15 +130,16 @@ export const fetchTerrainExtent = (
return request(`${base(projectId)}/terrain/extent?${query}`);
};
/** 받기 — 칸 상한 넘음 = 오류(413 글). */
export const startTerrain = (
projectId: string,
marginM: number | null,
extra: Vertex[],
where: TerrainCenter = {},
aim: TerrainAim = {},
): Promise<TerrainStart> =>
request(`${base(projectId)}/terrain`, {
method: "POST",
body: JSON.stringify({ margin_m: marginM, extra, ...where }),
body: JSON.stringify({ margin_m: marginM, extra, ...aim }),
});
export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> =>
@@ -246,18 +250,17 @@ export const fetchPlanZeroOrder = (projectId: string): Promise<PlanZeroOrder> =>
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;
/** 잘린 임도 상자 [서, 남, 동, 북] 경위도 · 없으면 null(595-4). */
bounds?: [number, number, number, number] | null;
/** 기준점 ~ 가장 가까운 임도(m). */
distance_m?: number | null;
}
/** 올린 적 없으면 오류(404 글 그대로). */
+61 -29
View File
@@ -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)
+17 -11
View File
@@ -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)}
# ── 급경사 ──────────────────────────────────────────────
+158 -28
View File
@@ -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}
+69 -33
View File
@@ -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,
}
+36 -3
View File
@@ -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)
+159 -4
View File
@@ -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}
+23 -3
View File
@@ -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
# ── 급경사 ──
@@ -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)