Files
Aislo/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py
T
eomsangdonandClaude Opus 5 31e3eb4ecf fix(B04): 도엽 서피스를 2D 거리보간으로 만든다 — TIN 제거
메시(TIN)로 먼저 만들고 거기서 등고선을 뽑던 순서가 계단의 원인이었다.
사용자 지시대로 2D를 먼저 완결한다:
- Delaunay TIN 제거. 표고별 거리장에서 각 셀의 가장 가까운 두 '서로 다른'
  등고 라인을 찾아 z=(L1·d2+L2·d1)/(d1+d2)로 보간한다. 같은 표고 정점
  3개짜리 평탄 삼각형이 없으므로 계단이 원리적으로 생기지 않는다.
- 폐합 등고선 안쪽은 거리보간이 분화구를 만들므로, 안에 다른 제약이 없는
  영역을 마루/웅덩이로 보고 바깥 사면 경사로 ±(간격-0.5m)까지 연장한다.
- 계곡 구조선 앵커는 1m 양자화해 같은 격자에 제약으로 굽는다.
- 등고 라벨 40개 상한(긴 것부터) — 1m 등고선이 잘게 쪼개져 라벨 수백 개가
  매 프레임 재계산되며 화면이 멈췄다(사용자 보고). 61 FPS 회복.

실측(c1bb453f): 평탄 셀 2.48%→0.02%, 최대 평탄 덩어리 2406→912㎡,
경사 2% 미만 셀 17145→14444, 생성 36.5s→8.0s, 노선 |Δz| 2.76m.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:33:56 +09:00

525 lines
23 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.
"""도엽등고선 3D 서피스 — 1:5,000 수치지형도 등고선으로 DTM 격자를 만든다.
LAS 없는 설계(2026-08-30 사용자 확정)의 지형 원천이자, LAS가 있어도 참고용으로
같이 만들어 두는 서피스다. 산출 형식은 LAS 파이프라인의 DTM과 완전히 같게 맞춘다
(`dtm_sheet.npz`: x/y/z/valid_mask) — 종·횡단·배수 세부설계가 쓰는
`build_surface_sampler(models_dir, "sheet", "dtm", smooth=False)`가 무수정으로 돈다.
절취 범위는 노선 XY bbox + `SHEET_SURFACE_MARGIN_M`(300m) 직사각형(사용자 확정),
격자는 `SHEET_SURFACE_GRID_M`(1m).
순서는 **2D 먼저, 메시는 맨 마지막**이다(2026-08-30 사용자 지시):
① 등고 라인을 격자에 굽고 ② 계곡 구조선 앵커를 제약으로 더한 뒤
③ 등고선 사이 거리 비례 보간으로 표고 격자를 만들고 ④ 마루를 캡한다.
격자에서 뽑는 1m 등고선이 곧 2D 보간선이며, 메시(glb)는 그 격자의 표현일 뿐이다.
"""
import json
import logging
import time
from pathlib import Path
from typing import Any
import numpy as np
from pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
atomic_npz,
clip_and_compact_mesh,
grid_faces,
grid_vertices,
write_glb,
)
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import grid_spec_from_bounds
from config.config_system import (
SHEET_SURFACE_GRID_M,
SHEET_SURFACE_MARGIN_M,
SURFACE_MAX_PREVIEW_VERTICES,
)
logger = logging.getLogger(__name__)
# 도엽 병합 산출물 파일명 (B04_PreProcess_Router_Watershed와 같은 값)
_CONTOUR_FILE = "도엽_등고선.geojson"
_STREAM_FILE = "도엽_하천중심선.geojson"
# 산출 모델 식별자 — surface_models.generation_params.source_filter 및 파일 stem에 쓴다.
SHEET_SOURCE_FILTER = "sheet"
def _load_features_metric(
processed_dir: Path, epsg: int, filename: str = _CONTOUR_FILE
) -> list[dict[str, Any]]:
"""병합 도엽 레이어(WGS84)를 읽어 사업지 CRS(m)로 재투영한다."""
path = processed_dir / filename
if not path.exists():
logger.warning("도엽 서피스: 도엽 레이어 파일이 없습니다: %s", path)
return []
try:
with path.open("r", encoding="utf-8") as file:
data = json.load(file)
except (OSError, json.JSONDecodeError):
logger.warning("도엽 서피스: 등고선 GeoJSON을 읽지 못했습니다: %s", path)
return []
features = data.get("features")
if not isinstance(features, list):
return []
transformer = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True)
def _map(coords: Any) -> Any:
if not isinstance(coords, list):
return coords
if coords and isinstance(coords[0], (int, float)):
x, y = transformer.transform(coords[0], coords[1])
return [x, y, *coords[2:]]
return [_map(item) for item in coords]
converted: list[dict[str, Any]] = []
for feature in features:
geometry = feature.get("geometry") or {}
coordinates = _map(geometry.get("coordinates"))
if coordinates is None:
continue
converted.append(
{
"type": "Feature",
"properties": feature.get("properties") or {},
"geometry": {"type": geometry.get("type"), "coordinates": coordinates},
}
)
return converted
def _preview_mesh(
x: np.ndarray, y: np.ndarray, z: np.ndarray, valid: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""프리뷰용 정점·면 — 정점 수가 상한을 넘으면 격자를 성기게 딴다."""
stride = 1
while (len(x) // stride + 1) * (len(y) // stride + 1) > SURFACE_MAX_PREVIEW_VERTICES:
stride += 1
px, py = x[::stride], y[::stride]
pz, pv = z[::stride, ::stride], valid[::stride, ::stride]
vertices = grid_vertices(px, py, pz.astype(np.float64))
faces = grid_faces(len(py), len(px))
return clip_and_compact_mesh(vertices, faces, pv.reshape(-1))
def _stream_breakline_vertices(
spec: Any, burned: np.ndarray, stream_features: list[dict[str, Any]]
) -> tuple[np.ndarray, np.ndarray] | None:
"""계곡 기준선(하천중심선)을 따라 등고 교차점 사이를 보간한 정점열을 만든다.
구조선 기반 보간(2026-08-30 사용자 확정): 계곡 축을 먼저 세우고, 축이 등고선과
만나는 점을 Z 앵커로 삼아 앵커 사이를 선 길이 비례로 보간한다. 계곡 바닥이
등고선 사이에서도 연속으로 내려가는 가상 종단이 되어 골짜기 평탄·역경사가 준다.
"""
from shapely import segmentize
from shapely.geometry import shape
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import iter_linestrings
sample_step_m = 2.0
vertex_stride = 3 # 2m 샘플 → 6m 간격 정점 (등고 정점 재샘플 밀도와 유사)
collected_xy: list[np.ndarray] = []
collected_z: list[np.ndarray] = []
for feature in stream_features:
geometry = feature.get("geometry")
if not geometry:
continue
try:
parsed = shape(geometry)
except Exception: # noqa: BLE001
continue
for line in iter_linestrings(parsed):
coords = np.asarray(segmentize(line, sample_step_m).coords, dtype=np.float64)
if len(coords) < 3:
continue
xy = coords[:, :2]
arc = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))])
row, col = spec.world_to_rc(xy[:, 0].copy(), xy[:, 1].copy())
inside = (row >= 0) & (col >= 0)
levels_at = np.full(len(xy), np.nan, dtype=np.float32)
levels_at[inside] = burned[row[inside], col[inside]]
hit = np.isfinite(levels_at)
if hit.sum() < 2:
continue
# 등고선 통과 구간(연속 같은 표고 샘플 묶음) 하나 = 앵커 하나.
anchor_arc: list[float] = []
anchor_z: list[float] = []
run_start: int | None = None
for i in range(len(xy) + 1):
is_hit = i < len(xy) and hit[i]
if is_hit and run_start is not None and levels_at[i] != levels_at[run_start]:
is_hit = False # 표고가 바뀌면 묶음을 끊는다
if is_hit:
if run_start is None:
run_start = i
elif run_start is not None:
anchor_arc.append(float(arc[run_start : i if i <= len(xy) else len(xy)].mean()))
anchor_z.append(float(levels_at[run_start]))
run_start = i if i < len(xy) and hit[i] else None
if len(anchor_arc) < 2:
continue
# 앵커 사이 구간만 채택 — 첫 앵커 이전·마지막 앵커 이후는 근거가 없다.
span = (arc >= anchor_arc[0]) & (arc <= anchor_arc[-1]) & inside & ~hit
take = np.flatnonzero(span)[::vertex_stride]
if not len(take):
continue
collected_xy.append(xy[take])
collected_z.append(np.interp(arc[take], anchor_arc, anchor_z))
if not collected_xy:
return None
return np.vstack(collected_xy), np.concatenate(collected_z)
def _interpolate_between_contours(burned: np.ndarray, cell_m: float) -> np.ndarray:
"""2D 거리 보간 — 셀마다 가장 가까운 서로 다른 표고 두 라인 사이를 선형 보간한다.
등고선 정점 Delaunay TIN은 같은 표고 정점 3개짜리 평탄 삼각형이 계단을 만든다.
여기서는 삼각망을 쓰지 않고, 지도 제작의 표준인 **등고선 사이 비례 보간**을
2D에서 직접 한다(2026-08-30 사용자 지시 — 메시는 맨 마지막):
z = (L1·d2 + L2·d1) / (d1 + d2)
d1·L1 = 가장 가까운 등고 라인까지의 거리·표고, d2·L2 = **표고가 다른 것 중**
가장 가까운 라인. 두 라인 사이에서 z가 거리에 비례해 연속으로 변하므로 계단이
원리적으로 생기지 않는다. 라인 위(d1=0)에서는 그 표고 그대로다.
표고별 거리장을 한 번씩 구하며 **가장 작은 두 값**을 추적한다 — 표고가 서로 다른
것끼리 비교하므로 L1≠L2가 보장된다. (KD-tree로 k개 이웃을 뽑는 방식은 이웃이
전부 같은 라인의 셀이라 다른 표고를 못 찾는다.)
`burned`: 라인 셀 = 표고, 그 외 NaN. 계곡 구조선 앵커도 같은 격자에 구워 두면
같은 규칙으로 제약이 된다.
"""
from scipy.ndimage import distance_transform_edt
levels = np.unique(burned[np.isfinite(burned)])
if len(levels) < 2:
return np.full(burned.shape, levels[0] if len(levels) else np.nan, dtype=np.float32)
infinity = np.float32(np.inf)
first_d = np.full(burned.shape, infinity, dtype=np.float32)
first_z = np.zeros(burned.shape, dtype=np.float32)
second_d = np.full(burned.shape, infinity, dtype=np.float32)
second_z = np.zeros(burned.shape, dtype=np.float32)
for level in levels:
distance = distance_transform_edt(burned != level, sampling=cell_m).astype(np.float32)
beats_first = distance < first_d
# 1등이 2등으로 밀린다.
second_d = np.where(beats_first, first_d, second_d)
second_z = np.where(beats_first, first_z, second_z)
first_d = np.where(beats_first, distance, first_d)
first_z = np.where(beats_first, np.float32(level), first_z)
beats_second = ~beats_first & (distance < second_d)
second_d = np.where(beats_second, distance, second_d)
second_z = np.where(beats_second, np.float32(level), second_z)
total = first_d + second_d
usable = np.isfinite(second_d) & (total > 1e-9)
surface = first_z.astype(np.float32)
surface[usable] = (
(
first_z[usable].astype(np.float64) * second_d[usable].astype(np.float64)
+ second_z[usable].astype(np.float64) * first_d[usable].astype(np.float64)
)
/ total[usable].astype(np.float64)
).astype(np.float32)
logger.info("도엽 서피스: 2D 거리보간 %d셀 (제약 표고 %d단)", surface.size, len(levels))
return surface
def _burn_stream_anchors(
spec: Any, burned: np.ndarray, stream_features: list[dict[str, Any]]
) -> int:
"""계곡 구조선 앵커 보간값을 등고 격자에 제약으로 굽는다. 구운 셀 수 반환."""
vertices = _stream_breakline_vertices(spec, burned, stream_features)
if vertices is None:
return 0
xy, z = vertices
row, col = spec.world_to_rc(xy[:, 0].copy(), xy[:, 1].copy())
inside = (row >= 0) & (col >= 0)
row, col, z = row[inside], col[inside], z[inside]
free = ~np.isfinite(burned[row, col]) # 등고 라인 위는 덮지 않는다
# 1m로 양자화 — 거리장을 표고별로 한 번씩 도는 방식이라 레벨 수가 곧 비용이다.
# 원천이 5m 주곡선이므로 0.5m 이내 반올림은 정확도에 영향이 없다.
burned[row[free], col[free]] = np.round(z[free]).astype(np.float32)
return int(free.sum())
def _resolve_enclosed_interiors(
burned: np.ndarray, present: list[float], surface: np.ndarray, cell_m: float, interval_m: float
) -> int:
"""폐합 등고선 안쪽(봉우리·웅덩이)을 바깥 사면 경사로 연장한다 (in-place).
거리 보간은 "가장 가까운 서로 다른 두 라인 사이"를 채우므로, 마지막 등고선
안쪽에는 더 높은 라인이 없어 아래쪽 라인 쪽으로 끌려 **분화구처럼 파인다**.
등고선이 'ㅜ'에서 점점 짧아지다 사라지는 마루가 바로 이 자리다(2026-08-30
사용자 지적).
폐합 라인 내부에 다른 표고 제약이 하나도 없으면 그 안이 마루(바깥이 낮을 때)
또는 웅덩이(바깥이 높을 때)다. 바깥 사면의 국소 경사를 안쪽으로 연장하되
±(간격−0.5m)로 제한한다 — 다음 등고선이 없다는 사실과 모순되지 않는 범위다.
처리한 영역 수를 반환한다.
"""
from scipy.ndimage import binary_dilation, binary_fill_holes, distance_transform_edt, label
constrained = np.isfinite(burned)
band_cells = 8
limit = max(interval_m - 0.5, 0.5)
resolved = 0
for level in present:
mask = burned == level
interior = binary_fill_holes(mask) & ~mask
if not interior.any():
continue
components, count = label(interior)
for component_id in range(1, count + 1):
component = components == component_id
if (constrained & component).any():
continue # 안에 다른 제약이 있으면 마루가 아니다(보통의 감싸는 링)
ring = binary_dilation(binary_fill_holes(mask) | mask) & ~component & ~mask
ring &= np.isfinite(surface)
if not ring.any():
continue
outside_mean = float(surface[ring].mean())
direction = 1.0 if outside_mean < level else -1.0
inner = distance_transform_edt(component, sampling=cell_m)
# 바깥 사면 경사 — 라인 밖 band_cells 이내 유효 셀의 (낙차 / 거리) 평균.
outer_distance = distance_transform_edt(~(mask | component), sampling=cell_m)
band = (outer_distance > 0) & (outer_distance <= band_cells * cell_m)
band &= np.isfinite(surface) & ~component & ~mask
if band.any():
slope = float(
np.mean(np.abs(level - surface[band].astype(np.float64)) / outer_distance[band])
)
else:
slope = 0.0
if slope > 1e-3:
offset = np.minimum(slope * inner[component], limit)
else:
peak = float(inner.max())
offset = (limit / 2.0) * (inner[component] / peak) if peak > 0 else 0.0
surface[component] = level + direction * offset
resolved += 1
if resolved:
logger.info("도엽 서피스: 폐합 등고선 내부 %d곳을 사면 경사로 연장", resolved)
return resolved
def build_sheet_surface_model(
project_root: Path,
processed_dir: Path,
models_dir: Path,
route_xy: np.ndarray,
epsg: int,
) -> dict[str, Any] | None:
"""도엽등고선으로 DTM npz·프리뷰 glb를 만들고 surface_models 등록용 dict를 돌려준다.
`route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m). 실패하면 None — 호출측은
분석을 계속한다(도엽 미확보 지역 폴백).
"""
started = time.monotonic()
features = _load_features_metric(processed_dir, epsg)
if not features:
return None
x_min = float(np.min(route_xy[:, 0])) - SHEET_SURFACE_MARGIN_M
x_max = float(np.max(route_xy[:, 0])) + SHEET_SURFACE_MARGIN_M
y_min = float(np.min(route_xy[:, 1])) - SHEET_SURFACE_MARGIN_M
y_max = float(np.max(route_xy[:, 1])) + SHEET_SURFACE_MARGIN_M
spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, SHEET_SURFACE_GRID_M)
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Descent import rasterize_contours
# ① 2D — 등고 라인을 격자에 굽는다(라인 셀 = 표고, 그 외 NaN).
burned, levels = rasterize_contours(spec, features, None)
present = sorted({level for level in levels if bool((burned == level).any())})
if len(present) < 2:
logger.warning("도엽 서피스: 절취 범위 안에 등고선이 부족합니다.")
return None
# 등고 간격(m) — 마루 캡 크기의 근거. 레벨이 하나뿐이면 5m(1:5,000 주곡선) 폴백.
interval_m = float(np.diff(np.array(present)).min()) if len(present) > 1 else 5.0
# ② 2D — 계곡 구조선(하천중심선) 앵커 보간값을 같은 격자에 제약으로 굽는다.
stream_features = _load_features_metric(processed_dir, epsg, _STREAM_FILE)
if stream_features:
burned_stream = _burn_stream_anchors(spec, burned, stream_features)
logger.info("도엽 서피스: 계곡 구조선 제약 %d셀", burned_stream)
# ③ 2D — 등고선 사이 거리 비례 보간(메시 없음). 여기서 나온 격자에서 1m 등고선을
# 뽑으므로 화면 등고선이 곧 2D 보간선이다(2026-08-30 사용자 지시).
surface = _interpolate_between_contours(burned, spec.cell_m)
# ④ 폐합 등고선 안쪽(마루·웅덩이)을 바깥 사면 경사로 연장 (2026-08-30 사용자 확정).
_resolve_enclosed_interiors(burned, present, surface, spec.cell_m, interval_m)
# DtmGridSampler 규약에 맞춰 y 오름차순으로 뒤집어 저장한다.
x_coords = spec.cell_centers_x()
y_coords = spec.cell_centers_y()[::-1]
z_grid = surface[::-1, :].astype(np.float32)
valid_grid = np.isfinite(z_grid)
if not valid_grid.any():
logger.warning("도엽 서피스: 유효 표고 셀이 없습니다.")
return None
stem = f"dtm_{SHEET_SOURCE_FILTER}"
model_path = models_dir / f"{stem}.npz"
preview_path = models_dir / f"{stem}_preview.glb"
finite_z = z_grid[valid_grid]
# bounds를 npz에 같이 넣는다 — 등고선 API가 이 값을 화면 원점으로 쓴다. 없으면
# LAS structured.npz로 폴백해 메시(glb) 원점과 어긋난다(LAS 없는 설계는 아예 실패).
bounds = np.array(
[
[x_coords[0], x_coords[-1]],
[y_coords[0], y_coords[-1]],
[float(finite_z.min()), float(finite_z.max())],
]
)
atomic_npz(
model_path,
x=x_coords,
y=y_coords,
z=z_grid,
valid_mask=valid_grid,
bounds=bounds,
resolution=np.array([SHEET_SURFACE_GRID_M], np.float32),
)
vertices, faces = _preview_mesh(x_coords, y_coords, z_grid, valid_grid)
write_glb(preview_path, vertices, faces, bounds)
logger.info(
"도엽 서피스 생성 완료: %d×%d 격자, 등고 %d단 (%.1fs)",
spec.n_rows,
spec.n_cols,
len(present),
time.monotonic() - started,
)
return {
"model_type": "dtm",
"source_filter": SHEET_SOURCE_FILTER,
"representation": "regular_grid",
"model_file_path": str(model_path.relative_to(project_root)).replace("\\", "/"),
"resolution_m": SHEET_SURFACE_GRID_M,
"generation_params": {
"source_filter": SHEET_SOURCE_FILTER,
"representation": "regular_grid",
"source": "map_sheet_contours",
"margin_m": SHEET_SURFACE_MARGIN_M,
},
"layers": [
{
"layer_name": f"dtm_{SHEET_SOURCE_FILTER}_preview",
"geometry_type": "MESH",
"file_path": str(preview_path.relative_to(project_root)).replace("\\", "/"),
"file_format": "glb",
}
],
}
def build_sheet_surface_from_route(
project_root: Path, processed_dir: Path, models_dir: Path
) -> dict[str, Any] | None:
"""B03 업로드 계획노선 CSV를 찾아 도엽 서피스를 만든다. 없거나 실패하면 None."""
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route_csv,
)
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
if route_file is None:
logger.warning("도엽 서피스: 계획 노선 파일이 없습니다.")
return None
planned = read_planned_route_csv(route_file)
if planned is None or len(planned.vertices) < 2:
logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다: %s", route_file.name)
return None
route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64)
return build_sheet_surface_model(
project_root, processed_dir, models_dir, route_xy, planned.epsg or 5186
)
def run_sheet_surface_analysis(
project_root: Path,
route_csv_path: Path,
*,
on_progress: Any = None,
) -> dict[str, Any]:
"""LAS 없는 WF1 — 도엽 확보 후 도엽등고선 서피스만으로 분석 결과를 만든다.
반환 형식은 `run_surface_analysis()`와 같다(save_surface_analysis_to_db 호환).
"""
from common_util.common_util_route_geometry import read_planned_route_csv
def _report(percent: int, stage: str, message: str) -> None:
if on_progress is not None:
on_progress(percent, stage, message)
stage_root = project_root / "B04_PreProcess"
processed_dir = stage_root / "processed"
models_dir = stage_root / "models"
processed_dir.mkdir(parents=True, exist_ok=True)
models_dir.mkdir(parents=True, exist_ok=True)
planned = read_planned_route_csv(route_csv_path)
if planned is None or len(planned.vertices) < 2:
raise ValueError(f"계획 노선 파일을 읽지 못했습니다: {route_csv_path.name}")
epsg = planned.epsg or 5186
route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64)
bounds_dict = {
"x": [float(route_xy[:, 0].min()), float(route_xy[:, 0].max())],
"y": [float(route_xy[:, 1].min()), float(route_xy[:, 1].max())],
"z": [
float(min(v.z for v in planned.vertices)),
float(max(v.z for v in planned.vertices)),
],
}
# VWorld 지도·GIS 벡터·도엽 확보 — LAS 경로와 같은 공용 블록 (지연 import로 순환 회피)
_report(30, "download_maps", "VWorld 지도 및 수치지형도 도엽 확보 중")
from B04_PreProcess.B04_PreProcess_Engine import download_geodata
download_geodata(
project_root,
processed_dir,
bounds_dict,
route_csv_path.parent,
rebuild=False,
default_epsg=f"EPSG:{epsg}",
report=_report,
)
_report(70, "surface_model", "도엽등고선 3D 서피스 생성 중")
model = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, epsg)
if model is None:
raise ValueError("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.")
_report(95, "saving", "결과 저장 중")
return {
"processed": {
"processed_file_path": str(
(processed_dir / _CONTOUR_FILE).relative_to(project_root)
).replace("\\", "/"),
"converted_file_path": None,
"point_count": int(len(route_xy)),
"bounds": {
"x_min": bounds_dict["x"][0],
"x_max": bounds_dict["x"][1],
"y_min": bounds_dict["y"][0],
"y_max": bounds_dict["y"][1],
},
"statistics": {
"min_z": bounds_dict["z"][0],
"max_z": bounds_dict["z"][1],
"mean_z": None,
},
},
"ground_summary": {},
"manifest": {"status": "sheet_only"},
"models": [model],
}