Files
Aislo/D04_PlanConfirm/D04_PlanConfirm_Export.py
T
eomsangdonandClaude Opus 5.5 7c94294f85 feat(563-2): D04 확정 · 업체 지정 · 내보내기 · 연결 = 조각 단위 — 옛 연도 행 프로젝트 그대로
- 단위 = plan_route_pieces 한 줄 · 조각 없는 연도 행 = 연도 하나 · 확정은 연도 단위 그대로
- 요약 pieces(이름 「본선 0+0~12+10」) · 업체 행 piece_id · 지정 회사 = 자기 조각만
- 내보내기 폴더 = 연도_선_조각 · 전체 = 선마다 레코드 · 좌표표 선 · 조각 칸
- 확정 메일 = 업체마다 자기 조각 zip · 연결 · 가져오기 = 조각 꺾은선
- 받은 예정노선 목록 = 조각 이름 · 길이
- 시험 531 둘 = 조각 없음 가짜 더함 · 새 563_2 지킴 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:37:18 +09:00

299 lines
12 KiB
Python

"""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`.
계산 자리 ③ 서버 단독 — 화면이 만지지 않는다. 순수 함수(값 → 바이트)만 두고
파일은 임시 폴더에서만 쓴다.
짝: `station_label` ↔ `common_util/common_util_station.ts` `stationLabel(…, 1)`
(거울 시험 `test_545_1_station_label_mirror.py`).
"""
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]')
STATION_INTERVAL_M = 20.0 # D03 `PLAN_STATION_INTERVAL_M` 와 같음
def station_label(chainage_m: float, interval_m: float = STATION_INTERVAL_M) -> str:
"""측점 표기 `61+14.5` — TS `stationLabel(chainage, interval, 1)` 짝(소수 한 자리 · 반올림 위로)."""
safe = interval_m if interval_m > 0 else 1.0
number = math.floor((chainage_m + 1e-6) / safe)
rest = chainage_m - number * safe
if abs(rest) < 0.05:
rest = 0.0
if rest >= safe - 0.05:
number, rest = number + 1, 0.0
tenth = math.floor(rest * 10 + 0.5) / 10
return f"{number}+{int(tenth) if tenth == int(tenth) else tenth}"
@dataclass
class YearRoute:
"""한 조각(또는 옛 연도)의 노선 — `points` 는 `crs_epsg` 좌표의 (x, y) 꺾은선(시점 → 종점).
누가거리 두 값이 있으면 그대로 칸에 쓰고(조각 = 선마다 No.0), 없으면 연도 순서로 이어 셈.
`piece` = 조각 id(옛 연도 노선은 None) · `line` = 선 이름(파일 이름 · 전체 묶음 열쇠).
"""
year: int
points: list[tuple[float, float]]
crs_epsg: int
start_station_m: float | None = None
end_station_m: float | None = None
piece: str | None = None
line: str | None = None
@property
def key(self) -> str | int:
return self.piece or self.year
@dataclass
class ExportBundle:
"""`by_year[열쇠]` = 그 조각(열쇠 = 조각 id · 옛은 연도) 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}"
Record = tuple[list[tuple[float, float]], int | None, tuple[float, float]]
def _write_shapefile(records: list[Record], region: str) -> dict[str, bytes]:
"""5179 꺾은선 레코드(점 · 연도 · 누가 두 값) → `.shp/.shx/.dbf/.prj/.cpg` 바이트
(파일명은 호출부가 붙임: 키 = 확장자)."""
lengths = [polyline_length_m(points) for points, _, _ in records]
frame = gpd.GeoDataFrame(
{
"대상지": [_clip_bytes(_target_text(region, m)) for m in lengths],
"길이": [round(m / 1000, 3) for m in lengths],
"연도": [year for _, year, _ in records],
"시점누가": [round(stations[0], 2) for _, _, stations in records],
"종점누가": [round(stations[1], 2) for _, _, stations in records],
},
geometry=[LineString(points) for points, _, _ in records],
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),
station_label(row["start_station"]),
station_label(row["end_station"]),
round(row["start_station"], 2),
round(row["end_station"], 2),
round(row["length_m"], 2),
row["line"],
row["piece"],
]
)
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 안 측점은 칸으로만 보관(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,
"key": item.key,
"piece": item.piece,
"line": item.line,
}
)
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']}"
if row["piece"]:
base += f"_{safe_name(row['line'] or '')}_{safe_name(row['piece'])}"
stations = (row["start_station"], row["end_station"])
files = _named(base, _write_shapefile([(row["points"], row["year"], stations)], region))
if with_xlsx:
files[xlsx_name] = _coordinate_xlsx(project_name, [row])
bundle.by_year[row["key"]] = files
bundle.lengths_m[row["key"]] = row["length_m"]
# 전체: 선마다 한 레코드 · 앞 조각 종점 = 다음 조각 시점이라 겹치는 점 하나는 뺌.
by_line: dict[str | None, list[dict]] = {}
for row in rows:
by_line.setdefault(row["line"], []).append(row)
records: list[Record] = []
for parts in by_line.values():
parts.sort(key=lambda r: r["start_station"])
merged: list[tuple[float, float]] = []
for row in parts:
same = merged and math.dist(merged[-1], row["points"][0]) < 1e-6
merged.extend(row["points"][1:] if same else row["points"])
records.append((merged, None, (parts[0]["start_station"], parts[-1]["end_station"])))
total_m = sum(polyline_length_m(points) for points, _, _ in records)
bundle.lengths_m[0] = total_m
base = f"대상지_{region_name}.{total_m / 1000:.1f}_전체"
bundle.all = _named(base, _write_shapefile(records, region))
if with_xlsx:
bundle.all[xlsx_name] = _coordinate_xlsx(project_name, rows)
return bundle
def company_zip(project_name: str, label: str | 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)}_예정노선_{safe_name(str(label))}.zip", buffer.getvalue()