# -*- coding: utf-8 -*- """586-0 TM ↔ 경위도 거울 · 화면 TM 틀 — 그은 선 · 급경사 그림을 위성 · 도엽 겹과 같은 자리에. - `D03_PlanAnalysis_Tm.ts` 의 TM ↔ 경위도 = pyproj(`common_util_crs`) 와 1 mm 안(5185~5188 · 5179) - 모르는 좌표계(5174 Bessel) = null(지금 꼴로 그림) - 화면 TM 틀(가운데 1차 맞춤) = 정식 변환 자리와 화면 안 0.5 px 안 · 되돌림 = 1 mm 안 - 옛 「같은 직사각형」 꼴은 D 프로젝트 귀퉁이에서 수십 m 어긋남(고친 까닭) - 586-1 위성 조각 대리 = 범위 밖 400 Node 헬퍼(`helper_586_tm_frame.cjs`)가 진짜 TS 를 돌린다. """ import json import math import os import subprocess import pytest from common_util.common_util_crs import lonlat_to_tm, tm_to_lonlat HERE = os.path.dirname(os.path.abspath(__file__)) # 검증_sub2_D 위성 상자(EPSG:5186 · vworld-meta) META = { "x_min": 282572.566652281, "x_max": 288034.8885857916, "y_min": 408543.914277056, "y_max": 411930.1592677671, "lon_min": 127.9193115234375, "lon_max": 127.979736328125, "lat_min": 36.270850207239036, "lat_max": 36.30184530368433, } POINTS = [ (5186, 285303.7, 410237.0), (5186, 282572.6, 411930.2), (5186, 288034.9, 408543.9), (5185, 180000.0, 450000.0), (5187, 210000.0, 350000.0), (5188, 190000.0, 520000.0), (5179, 960000.0, 1800000.0), ] LONLAT = [(5186, 127.95, 36.28), (5187, 129.1, 35.5), (5179, 127.0, 37.5), (5185, 126.3, 34.8)] def _view(w, h, scale, ox, oy): """지금 캔버스 꼴 — 상자 비율 그대로 화면에 맞추고 배율 · 옮김.""" ratio = (META["x_max"] - META["x_min"]) / (META["y_max"] - META["y_min"]) mw, mh = (w, w / ratio) if ratio > w / h else (h * ratio, h) x0 = (w - mw) / 2 * scale + w / 2 * (1 - scale) + ox y0 = (h - mh) / 2 * scale + h / 2 * (1 - scale) + oy return [w, h, x0, y0, mw * scale, mh * scale] @pytest.fixture(scope="module") def r(): box = [META["lon_min"], META["lat_min"], META["lon_max"], META["lat_max"]] corners = [ (META["x_min"], META["y_max"]), (META["x_max"], META["y_max"]), (META["x_min"], META["y_min"]), (META["x_max"], META["y_min"]), (285303.7, 410237.0), ] frames = [ {"epsg": 5186, "box": box, "view": _view(1400, 900, 1, 0, 0), "probes": corners}, { "epsg": 5186, "box": box, "view": _view(1400, 900, 40, -3000, 2000), "probes": [(285303.7 + dx, 410237.0 + dy) for dx in (-40, 0, 40) for dy in (-25, 25)], }, ] payload = {"points": POINTS, "lonlat": LONLAT, "frames": frames} proc = subprocess.run( ["node", os.path.join(HERE, "helper_586_tm_frame.cjs")], input=json.dumps(payload), capture_output=True, text=True, encoding="utf-8", timeout=60, ) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) def test_TM_경위도_pyproj_와_같음(r): for (epsg, x, y), (lon, lat) in zip(POINTS, r["toLonLat"]): want_lon, want_lat = tm_to_lonlat(x, y, epsg) assert abs(lon - want_lon) < 1e-8 and abs(lat - want_lat) < 1e-8, (epsg, x, y) for (epsg, lon, lat), (x, y) in zip(LONLAT, r["toTm"]): want_x, want_y = lonlat_to_tm(lon, lat, epsg) assert math.hypot(x - want_x, y - want_y) < 1e-3, (epsg, lon, lat) def test_모르는_좌표계는_null(r): assert r["unknown"] is None def test_화면_TM_틀이_정식_자리와_같음(r): for frame in r["frames"]: for probe in frame["probes"]: (ex, ey), (gx, gy) = probe["exact"], probe["got"] assert math.hypot(ex - gx, ey - gy) < 0.5, probe # 되돌림(화면 → TM) probes = [(285303.7 + dx, 410237.0 + dy) for dx in (-40, 0, 40) for dy in (-25, 25)] for (x, y), probe in zip(probes, r["frames"][1]["probes"]): bx, by = probe["back"] assert math.hypot(bx - x, by - y) < 1e-3 def test_옛_같은_직사각형_꼴은_귀퉁이에서_어긋남(): """고친 까닭 — TM 상자 · 경위도 상자를 같은 직사각형으로 보면 위 오른쪽 귀퉁이 수십 m.""" x, y = META["x_max"], META["y_max"] lon, lat = tm_to_lonlat(x, y, 5186) w = META["x_max"] - META["x_min"] h = META["y_max"] - META["y_min"] dx = ((lon - META["lon_min"]) / (META["lon_max"] - META["lon_min"]) - 1) * w dy = ((META["lat_max"] - lat) / (META["lat_max"] - META["lat_min"])) * h assert math.hypot(dx, dy) > 50 def test_위성_조각_대리는_범위_밖을_막음(): """586-1 GL 바탕 위성 조각 — z6~19 · 칸 번호 범위 밖 = 400(브이월드에 안 물음).""" import asyncio import uuid from fastapi import HTTPException from D03_PlanAnalysis.D03_PlanAnalysis_Router import plan_satellite_tile for z, x, y in ((5, 0, 0), (20, 0, 0), (14, 2**14, 0), (14, 0, -1)): with pytest.raises(HTTPException) as error: asyncio.run(plan_satellite_tile(uuid.uuid4(), z, x, y)) assert error.value.status_code == 400 def test_위성_조각은_서버에_기억해_다시_안_받음(tmp_path, monkeypatch): """586-2 — 받은 조각 = 프로젝트 `_derived/plan_tile` · 두 번째는 브이월드에 안 물음.""" import asyncio import urllib.request import uuid from D03_PlanAnalysis import D03_PlanAnalysis_Router as router project = uuid.uuid4() monkeypatch.setitem(router._TILE_ROOTS, project, tmp_path) calls = [] class Reply: def __enter__(self): return self def __exit__(self, *_): return False def read(self): return b"jpeg" monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **k: calls.append(1) or Reply()) for _ in range(2): reply = asyncio.run(router.plan_satellite_tile(project, 14, 13990, 6380)) assert reply.body == b"jpeg" assert len(calls) == 1 saved = tmp_path / "_derived" / "plan_tile" / "Satellite" / "14" / "13990_6380.jpeg" assert saved.read_bytes() == b"jpeg" assert not list(saved.parent.glob("*.part"))