"""D04 예정노선 내보내기 — 연도별 노선(꺾은선)을 용화 예정노선 양식 shapefile 한 벌로 만든다. 양식 = `resources/knowledge/original/실무문서/2026년 임도 용화/예정노선/대상지_일월.용화.2.2.*` (분석 345-C): 2D 폴리라인 1레코드 · UTM-K(EPSG 5179) · cp949 · 칸 `대상지` C(20) · `길이` N(11,3) km + 덧칸 `연도` N(4) · `시점누가` · `종점누가` N(11,2) m. `.prj` 는 용화 원문 그대로 · `.cpg` 는 `949`. 계산 자리 ③ 서버 단독 — 화면이 만지지 않는다. 순수 함수(값 → 바이트)만 두고 파일은 임시 폴더에서만 쓴다. """ from __future__ import annotations import io import math import re import tempfile import zipfile from dataclasses import dataclass, field from pathlib import Path import geopandas as gpd from openpyxl import Workbook from pyproj import Transformer from shapely.geometry import LineString EXPORT_EPSG = 5179 SHP_SUFFIXES = (".shp", ".shx", ".dbf", ".prj", ".cpg") MIME_ZIP = "application/zip" MIME_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" # 용화 `.prj` 원문(ESRI WKT) 그대로 — 양식 일치(시험이 용화 파일과 바이트 비교). _YONGHWA_PRJ = ( 'PROJCS["PCS_ITRF2000_UTM_K",GEOGCS["GCS_ITRF_2000",DATUM["D_ITRF_2000",' 'SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],' 'UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],' 'PARAMETER["False_Easting",1000000.0],PARAMETER["False_Northing",2000000.0],' 'PARAMETER["Central_Meridian",127.5],PARAMETER["Scale_Factor",0.9996],' 'PARAMETER["Latitude_Of_Origin",38.0],UNIT["METER",1.0]]' ) _SCHEMA = { "geometry": "LineString", "properties": { "대상지": "str:20", "길이": "float:11.3", "연도": "int:4", "시점누가": "float:11.2", "종점누가": "float:11.2", }, } _BAD_FILENAME = re.compile(r'[\\/:*?"<>|\r\n\t]') @dataclass class YearRoute: """한 연도의 노선 — `points` 는 `crs_epsg` 좌표의 (x, y) 꺾은선(시점 → 종점). 누가거리 두 값이 있으면 그대로 칸에 쓰고(D03 이은 값), 없으면 연도 순서로 이어 셈. """ year: int points: list[tuple[float, float]] crs_epsg: int start_station_m: float | None = None end_station_m: float | None = None @dataclass class ExportBundle: """`by_year[연도]` = 그 연도 shp 한 벌 + 좌표표 · `all` = 전 연도 이은 한 벌 + 좌표표.""" by_year: dict[int, dict[str, bytes]] = field(default_factory=dict) all: dict[str, bytes] = field(default_factory=dict) lengths_m: dict[int, float] = field(default_factory=dict) def safe_name(text: str) -> str: """파일명에 못 쓰는 글자를 `_` 로.""" return _BAD_FILENAME.sub("_", text).strip() or "_" def polyline_length_m(points: list[tuple[float, float]]) -> float: return sum(math.dist(a, b) for a, b in zip(points, points[1:], strict=False)) def to_utmk(points: list[tuple[float, float]], crs_epsg: int) -> list[tuple[float, float]]: """프로젝트 좌표 → UTM-K(5179). 같은 좌표계면 그대로.""" pts = [(float(p[0]), float(p[1])) for p in points] if crs_epsg == EXPORT_EPSG: return pts tf = Transformer.from_crs(crs_epsg, EXPORT_EPSG, always_xy=True) return [tf.transform(x, y) for x, y in pts] def _clip_bytes(text: str, limit: int = 20) -> str: """dbf 문자 칸(cp949 바이트) 한도에 글자 단위로 맞춤.""" while len(text.encode("cp949", errors="replace")) > limit: text = text[:-1] return text def _target_text(region: str, length_m: float) -> str: return f"{region}.{length_m / 1000:.1f}" def _write_shapefile( points: list[tuple[float, float]], year: int | None, stations: tuple[float, float], region: str ) -> dict[str, bytes]: """5179 꺾은선 → `.shp/.shx/.dbf/.prj/.cpg` 바이트 (파일명은 호출부가 붙임: 키 = 확장자).""" length_m = polyline_length_m(points) frame = gpd.GeoDataFrame( { "대상지": [_clip_bytes(_target_text(region, length_m))], "길이": [round(length_m / 1000, 3)], "연도": [year], "시점누가": [round(stations[0], 2)], "종점누가": [round(stations[1], 2)], }, geometry=[LineString(points)], crs=EXPORT_EPSG, ) with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "r.shp" frame.to_file( out, driver="ESRI Shapefile", encoding="cp949", engine="fiona", schema=_SCHEMA ) out.with_suffix(".prj").write_text(_YONGHWA_PRJ, encoding="ascii") out.with_suffix(".cpg").write_text("949", encoding="ascii") return {suffix: out.with_suffix(suffix).read_bytes() for suffix in SHP_SUFFIXES} def _coordinate_xlsx(project_name: str, rows: list[dict]) -> bytes: """사람이 읽는 좌표표 — 연도별 시점 · 종점(UTM-K · 위경도) · 누가 · 연장.""" to_ll = Transformer.from_crs(EXPORT_EPSG, 4326, always_xy=True) book = Workbook() sheet = book.active sheet.title = "좌표표" sheet.append([f"{project_name} 예정노선 좌표표 (UTM-K EPSG:5179)"]) sheet.append( [ "연도", "시점 X", "시점 Y", "종점 X", "종점 Y", "시점 경도", "시점 위도", "종점 경도", "종점 위도", "시점누가(m)", "종점누가(m)", "연장(m)", ] ) for row in rows: (sx, sy), (ex, ey) = row["points"][0], row["points"][-1] (slon, slat), (elon, elat) = to_ll.transform(sx, sy), to_ll.transform(ex, ey) sheet.append( [ row["year"], round(sx, 3), round(sy, 3), round(ex, 3), round(ey, 3), round(slon, 7), round(slat, 7), round(elon, 7), round(elat, 7), round(row["start_station"], 2), round(row["end_station"], 2), round(row["length_m"], 2), ] ) buffer = io.BytesIO() book.save(buffer) return buffer.getvalue() def _named(base: str, parts: dict[str, bytes]) -> dict[str, bytes]: return {f"{base}{suffix}": data for suffix, data in parts.items()} def build_export( project_name: str, region: str, years: list[YearRoute], first_station_m: float = 0.0, with_xlsx: bool = True, ) -> ExportBundle: """연도 노선들 → 연도별 한 벌 + 전체 한 벌(+ 좌표표). 누가거리 = `first_station_m` 에서 시작해 연도 순서로 이어 셈(직전 연도 종점 = 다음 연도 시점). shp 안 측점은 파일마다 0 부터가 아니라 이 누가값을 칸으로만 보관(B03 은 칸을 무시). """ ordered = sorted(years, key=lambda y: y.year) rows: list[dict] = [] station = float(first_station_m) for item in ordered: points = to_utmk(item.points, item.crs_epsg) if len(points) < 2: raise ValueError(f"{item.year}년 노선 점이 2개 미만입니다.") length_m = polyline_length_m(points) if item.start_station_m is not None and item.end_station_m is not None: start, end = float(item.start_station_m), float(item.end_station_m) else: start, end = station, station + length_m rows.append( { "year": item.year, "points": points, "length_m": length_m, "start_station": start, "end_station": end, } ) station = end bundle = ExportBundle() region_name = safe_name(region) xlsx_name = f"좌표표_{safe_name(project_name)}.xlsx" for row in rows: base = f"대상지_{region_name}.{row['length_m'] / 1000:.1f}_{row['year']}" files = _named( base, _write_shapefile( row["points"], row["year"], (row["start_station"], row["end_station"]), region ), ) if with_xlsx: files[xlsx_name] = _coordinate_xlsx(project_name, [row]) bundle.by_year[row["year"]] = files bundle.lengths_m[row["year"]] = row["length_m"] # 전체: 앞 연도 종점 = 다음 연도 시점이라 겹치는 점 하나는 뺌. merged: list[tuple[float, float]] = [] for row in rows: merged.extend( row["points"][1:] if merged and merged[-1] == row["points"][0] else row["points"] ) total_m = polyline_length_m(merged) bundle.lengths_m[0] = total_m base = f"대상지_{region_name}.{total_m / 1000:.1f}_전체" bundle.all = _named( base, _write_shapefile(merged, None, (rows[0]["start_station"], rows[-1]["end_station"]), region), ) if with_xlsx: bundle.all[xlsx_name] = _coordinate_xlsx(project_name, rows) return bundle def company_zip(project_name: str, year: int, files: dict[str, bytes]) -> tuple[str, bytes]: """업체에 보낼 `<프로젝트명>_예정노선_<연도>.zip` (이름, 바이트).""" buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: for name, data in files.items(): archive.writestr(name, data) return f"{safe_name(project_name)}_예정노선_{year}.zip", buffer.getvalue()