Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix 162 #171

Closed
wants to merge 12 commits into from
2 changes: 1 addition & 1 deletion .github/workflows/conda.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
shell: bash -l {0}
run: |
conda install -c conda-forge conda-build scikit-build-core numpy anaconda-client conda-libmamba-solver -y
conda build -c conda-forge -c loop3d --output-folder conda conda --python ${{matrix.python-version}}
conda build -c conda-forge -c loop3d --output-folder conda conda --python ${{matrix.python-version}}reve
anaconda upload --label main conda/*/*.tar.bz2

- name: upload artifacts
Expand Down
19 changes: 6 additions & 13 deletions map2loop/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,6 @@ def update_from_dictionary(self, dictionary: dict, lower: bool = True):
# make sure dictionary doesn't contain legacy keys
self.check_for_legacy_keys(dictionary)

# make sure it has the minimum requirements
self.validate_config_dictionary(dictionary)

if "structure" in dictionary:
self.structure_config.update(dictionary["structure"])
for key in dictionary["structure"].keys():
Expand Down Expand Up @@ -218,25 +215,20 @@ def update_from_file(

@beartype.beartype
def validate_config_dictionary(self, config_dict: dict) -> None:
"""
Validate the structure and keys of the configuration dictionary.

Args:
config_dict (dict): The config dictionary to validate.

Raises:
ValueError: If the dictionary does not meet the minimum requirements for ma2p2loop.
"""
required_keys = {
"structure": {"dipdir_column", "dip_column"},
"geology": {"unitname_column", "alt_unitname_column"},
}

# Loop over "structure" and "geology"
for section, keys in required_keys.items():

# 1) Check that "section" exists
if section not in config_dict:
logger.error(f"Missing required section '{section}' in config dictionary.")
raise ValueError(f"Missing required section '{section}' in config dictionary.")


# 2) Check that each required key is in config_dict[section]
for key in keys:
if key not in config_dict[section]:
logger.error(
Expand All @@ -246,6 +238,7 @@ def validate_config_dictionary(self, config_dict: dict) -> None:
f"Missing required key '{key}' for '{section}' section of the config dictionary."
)


@beartype.beartype
def check_for_legacy_keys(self, config_dict: dict) -> None:

Expand Down
89 changes: 83 additions & 6 deletions map2loop/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
gdal.UseExceptions()
import geopandas
import beartype
from beartype.typing import Union, List
from beartype.typing import Union, List, Dict, Any
import pathlib
import numpy
import pandas
Expand Down Expand Up @@ -75,7 +75,7 @@ def __init__(
loop_project_filename: str = "",
overwrite_loopprojectfile: bool = False,
**kwargs,
):
):
"""
The initialiser for the map2loop project

Expand Down Expand Up @@ -119,6 +119,30 @@ def __init__(
TypeError: Type of bounding_box not a dict or tuple
ValueError: use_australian_state_data not in state list ['WA', 'SA', 'QLD', 'NSW', 'TAS', 'VIC', 'ACT', 'NT']
"""

# Throw error if unexpected keyword arguments are passed to project
allowed_kwargs = {"metadata_filename"}
for key in kwargs.keys():
if key not in allowed_kwargs:
logger.error(
f"Unexpected keyword argument '{key}' passed to Project. Allowed keywords: {', '.join(allowed_kwargs)}."
)
raise TypeError(
f"Project got an unexpected keyword argument '{key}' - please double-check this before proceeding with map2loop processing"
)

# make sure all the needed arguments are provided
if not use_australian_state_data: # this check has to skip if using Loop server data
self.validate_required_inputs(
bounding_box=bounding_box,
working_projection=working_projection,
geology_filename=geology_filename,
structure_filename=structure_filename,
dtm_filename=dtm_filename,
config_dictionary=config_dictionary,
config_filename=config_filename,
)

self._error_state = ErrorState.NONE
self._error_state_msg = ""
self.verbose_level = verbose_level
Expand Down Expand Up @@ -207,12 +231,12 @@ def __init__(
self.map_data.set_config_filename(config_filename)

if config_dictionary != {}:
self.map_data.config.validate_config_dictionary(config_dictionary)
self.map_data.config.update_from_dictionary(config_dictionary)

if clut_filename != "":
self.map_data.set_colour_filename(clut_filename)



# Load all data (both shape and raster)
self.map_data.load_all_map_data()
Expand All @@ -233,6 +257,58 @@ def __init__(
if len(kwargs):
logger.warning(f"Unused keyword arguments: {kwargs}")

@beartype.beartype
def validate_required_inputs(
self,
bounding_box: Dict[str, Union[float, int]],
working_projection: str,
geology_filename: str,
structure_filename: str,
dtm_filename: str,
config_filename: str = None,
config_dictionary: Dict[str, Any] = {},
) -> None:

required_inputs = {
"bounding_box": bounding_box,
"working_projection": working_projection, # this may be removed when fix is added for https://github.com/Loop3D/map2loop/issues/103
"geology_filename": geology_filename,
"structure_filename": structure_filename,
"dtm_filename": dtm_filename,
}

# Check for missing required inputs in project
missing_inputs = [key for key, value in required_inputs.items() if not value]

if missing_inputs:
missing_list = ", ".join(missing_inputs)
logger.error(
f"Project construction is missing required inputs: {missing_list}. "
"Please add them to the Project()."
)
raise ValueError(
f"Project construction is missing required inputs: {missing_list}. "
"Please add them to the Project()."
)

# Either config_filename or config_dictionary must be provided (but not both or neither)
if not config_filename and not config_dictionary:
logger.error(
"A config file is required to run map2loop - use either 'config_filename' or 'config_dictionary' to initialise the project."
)
raise ValueError(
"A config file is required to run map2loop - use either 'config_filename' or 'config_dictionary' to initialise the project."
)
if config_filename and config_dictionary:
logger.error(
"Both 'config_filename' and 'config_dictionary' were provided. Please specify only one config."
)
raise ValueError(
"Both 'config_filename' and 'config_dictionary' were provided. Please specify only one config."
)



# Getters and Setters
@beartype.beartype
def set_ignore_lithology_codes(self, codes: list):
Expand Down Expand Up @@ -734,9 +810,10 @@ def save_into_projectfile(self):
logger.info('Saving data into loop project file')
if not self.loop_filename:
logger.info('No loop project file specified, creating a new one')
self.loop_filename = os.path.join(
self.map_data.tmp_path, os.path.basename(self.map_data.tmp_path) + ".loop3d"
)
output_dir = pathlib.Path.cwd()
output_dir.mkdir(parents=True, exist_ok=True)
filename = "new_project.loop3d"
self.loop_filename = str(output_dir / filename)

file_exists = os.path.isfile(self.loop_filename)

Expand Down
148 changes: 148 additions & 0 deletions tests/project/test_config_arguments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import pytest
import pathlib
from map2loop.project import Project
import map2loop

# ------------------------------------------------------------------------------
# Common fixtures or helper data (bounding box, minimal filenames, etc.)
# ------------------------------------------------------------------------------

@pytest.fixture
def minimal_bounding_box():
return {
"minx": 515687.31005864,
"miny": 7493446.76593407,
"maxx": 562666.860106543,
"maxy": 7521273.57407786,
"base": -3200,
"top": 3000,
}

@pytest.fixture
def geology_file():
return str(
pathlib.Path(map2loop.__file__).parent
/ pathlib.Path('_datasets/geodata_files/hamersley/geology.geojson')
)

@pytest.fixture
def structure_file():
return str(
pathlib.Path(map2loop.__file__).parent
/ pathlib.Path('_datasets/geodata_files/hamersley/structure.geojson')
)

@pytest.fixture
def dtm_file():
return str(
pathlib.Path(map2loop.__file__).parent
/ pathlib.Path('_datasets/geodata_files/hamersley/dtm_rp.tif')
)

@pytest.fixture
def valid_config_dictionary():
"""
A valid config dictionary that meets the 'structure' and 'geology' requirements
"""
return {
"structure": {
"dipdir_column": "azimuth2",
"dip_column": "dip"
},
"geology": {
"unitname_column": "unitname",
"alt_unitname_column": "code",
}
}



# 1) config_filename and config_dictionary both present should raise ValueError
def test_config_filename_and_dictionary_raises_error(
minimal_bounding_box, geology_file, dtm_file, structure_file, valid_config_dictionary
):

with pytest.raises(ValueError, match="Both 'config_filename' and 'config_dictionary' were provided"):
Project(
bounding_box=minimal_bounding_box,
working_projection="EPSG:28350",
geology_filename=geology_file,
dtm_filename=dtm_file,
structure_filename=structure_file,
config_filename="dummy_config.json",
config_dictionary=valid_config_dictionary,
)

# 2) No config_filename or config_dictionary should raise ValueError
def test_no_config_provided_raises_error(
minimal_bounding_box, geology_file, dtm_file, structure_file
):

with pytest.raises(ValueError, match="A config file is required to run map2loop"):
Project(
bounding_box=minimal_bounding_box,
working_projection="EPSG:28350",
geology_filename=geology_file,
dtm_filename=dtm_file,
structure_filename=structure_file,
)

# 3) Passing an unexpected argument should raise TypeError
def test_unexpected_argument_raises_error(
minimal_bounding_box, geology_file, dtm_file, structure_file, valid_config_dictionary
):

with pytest.raises(TypeError, match="unexpected keyword argument 'config_file'"):
Project(
bounding_box=minimal_bounding_box,
working_projection="EPSG:28350",
geology_filename=geology_file,
dtm_filename=dtm_file,
structure_filename=structure_file,
config_dictionary=valid_config_dictionary,
config_file="wrong_kwarg.json",
)

# 4) Dictionary missing a required key should raise ValueError

def test_dictionary_missing_required_key_raises_error(
minimal_bounding_box, geology_file, dtm_file, structure_file
):

invalid_dictionary = {
"structure": {"dipdir_column": "azimuth2", "dip_column": "dip"},
"geology": {"unitname_column": "unitname"} # alt_unitname_column missing
}

with pytest.raises(ValueError, match="Missing required key 'alt_unitname_column' for 'geology'"):
Project(
bounding_box=minimal_bounding_box,
working_projection="EPSG:28350",
geology_filename=geology_file,
dtm_filename=dtm_file,
structure_filename=structure_file,
config_dictionary=invalid_dictionary,
)

# 5) All good => The Project should be created without errors
def test_good_config_runs_successfully(
minimal_bounding_box, geology_file, dtm_file, structure_file, valid_config_dictionary
):
project = None
try:
project = Project(
bounding_box=minimal_bounding_box,
working_projection="EPSG:28350",
geology_filename=geology_file,
dtm_filename=dtm_file,
structure_filename=structure_file,
config_dictionary=valid_config_dictionary,
)
except Exception as e:
pytest.fail(f"Project initialization raised an unexpected exception: {e}")

assert project is not None, "Project was not created."
assert project.map_data.config.structure_config["dipdir_column"] == "azimuth2"
assert project.map_data.config.structure_config["dip_column"] == "dip"
assert project.map_data.config.geology_config["unitname_column"] == "unitname"
assert project.map_data.config.geology_config["alt_unitname_column"] == "code"
38 changes: 20 additions & 18 deletions tests/project/test_ignore_codes_setters_getters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from map2loop.project import Project
from map2loop.m2l_enums import Datatype
import map2loop
from unittest.mock import patch


# Sample test function for lithology and fault ignore codes
Expand All @@ -21,24 +22,25 @@ def test_set_get_ignore_codes():
"structure": {"dipdir_column": "azimuth2", "dip_column": "dip"},
"geology": {"unitname_column": "unitname", "alt_unitname_column": "code"},
}

project = Project(
working_projection='EPSG:28350',
bounding_box=bbox_3d,
geology_filename=str(
pathlib.Path(map2loop.__file__).parent
/ pathlib.Path('_datasets/geodata_files/hamersley/geology.geojson')
),
fault_filename=str(
pathlib.Path(map2loop.__file__).parent
/ pathlib.Path('_datasets/geodata_files/hamersley/faults.geojson')
),
dtm_filename=str(
pathlib.Path(map2loop.__file__).parent
/ pathlib.Path('_datasets/geodata_files/hamersley/dtm_rp.tif')
),
config_dictionary=config_dictionary,
)
with patch.object(Project, 'validate_required_inputs', return_value=None):
project = Project(
working_projection='EPSG:28350',
bounding_box=bbox_3d,
geology_filename=str(
pathlib.Path(map2loop.__file__).parent
/ pathlib.Path('_datasets/geodata_files/hamersley/geology.geojson')
),
fault_filename=str(
pathlib.Path(map2loop.__file__).parent
/ pathlib.Path('_datasets/geodata_files/hamersley/faults.geojson')
),
dtm_filename=str(
pathlib.Path(map2loop.__file__).parent
/ pathlib.Path('_datasets/geodata_files/hamersley/dtm_rp.tif')
),
config_dictionary=config_dictionary,
structure_filename="",
)

# Define test ignore codes for lithology and faults
lithology_codes = ["cover", "Fortescue_Group", "A_FO_od"]
Expand Down