260705_2
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import laspy
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from pyproj import CRS
|
||||
from rasterio.transform import from_origin
|
||||
|
||||
from B03_FileInput.B03_FileInput_Engine_Analyze import (
|
||||
analyze_input_metadata,
|
||||
analyze_las_metadata,
|
||||
analyze_prj_metadata,
|
||||
analyze_tfw_metadata,
|
||||
analyze_tif_metadata,
|
||||
)
|
||||
|
||||
|
||||
class AnalyzeLasMetadataTest(unittest.TestCase):
|
||||
def test_analyze_las_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
source = Path(temporary_directory) / "sample.las"
|
||||
las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2"))
|
||||
las.x = np.array([100.0, 102.0])
|
||||
las.y = np.array([200.0, 204.0])
|
||||
las.z = np.array([10.0, 14.0])
|
||||
las.classification = np.array([2, 5], dtype=np.uint8)
|
||||
las.write(source)
|
||||
|
||||
metadata = analyze_las_metadata(source)
|
||||
|
||||
self.assertEqual(metadata["file"], "sample.las")
|
||||
self.assertEqual(metadata["point_count"], 2)
|
||||
self.assertEqual(metadata["bounds"]["x"], [100.0, 102.0])
|
||||
self.assertEqual(metadata["bounds"]["y"], [200.0, 204.0])
|
||||
self.assertEqual(metadata["bounds"]["z"], [10.0, 14.0])
|
||||
self.assertEqual(metadata["classification_summary"], {"2": 1, "5": 1})
|
||||
|
||||
def test_analyze_prj_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
source = Path(temporary_directory) / "result.prj"
|
||||
source.write_text(CRS.from_epsg(5179).to_wkt(), encoding="utf-8")
|
||||
|
||||
metadata = analyze_prj_metadata(source)
|
||||
|
||||
self.assertEqual(metadata["file"], "result.prj")
|
||||
self.assertEqual(metadata["epsg"], 5179)
|
||||
self.assertTrue(metadata["is_valid"])
|
||||
|
||||
source.write_text("invalid wkt", encoding="utf-8")
|
||||
invalid_metadata = analyze_prj_metadata(source)
|
||||
self.assertFalse(invalid_metadata["is_valid"])
|
||||
self.assertIn("error", invalid_metadata)
|
||||
|
||||
def test_analyze_tfw_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
source = Path(temporary_directory) / "result.tfw"
|
||||
source.write_text("0.5\n0\n0\n-0.5\n100\n200\n", encoding="utf-8")
|
||||
|
||||
metadata = analyze_tfw_metadata(source)
|
||||
|
||||
self.assertTrue(metadata["is_valid"])
|
||||
self.assertEqual(metadata["pixel_size_x"], 0.5)
|
||||
self.assertEqual(metadata["pixel_size_y"], -0.5)
|
||||
self.assertEqual(metadata["origin_x"], 100.0)
|
||||
self.assertEqual(metadata["origin_y"], 200.0)
|
||||
|
||||
source.write_text("nan\n0\n0\n-0.5\n100\n200\n", encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
analyze_tfw_metadata(source)
|
||||
|
||||
def test_analyze_tif_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
source = Path(temporary_directory) / "result.tif"
|
||||
with rasterio.open(
|
||||
source,
|
||||
mode="w",
|
||||
driver="GTiff",
|
||||
width=3,
|
||||
height=2,
|
||||
count=1,
|
||||
dtype="float32",
|
||||
crs="EPSG:5179",
|
||||
transform=from_origin(100, 200, 2, 2),
|
||||
nodata=-9999,
|
||||
) as dataset:
|
||||
dataset.write(np.ones((1, 2, 3), dtype=np.float32))
|
||||
|
||||
metadata = analyze_tif_metadata(source)
|
||||
|
||||
self.assertEqual(metadata["file"], "result.tif")
|
||||
self.assertEqual(metadata["width"], 3)
|
||||
self.assertEqual(metadata["height"], 2)
|
||||
self.assertEqual(metadata["count"], 1)
|
||||
self.assertEqual(metadata["epsg"], 5179)
|
||||
self.assertEqual(metadata["resolution"], [2.0, 2.0])
|
||||
self.assertEqual(metadata["likely_type"], "dem")
|
||||
|
||||
def test_analyze_input_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
source = Path(temporary_directory) / "drawing.dxf"
|
||||
source.write_bytes(b"0\nSECTION\n")
|
||||
|
||||
metadata = analyze_input_metadata(source)
|
||||
|
||||
self.assertEqual(
|
||||
metadata,
|
||||
{"file": "drawing.dxf", "extension": "dxf", "size_bytes": 10},
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from B03_FileInput.B03_FileInput_Engine import (
|
||||
resolve_upload_destination,
|
||||
save_upload_stream,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor
|
||||
|
||||
|
||||
class ResolveUploadDestinationTest(unittest.TestCase):
|
||||
def test_resolve_upload_destination(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
descriptor = FileUploadDescriptor(
|
||||
original_filename="cloud_merged.LAS",
|
||||
size_bytes=1024,
|
||||
)
|
||||
|
||||
destination = resolve_upload_destination(temporary_directory, descriptor)
|
||||
|
||||
self.assertEqual(
|
||||
destination,
|
||||
Path(temporary_directory).resolve()
|
||||
/ "B03_FileInput"
|
||||
/ "input"
|
||||
/ "las"
|
||||
/ "cloud_merged.LAS",
|
||||
)
|
||||
self.assertTrue(destination.parent.is_dir())
|
||||
|
||||
|
||||
class SaveUploadStreamTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_save_upload_stream(self) -> None:
|
||||
class FakeUpload:
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self.chunks = iter(chunks)
|
||||
|
||||
async def read(self, _size: int) -> bytes:
|
||||
return next(self.chunks, b"")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
destination = Path(temporary_directory) / "cloud.las"
|
||||
upload = FakeUpload([b"abc", b"def"])
|
||||
|
||||
written_bytes = await save_upload_stream(upload, destination) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(written_bytes, 6)
|
||||
self.assertEqual(destination.read_bytes(), b"abcdef")
|
||||
self.assertEqual(list(destination.parent.glob("*.upload")), [])
|
||||
|
||||
with patch("B03_FileInput.B03_FileInput_Engine.UPLOAD_MAX_MB", 0):
|
||||
with self.assertRaises(ValueError):
|
||||
await save_upload_stream( # type: ignore[arg-type]
|
||||
FakeUpload([b"too-large"]),
|
||||
destination,
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
import json
|
||||
import unittest
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import (
|
||||
create_input_file,
|
||||
get_project_storage_relative_path,
|
||||
)
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
"""asyncmy 커서 흉내 (async context manager + execute/fetchone/lastrowid)."""
|
||||
|
||||
def __init__(self, *, lastrowid: int = 0, row: tuple[Any, ...] | None = None) -> None:
|
||||
self.query = ""
|
||||
self.arguments: tuple[Any, ...] = ()
|
||||
self.lastrowid = lastrowid
|
||||
self._row = row
|
||||
|
||||
async def __aenter__(self) -> "FakeCursor":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: Any) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, query: str, arguments: tuple[Any, ...] | None = None) -> None:
|
||||
self.query = query
|
||||
self.arguments = arguments or ()
|
||||
|
||||
async def fetchone(self) -> tuple[Any, ...] | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
"""asyncmy 커넥션 흉내 (cursor() 팩토리)."""
|
||||
|
||||
def __init__(self, cursor: FakeCursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def cursor(self) -> FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
|
||||
class CreateInputFileTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create_input_file(self) -> None:
|
||||
cursor = FakeCursor(lastrowid=17)
|
||||
connection = FakeConnection(cursor)
|
||||
project_id = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
metadata = {"point_count": 2, "crs": "EPSG:5179"}
|
||||
|
||||
input_file_id = await create_input_file( # type: ignore[arg-type]
|
||||
connection,
|
||||
project_id=project_id,
|
||||
file_type="las",
|
||||
original_filename="cloud.las",
|
||||
relative_path="B03_FileInput/input/las/cloud.las",
|
||||
file_size_bytes=1024 * 1024,
|
||||
upload_by=3,
|
||||
crs_epsg=5179,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
self.assertEqual(input_file_id, 17)
|
||||
self.assertIn("INSERT INTO input_files", cursor.query)
|
||||
self.assertEqual(cursor.arguments[0], str(project_id))
|
||||
self.assertEqual(cursor.arguments[3], "B03_FileInput/input/las/cloud.las")
|
||||
self.assertEqual(cursor.arguments[4], 1.0)
|
||||
self.assertEqual(json.loads(cursor.arguments[7]), metadata)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
await create_input_file( # type: ignore[arg-type]
|
||||
connection,
|
||||
project_id=project_id,
|
||||
file_type="las",
|
||||
original_filename="cloud.las",
|
||||
relative_path="../outside/cloud.las",
|
||||
file_size_bytes=1,
|
||||
upload_by=None,
|
||||
crs_epsg=None,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
async def test_get_project_storage_relative_path(self) -> None:
|
||||
project_id = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
connection = FakeConnection(FakeCursor(row=("storage/company/user/project-id",)))
|
||||
relative_path = await get_project_storage_relative_path( # type: ignore[arg-type]
|
||||
connection,
|
||||
project_id,
|
||||
)
|
||||
self.assertEqual(relative_path, "storage/company/user/project-id")
|
||||
|
||||
for row in (None, ("../outside",), ("/absolute/path",)):
|
||||
with self.subTest(row=row):
|
||||
with self.assertRaises((LookupError, ValueError)):
|
||||
await get_project_storage_relative_path( # type: ignore[arg-type]
|
||||
FakeConnection(FakeCursor(row=row)),
|
||||
project_id,
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
|
||||
import laspy
|
||||
import numpy as np
|
||||
|
||||
from B03_FileInput.B03_FileInput_Router import upload_project_files
|
||||
from B03_FileInput.B03_FileInput_Schema import FileUploadResponse
|
||||
|
||||
|
||||
class UploadProjectFilesTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_upload_project_files(self) -> None:
|
||||
class AsyncContext:
|
||||
def __init__(self, value: Any = None) -> None:
|
||||
self.value = value
|
||||
|
||||
async def __aenter__(self) -> Any:
|
||||
return self.value
|
||||
|
||||
async def __aexit__(self, *_args: Any) -> None:
|
||||
return None
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, connection: "FakeConnection") -> None:
|
||||
self.connection = connection
|
||||
self.lastrowid = 0
|
||||
|
||||
async def __aenter__(self) -> "FakeCursor":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: Any) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, query: str, _arguments: Any = None) -> None:
|
||||
if query.strip().upper().startswith("SELECT"):
|
||||
self._row: tuple[Any, ...] | None = ("storage/company/user/project-id",)
|
||||
else:
|
||||
self.lastrowid = self.connection.next_id
|
||||
self.connection.next_id += 1
|
||||
self._row = None
|
||||
|
||||
async def fetchone(self) -> tuple[Any, ...] | None:
|
||||
return getattr(self, "_row", None)
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self) -> None:
|
||||
self.next_id = 1
|
||||
self.committed = False
|
||||
self.rolled_back = False
|
||||
|
||||
def cursor(self) -> FakeCursor:
|
||||
return FakeCursor(self)
|
||||
|
||||
async def begin(self) -> None:
|
||||
return None
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
async def rollback(self) -> None:
|
||||
self.rolled_back = True
|
||||
|
||||
class FakePool:
|
||||
def __init__(self, connection: FakeConnection) -> None:
|
||||
self.connection = connection
|
||||
|
||||
def acquire(self) -> AsyncContext:
|
||||
return AsyncContext(self.connection)
|
||||
|
||||
class FakeUpload:
|
||||
def __init__(self, filename: str, content: bytes) -> None:
|
||||
self.filename = filename
|
||||
self.size = len(content)
|
||||
self.file = io.BytesIO(content)
|
||||
self.closed = False
|
||||
|
||||
async def read(self, size: int) -> bytes:
|
||||
return self.file.read(size)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.file.close()
|
||||
self.closed = True
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
project_root = Path(temporary_directory) / "project"
|
||||
project_root.mkdir()
|
||||
source = Path(temporary_directory) / "source.las"
|
||||
las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2"))
|
||||
las.x = np.array([1.0])
|
||||
las.y = np.array([2.0])
|
||||
las.z = np.array([3.0])
|
||||
las.write(source)
|
||||
upload = FakeUpload("cloud.las", source.read_bytes())
|
||||
pool = FakePool(FakeConnection())
|
||||
|
||||
with (
|
||||
patch("B03_FileInput.B03_FileInput_Router.get_db_pool", return_value=pool),
|
||||
patch(
|
||||
"B03_FileInput.B03_FileInput_Router.resolve_stored_project_path",
|
||||
return_value=str(project_root),
|
||||
),
|
||||
):
|
||||
response = await upload_project_files( # type: ignore[arg-type]
|
||||
UUID("550e8400-e29b-41d4-a716-446655440000"),
|
||||
[upload],
|
||||
)
|
||||
|
||||
self.assertIsInstance(response, FileUploadResponse)
|
||||
assert isinstance(response, FileUploadResponse)
|
||||
self.assertEqual(len(response.files), 1)
|
||||
self.assertEqual(response.files[0].input_file_id, 1)
|
||||
self.assertTrue(
|
||||
(project_root / "B03_FileInput" / "input" / "las" / "cloud.las").is_file()
|
||||
)
|
||||
self.assertTrue((project_root / "B03_FileInput" / "metadata.json").is_file())
|
||||
self.assertTrue((project_root / "workflow.json").is_file())
|
||||
self.assertTrue(upload.closed)
|
||||
@@ -0,0 +1,30 @@
|
||||
import unittest
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor
|
||||
from config.config_system import UPLOAD_MAX_MB
|
||||
|
||||
|
||||
class FileUploadDescriptorTest(unittest.TestCase):
|
||||
def test_file_upload_descriptor(self) -> None:
|
||||
descriptor = FileUploadDescriptor(
|
||||
original_filename="cloud_merged.LAS",
|
||||
size_bytes=1024,
|
||||
)
|
||||
self.assertEqual(descriptor.original_filename, "cloud_merged.LAS")
|
||||
|
||||
invalid_values = (
|
||||
{"original_filename": "../cloud.las", "size_bytes": 1024},
|
||||
{"original_filename": "nested/cloud.las", "size_bytes": 1024},
|
||||
{"original_filename": "cloud.exe", "size_bytes": 1024},
|
||||
{
|
||||
"original_filename": "cloud.las",
|
||||
"size_bytes": UPLOAD_MAX_MB * 1024 * 1024 + 1,
|
||||
},
|
||||
{"original_filename": "cloud.las", "size_bytes": 0},
|
||||
)
|
||||
for invalid_value in invalid_values:
|
||||
with self.subTest(invalid_value=invalid_value):
|
||||
with self.assertRaises(ValidationError):
|
||||
FileUploadDescriptor(**invalid_value)
|
||||
@@ -0,0 +1,64 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import laspy
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import (
|
||||
build_ground_masks,
|
||||
summarize_masks,
|
||||
)
|
||||
|
||||
|
||||
class GroundMasksTest(unittest.TestCase):
|
||||
def test_build_and_summarize(self) -> None:
|
||||
coords = np.linspace(0.0, 20.0, 21)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 5.0 + 0.03 * gx
|
||||
xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64)
|
||||
bounds = np.array([[0.0, 20.0], [0.0, 20.0], [float(z.min()), float(z.max())]])
|
||||
data = {"xyz": xyz, "bounds": bounds}
|
||||
|
||||
masks = build_ground_masks(data, ["grid_min_z"])
|
||||
self.assertIn("grid_min_z", masks)
|
||||
self.assertEqual(masks["grid_min_z"].dtype, bool)
|
||||
|
||||
summary = summarize_masks(data, masks)
|
||||
self.assertEqual(summary["grid_min_z"]["total_point_count"], len(xyz))
|
||||
self.assertGreater(summary["grid_min_z"]["ground_ratio"], 0.9)
|
||||
|
||||
def test_unknown_filter(self) -> None:
|
||||
data = {"xyz": np.zeros((1, 3)), "bounds": np.zeros((3, 2))}
|
||||
with self.assertRaises(ValueError):
|
||||
build_ground_masks(data, ["unknown"])
|
||||
|
||||
|
||||
class RunSurfaceAnalysisTest(unittest.TestCase):
|
||||
def test_full_pipeline(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "proj"
|
||||
root.mkdir()
|
||||
coords = np.linspace(0.0, 30.0, 31)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 5.0 + 0.04 * gx + 0.02 * gy
|
||||
src = Path(directory) / "cloud.las"
|
||||
las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2"))
|
||||
las.x, las.y, las.z = gx.ravel(), gy.ravel(), z.ravel()
|
||||
las.write(src)
|
||||
|
||||
result = run_surface_analysis(
|
||||
root, src, source_filters=["grid_min_z"], methods=["dtm", "tin"]
|
||||
)
|
||||
|
||||
self.assertEqual(result["processed"]["point_count"], 961)
|
||||
self.assertEqual(result["manifest"]["status"], "completed")
|
||||
self.assertGreater(result["ground_summary"]["grid_min_z"]["ground_ratio"], 0.9)
|
||||
model_types = {m["model_type"] for m in result["models"]}
|
||||
self.assertEqual(model_types, {"dtm", "tin"})
|
||||
for model in result["models"]:
|
||||
self.assertTrue(model["model_file_path"].startswith("B04_wf1_Surface/"))
|
||||
self.assertTrue(model["layers"])
|
||||
# structured.npz가 processed 폴더에 생성됨
|
||||
self.assertTrue((root / "B04_wf1_Surface" / "processed" / "structured.npz").is_file())
|
||||
@@ -0,0 +1,48 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_CSF import filter_csf
|
||||
|
||||
|
||||
class FilterCsfTest(unittest.TestCase):
|
||||
def _ground_with_trees(self) -> dict[str, np.ndarray]:
|
||||
"""평탄한 지면 격자 위에 높이 솟은 수목 포인트를 얹은 합성 데이터."""
|
||||
coords = np.linspace(0.0, 10.0, 11)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
ground = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 5.0)])
|
||||
trees = np.array(
|
||||
[
|
||||
[2.0, 2.0, 15.0],
|
||||
[5.0, 5.0, 18.0],
|
||||
[8.0, 8.0, 16.0],
|
||||
]
|
||||
)
|
||||
xyz = np.vstack([ground, trees])
|
||||
return {"xyz": xyz}, ground.shape[0]
|
||||
|
||||
def test_classifies_ground_and_rejects_trees(self) -> None:
|
||||
structured, ground_count = self._ground_with_trees()
|
||||
|
||||
mask = filter_csf(structured, cloth_resolution=1.5, class_threshold=0.5)
|
||||
|
||||
self.assertEqual(mask.shape[0], structured["xyz"].shape[0])
|
||||
self.assertEqual(mask.dtype, bool)
|
||||
# 지면 포인트는 대부분 지면으로 분류되어야 한다.
|
||||
self.assertGreater(mask[:ground_count].mean(), 0.8)
|
||||
# 솟은 수목 포인트는 지면에서 제외되어야 한다.
|
||||
self.assertFalse(mask[ground_count:].any())
|
||||
|
||||
def test_empty_input(self) -> None:
|
||||
mask = filter_csf({"xyz": np.zeros((0, 3))})
|
||||
self.assertEqual(mask.shape[0], 0)
|
||||
self.assertEqual(mask.dtype, bool)
|
||||
|
||||
def test_invalid_parameters(self) -> None:
|
||||
structured, _ = self._ground_with_trees()
|
||||
with self.assertRaises(ValueError):
|
||||
filter_csf(structured, cloth_resolution=0.0)
|
||||
with self.assertRaises(ValueError):
|
||||
filter_csf(structured, rigidness=5)
|
||||
with self.assertRaises(ValueError):
|
||||
filter_csf(structured, iterations=0)
|
||||
@@ -0,0 +1,25 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_Grid import filter_grid_min_z
|
||||
|
||||
|
||||
class FilterGridMinZTest(unittest.TestCase):
|
||||
def test_filter_grid_min_z(self) -> None:
|
||||
structured = {
|
||||
"xyz": np.array(
|
||||
[
|
||||
[0.0, 0.0, 10.0],
|
||||
[0.5, 0.5, 11.0],
|
||||
[0.8, 0.8, 12.0],
|
||||
]
|
||||
),
|
||||
"bounds": np.array([[0.0, 0.8], [0.0, 0.8], [10.0, 12.0]]),
|
||||
}
|
||||
|
||||
mask = filter_grid_min_z(structured, cell_size=2.0, height_threshold=1.5)
|
||||
|
||||
np.testing.assert_array_equal(mask, [True, True, False])
|
||||
with self.assertRaises(ValueError):
|
||||
filter_grid_min_z(structured, cell_size=0)
|
||||
@@ -0,0 +1,42 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_PMF import filter_pmf
|
||||
|
||||
|
||||
class FilterPmfTest(unittest.TestCase):
|
||||
def _ground_with_trees(self) -> tuple[dict[str, np.ndarray], int]:
|
||||
coords = np.linspace(0.0, 40.0, 21)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
ground = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 5.0)])
|
||||
trees = np.array(
|
||||
[
|
||||
[10.0, 10.0, 20.0],
|
||||
[20.0, 20.0, 25.0],
|
||||
[30.0, 30.0, 22.0],
|
||||
]
|
||||
)
|
||||
xyz = np.vstack([ground, trees])
|
||||
bounds = np.array([[0.0, 40.0], [0.0, 40.0], [5.0, 25.0]])
|
||||
return {"xyz": xyz, "bounds": bounds}, ground.shape[0]
|
||||
|
||||
def test_classifies_ground_and_rejects_trees(self) -> None:
|
||||
structured, ground_count = self._ground_with_trees()
|
||||
mask = filter_pmf(structured)
|
||||
self.assertEqual(mask.shape[0], structured["xyz"].shape[0])
|
||||
self.assertEqual(mask.dtype, bool)
|
||||
self.assertGreater(mask[:ground_count].mean(), 0.9)
|
||||
self.assertFalse(mask[ground_count:].any())
|
||||
|
||||
def test_empty_input(self) -> None:
|
||||
bounds = np.array([[0.0, 1.0], [0.0, 1.0], [0.0, 1.0]])
|
||||
mask = filter_pmf({"xyz": np.zeros((0, 3)), "bounds": bounds})
|
||||
self.assertEqual(mask.shape[0], 0)
|
||||
|
||||
def test_invalid_parameters(self) -> None:
|
||||
structured, _ = self._ground_with_trees()
|
||||
with self.assertRaises(ValueError):
|
||||
filter_pmf(structured, cell_size=0)
|
||||
with self.assertRaises(ValueError):
|
||||
filter_pmf(structured, initial_window_size=0)
|
||||
@@ -0,0 +1,59 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_RANSAC import (
|
||||
filter_ransac,
|
||||
fit_plane_ransac,
|
||||
)
|
||||
|
||||
|
||||
class FitPlaneRansacTest(unittest.TestCase):
|
||||
def test_fits_horizontal_plane(self) -> None:
|
||||
coords = np.linspace(0.0, 5.0, 6)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
plane = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 2.0)])
|
||||
outliers = np.array([[1.0, 1.0, 10.0], [3.0, 3.0, 12.0]])
|
||||
points = np.vstack([plane, outliers])
|
||||
inliers = fit_plane_ransac(points, distance_threshold=0.3)
|
||||
self.assertTrue(inliers[: plane.shape[0]].all())
|
||||
self.assertFalse(inliers[plane.shape[0] :].any())
|
||||
|
||||
def test_too_few_points(self) -> None:
|
||||
points = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]])
|
||||
inliers = fit_plane_ransac(points)
|
||||
self.assertTrue(inliers.all())
|
||||
|
||||
|
||||
class FilterRansacTest(unittest.TestCase):
|
||||
def _ground_with_trees(self) -> tuple[dict[str, np.ndarray], int]:
|
||||
coords = np.linspace(0.0, 20.0, 11)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
ground = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 3.0)])
|
||||
trees = np.array([[5.0, 5.0, 15.0], [15.0, 15.0, 18.0]])
|
||||
xyz = np.vstack([ground, trees])
|
||||
bounds = np.array([[0.0, 20.0], [0.0, 20.0], [3.0, 18.0]])
|
||||
return {"xyz": xyz, "bounds": bounds}, ground.shape[0]
|
||||
|
||||
def test_classifies_ground(self) -> None:
|
||||
structured, ground_count = self._ground_with_trees()
|
||||
progress: list[int] = []
|
||||
mask = filter_ransac(structured, progress_callback=progress.append)
|
||||
self.assertEqual(mask.shape[0], structured["xyz"].shape[0])
|
||||
self.assertGreater(mask[:ground_count].mean(), 0.9)
|
||||
# progress는 호출되며 단조 증가하고 마지막에 100으로 완료된다.
|
||||
self.assertTrue(progress)
|
||||
self.assertEqual(progress, sorted(progress))
|
||||
self.assertEqual(progress[-1], 100)
|
||||
|
||||
def test_empty_input(self) -> None:
|
||||
bounds = np.array([[0.0, 1.0], [0.0, 1.0], [0.0, 1.0]])
|
||||
mask = filter_ransac({"xyz": np.zeros((0, 3)), "bounds": bounds})
|
||||
self.assertEqual(mask.shape[0], 0)
|
||||
|
||||
def test_invalid_parameters(self) -> None:
|
||||
structured, _ = self._ground_with_trees()
|
||||
with self.assertRaises(ValueError):
|
||||
filter_ransac(structured, local_grid_size=0)
|
||||
with self.assertRaises(ValueError):
|
||||
filter_ransac(structured, ransac_n=2)
|
||||
@@ -0,0 +1,61 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models
|
||||
from config.config_system import build_surface_model_config
|
||||
|
||||
|
||||
class BuildAllTerrainModelsTest(unittest.TestCase):
|
||||
def _synthetic_slope(self) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
|
||||
coords = np.linspace(0.0, 40.0, 41)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 5.0 + 0.05 * gx + 0.03 * gy
|
||||
xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64)
|
||||
bounds = np.array([[0.0, 40.0], [0.0, 40.0], [float(z.min()), float(z.max())]])
|
||||
mask = np.ones(len(xyz), dtype=bool)
|
||||
return {"xyz": xyz, "bounds": bounds}, {"grid_min_z": mask}
|
||||
|
||||
def test_builds_all_representations(self) -> None:
|
||||
structured, masks = self._synthetic_slope()
|
||||
config = build_surface_model_config()
|
||||
config["source_filters"] = ["grid_min_z"]
|
||||
progress: list[tuple[int, str]] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output_dir = Path(directory) / "proj" / "models"
|
||||
manifest = build_all_terrain_models(
|
||||
structured, masks, output_dir, config, progress=lambda p, m: progress.append((p, m))
|
||||
)
|
||||
|
||||
self.assertEqual(manifest["status"], "completed")
|
||||
self.assertEqual(manifest["failure_count"], 0)
|
||||
methods = manifest["source_filters"]["grid_min_z"]["methods"]
|
||||
self.assertEqual(set(methods), {"tin", "dtm", "nurbs", "implicit", "meshfree"})
|
||||
for meta in methods.values():
|
||||
self.assertEqual(meta["status"], "completed")
|
||||
# DTM/TIN은 스무딩까지 완료
|
||||
self.assertEqual(methods["dtm"]["smooth"]["status"], "completed")
|
||||
self.assertEqual(methods["tin"]["smooth"]["status"], "completed")
|
||||
# 등고선 캐시와 manifest 생성 확인
|
||||
files = list(output_dir.iterdir())
|
||||
self.assertTrue(any("contour_" in f.name for f in files))
|
||||
self.assertTrue((output_dir / "manifest.json").is_file())
|
||||
self.assertEqual(progress[-1][0], 100)
|
||||
|
||||
def test_cache_reuse_on_second_call(self) -> None:
|
||||
structured, masks = self._synthetic_slope()
|
||||
config = build_surface_model_config()
|
||||
config["source_filters"] = ["grid_min_z"]
|
||||
config["precompute"] = ["dtm"]
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output_dir = Path(directory) / "proj" / "models"
|
||||
build_all_terrain_models(structured, masks, output_dir, config)
|
||||
second = build_all_terrain_models(structured, masks, output_dir, config)
|
||||
self.assertEqual(second["status"], "completed")
|
||||
self.assertEqual(
|
||||
second["source_filters"]["grid_min_z"]["methods"]["dtm"]["status"], "completed"
|
||||
)
|
||||
@@ -0,0 +1,143 @@
|
||||
import json
|
||||
import unittest
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Repository import (
|
||||
create_processed_point_cloud,
|
||||
create_surface_model,
|
||||
create_terrain_layer,
|
||||
list_surface_models,
|
||||
)
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, *, lastrowid: int = 0, rows: list[tuple[Any, ...]] | None = None) -> None:
|
||||
self.query = ""
|
||||
self.arguments: tuple[Any, ...] = ()
|
||||
self.lastrowid = lastrowid
|
||||
self._rows = rows or []
|
||||
|
||||
async def __aenter__(self) -> "FakeCursor":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: Any) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, query: str, arguments: tuple[Any, ...] | None = None) -> None:
|
||||
self.query = query
|
||||
self.arguments = arguments or ()
|
||||
|
||||
async def fetchall(self) -> list[tuple[Any, ...]]:
|
||||
return self._rows
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, cursor: FakeCursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def cursor(self) -> FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
|
||||
PROJECT_ID = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
|
||||
class ProcessedPointCloudTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create(self) -> None:
|
||||
cursor = FakeCursor(lastrowid=11)
|
||||
new_id = await create_processed_point_cloud( # type: ignore[arg-type]
|
||||
FakeConnection(cursor),
|
||||
input_file_id=3,
|
||||
project_id=PROJECT_ID,
|
||||
process_type="filtered",
|
||||
processed_file_path="B04_wf1_Surface/processed/cloud.las",
|
||||
converted_format="ply",
|
||||
converted_file_path="B04_wf1_Surface/processed/cloud.ply",
|
||||
point_count=1000,
|
||||
bounds={"x_min": 0.0, "x_max": 10.0, "y_min": 0.0, "y_max": 10.0},
|
||||
statistics={"min_z": 1.0, "max_z": 5.0, "mean_z": 3.0, "density_per_sqm": 10.0},
|
||||
classification_summary={"ground": 800},
|
||||
processing_params={"filter": "csf"},
|
||||
)
|
||||
self.assertEqual(new_id, 11)
|
||||
self.assertIn("INSERT INTO processed_point_cloud", cursor.query)
|
||||
self.assertEqual(cursor.arguments[1], str(PROJECT_ID))
|
||||
self.assertEqual(cursor.arguments[6], 1000)
|
||||
self.assertEqual(json.loads(cursor.arguments[15]), {"ground": 800})
|
||||
|
||||
async def test_rejects_path_outside_stage(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
await create_processed_point_cloud( # type: ignore[arg-type]
|
||||
FakeConnection(FakeCursor(lastrowid=1)),
|
||||
input_file_id=3,
|
||||
project_id=PROJECT_ID,
|
||||
process_type="filtered",
|
||||
processed_file_path="B05_wf2_Route/x.las",
|
||||
converted_format=None,
|
||||
converted_file_path=None,
|
||||
point_count=None,
|
||||
bounds=None,
|
||||
statistics=None,
|
||||
classification_summary=None,
|
||||
processing_params=None,
|
||||
)
|
||||
|
||||
|
||||
class SurfaceModelTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create(self) -> None:
|
||||
cursor = FakeCursor(lastrowid=22)
|
||||
new_id = await create_surface_model( # type: ignore[arg-type]
|
||||
FakeConnection(cursor),
|
||||
project_id=PROJECT_ID,
|
||||
model_type="dtm_grid",
|
||||
source_file_id=3,
|
||||
processed_cloud_id=11,
|
||||
crs_epsg=5178,
|
||||
resolution_m=1.0,
|
||||
model_file_path="B04_wf1_Surface/models/dtm.npz",
|
||||
generation_params={"algorithm": "dtm"},
|
||||
)
|
||||
self.assertEqual(new_id, 22)
|
||||
self.assertIn("INSERT INTO surface_models", cursor.query)
|
||||
self.assertEqual(cursor.arguments[7], "B04_wf1_Surface/models/dtm.npz")
|
||||
|
||||
|
||||
class TerrainLayerTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create(self) -> None:
|
||||
cursor = FakeCursor(lastrowid=33)
|
||||
new_id = await create_terrain_layer( # type: ignore[arg-type]
|
||||
FakeConnection(cursor),
|
||||
surface_model_id=22,
|
||||
layer_name="지표",
|
||||
geometry_type="GRID",
|
||||
layer_file_path="B04_wf1_Surface/models/layer_ground.geojson",
|
||||
file_format="geojson",
|
||||
file_size_mb=1.5,
|
||||
statistics={"point_count": 500},
|
||||
)
|
||||
self.assertEqual(new_id, 33)
|
||||
self.assertIn("INSERT INTO terrain_layers", cursor.query)
|
||||
|
||||
|
||||
class ListSurfaceModelsTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_list(self) -> None:
|
||||
import datetime
|
||||
|
||||
rows = [
|
||||
(
|
||||
22,
|
||||
"dtm_grid",
|
||||
"COMPLETE",
|
||||
1.0,
|
||||
"B04_wf1_Surface/models/dtm.npz",
|
||||
datetime.datetime(2026, 7, 5, 12, 0, 0),
|
||||
),
|
||||
]
|
||||
result = await list_surface_models( # type: ignore[arg-type]
|
||||
FakeConnection(FakeCursor(rows=rows)), PROJECT_ID
|
||||
)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["id"], 22)
|
||||
self.assertEqual(result[0]["model_type"], "dtm_grid")
|
||||
self.assertTrue(result[0]["created_at"].startswith("2026-07-05"))
|
||||
@@ -0,0 +1,40 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import laspy
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las
|
||||
|
||||
|
||||
class StructurizeLasTest(unittest.TestCase):
|
||||
def test_structurize_las(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
source = Path(temporary_directory) / "source.las"
|
||||
output_dir = Path(temporary_directory) / "processed"
|
||||
las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2"))
|
||||
las.x = np.array([100.0, 102.0])
|
||||
las.y = np.array([200.0, 204.0])
|
||||
las.z = np.array([10.0, 14.0])
|
||||
las.intensity = np.array([7, 9], dtype=np.uint16)
|
||||
las.red = np.array([65535, 256], dtype=np.uint16)
|
||||
las.green = np.array([32768, 512], dtype=np.uint16)
|
||||
las.blue = np.array([0, 768], dtype=np.uint16)
|
||||
las.classification = np.array([2, 5], dtype=np.uint8)
|
||||
las.write(source)
|
||||
progress: list[int] = []
|
||||
|
||||
target = structurize_las(source, output_dir, progress.append)
|
||||
|
||||
self.assertEqual(target, output_dir / "structured.npz")
|
||||
self.assertEqual(progress[-1], 100)
|
||||
with np.load(target) as structured:
|
||||
np.testing.assert_allclose(
|
||||
structured["xyz"],
|
||||
np.array([[100.0, 200.0, 10.0], [102.0, 204.0, 14.0]]),
|
||||
)
|
||||
np.testing.assert_array_equal(structured["intensity"], [7, 9])
|
||||
np.testing.assert_array_equal(structured["rgb"], [[255, 128, 0], [1, 2, 3]])
|
||||
np.testing.assert_array_equal(structured["classification"], [2, 5])
|
||||
np.testing.assert_array_equal(structured["total_points"], [2])
|
||||
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design
|
||||
from B05_wf2_Route.B05_wf2_Route_Router import router
|
||||
from config.config_system import build_surface_model_config
|
||||
|
||||
|
||||
class RouteEngineTest(unittest.TestCase):
|
||||
def _make_dtm(self, root: Path) -> None:
|
||||
models = root / "B04_wf1_Surface" / "models"
|
||||
coords = np.linspace(0.0, 60.0, 61)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 5.0 + 0.03 * gx + 0.02 * gy
|
||||
xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64)
|
||||
bounds = np.array([[0.0, 60.0], [0.0, 60.0], [float(z.min()), float(z.max())]])
|
||||
cfg = build_surface_model_config()
|
||||
cfg["source_filters"] = ["grid_min_z"]
|
||||
cfg["precompute"] = ["dtm"]
|
||||
build_all_terrain_models(
|
||||
{"xyz": xyz, "bounds": bounds},
|
||||
{"grid_min_z": np.ones(len(xyz), dtype=bool)},
|
||||
models,
|
||||
cfg,
|
||||
)
|
||||
|
||||
def test_run_route_design(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "proj"
|
||||
self._make_dtm(root)
|
||||
points = {
|
||||
"bp": {"x": 5.0, "y": 5.0},
|
||||
"ep": {"x": 55.0, "y": 55.0},
|
||||
"cp": [],
|
||||
"ap": [],
|
||||
"fp": [],
|
||||
}
|
||||
design = run_route_design(root, "grid_min_z", "dtm", False, points, {})
|
||||
|
||||
# GeoJSON 저장 확인
|
||||
self.assertEqual(design["route_data_path"], "B05_wf2_Route/route/route_main.geojson")
|
||||
geojson_file = root / design["route_data_path"]
|
||||
self.assertTrue(geojson_file.is_file())
|
||||
geo = json.loads(geojson_file.read_text(encoding="utf-8"))
|
||||
self.assertEqual(geo["geometry"]["type"], "LineString")
|
||||
self.assertGreater(len(geo["geometry"]["coordinates"]), 2)
|
||||
|
||||
# 렌더링 샘플 & 통계
|
||||
self.assertTrue(design["render_points"])
|
||||
self.assertEqual(design["render_points"][0]["sequence_num"], 0)
|
||||
self.assertIsNotNone(design["statistics"]["max_slope"])
|
||||
self.assertTrue(design["solver_result"]["required_points_ok"])
|
||||
|
||||
|
||||
class RouterRegistrationTest(unittest.TestCase):
|
||||
def test_routes_registered(self) -> None:
|
||||
paths = {r.path for r in router.routes}
|
||||
self.assertIn("/api/projects/{project_id}/route/solve", paths)
|
||||
self.assertIn("/api/projects/{project_id}/route/confirm", paths)
|
||||
@@ -0,0 +1,148 @@
|
||||
import json
|
||||
import unittest
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
confirm_route,
|
||||
create_route,
|
||||
create_route_statistics,
|
||||
get_latest_route,
|
||||
insert_route_points,
|
||||
)
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, *, lastrowid: int = 0, row: tuple[Any, ...] | None = None) -> None:
|
||||
self.query = ""
|
||||
self.arguments: Any = ()
|
||||
self.many_rows: list[tuple[Any, ...]] = []
|
||||
self.lastrowid = lastrowid
|
||||
self._row = row
|
||||
|
||||
async def __aenter__(self) -> "FakeCursor":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: Any) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, query: str, arguments: Any = None) -> None:
|
||||
self.query = query
|
||||
self.arguments = arguments or ()
|
||||
|
||||
async def executemany(self, query: str, rows: list[tuple[Any, ...]]) -> None:
|
||||
self.query = query
|
||||
self.many_rows = rows
|
||||
|
||||
async def fetchone(self) -> tuple[Any, ...] | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, cursor: FakeCursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def cursor(self) -> FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
|
||||
PROJECT_ID = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
|
||||
class CreateRouteTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create(self) -> None:
|
||||
cursor = FakeCursor(lastrowid=7)
|
||||
route_id = await create_route( # type: ignore[arg-type]
|
||||
FakeConnection(cursor),
|
||||
project_id=PROJECT_ID,
|
||||
surface_model_id=22,
|
||||
total_length_m=100.0,
|
||||
start_chainage_m=0.0,
|
||||
end_chainage_m=100.0,
|
||||
grade_percent=[2.5, 1.8],
|
||||
constraints={"max_grade": 0.14},
|
||||
algorithm_params={"weights": {"dist": 1.0}},
|
||||
route_data_path="B05_wf2_Route/route/route_main.geojson",
|
||||
)
|
||||
self.assertEqual(route_id, 7)
|
||||
self.assertIn("INSERT INTO routes", cursor.query)
|
||||
self.assertEqual(cursor.arguments[0], str(PROJECT_ID))
|
||||
self.assertEqual(json.loads(cursor.arguments[6]), [2.5, 1.8])
|
||||
|
||||
async def test_rejects_path_outside_stage(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
await create_route( # type: ignore[arg-type]
|
||||
FakeConnection(FakeCursor(lastrowid=1)),
|
||||
project_id=PROJECT_ID,
|
||||
surface_model_id=None,
|
||||
total_length_m=None,
|
||||
start_chainage_m=None,
|
||||
end_chainage_m=None,
|
||||
grade_percent=None,
|
||||
constraints=None,
|
||||
algorithm_params=None,
|
||||
route_data_path="B04_wf1_Surface/x.geojson",
|
||||
)
|
||||
|
||||
|
||||
class RoutePointsTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_insert_many(self) -> None:
|
||||
cursor = FakeCursor()
|
||||
count = await insert_route_points( # type: ignore[arg-type]
|
||||
FakeConnection(cursor),
|
||||
7,
|
||||
[
|
||||
{"chainage_m": 0.0, "elevation_m": 5.0, "slope_percent": 0.0, "sequence_num": 0},
|
||||
{"chainage_m": 10.0, "elevation_m": 5.3, "slope_percent": 3.0, "sequence_num": 1},
|
||||
],
|
||||
)
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual(len(cursor.many_rows), 2)
|
||||
self.assertEqual(cursor.many_rows[0][0], 7)
|
||||
|
||||
async def test_insert_empty(self) -> None:
|
||||
count = await insert_route_points(FakeConnection(FakeCursor()), 7, []) # type: ignore[arg-type]
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
|
||||
class RouteStatisticsTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create(self) -> None:
|
||||
cursor = FakeCursor(lastrowid=3)
|
||||
stat_id = await create_route_statistics( # type: ignore[arg-type]
|
||||
FakeConnection(cursor),
|
||||
route_id=7,
|
||||
min_slope=0.0,
|
||||
max_slope=7.0,
|
||||
mean_slope=3.5,
|
||||
cost_score=12.3,
|
||||
)
|
||||
self.assertEqual(stat_id, 3)
|
||||
self.assertIn("INSERT INTO route_statistics", cursor.query)
|
||||
|
||||
|
||||
class LatestRouteTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_found(self) -> None:
|
||||
import datetime
|
||||
|
||||
row = (
|
||||
7,
|
||||
"CONFIRMED",
|
||||
100.0,
|
||||
"B05_wf2_Route/route/route_main.geojson",
|
||||
datetime.datetime(2026, 7, 5, 12, 0, 0),
|
||||
)
|
||||
result = await get_latest_route(FakeConnection(FakeCursor(row=row)), PROJECT_ID) # type: ignore[arg-type]
|
||||
self.assertEqual(result["id"], 7)
|
||||
self.assertEqual(result["status"], "CONFIRMED")
|
||||
|
||||
async def test_none(self) -> None:
|
||||
result = await get_latest_route(FakeConnection(FakeCursor(row=None)), PROJECT_ID) # type: ignore[arg-type]
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class ConfirmRouteTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_confirm(self) -> None:
|
||||
cursor = FakeCursor()
|
||||
await confirm_route(FakeConnection(cursor), 7) # type: ignore[arg-type]
|
||||
self.assertIn("UPDATE routes SET status = 'CONFIRMED'", cursor.query)
|
||||
self.assertEqual(cursor.arguments[0], 7)
|
||||
@@ -0,0 +1,44 @@
|
||||
import math
|
||||
import unittest
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_RidgeValley import (
|
||||
_fillet_alignment,
|
||||
_turn_angle,
|
||||
resolve_grade_bounds,
|
||||
)
|
||||
|
||||
|
||||
class ResolveGradeBoundsTest(unittest.TestCase):
|
||||
def test_defaults(self) -> None:
|
||||
gb = resolve_grade_bounds({})
|
||||
self.assertAlmostEqual(gb["min_uphill_grade"], 0.08)
|
||||
self.assertAlmostEqual(gb["max_uphill_grade"], 0.14)
|
||||
# 엣지용은 방향 중 더 엄격한 값
|
||||
self.assertAlmostEqual(gb["max_grade_for_edges"], 0.14)
|
||||
self.assertAlmostEqual(gb["min_grade_for_edges"], 0.08)
|
||||
|
||||
def test_override(self) -> None:
|
||||
gb = resolve_grade_bounds({"max_uphill_grade": 0.10, "max_downhill_grade": 0.12})
|
||||
self.assertAlmostEqual(gb["max_grade_for_edges"], 0.10)
|
||||
|
||||
|
||||
class TurnAngleTest(unittest.TestCase):
|
||||
def test_straight_is_zero(self) -> None:
|
||||
self.assertAlmostEqual(_turn_angle([0, 0], [1, 0], [2, 0]), 0.0, places=6)
|
||||
|
||||
def test_right_angle(self) -> None:
|
||||
self.assertAlmostEqual(_turn_angle([0, 0], [1, 0], [1, 1]), math.pi / 2, places=6)
|
||||
|
||||
|
||||
class FilletAlignmentTest(unittest.TestCase):
|
||||
def test_straight_nodes_preserved(self) -> None:
|
||||
nodes = [[0, 0, 0], [10, 0, 1], [20, 0, 2]]
|
||||
poly, radii = _fillet_alignment(nodes, 12.0, 2.0)
|
||||
self.assertGreaterEqual(len(poly), 2)
|
||||
# 직선이므로 시작·끝 좌표 보존
|
||||
self.assertAlmostEqual(poly[0][0], 0.0)
|
||||
self.assertAlmostEqual(poly[-1][0], 20.0)
|
||||
|
||||
def test_single_node(self) -> None:
|
||||
poly, radii = _fillet_alignment([[0, 0, 0]], 12.0, 2.0)
|
||||
self.assertEqual(len(poly), 1)
|
||||
@@ -0,0 +1,51 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Skeleton import (
|
||||
d8_flow_accumulation_numpy,
|
||||
extract_skeleton_from_grid,
|
||||
)
|
||||
|
||||
|
||||
class D8FlowAccumulationTest(unittest.TestCase):
|
||||
def test_valley_concentrates_flow(self) -> None:
|
||||
# V자 계곡: 중앙 열(x=20)이 가장 낮다 → 흐름이 중앙에 모인다.
|
||||
coords = np.linspace(0, 40, 41)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 10.0 + np.abs(gx - 20.0) * 0.3
|
||||
valid = np.ones_like(z, dtype=bool)
|
||||
|
||||
acc = d8_flow_accumulation_numpy(z, valid)
|
||||
# 누적 최대 셀은 중앙 열(index 20) 근처여야 한다.
|
||||
_, max_col = np.unravel_index(acc.argmax(), acc.shape)
|
||||
self.assertTrue(18 <= max_col <= 22)
|
||||
self.assertGreater(acc.max(), 10.0)
|
||||
|
||||
def test_flat_grid_uniform(self) -> None:
|
||||
z = np.full((10, 10), 5.0)
|
||||
valid = np.ones_like(z, dtype=bool)
|
||||
acc = d8_flow_accumulation_numpy(z, valid)
|
||||
# 평지는 하강 이웃이 없어 각 셀 누적이 1.
|
||||
self.assertTrue(np.allclose(acc, 1.0))
|
||||
|
||||
|
||||
class ExtractSkeletonTest(unittest.TestCase):
|
||||
def test_valley_detected_with_low_threshold(self) -> None:
|
||||
coords = np.linspace(0, 40, 41)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 10.0 + np.abs(gx - 20.0) * 0.3
|
||||
valid = np.ones_like(z, dtype=bool)
|
||||
thresholds = {
|
||||
"valley_acc": 5.0,
|
||||
"main_valley_acc": 30.0,
|
||||
"ridge_acc": 5.0,
|
||||
"main_ridge_acc": 30.0,
|
||||
}
|
||||
result = extract_skeleton_from_grid(
|
||||
coords, coords, z, valid, 1.0, thresholds=thresholds, use_whitebox=False
|
||||
)
|
||||
self.assertEqual(set(result), {"main_ridge", "minor_ridge", "main_valley", "minor_valley"})
|
||||
# 계곡 클래스에 최소 하나의 polyline이 검출되어야 한다.
|
||||
valley_total = len(result["main_valley"]) + len(result["minor_valley"])
|
||||
self.assertGreater(valley_total, 0)
|
||||
@@ -0,0 +1,103 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Geometry import (
|
||||
circumradius_2d,
|
||||
point_to_polyline_dist_2d,
|
||||
single_segment_dijkstra,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Solver import solve_optimal_route
|
||||
from config.config_system import build_surface_model_config
|
||||
|
||||
|
||||
class GeometryTest(unittest.TestCase):
|
||||
def test_circumradius_straight_line_is_inf(self) -> None:
|
||||
r = circumradius_2d([0, 0], [1, 0], [2, 0])
|
||||
self.assertEqual(r, float("inf"))
|
||||
|
||||
def test_circumradius_right_angle(self) -> None:
|
||||
r = circumradius_2d([0, 0], [1, 0], [1, 1])
|
||||
self.assertTrue(np.isfinite(r) and r > 0)
|
||||
|
||||
def test_point_to_polyline_distance(self) -> None:
|
||||
poly = [[0, 0], [10, 0]]
|
||||
self.assertAlmostEqual(point_to_polyline_dist_2d(5, 3, poly), 3.0, places=6)
|
||||
|
||||
def test_dijkstra_on_flat_grid(self) -> None:
|
||||
coords = np.linspace(0.0, 10.0, 11)
|
||||
z = np.full((11, 11), 5.0)
|
||||
valid = np.ones((11, 11), dtype=bool)
|
||||
dz_dx = np.zeros((11, 11))
|
||||
dz_dy = np.zeros((11, 11))
|
||||
weights = {"dist": 1.0, "grade": 2.0, "side": 1.5, "curve": 0.5, "avoid": 10.0}
|
||||
path = single_segment_dijkstra(
|
||||
0,
|
||||
0,
|
||||
10,
|
||||
10,
|
||||
coords,
|
||||
coords,
|
||||
z,
|
||||
valid,
|
||||
dz_dx,
|
||||
dz_dy,
|
||||
[],
|
||||
weights,
|
||||
0.14,
|
||||
1.0,
|
||||
12.0,
|
||||
)
|
||||
self.assertTrue(path)
|
||||
self.assertEqual(path[0], (0, 0))
|
||||
self.assertEqual(path[-1], (10, 10))
|
||||
|
||||
|
||||
class SolveOptimalRouteTest(unittest.TestCase):
|
||||
def _make_dtm(self, root: Path) -> None:
|
||||
models = root / "B04_wf1_Surface" / "models"
|
||||
coords = np.linspace(0.0, 60.0, 61)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 5.0 + 0.03 * gx + 0.02 * gy
|
||||
xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64)
|
||||
bounds = np.array([[0.0, 60.0], [0.0, 60.0], [float(z.min()), float(z.max())]])
|
||||
cfg = build_surface_model_config()
|
||||
cfg["source_filters"] = ["grid_min_z"]
|
||||
cfg["precompute"] = ["dtm"]
|
||||
build_all_terrain_models(
|
||||
{"xyz": xyz, "bounds": bounds},
|
||||
{"grid_min_z": np.ones(len(xyz), dtype=bool)},
|
||||
models,
|
||||
cfg,
|
||||
)
|
||||
|
||||
def test_diagonal_route(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "proj"
|
||||
self._make_dtm(root)
|
||||
points = {
|
||||
"bp": {"x": 5.0, "y": 5.0},
|
||||
"ep": {"x": 55.0, "y": 55.0},
|
||||
"cp": [],
|
||||
"ap": [],
|
||||
"fp": [],
|
||||
}
|
||||
result = solve_optimal_route(root, "grid_min_z", False, points, {}, method="dtm")
|
||||
|
||||
self.assertGreater(len(result["polyline"]), 2)
|
||||
self.assertTrue(result["required_points_ok"])
|
||||
self.assertEqual(len(result["segments"]), 1)
|
||||
# 대각선 50m×50m → 길이 ≈ 70.7m
|
||||
self.assertAlmostEqual(result["metrics"]["length_m"], 70.71, delta=5.0)
|
||||
|
||||
def test_missing_bp_returns_empty(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "proj"
|
||||
self._make_dtm(root)
|
||||
points = {"bp": None, "ep": {"x": 55.0, "y": 55.0}, "cp": [], "ap": [], "fp": []}
|
||||
result = solve_optimal_route(root, "grid_min_z", False, points, {}, method="dtm")
|
||||
self.assertEqual(result["polyline"], [])
|
||||
self.assertFalse(result["required_points_ok"])
|
||||
@@ -0,0 +1,71 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import (
|
||||
CallableSurfaceSampler,
|
||||
DtmGridSampler,
|
||||
)
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import (
|
||||
SectionGenerationOptions,
|
||||
format_station,
|
||||
generate_sections,
|
||||
)
|
||||
|
||||
|
||||
class FormatStationTest(unittest.TestCase):
|
||||
def test_format(self) -> None:
|
||||
self.assertEqual(format_station(0.0), "STA.0+000.000")
|
||||
self.assertEqual(format_station(1234.5), "STA.1+234.500")
|
||||
self.assertEqual(format_station(20.0), "STA.0+020.000")
|
||||
|
||||
|
||||
class DtmGridSamplerTest(unittest.TestCase):
|
||||
def test_sample_inside_and_outside(self) -> None:
|
||||
x = np.array([0.0, 1.0, 2.0])
|
||||
y = np.array([0.0, 1.0, 2.0])
|
||||
z = np.array([[0.0, 1.0, 2.0], [1.0, 2.0, 3.0], [2.0, 3.0, 4.0]])
|
||||
valid = np.ones((3, 3), dtype=bool)
|
||||
sampler = DtmGridSampler(x, y, z, valid)
|
||||
|
||||
zq, vq = sampler.sample_xy(np.array([[0.5, 0.5], [10.0, 10.0]]))
|
||||
self.assertTrue(vq[0])
|
||||
self.assertFalse(vq[1]) # 격자 밖
|
||||
self.assertAlmostEqual(zq[0], 1.0) # 이중선형 보간
|
||||
|
||||
def test_invalid_shape(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
DtmGridSampler(np.array([0.0]), np.array([0.0, 1.0]), np.zeros((2, 1)), np.ones((2, 1)))
|
||||
|
||||
|
||||
class GenerateSectionsTest(unittest.TestCase):
|
||||
def test_straight_route(self) -> None:
|
||||
sampler = CallableSurfaceSampler(lambda xy: 5.0 + 0.05 * xy[:, 0] + 0.02 * xy[:, 1])
|
||||
polyline = [[float(x), 0.0, 5.0 + 0.05 * x] for x in range(0, 101, 10)]
|
||||
|
||||
result = generate_sections(
|
||||
polyline,
|
||||
sampler,
|
||||
SectionGenerationOptions(station_interval_m=20.0, cross_half_width_m=10.0),
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "completed")
|
||||
self.assertAlmostEqual(result["longitudinal"]["length_m"], 100.0)
|
||||
self.assertEqual(result["summary"]["station_count"], 6)
|
||||
self.assertEqual(len(result["cross_sections"]), 6)
|
||||
# 횡단 샘플: ±10m를 0.5m 간격 → 41개
|
||||
self.assertEqual(len(result["cross_sections"][0]["samples"]), 41)
|
||||
# BP/EP 종류 표기
|
||||
self.assertEqual(result["longitudinal"]["stations"][0]["kind"], "bp")
|
||||
self.assertEqual(result["longitudinal"]["stations"][-1]["kind"], "ep")
|
||||
|
||||
def test_invalid_options(self) -> None:
|
||||
sampler = CallableSurfaceSampler(lambda xy: np.zeros(len(xy)))
|
||||
polyline = [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]]
|
||||
with self.assertRaises(ValueError):
|
||||
generate_sections(polyline, sampler, SectionGenerationOptions(station_interval_m=0.0))
|
||||
|
||||
def test_too_short_polyline(self) -> None:
|
||||
sampler = CallableSurfaceSampler(lambda xy: np.zeros(len(xy)))
|
||||
with self.assertRaises(ValueError):
|
||||
generate_sections([[0.0, 0.0, 0.0]], sampler)
|
||||
@@ -0,0 +1,18 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
|
||||
class AtomicWriteJsonTest(unittest.TestCase):
|
||||
def test_atomic_write_json(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
target = Path(temporary_directory) / "nested" / "result.json"
|
||||
value = {"status": "완료", "items": [1, 2, 3]}
|
||||
|
||||
atomic_write_json(target, value)
|
||||
|
||||
self.assertEqual(json.loads(target.read_text(encoding="utf-8")), value)
|
||||
self.assertEqual(list(target.parent.glob("*.tmp")), [])
|
||||
@@ -0,0 +1,63 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from common_util.common_util_workflow import (
|
||||
load_project_workflow,
|
||||
patch_project_workflow_stale,
|
||||
)
|
||||
|
||||
|
||||
class LoadProjectWorkflowTest(unittest.TestCase):
|
||||
def test_load_project_workflow(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
project_root = Path(temporary_directory)
|
||||
|
||||
self.assertEqual(
|
||||
load_project_workflow(project_root),
|
||||
{
|
||||
"current_stage": "scan",
|
||||
"completed": [],
|
||||
"stale_from": None,
|
||||
"stage1_confirmed": None,
|
||||
},
|
||||
)
|
||||
|
||||
saved = {"current_stage": "route", "completed": ["scan"]}
|
||||
(project_root / "workflow.json").write_text(
|
||||
json.dumps(saved, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.assertEqual(load_project_workflow(project_root), saved)
|
||||
|
||||
(project_root / "workflow.json").write_text("[]", encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
load_project_workflow(project_root)
|
||||
|
||||
def test_patch_project_workflow_stale(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
project_root = Path(temporary_directory)
|
||||
workflow_path = project_root / "workflow.json"
|
||||
|
||||
patch_project_workflow_stale(project_root, "route")
|
||||
self.assertFalse(workflow_path.exists())
|
||||
|
||||
saved = {
|
||||
"current_stage": "surface",
|
||||
"completed": ["scan"],
|
||||
"stale_from": None,
|
||||
"stage1_confirmed": {"method": "tin"},
|
||||
}
|
||||
workflow_path.write_text(
|
||||
json.dumps(saved, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
patch_project_workflow_stale(project_root, "route")
|
||||
|
||||
updated = json.loads(workflow_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(updated["stale_from"], "route")
|
||||
self.assertEqual(updated["current_stage"], saved["current_stage"])
|
||||
self.assertEqual(updated["completed"], saved["completed"])
|
||||
self.assertEqual(updated["stage1_confirmed"], saved["stage1_confirmed"])
|
||||
@@ -0,0 +1,59 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from common_util.common_util_storage import (
|
||||
get_project_stage_path,
|
||||
resolve_stored_project_path,
|
||||
)
|
||||
from config.config_system import get_project_storage_path
|
||||
|
||||
|
||||
class ProjectStoragePathTest(unittest.TestCase):
|
||||
def test_get_project_storage_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as storage_root:
|
||||
with patch("config.config_system.STORAGE_BASE_DIR", storage_root):
|
||||
result = get_project_storage_path("회사", "사용자", "project-id")
|
||||
|
||||
self.assertEqual(
|
||||
result,
|
||||
os.path.join(storage_root, "회사", "사용자", "project-id"),
|
||||
)
|
||||
self.assertTrue(os.path.isdir(result))
|
||||
self.assertNotIn(f"{os.sep}projects{os.sep}", result)
|
||||
|
||||
for invalid_segment in ("", ".", "..", "../escape", "nested/path", "nested\\path"):
|
||||
with self.subTest(invalid_segment=invalid_segment):
|
||||
with self.assertRaises(ValueError):
|
||||
get_project_storage_path(invalid_segment, "사용자", "project-id")
|
||||
|
||||
def test_get_project_stage_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as project_root:
|
||||
result = get_project_stage_path(project_root, "B03_FileInput")
|
||||
|
||||
self.assertEqual(result, os.path.join(project_root, "B03_FileInput"))
|
||||
self.assertTrue(os.path.isdir(result))
|
||||
|
||||
for invalid_stage in ("", "B02_ProjRegister", "../escape"):
|
||||
with self.subTest(invalid_stage=invalid_stage):
|
||||
with self.assertRaises(ValueError):
|
||||
get_project_stage_path(project_root, invalid_stage)
|
||||
|
||||
def test_resolve_stored_project_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as storage_root:
|
||||
with patch("common_util.common_util_storage.STORAGE_BASE_DIR", storage_root):
|
||||
result = resolve_stored_project_path("storage/company/user/project-id")
|
||||
expected = os.path.join(storage_root, "company", "user", "project-id")
|
||||
|
||||
self.assertEqual(result, expected)
|
||||
self.assertTrue(os.path.isdir(result))
|
||||
|
||||
for invalid_path in ("company/user/project", "../outside", "storage"):
|
||||
with self.subTest(invalid_path=invalid_path):
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_stored_project_path(invalid_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user