qgis-tem-loader

qgis plugin for loading TEM geophysical inversion XYZ files as 3D objects
git clone git://src.adamsgaard.dk/qgis-tem-loader # fast
git clone https://src.adamsgaard.dk/qgis-tem-loader.git # slow
Log | Files | Refs | README | LICENSE Back to index

test_core.py (11733B)


      1 import importlib
      2 from pathlib import Path
      3 from tempfile import TemporaryDirectory
      4 import shutil
      5 import sys
      6 import types
      7 import unittest
      8 from unittest.mock import Mock, patch
      9 import xml.etree.ElementTree as ET
     10 
     11 from tem_loader.core import detect_source_epsg, process_xyz, write_csv
     12 
     13 
     14 FIXTURE_DIR = Path(__file__).parent / "data"
     15 STYLES_DIR = Path(__file__).resolve().parent.parent / "tem_loader" / "styles"
     16 
     17 
     18 class ProcessXYZTests(unittest.TestCase):
     19     def test_temimage_4_0_4_6_fixture(self):
     20         path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz"
     21         points, doi_points, layers = process_xyz(path)
     22 
     23         self.assertEqual(len(points), 166)
     24         self.assertEqual(len(doi_points), 166)
     25         self.assertEqual(len(layers), 4814)
     26         self.assertEqual(points[0]["StationNo"], "1_00002")
     27         self.assertEqual(points[0]["NumLayers"], 30)
     28         self.assertEqual(layers[0]["Layer"], 1)
     29 
     30     def test_temimage_4_0_7_8_fixture(self):
     31         path = FIXTURE_DIR / "profiler_temimager_4_0_7_8.xyz"
     32         points, doi_points, layers = process_xyz(path)
     33 
     34         self.assertEqual(len(points), 44)
     35         self.assertEqual(len(doi_points), 44)
     36         self.assertEqual(len(layers), 1276)
     37         self.assertEqual(points[0]["StationNo"], "20_00004")
     38         self.assertEqual(points[0]["NumLayers"], 30)
     39 
     40     def test_stem_temimage_4_0_4_6_fixture(self):
     41         path = FIXTURE_DIR / "stem_temimager_4_0_4_6.xyz"
     42         points, doi_points, layers = process_xyz(path)
     43 
     44         self.assertEqual(len(points), 14)
     45         self.assertEqual(len(doi_points), 14)
     46         self.assertEqual(len(layers), 406)
     47         self.assertEqual(points[0]["StationNo"], "1_00001")
     48         self.assertEqual(points[0]["NumLayers"], 30)
     49 
     50     def test_aarhus_workbench_fixture(self):
     51         path = FIXTURE_DIR / "stem_workbench_2026_1.xyz"
     52         points, doi_points, layers = process_xyz(path)
     53 
     54         self.assertEqual(len(points), 158)
     55         self.assertEqual(len(doi_points), 158)
     56         self.assertEqual(len(layers), 4740)
     57         self.assertEqual(points[0]["Line"], "1")
     58         self.assertEqual(points[0]["StationNo"], "1_00001")
     59         self.assertEqual(points[0]["NumLayers"], 30)
     60         self.assertAlmostEqual(doi_points[0]["DOI"], 264.862)
     61         self.assertEqual(layers[0]["DepthTop"], 0.0)
     62         self.assertEqual(layers[0]["DepthBottom"], 2.0)
     63         self.assertAlmostEqual(layers[-1]["DepthBottom"], 599.977)
     64 
     65     def test_aarhus_workbench_2024_2_0_0_stem_40x40_fixture(self):
     66         path = FIXTURE_DIR / "stem_40x40_workbench_2024_2_0_0.xyz"
     67         points, doi_points, layers = process_xyz(path)
     68 
     69         self.assertEqual(len(points), 20)
     70         self.assertEqual(len(doi_points), 20)
     71         self.assertEqual(len(layers), 700)
     72         self.assertEqual(points[0]["Line"], "10001")
     73         self.assertEqual(points[0]["StationNo"], "10001_00001")
     74         self.assertEqual(points[0]["NumLayers"], 35)
     75         self.assertAlmostEqual(points[0]["X"], 502395.50)
     76         self.assertAlmostEqual(points[0]["Y"], 6287846.60)
     77         self.assertAlmostEqual(doi_points[0]["DOI"], 204.463)
     78         self.assertEqual(layers[0]["DepthTop"], 0.0)
     79         self.assertEqual(layers[0]["DepthBottom"], 3.0)
     80 
     81     def test_aarhus_workbench_2024_2_0_0_stem_80x80_fixture(self):
     82         path = FIXTURE_DIR / "stem_80x80_workbench_2024_2_0_0.xyz"
     83         points, doi_points, layers = process_xyz(path)
     84 
     85         self.assertEqual(len(points), 3)
     86         self.assertEqual(len(doi_points), 3)
     87         self.assertEqual(len(layers), 105)
     88         self.assertEqual(points[0]["Line"], "1")
     89         self.assertEqual(points[0]["StationNo"], "1_00001")
     90         self.assertEqual(points[0]["NumLayers"], 35)
     91         self.assertAlmostEqual(points[0]["X"], 502873.80)
     92         self.assertAlmostEqual(points[0]["Y"], 6287746.70)
     93         self.assertAlmostEqual(doi_points[0]["DOI"], 285.405)
     94         self.assertEqual(layers[0]["DepthTop"], 0.0)
     95         self.assertEqual(layers[0]["DepthBottom"], 3.0)
     96 
     97     def test_aarhus_workbench_2024_2_0_0_stem_profiler_fixture(self):
     98         path = FIXTURE_DIR / "stem_profiler_workbench_2024_2_0_0.xyz"
     99         points, doi_points, layers = process_xyz(path)
    100 
    101         self.assertEqual(len(points), 274)
    102         self.assertEqual(len(doi_points), 274)
    103         self.assertEqual(len(layers), 9590)
    104         self.assertEqual(points[0]["Line"], "1")
    105         self.assertEqual(points[0]["StationNo"], "1_00001")
    106         self.assertEqual(points[0]["NumLayers"], 35)
    107         self.assertAlmostEqual(points[0]["X"], 502941.82)
    108         self.assertAlmostEqual(points[0]["Y"], 6287498.15)
    109         self.assertAlmostEqual(doi_points[0]["DOI"], 133.160)
    110         self.assertEqual(layers[0]["DepthTop"], 0.0)
    111         self.assertEqual(layers[0]["DepthBottom"], 2.0)
    112 
    113     def test_detect_source_epsg_for_aarhus_workbench_fixture(self):
    114         path = FIXTURE_DIR / "stem_workbench_2026_1.xyz"
    115         self.assertEqual(detect_source_epsg(path), "EPSG:32637")
    116 
    117     def test_detect_source_epsg_for_workbench_2024_2_0_0_fixture(self):
    118         path = FIXTURE_DIR / "stem_40x40_workbench_2024_2_0_0.xyz"
    119         self.assertEqual(detect_source_epsg(path), "EPSG:32632")
    120 
    121     def test_detect_source_epsg_returns_none_when_not_declared(self):
    122         path = FIXTURE_DIR / "profiler_temimager_4_0_7_8.xyz"
    123         self.assertIsNone(detect_source_epsg(path))
    124 
    125     def test_write_csv_writes_expected_headers(self):
    126         source = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz"
    127         points, _, _ = process_xyz(source)
    128 
    129         with TemporaryDirectory() as tmp:
    130             tmp_source = Path(tmp) / source.name
    131             shutil.copyfile(source, tmp_source)
    132             out_path = write_csv(points, tmp_source, ".points.csv")
    133 
    134             self.assertTrue(out_path.exists())
    135             self.assertIn("StationNo", out_path.read_text().splitlines()[0])
    136 
    137     def test_process_xyz_rejects_metadata_only_file(self):
    138         with TemporaryDirectory() as tmp:
    139             path = Path(tmp) / "metadata_only.xyz"
    140             path.write_text("/ epsg:32632\n/ no header here\n")
    141 
    142             with self.assertRaisesRegex(
    143                 ValueError, "supported header row"
    144             ):
    145                 process_xyz(path)
    146 
    147     def test_process_xyz_rejects_unsupported_header(self):
    148         with TemporaryDirectory() as tmp:
    149             path = Path(tmp) / "unsupported.xyz"
    150             path.write_text("A B C\n1 2 3\n")
    151 
    152             with self.assertRaisesRegex(
    153                 ValueError, "supported header row"
    154             ):
    155                 process_xyz(path)
    156 
    157     def test_process_xyz_rejects_mismatched_row_length(self):
    158         with TemporaryDirectory() as tmp:
    159             path = Path(tmp) / "broken.xyz"
    160             path.write_text(
    161                 "/ X Y Z DOI DataResidual NumLayers Line StationNo\n"
    162                 "1 2 3 4 5 6 7\n"
    163             )
    164 
    165             with self.assertRaisesRegex(
    166                 ValueError, r"Row 2 has 7 columns, expected 8"
    167             ):
    168                 process_xyz(path)
    169 
    170     def test_fixture_doi_values_fit_fixed_scale(self):
    171         for path in sorted(FIXTURE_DIR.glob("*.xyz")):
    172             _, doi_points, _ = process_xyz(path)
    173             values = [row["DOI"] for row in doi_points]
    174 
    175             self.assertGreaterEqual(min(values), 0.0, path.name)
    176             self.assertLessEqual(max(values), 500.0, path.name)
    177 
    178     def test_doi_style_uses_fixed_zero_to_five_hundred_ranges(self):
    179         tree = ET.parse(STYLES_DIR / "doi.qml")
    180         renderer = tree.getroot().find(".//renderer-v2")
    181         self.assertIsNotNone(renderer)
    182         self.assertEqual(renderer.attrib["attr"], "DOI")
    183 
    184         ranges = renderer.findall("./ranges/range")
    185         self.assertEqual(len(ranges), 10)
    186         self.assertEqual(ranges[0].attrib["lower"], "0.000000000000000")
    187         self.assertEqual(ranges[-1].attrib["upper"], "500.000000000000000")
    188         self.assertEqual(
    189             [(r.attrib["lower"], r.attrib["upper"]) for r in ranges],
    190             [
    191                 ("0.000000000000000", "50.000000000000000"),
    192                 ("50.000000000000000", "100.000000000000000"),
    193                 ("100.000000000000000", "150.000000000000000"),
    194                 ("150.000000000000000", "200.000000000000000"),
    195                 ("200.000000000000000", "250.000000000000000"),
    196                 ("250.000000000000000", "300.000000000000000"),
    197                 ("300.000000000000000", "350.000000000000000"),
    198                 ("350.000000000000000", "400.000000000000000"),
    199                 ("400.000000000000000", "450.000000000000000"),
    200                 ("450.000000000000000", "500.000000000000000"),
    201             ],
    202         )
    203 
    204         method = renderer.find("./classificationMethod")
    205         self.assertIsNotNone(method)
    206         self.assertEqual(method.attrib["id"], "EqualInterval")
    207 
    208 
    209 class PluginTests(unittest.TestCase):
    210     def _import_plugin_module(self):
    211         class FakeSignal:
    212             def connect(self, _callback):
    213                 pass
    214 
    215         class FakeAction:
    216             def __init__(self, *_args, **_kwargs):
    217                 self.triggered = FakeSignal()
    218 
    219         class FakeFileDialog:
    220             paths = []
    221 
    222             @staticmethod
    223             def getOpenFileNames(*_args, **_kwargs):
    224                 return FakeFileDialog.paths, ""
    225 
    226         class FakeMessageBox:
    227             warnings = []
    228 
    229             @staticmethod
    230             def warning(*args):
    231                 FakeMessageBox.warnings.append(args)
    232 
    233         qtwidgets = types.ModuleType("qgis.PyQt.QtWidgets")
    234         qtwidgets.QAction = FakeAction
    235         qtwidgets.QFileDialog = FakeFileDialog
    236         qtwidgets.QMessageBox = FakeMessageBox
    237 
    238         qgis_core = types.ModuleType("qgis.core")
    239         qgis_core.QgsProject = type("QgsProject", (), {})
    240         qgis_core.QgsVectorLayer = type("QgsVectorLayer", (), {})
    241         qgis_core.QgsCoordinateReferenceSystem = type(
    242             "QgsCoordinateReferenceSystem", (), {}
    243         )
    244 
    245         module_map = {
    246             "qgis": types.ModuleType("qgis"),
    247             "qgis.PyQt": types.ModuleType("qgis.PyQt"),
    248             "qgis.PyQt.QtWidgets": qtwidgets,
    249             "qgis.core": qgis_core,
    250         }
    251 
    252         with patch.dict(sys.modules, module_map):
    253             sys.modules.pop("tem_loader.tem_loader", None)
    254             module = importlib.import_module("tem_loader.tem_loader")
    255 
    256         return module, FakeFileDialog, FakeMessageBox
    257 
    258     def test_run_continues_after_failed_file_and_shows_filename(self):
    259         module, file_dialog, message_box = self._import_plugin_module()
    260         file_dialog.paths = ["/tmp/bad.xyz", "/tmp/good.xyz"]
    261         iface = Mock()
    262         iface.mainWindow.return_value = object()
    263         plugin = module.TEMLoaderPlugin(iface)
    264         plugin._load_xyz = Mock(
    265             side_effect=[ValueError("Row 3 has 4 columns, expected 6"), None]
    266         )
    267 
    268         plugin.run()
    269 
    270         self.assertEqual(plugin._load_xyz.call_count, 2)
    271         self.assertEqual(len(message_box.warnings), 1)
    272         self.assertIn("bad.xyz", message_box.warnings[0][2])
    273         self.assertIn("Row 3 has 4 columns, expected 6", message_box.warnings[0][2])
    274 
    275     def test_build_delimited_text_uri_preserves_local_file_uri(self):
    276         module, _, _ = self._import_plugin_module()
    277         csv_path = Mock()
    278         resolved = Mock()
    279         resolved.as_uri.return_value = "file:///C:/temp/model.layers.csv"
    280         csv_path.resolve.return_value = resolved
    281 
    282         uri = module._build_delimited_text_uri(
    283             csv_path, "LineString", "EPSG:32632"
    284         )
    285 
    286         self.assertEqual(
    287             uri,
    288             "file:///C:/temp/model.layers.csv"
    289             "?type=LineString"
    290             "&crs=EPSG:32632"
    291             "&wktField=Geometry"
    292             "&delimiter=,"
    293             "&xField=X&yField=Y",
    294         )