feat(D 예정노선): 받기 상한 = 칸 500 만 → 공용 창고 도엽 36 장 (PLAN 613-1 · 요구 493)

- 한 번에 이어 붙이는 도엽 수 36 장(6 × 6) 넘으면 413 + 알림 글(몇 장 · 줄이는 법)
- 범위 보기 응답에 store_sheets · max_sheets
- 시험 530_1 · 530_2 갱신

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 23:11:41 +09:00
co-authored by Claude Sonnet 5.5
parent 0a54d52fff
commit db5a2f0408
5 changed files with 60 additions and 25 deletions
+4 -3
View File
@@ -63,8 +63,9 @@ export interface TerrainExtent {
covered: boolean;
sheets: Array<{ no: string; ring: Array<[number, number]>; stored: boolean }>;
cells: number;
/** 받기 칸 상한(넘으면 받기 413). */
max_cells: number;
/** 공용 창고 이어 붙일 도엽 수 · 한 번에 상한(넘으면 받기 413). */
store_sheets: number;
max_sheets: number;
}
/** 그은 선 서버 꼴(562-2) — 마크 = 조각 경계 · 조각 없는 구역 = 연도 없음. */
@@ -130,7 +131,7 @@ export const fetchTerrainExtent = (
return request(`${base(projectId)}/terrain/extent?${query}`);
};
/** 받기 — 칸 상한 넘음 = 오류(413 글). */
/** 받기 — 도엽 상한 넘음 = 오류(413 글). */
export const startTerrain = (
projectId: string,
marginM: number | null,
+6 -9
View File
@@ -14,7 +14,7 @@
(PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐).
위치 바꿈(PLAN 595 · 꼴 = PLAN 595 절) = 몸 `center`(위치 칸 캐시 기준점) 둘레를 더함 ·
받은 범위에서 `FAR_M` 넘음 = 「far」 물음 · 다른 TM 띠 = 「band」 물음 → `reset` = 옛 범위 버리고
center 둘레만 · 칸 `MAX_CELLS` 넘음 = 413 · 진행은 `models_plan/plan_job_<일>.json` 에도 적어
center 둘레만 · 창고 도엽 `MAX_SHEETS` 넘음 = 413 · 진행은 `models_plan/plan_job_<일>.json` 에도 적어
서버가 다시 켜지면 「끊김」(interrupted).
노선 정본(손 그은 선)은
`D03_PlanAnalysis_Network`(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음).
@@ -221,7 +221,7 @@ 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` ·
위치 칸 가운데가 멀거나 다른 띠 = 물음(`far` · `band`) · 칸 상한 넘음 = 413."""
위치 칸 가운데가 멀거나 다른 띠 = 물음(`far` · `band`) · 도엽 상한 넘음 = 413."""
body = body or TerrainBody()
center = (body.center.lon, body.center.lat) if body.center else None
root, epsg, ends, band = await _plan_ends(project_id, center, body.reset)
@@ -243,13 +243,10 @@ async def plan_terrain(project_id: UUID, body: TerrainBody | None = None) -> dic
"옛 받은 범위를 버리고 새로 받을까요?",
}
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}만(지점 · 선을 모으거나 위치를 옮겨 새로 받으세요)",
)
want = terrain.wanted_box(points, terrain.clamp_margin(body.margin_m), have)
count = terrain.store_sheet_count(want, epsg)
if count > terrain.MAX_SHEETS:
raise HTTPException(status_code=413, detail=terrain.sheet_limit_message(count))
async def work(report: Any) -> None:
got = await asyncio.to_thread(
+19 -5
View File
@@ -5,7 +5,7 @@
- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음) ·
새로(`reset` · 위치를 멀리 옮김) = 옛 받은 범위 · 병합 도엽 · 위성 범위를 버리고 새 상자만 ·
칸 상한 `MAX_CELLS`(넘으면 라우터가 거절)
칸 상한 도엽 `MAX_SHEETS`(공용 창고 · 넘으면 라우터가 거절)
- 도엽 = 범위 상자에 닿는 도엽 모두 → `ensure_sheets`(없는 것만 · prune 없음)
- 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀)
- 위성 = 범위 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀)
@@ -65,9 +65,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)
# 위치 바꿈이 받은 범위에서 이만큼 넘게 멀면 묻고 새로 · 받을 도엽 상한(요구서_463 3장 4 · 613-1)
FAR_M = 5000.0
MAX_CELLS = 5_000_000
MAX_SHEETS = 36 # 한 번에 이어 붙이는 공용 창고 도엽 수 상한(6 × 6)
# ponytail: 새 도엽 함께 셀 수 — 장당 메모리 1 GB 안팎 · 넘으면 차례로
WARM_WORKERS = 4
STAGE = Path("B04_PreProcess")
@@ -125,10 +125,23 @@ def wanted_box(points: list[XY], margin_m: float, have: Box | None) -> Box:
def cells_of(box: Box) -> int:
"""DEM 칸 수 어림 — 받기 시간 · 메모리 가늠 · 상한 판정."""
"""DEM 칸 수 어림 — 받기 시간 · 메모리 가늠."""
return int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2)
def store_sheet_count(box: Box, epsg: int) -> int:
"""공용 창고에서 이어 붙일 도엽 수 — 상한 판정."""
return len(terrain_store.sheets_for_box(box, epsg))
def sheet_limit_message(count: int) -> str:
"""도엽 상한 넘음 알림 글 — 몇 장 · 줄이는 법."""
return (
f"받을 범위가 너무 넓습니다 — 도엽 {count}장 · 한 번에 {MAX_SHEETS}장까지"
"(지점 · 선을 모으거나 위치를 옮겨 새로 받으세요)"
)
def distance_to_box(xy: XY, box: Box) -> float:
"""점 ~ 상자 거리(m · 안이면 0)."""
dx = max(box[0] - xy[0], 0.0, xy[0] - box[2])
@@ -195,7 +208,8 @@ def plan_extent(
"covered": covers(have, box),
"sheets": sheets,
"cells": cells_of(box),
"max_cells": MAX_CELLS,
"store_sheets": store_sheet_count(box, epsg),
"max_sheets": MAX_SHEETS,
}
+22 -2
View File
@@ -157,8 +157,28 @@ def test_cells_distance_and_limit(fake) -> None:
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
assert terrain.MAX_SHEETS == 36 and terrain.FAR_M == 5000
view = terrain.plan_extent(root, [A, B], EPSG)
assert view["max_sheets"] == terrain.MAX_SHEETS and "max_cells" not in view
assert view["store_sheets"] == terrain.store_sheet_count(tuple(view["box_m"]), EPSG)
def test_sheet_limit_counts_store_sheets(fake) -> None:
"""613-1 — 상한 = 공용 창고 도엽 수(칸 수 아님) · 36 장까지 · 넘으면 알림 글에 장 수."""
root, _ = fake
small = terrain.plan_extent(root, [A, B], EPSG)
assert 1 <= small["store_sheets"] <= 4 and small["cells"] < 2_000_000
# 남쪽 1.5 km 늘림 — 옛 칸 상한(500 만 칸)은 넘지만 도엽은 상한 안
south = (A[0], A[1] - 6000.0)
wide = terrain.plan_extent(root, [A, B, south], EPSG)
assert wide["store_sheets"] <= terrain.MAX_SHEETS
# 37 장 이상 — 약 6 도엽 폭 넘게 벌림
huge = terrain.plan_extent(root, [A, (A[0] + 20000.0, A[1] + 22000.0)], EPSG)
assert huge["store_sheets"] > terrain.MAX_SHEETS
text = terrain.sheet_limit_message(huge["store_sheets"])
assert (
f"도엽 {huge['store_sheets']}장" in text and "36장까지" in text and "새로 받으세요" in text
)
def test_extent_no_download_and_covered(fake) -> None:
+9 -6
View File
@@ -227,13 +227,16 @@ def test_center_other_band_asks_then_moves(posting) -> None:
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 만 칸
def test_sheet_limit_413(posting) -> None:
"""613-1 — 6.7 × 6.7 km(옛 칸 상한 넘음)는 받아짐 · 도엽 37 장 이상은 413 + 장 수 알림."""
posting.plan["points"].append({"x": OX + 6000.0, "y": OY + 6000.0})
assert posting.run_post({}).status_code == 200
assert len(posting.calls) == 1
posting.plan["points"].append({"x": OX + 22000.0, "y": OY + 24000.0})
res = posting.run_post({})
assert res.status_code == 413 and "상한 500만" in res.json()["detail"]
assert posting.calls == []
detail = res.json()["detail"]
assert res.status_code == 413 and "한 번에 36장까지" in detail and "도엽 " in detail
assert len(posting.calls) == 1
def test_progress_interrupted_after_restart(client, monkeypatch) -> None: