Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py
T
eomsangdonandClaude Sonnet 5.5 40cecf27a2 feat(D 예정노선): 올린 임도망 묶음 쌓기 · 목록(고르기 · 지움) · 지도 밝은 분홍 + 검은 테 (PLAN 605-1 · 605-2 · 요구 476-1)
- 서버 — 올릴 때마다 묶음 하나(`D03_PlanAnalysis/임도망도/<번호>`) · 옛 한 파일 꼴 = 묶음 legacy · 합친 값 + 묶음 줄 · 목록 GET · 묶음 DELETE · 20 묶음까지
- 화면 — 위치 칸 목록(묶음 줄 · 펼치면 선 줄 · 고름 = 지도 강조 + 맞춤 · 지움 = 물음 뒤) · 색 주황 → 밝은 분홍(`--draw-map-forest-road-file`)
- 시험 584_3 — 묶음 쌓기 · 지움 · legacy · 20 묶음 · 화면 정의

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 21:49:20 +09:00

344 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 595-4 · 요구 443 · 463).
- 출처 = 사용자가 받은 산림청 임도망도 SHP(FGIS 무료신청 · 산림빅데이터거래소 · 시군구 단위 · 선)
- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 자르는 범위
안 선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError` + 기준점 ~ 가장 가까운 임도 거리)
- 자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형(`clip_box` · 지형 받기 전에도 기준점만으로)
- 원본 = 올릴 때마다 묶음 하나 — 프로젝트 `D03_PlanAnalysis/임도망도/<번호>.zip`(낱 파일은 zip 으로
묶음) + `<번호>.json`(파일 이름 · 올린 때) · 옛 한 파일 꼴(`임도망도.zip` · `.json`)은 묶음
`legacy` 하나로 읽음 · 지움 = 그 묶음만(PLAN 605-1)
- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 자르는 범위로 잘라 가닥 · 길이 ·
잘린 상자(`bounds` 경위도) · 기준점 거리 → 파생 저장본(열쇠 = 원본 지문 · 범위 · 기준점)
계산 자리 = ③ 서버 단독.
"""
from __future__ import annotations
import io
import json
import math
import os
import re
import tempfile
import uuid
import zipfile
from pathlib import Path
from typing import Any
from common_util.common_util_derived_store import (
derived_key,
file_stamp,
read_derived,
write_derived,
)
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" # 옛 한 파일 꼴 = 묶음 legacy
SOURCE_META = "임도망도.json"
BUNDLE_DIR = "임도망도"
LEGACY = "legacy"
MAX_BUNDLES = 20
DEFAULT_EPSG = 5179
DBF_ENCODING = "cp949"
MAX_BYTES = 100 * 1024 * 1024
SHP_PARTS = (".shp", ".shx", ".dbf", ".prj", ".cpg")
ROAD_KIND = "plan_forest_road"
ROAD_VERSION = 3 # 묶음 열쇠
KEEP = 2 * MAX_BUNDLES # 묶음마다 파생 저장본 한 벌
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:
return Path(project_root) / FOLDER
def pack(files: list[tuple[str, bytes]]) -> bytes:
"""올린 파일 → zip 바이트 — zip 하나는 그대로 · 낱 파일은 shp · shx · dbf 가 다 있어야 묶음."""
if len(files) == 1 and files[0][0].lower().endswith(".zip"):
return files[0][1]
parts = [(name, data) for name, data in files if Path(name).suffix.lower() in SHP_PARTS]
have = {Path(name).suffix.lower() for name, _ in parts}
lack = [ext for ext in (".shp", ".shx", ".dbf") if ext not in have]
if lack:
raise ValueError(
f"SHP 묶음에 {' · '.join(lack)} 이(가) 없습니다 — "
"zip 하나 또는 shp · shx · dbf · prj 를 함께 고르세요."
)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for name, data in parts:
zf.writestr(Path(name).name, data)
return buffer.getvalue()
def read_shapes(zip_path: Path) -> list[Any]:
"""zip 안 SHP 마다 GeoDataFrame(좌표계 = prj · 없으면 5179) — 못 읽으면 `ValueError`."""
import geopandas as gpd
try:
zf = zipfile.ZipFile(zip_path)
except zipfile.BadZipFile as error:
raise ValueError("zip 파일을 열지 못했습니다.") from error
frames = []
with zf, tempfile.TemporaryDirectory() as folder:
members = [n for n in zf.namelist() if Path(n).suffix.lower() in SHP_PARTS]
if not any(n.lower().endswith(".shp") for n in members):
raise ValueError("zip 안에 .shp 파일이 없습니다.")
for n in members: # 폴더 없이 이름만 = 바깥으로 안 풂
(Path(folder) / Path(n).name).write_bytes(zf.read(n))
for shp in sorted(Path(folder).glob("*.shp")):
options = {} if shp.with_suffix(".cpg").is_file() else {"encoding": DBF_ENCODING}
try:
frame = gpd.read_file(shp, **options)
except Exception as error: # noqa: BLE001 — 읽기 엔진 오류 종류가 많음
raise ValueError(f"{shp.name} 을(를) 읽지 못했습니다 — {error}") from error
if frame.crs is None:
frame = frame.set_crs(DEFAULT_EPSG)
frames.append(frame)
return frames
def _plain(value: Any) -> Any:
if isinstance(value, float) and not math.isfinite(value):
return None
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, 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.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()]
shapes = shapes[shapes.geometry.geom_type.isin(("LineString", "MultiLineString"))]
total += len(shapes)
metric = shapes.to_crs(epsg)
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)
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 {
"count": len(features),
"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 _paths(project_root: Path, bundle: str) -> tuple[Path, Path]:
"""(zip, 이름표 json) — `legacy` = 옛 한 파일 꼴."""
folder = source_dir(project_root)
if bundle == LEGACY:
return folder / SOURCE_ZIP, folder / SOURCE_META
return folder / BUNDLE_DIR / f"{bundle}.zip", folder / BUNDLE_DIR / f"{bundle}.json"
def _meta(path: Path) -> dict[str, Any]:
try:
return json.loads(path.read_text("utf-8"))
except (OSError, ValueError):
return {}
def _name(files: list[str]) -> str:
"""묶음 줄 이름 — 파일 하나(zip) = 그 이름 · 낱 파일 묶음 = 첫 파일 줄기."""
return "" if not files else files[0] if len(files) == 1 else Path(files[0]).stem
def bundle_ids(project_root: Path) -> list[str]:
"""올린 순서(올린 때) — 옛 한 파일 꼴 포함."""
folder = source_dir(project_root)
ids = [p.stem for p in (folder / BUNDLE_DIR).glob("*.zip")]
if (folder / SOURCE_ZIP).is_file():
ids.append(LEGACY)
def when(bundle: str) -> tuple[str, str]:
zip_path, meta_path = _paths(project_root, bundle)
return _meta(meta_path).get("uploaded_at", ""), file_stamp(zip_path) or ""
return sorted(ids, key=when) # 같은 초면 파일 시각 순
def bundle_layer(
project_root: Path, bundle: str, box: Box, epsg: int, origin: XY | None = None
) -> dict[str, Any]:
"""묶음 하나 — 원본 지문 · 범위 · 기준점 같으면 저장본 · 없으면 `FileNotFoundError`."""
path, meta = _paths(project_root, bundle)
stamp = file_stamp(path)
if stamp is None:
raise FileNotFoundError(f"올린 임도망도 묶음 {bundle} 이(가) 없습니다.")
at = [round(v, 1) for v in origin] if origin else None
key = derived_key(
ROAD_KIND, ROAD_VERSION, bundle, 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, origin)
value["source"] = _meta(meta) or None
write_derived(project_root, ROAD_KIND, key, value, KEEP)
return value
def road_layer(project_root: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]:
"""올린 임도망도 전체 — 묶음마다 자른 값을 합침(선 `__bundle` = 묶음 번호 ·
`__line` = 묶음 안 차례 · `bundles` = 묶음 줄 · 가닥 · 길이 · 원본 선 수 = 합 ·
기준점 거리 = 가장 가까운 것) · 올린 적 없으면 `FileNotFoundError`."""
ids = bundle_ids(project_root)
if not ids:
raise FileNotFoundError(
"올린 임도망도가 없습니다 — [임도망 올리기]로 시군구 SHP 를 올리세요."
)
rows, features, crs, edges, gaps = [], [], set(), [], []
for bundle in ids:
got = bundle_layer(project_root, bundle, box, epsg, origin)
source = got.get("source") or {}
files = source.get("files") or []
rows.append(
{
"id": bundle,
"name": source.get("name") or _name(files) or bundle,
"files": files,
"bytes": source.get("bytes"),
"uploaded_at": source.get("uploaded_at"),
"count": got["count"],
"length_m": got["length_m"],
"total": got["total"],
"bounds": got["bounds"],
"distance_m": got["distance_m"],
}
)
crs.update(got["crs"])
if got["bounds"]:
edges.append(got["bounds"])
if got["distance_m"] is not None:
gaps.append(got["distance_m"])
for line, feature in enumerate(got["collection"]["features"]):
props = {**feature["properties"], "__bundle": bundle, "__line": line}
features.append({**feature, "properties": props})
return {
"count": sum(r["count"] for r in rows),
"length_m": round(sum(r["length_m"] for r in rows), 1),
"total": sum(r["total"] for r in rows),
"crs": sorted(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": min(gaps) if gaps else None,
"source": {k: rows[-1][k] for k in ("files", "bytes", "uploaded_at")},
"bundles": rows,
"collection": {"type": "FeatureCollection", "features": features},
}
def save_roads(
project_root: Path,
files: list[tuple[str, bytes]],
box: Box,
epsg: int,
origin: XY | None = None,
) -> dict[str, Any]:
"""올림 — 묶어 읽어 보고 자르는 범위 안 선이 있으면 새 묶음으로 쌓음 · 아니면 `ValueError`
(쌓인 것 그대로 · 기준점 ~ 가장 가까운 임도 거리)."""
if len(bundle_ids(project_root)) >= MAX_BUNDLES:
raise ValueError(f"올린 묶음이 {MAX_BUNDLES} 개입니다 — 하나를 지우고 올리세요.")
data = pack(files)
bundle = uuid.uuid4().hex[:12]
zip_path, meta_path = _paths(project_root, bundle)
zip_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
dir=zip_path.parent, suffix=".zip.new", delete=False
) as temporary:
temporary.write(data)
trial = Path(temporary.name)
try:
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"임도가 기준점 ±{RADIUS_M / 1000:.0f} km · 받은 지형 밖뿐입니다{where}"
f"(파일 선 {got['total']}가닥) — 사업지 시군구 파일인지 보세요."
)
os.replace(trial, zip_path)
finally:
trial.unlink(missing_ok=True)
names = [Path(name).name for name, _ in files]
atomic_write_json(
meta_path,
{
"files": names,
"name": _name(names),
"bytes": len(data),
"uploaded_at": utc_now_text(),
},
)
return road_layer(project_root, box, epsg, origin)
def delete_bundle(project_root: Path, bundle: str) -> None:
"""묶음 하나 지움(저장본은 열쇠가 달라져 저절로 안 쓰임) — 없으면 `FileNotFoundError`."""
if bundle != LEGACY and not re.fullmatch(r"[0-9a-f]{12}", bundle):
raise FileNotFoundError(f"올린 임도망도 묶음 {bundle} 이(가) 없습니다.")
zip_path, meta_path = _paths(project_root, bundle)
if not zip_path.is_file():
raise FileNotFoundError(f"올린 임도망도 묶음 {bundle} 이(가) 없습니다.")
zip_path.unlink()
meta_path.unlink(missing_ok=True)