├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── documentation_request.yml │ ├── feature_request.yml │ └── question.yml ├── pull_request_template.md └── workflows │ ├── conda.yml │ ├── documentation.yml │ ├── linting_and_testing.yml │ ├── publish_conda.yml │ └── pypi.yml ├── .gitignore ├── .release-please-manifest.json ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── conda ├── bld.bat ├── build.sh └── meta.yaml ├── dependencies.txt ├── docs ├── Dockerfile ├── Makefile ├── build_docs.sh ├── docker-compose.yml ├── examples │ ├── README.rst │ ├── plot_data_checks_on_fault.py │ ├── plot_data_checks_on_structure.py │ ├── plot_hamersley.py │ └── plot_m2l_data_checks_on_datatype_geology.py ├── make.bat ├── requirements.txt └── source │ ├── API.rst │ ├── _static │ ├── HJSON_TEMPLATE.hjson │ ├── images │ │ ├── csv.png │ │ ├── fault_attributes_table.png │ │ ├── litho_attributes_table.png │ │ └── ori_attributes_table.png │ └── m2l_code_template.ipynb │ ├── _templates │ ├── custom-class-template.rst │ ├── custom-module-template.rst │ ├── custom.css │ └── page.html │ ├── conf.py │ ├── index.rst │ └── user_guide │ ├── changing_colours.rst │ ├── config_file.rst │ ├── explanation.rst │ ├── exporting.rst │ ├── fault_offset.rst │ ├── geomodelling.rst │ ├── getting_started.rst │ ├── index.rst │ ├── installation.rst │ ├── m2l_code_template.rst │ ├── setup_jupyter.rst │ ├── stratigraphic_order.rst │ └── stratigraphic_thickness.rst ├── map2loop ├── __init__.py ├── _datasets │ ├── clut_files │ │ ├── QLD_clut.csv │ │ ├── SA_clut.csv │ │ └── WA_clut.csv │ ├── config_files │ │ ├── NSW.json │ │ ├── QLD.json │ │ ├── SA.json │ │ ├── TAS.json │ │ ├── VIC.json │ │ └── WA.json │ └── geodata_files │ │ ├── hamersley │ │ ├── dtm_rp.tif │ │ ├── faults.geojson │ │ ├── geology.geojson │ │ └── structures.geojson │ │ └── load_map2loop_data.py ├── aus_state_urls.py ├── config.py ├── data_checks.py ├── deformation_history.py ├── fault_orientation.py ├── interpolators.py ├── logging.py ├── m2l_enums.py ├── map2model_wrapper.py ├── mapdata.py ├── project.py ├── sampler.py ├── sorter.py ├── stratigraphic_column.py ├── thickness_calculator.py ├── throw_calculator.py ├── utils.py └── version.py ├── pyproject.toml ├── release-please-config.json ├── setup.py └── tests ├── __init__.py ├── data_checks ├── test_config.py ├── test_input_data_faults.py ├── test_input_data_fold.py ├── test_input_data_geology.py └── test_input_data_structure.py ├── mapdata ├── test_mapdata_assign_random_colours_to_units.py ├── test_mapdata_dipdir.py ├── test_minimum_fault_length.py ├── test_set_get_recreate_bounding_box.py └── test_set_get_working_projection.py ├── project ├── test_config_arguments.py ├── test_ignore_codes_setters_getters.py ├── test_plot_hamersley.py └── test_thickness_calculations.py ├── sampler ├── geo_test.csv ├── test_SamplerSpacing.py └── test_SamplerSpacing_featureId.py ├── test_import.py ├── thickness ├── InterpolatedStructure │ └── test_interpolated_structure.py ├── StructurePoint │ └── test_ThicknessStructuralPoint.py └── ThicknessCalculatorAlpha │ └── test_ThicknessCalculatorAlpha.py └── utils └── test_rgb_and_hex_functions.py /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: "🐛 Bug Report" 2 | description: "Report a bug or an issue in map2loop" 3 | title: "[Bug] - " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | ## 🐛 Bug Report 10 | Thanks for submitting a bug report to map2loop! 11 | Please use this template to report a bug. Please provide as much detail as possible to help us reproduce and fix the issue efficiently. 12 | 13 | - type: textarea 14 | id: version 15 | attributes: 16 | label: Version 17 | description: What version of map2loop and LoopProjectFile are you running? You can find this information by running `import map2loop` and `map2loop.__version__` in your python terminal or jupyter notebook. 18 | placeholder: "Enter map2loop and LoopProjectFile versions" 19 | validations: 20 | required: true 21 | 22 | - type: textarea 23 | id: bug_description 24 | attributes: 25 | label: "Bug Description" 26 | description: "Describe the bug you encountered. Include details on what you expected to happen and what actually happened." 27 | placeholder: "Enter a detailed description of the bug" 28 | validations: 29 | required: true 30 | 31 | - type: textarea 32 | id: steps_to_reproduce 33 | attributes: 34 | label: "Minimal reproducible example" 35 | description: "Provide a minimal reproducible example with the code necessary to reproduce the bug. For more guidance, visit: [How to create a minimal complete reproducible example](https://forum.access-hive.org.au/t/how-to-create-a-minimal-complete-reproducible-example/843)" 36 | placeholder: "Enter the steps to reproduce the bug" 37 | validations: 38 | required: false 39 | 40 | - type: textarea 41 | id: additional_context 42 | attributes: 43 | label: "Additional Context" 44 | description: "Provide any other context or information that may be helpful in understanding and fixing the bug." 45 | placeholder: "Enter any additional context" 46 | validations: 47 | required: false 48 | 49 | - type: input 50 | id: environment 51 | attributes: 52 | label: "Environment" 53 | description: "Specify the environment in which the bug occurred (e.g., operating system, browser, application version)." 54 | placeholder: "Enter the environment details" 55 | validations: 56 | required: false 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation_request.yml: -------------------------------------------------------------------------------- 1 | name: "📓 Documentation Request" 2 | description: "Help us improve map2loop documentation!" 3 | title: "[Documentation] - " 4 | labels: ["documentation"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | ## 📓 Documentation Request 10 | Please use this template to suggest an improvement or addition to map2loop documentation. 11 | Provide as much detail as possible to help us understand and implement your request efficiently 12 | 13 | - type: textarea 14 | id: doc_details 15 | attributes: 16 | label: "Documentation Details" 17 | description: "Describe the documentation you would like to see. Include details on why it is needed and how it should be structured." 18 | placeholder: "Enter a detailed description of the documentation" 19 | validations: 20 | required: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: "🚀 Feature Request" 2 | description: "Suggest a new feature or enhancement for map2loop" 3 | title: "[Feature Request] - " 4 | labels: ["enhancement", "feature request"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | ## 🚀 Feature Request 10 | Please use this template to submit your feature request. Provide as much detail as possible to help us understand and implement your request efficiently. 11 | 12 | - type: checkboxes 13 | id: input1 14 | attributes: 15 | label: "💻" 16 | description: | 17 | Check this if you would like to try and implement your vision in a PR. 18 | The map2loop team will help you go through the process 19 | options: 20 | - label: Would you like to work on this feature? 21 | 22 | - type: textarea 23 | id: feature_description 24 | attributes: 25 | label: "Feature Description" 26 | description: "Describe the feature you would like to see. Include details on why it is needed and how it should work." 27 | placeholder: "Enter a detailed description of the feature" 28 | validations: 29 | required: true 30 | 31 | - type: textarea 32 | id: version 33 | attributes: 34 | label: Version 35 | description: What version of map2loop and LoopProjectFile are you running that doesn't have this feature? You can find this information by running `import map2loop` and `map2loop.__version__` in your python terminal or jupyter notebook. 36 | placeholder: "Enter map2loop and LoopProjectFile versions" 37 | validations: 38 | required: true 39 | 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yml: -------------------------------------------------------------------------------- 1 | name: "💬 Question" 2 | description: "Ask a question about map2loop!" 3 | title: "[Question] - " 4 | labels: ["question"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | ## 💬 Question 10 | Please use this template to ask a question about applying map2loop to your data. 11 | Provide as much detail as possible to help us understand and answer your question efficiently. 12 | 13 | - type: textarea 14 | id: question_details 15 | attributes: 16 | label: "Question Details" 17 | description: "Describe your question in detail. Include any context or background information that might be helpful." 18 | placeholder: "Enter the details of your question" 19 | validations: 20 | required: true 21 | 22 | - type: textarea 23 | id: relevant_code_snippets 24 | attributes: 25 | label: "Relevant code or data" 26 | description: "If applicable, provide any relevant code snippets or examples related to your question." 27 | placeholder: "Enter any relevant code snippets" 28 | validations: 29 | required: false 30 | 31 | - type: textarea 32 | id: additional_context 33 | attributes: 34 | label: "Additional Context" 35 | description: "Provide any other context or information that may be helpful in answering your question." 36 | placeholder: "Enter any additional context" 37 | validations: 38 | required: false -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 📝 Thanks for contributing to map2loop! 4 | Please describe the issue that this pull request addresses and summarize the changes you are implementing. 5 | Include relevant motivation and context, if appropriate. 6 | List any new dependencies that are required for this change. 7 | 8 | Fixes #(issue) 9 | 10 | ## Type of change 11 | 12 | - [ ] Documentation update 13 | - [ ] Bug fix (non-breaking change which fixes an issue) 14 | - [ ] New feature (non-breaking change which adds functionality) 15 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 16 | - [ ] Test improvement 17 | 18 | ## How Has This Been Tested? 19 | 20 | Please describe any tests that you ran to verify your changes. 21 | Provide branch name so we can reproduce. 22 | 23 | ## Checklist: 24 | 25 | - [ ] This branch is up-to-date with master 26 | - [ ] All gh-action checks are passing 27 | - [ ] I have performed a self-review of my own code 28 | - [ ] My code follows the style guidelines of this project 29 | - [ ] I have commented my code, particularly in hard-to-understand areas 30 | - [ ] I have made corresponding changes to the documentation 31 | - [ ] I have added tests that prove my fix is effective or that my feature works 32 | - [ ] My tests run with pytest from the map2loop folder 33 | - [ ] New and existing tests pass locally with my changes 34 | 35 | ## Checklist continued (if PR includes changes to documentation) 36 | - [ ] I have built the documentation locally with make.bat 37 | - [ ] I have built this documentation in docker, following the docker configuration in map2loop/docs 38 | - [ ] I have checked my spelling and grammar -------------------------------------------------------------------------------- /.github/workflows/conda.yml: -------------------------------------------------------------------------------- 1 | name: Build conda packages 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build_wheels: 8 | name: Build wheels on 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: ${{ fromJSON(vars.BUILD_OS)}} 14 | python-version: ${{ fromJSON(vars.PYTHON_VERSIONS) }} 15 | steps: 16 | - uses: conda-incubator/setup-miniconda@v3 17 | with: 18 | auto-update-conda: true 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - uses: actions/checkout@v4 22 | - name: update submodules 23 | run: | 24 | git submodule update --init --recursive 25 | - name: Conda build 26 | env: 27 | ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_TOKEN }} 28 | shell: bash -l {0} 29 | run: | 30 | conda config --env --add channels conda-forge 31 | conda config --env --add channels loop3d 32 | conda config --env --set channel_priority strict 33 | conda install -c conda-forge conda-build scikit-build-core numpy anaconda-client conda-libmamba-solver -y 34 | conda config --set solver libmamba 35 | conda build --output-folder conda conda --python ${{matrix.python-version}} 36 | anaconda upload --label main conda/*/*.tar.bz2 37 | 38 | - name: upload artifacts 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: conda-build-${{matrix.os}}-${{ matrix.python-version }} 42 | path: conda -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Documentation 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | documentation-test: 8 | runs-on: ubuntu-24.04 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | ref: ${{ github.ref }} 13 | 14 | - run: | 15 | cp CHANGELOG.md docs/source/CHANGELOG.md 16 | docker build . -t=docs -f docs/Dockerfile 17 | docker run -v $(pwd):/map2loop docs bash map2loop/docs/build_docs.sh 18 | - name: upload artifacts 19 | uses: actions/upload-artifact@v4 20 | with: 21 | name: docs 22 | path: docs/build/html 23 | 24 | documentation-deploy: 25 | runs-on: ubuntu-24.04 26 | if: GitHub.ref == 'refs/heads/master' 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: actions/download-artifact@v4 30 | with: 31 | name: docs 32 | path: docs 33 | - name: ls 34 | run: | 35 | ls -l docs 36 | - name: Deploy 🚀 37 | uses: JamesIves/github-pages-deploy-action@v4 38 | with: 39 | branch: gh-pages # The branch the action should deploy to. 40 | folder: docs # The folder the action should deploy. 41 | -------------------------------------------------------------------------------- /.github/workflows/linting_and_testing.yml: -------------------------------------------------------------------------------- 1 | name: Linting and Testing 2 | 3 | on: 4 | [push] 5 | 6 | jobs: 7 | linting: 8 | name: Linting 9 | runs-on: ubuntu-24.04 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Install dependencies 13 | run: | 14 | python -m pip install --upgrade pip 15 | pip install black ruff 16 | ruff check . --fix 17 | 18 | - uses: stefanzweifel/git-auto-commit-action@v5 19 | with: 20 | commit_message: "style: style fixes by ruff and autoformatting by black" 21 | 22 | 23 | testing: 24 | name: Testing 25 | runs-on: ubuntu-24.04 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Install GDAL 29 | run: | 30 | sudo add-apt-repository ppa:ubuntugis/ubuntugis-unstable 31 | sudo apt-get update 32 | sudo apt-get install -y libgdal-dev gdal-bin 33 | 34 | - name: Install dependencies 35 | run: | 36 | conda update -n base -c defaults conda -y 37 | conda install -n base conda-libmamba-solver -c conda-forge -y 38 | conda install -c conda-forge gdal -y 39 | conda install -c conda-forge -c loop3d --file dependencies.txt -y 40 | conda install pytest -y 41 | 42 | - name: Install map2loop 43 | run: | 44 | python -m pip install . 45 | 46 | - name: Run tests 47 | run: | 48 | pytest 49 | 50 | -------------------------------------------------------------------------------- /.github/workflows/publish_conda.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | workflow_dispatch: 6 | 7 | jobs: 8 | release-please: 9 | runs-on: ubuntu-24.04 10 | steps: 11 | - uses: GoogleCloudPlatform/release-please-action@v4 12 | id: release 13 | with: 14 | release-type: python 15 | package-name: map2loop 16 | - name: Debug release_created output 17 | run: | 18 | echo "Release created: ${{ steps.release.outputs.release_created }}" 19 | outputs: 20 | release_created: ${{ steps.release.outputs.release_created }} 21 | 22 | pypi: 23 | runs-on: ubuntu-24.04 24 | needs: release-please 25 | if: ${{ needs.release-please.outputs.release_created }} 26 | steps: 27 | - name: Trigger build for pypi and upload 28 | run: | 29 | curl -X POST \ 30 | -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 31 | -H "Accept: application/vnd.github.v3+json" \ 32 | https://api.github.com/repos/Loop3d/map2loop/actions/workflows/pypi.yml/dispatches \ 33 | -d '{"ref":"master"}' 34 | - name: Trigger build for conda and upload 35 | run: | 36 | curl -X POST \ 37 | -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 38 | -H "Accept: application/vnd.github.v3+json" \ 39 | https://api.github.com/repos/Loop3d/map2loop/actions/workflows/conda.yml/dispatches \ 40 | -d '{"ref":"master"}' 41 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to PYPI 2 | 3 | on: 4 | workflow_dispatch: 5 | jobs: 6 | 7 | sdist: 8 | name: Build sdist 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-python@v5 13 | - name: Install dependencies 14 | run: | 15 | python -m pip install --upgrade pip 16 | pip install build 17 | - name: Build sdist 18 | run: python -m build --sdist 19 | - uses: actions/upload-artifact@v4 20 | with: 21 | name: dist 22 | path: ./dist/*.tar.gz 23 | 24 | publish: 25 | name: Publish wheels to pypi 26 | runs-on: ubuntu-latest 27 | permissions: 28 | # IMPORTANT: this permission is mandatory for trusted publishing 29 | id-token: write 30 | needs: sdist 31 | steps: 32 | - uses: actions/download-artifact@v4 33 | with: 34 | name: dist 35 | path: dist 36 | - name: copy to wheelhouse 37 | run: | 38 | # cp -r wheelhouse/*/*.whl dist 39 | - uses: pypa/gh-action-pypi-publish@release/v1 40 | with: 41 | skip-existing: true 42 | verbose: true 43 | packages-dir: dist/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ### M2L stuff ### 3 | /model-test* 4 | .ipynb_checkpoints 5 | notebooks/Testing.ipynb 6 | .vim/ 7 | .vscode/ 8 | _version.py 9 | 3/ 10 | 11 | # Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,c++,cmake 12 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode,c++,cmake 13 | 14 | ### C++ ### 15 | # Prerequisites 16 | *.d 17 | 18 | # Compiled Object files 19 | *.slo 20 | *.lo 21 | *.o 22 | *.obj 23 | 24 | # Precompiled Headers 25 | *.gch 26 | *.pch 27 | 28 | # Compiled Dynamic libraries 29 | *.so 30 | *.dylib 31 | *.dll 32 | 33 | # Fortran module files 34 | *.mod 35 | *.smod 36 | 37 | # Compiled Static libraries 38 | *.lai 39 | *.la 40 | *.a 41 | *.lib 42 | 43 | # Executables 44 | *.exe 45 | *.out 46 | *.app 47 | 48 | ### CMake ### 49 | CMakeLists.txt.user 50 | CMakeCache.txt 51 | CMakeFiles 52 | CMakeScripts 53 | Testing 54 | cmake_install.cmake 55 | install_manifest.txt 56 | compile_commands.json 57 | CTestTestfile.cmake 58 | _deps 59 | 60 | ### CMake Patch ### 61 | # External projects 62 | *-prefix/ 63 | 64 | ### Python ### 65 | # Byte-compiled / optimized / DLL files 66 | __pycache__/ 67 | *.py[cod] 68 | *$py.class 69 | 70 | # C extensions 71 | 72 | # Distribution / packaging 73 | .Python 74 | build/ 75 | develop-eggs/ 76 | dist/ 77 | downloads/ 78 | eggs/ 79 | .eggs/ 80 | lib/ 81 | lib64/ 82 | parts/ 83 | sdist/ 84 | var/ 85 | wheels/ 86 | pip-wheel-metadata/ 87 | share/python-wheels/ 88 | *.egg-info/ 89 | .installed.cfg 90 | *.egg 91 | MANIFEST 92 | 93 | # PyInstaller 94 | # Usually these files are written by a python script from a template 95 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 96 | *.manifest 97 | *.spec 98 | 99 | # Installer logs 100 | pip-log.txt 101 | pip-delete-this-directory.txt 102 | 103 | # Unit test / coverage reports 104 | htmlcov/ 105 | .tox/ 106 | .nox/ 107 | .coverage 108 | .coverage.* 109 | .cache 110 | nosetests.xml 111 | coverage.xml 112 | *.cover 113 | *.py,cover 114 | .hypothesis/ 115 | .pytest_cache/ 116 | 117 | # Translations 118 | *.mo 119 | *.pot 120 | 121 | # Django stuff: 122 | *.log 123 | local_settings.py 124 | db.sqlite3 125 | db.sqlite3-journal 126 | 127 | # Flask stuff: 128 | instance/ 129 | .webassets-cache 130 | 131 | # Scrapy stuff: 132 | .scrapy 133 | 134 | # Sphinx documentation 135 | docs/_build/ 136 | 137 | # PyBuilder 138 | target/ 139 | 140 | # Jupyter Notebook 141 | .ipynb_checkpoints 142 | 143 | # IPython 144 | profile_default/ 145 | ipython_config.py 146 | 147 | # pyenv 148 | .python-version 149 | 150 | # pipenv 151 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 152 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 153 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 154 | # install all needed dependencies. 155 | #Pipfile.lock 156 | 157 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 158 | __pypackages__/ 159 | 160 | # Celery stuff 161 | celerybeat-schedule 162 | celerybeat.pid 163 | 164 | # SageMath parsed files 165 | *.sage.py 166 | 167 | # Environments 168 | .env 169 | .venv 170 | env/ 171 | venv/ 172 | ENV/ 173 | env.bak/ 174 | venv.bak/ 175 | 176 | # Spyder project settings 177 | .spyderproject 178 | .spyproject 179 | 180 | # Rope project settings 181 | .ropeproject 182 | 183 | # mkdocs documentation 184 | /site 185 | 186 | # mypy 187 | .mypy_cache/ 188 | .dmypy.json 189 | dmypy.json 190 | 191 | # Pyre type checker 192 | .pyre/ 193 | 194 | # pytype static type analyzer 195 | .pytype/ 196 | 197 | ### VisualStudioCode ### 198 | .vscode/* 199 | !.vscode/settings.json 200 | !.vscode/tasks.json 201 | !.vscode/launch.json 202 | !.vscode/extensions.json 203 | *.code-workspace 204 | 205 | ### VisualStudioCode Patch ### 206 | # Ignore all local history of files 207 | .history 208 | .vscode/ 209 | 210 | docs/build 211 | docs/source/_autosummary/* 212 | # End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,c++,cmake 213 | #ignore automatically generated doc files 214 | docs/source/_autosummary 215 | docs/source/_auto_examples/* 216 | docs/source/_auto_examples 217 | examples/*_tmp 218 | *.loop3d 219 | m2l_data_tmp/* 220 | docs/examples/m2l_data_tmp/* 221 | docs/source/sg_execution_times.rst 222 | docs/source/auto_examples/* 223 | tests/m2l_data_tmp/* 224 | 225 | tests/project/m2l_data_tmp/* 226 | tests/project/m2l_data_tmp/* 227 | tests/thickness/m2l_data_tmp/* 228 | docs/m2l_data_tmp/* 229 | tests/thickness/StructurePoint/m2l_data_tmp/* 230 | 231 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "3.2.2" 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 The Loop Project 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include map2loop/_datasets/clut_files/*.csv 2 | include map2loop/_datasets/config_files/*.json 3 | include map2loop/_datasets/geodata_files/hamersley/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub Release](https://img.shields.io/github/v/release/loop3d/map2loop) 2 | [![DOI](https://img.shields.io/static/v1?label=DOI&message=10.5194/gmd-14-5063-2021&color=blue)](https://doi.org/10.5194/gmd-14-5063-2021) 3 | ![License](https://img.shields.io/github/license/loop3d/map2loop) 4 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/map2loop?label=pip%20downloads) 5 | ![Conda Downloads](https://img.shields.io/conda/dn/loop3d/map2loop?label=Conda%20downloads) 6 | [![Testing](https://github.com/Loop3D/map2loop/actions/workflows/linting_and_testing.yml/badge.svg)](https://github.com/Loop3D/map2loop/actions/workflows/linting_and_testing.yml) 7 | [![Build and Deploy Documentation](https://github.com/Loop3D/map2loop/actions/workflows/documentation.yml/badge.svg)](https://github.com/Loop3D/map2loop/actions/workflows/documentation.yml) 8 | 9 | 10 | # Map2Loop 3.2 11 | 12 | Generate 3D geological model inputs from geological maps — a high-level implementation and extension of the original map2loop code developed by Prof. Mark Jessell at UWA. To see an example interactive model built with map2loop and LoopStructural, follow this link: 13 | 14 | 3D Model from the Hamersley region, Western Australia 15 | 16 | ## Install 17 | 18 | #### Option 1: Install with Anaconda 19 | 20 | This is the simplest and recommended installation process, with: 21 | 22 | ```bash 23 | conda install -c loop3d -c conda-forge map2loop 24 | ``` 25 | 26 | #### Option 2: Install with pip 27 | Installation with pip will require that GDAL is installed on your system prior to map2loop installation. 28 | This is because GDAL cannot be installed via pip (at least not with one line of code), and the GDAL installation process will vary depending on your OS. 29 | 30 | For more information on installing gdal, see GDAL's Pypi page. 31 | 32 | Once GDAL is available on your system, map2loop can be installed with: 33 | ```bash 34 | pip install map2loop 35 | ``` 36 | 37 | #### Option 3: From source 38 | 39 | ```bash 40 | git clone https://github.com/Loop3D/map2loop.git 41 | 42 | cd map2loop 43 | 44 | conda install gdal 45 | 46 | conda install -c loop3d -c conda-forge --file dependencies.txt 47 | 48 | pip install . 49 | ``` 50 | 51 | #### Option 4: From source & developer mode: 52 | ```bash 53 | git clone https://github.com/Loop3D/map2loop.git 54 | 55 | cd map2loop 56 | 57 | conda install gdal 58 | 59 | conda install -c loop3d -c conda-forge --file dependencies.txt 60 | 61 | pip install -e . 62 | ``` 63 | 64 | ### Documentation 65 | 66 | Map2loop's documentation is available here 67 | 68 | 69 | ## Usage 70 | 71 | Our notebooks cover use cases in more detail, but here is an example of processing Loop's South Australia remote geospatial data in just 20 lines of Python. 72 | 73 | First, let's import map2loop and define a bounding box. You can use GIS software to find one or use [Loop's Graphical User Interface](https://loop3d.github.io/downloads.html) for the best experience and complete toolset. Remember what projection your coordinates are in! 74 | 75 | ```python 76 | from map2loop.project import Project 77 | from map2loop.m2l_enums import VerboseLevel 78 | 79 | # Note that this region is defined in the EPSG 28354 projection and 80 | # x and y represent easting and northing respectively 81 | bbox_3d = { 82 | 'minx': 250805.1529856466, 83 | 'miny': 6405084.328058686, 84 | 'maxx': 336682.921539395, 85 | 'maxy': 6458336.085975628, 86 | 'base': -3200, 87 | 'top': 1200 88 | } 89 | ``` 90 | 91 | Then, specify: the state, directory for the output, the bounding box and projection from above - and hit go! That's it. 92 | 93 | ```python 94 | proj = Project(use_australian_state_data = "SA", 95 | working_projection = 'EPSG:28354', 96 | bounding_box = bbox_3d, 97 | loop_project_filename = "output.loop3d" 98 | ) 99 | 100 | proj.run_all() 101 | ``` 102 | 103 | This is a minimal example and a small part of Loop. 104 | 105 | Our _documentation and other resources outline how to extend map2loop and port to the LoopStructural modelling engine. We are working to incorporate geophysical tools and best provide visualisation and workflow consolidation in the GUI._ 106 | 107 | _Loop is led by Laurent Ailleres (Monash University) with a team of Work Package leaders from:_ 108 | 109 | - _Monash University: Roy Thomson, Lachlan Grose and Robin Armit_ 110 | - _University of Western Australia: Mark Jessell, Jeremie Giraud, Mark Lindsay and Guillaume Pirot_ 111 | - _Geological Survey of Canada: Boyan Brodaric and Eric de Kemp_ 112 | 113 | --- 114 | 115 | ### Known Issues and FAQs 116 | 117 | - Developing with docker on Windows means you won't have GPU passthrough and can’t use a discrete graphics card in the container even if you have one. 118 | - If Jupyter links require a token or password, it may mean port 8888 is already in use. To fix, either make docker map to another port on the host ie -p 8889:8888 or stop any other instances on 8888. 119 | 120 | ### Links 121 | 122 | [https://loop3d.github.io/](https://loop3d.github.io/) 123 | 124 | [https://github.com/Loop3D/LoopStructural](https://github.com/Loop3D/LoopStructural) 125 | -------------------------------------------------------------------------------- /conda/bld.bat: -------------------------------------------------------------------------------- 1 | mkdir %SP_DIR%\map2loop 2 | copy %RECIPE_DIR%\..\LICENSE %SP_DIR%\map2loop\ 3 | copy %RECIPE_DIR%\..\README.md %SP_DIR%\map2loop\ 4 | copy %RECIPE_DIR%\..\dependencies.txt %SP_DIR%\map2loop\ 5 | %PYTHON% -m pip install . -------------------------------------------------------------------------------- /conda/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | mkdir -p $SP_DIR/map2loop 3 | cp $RECIPE_DIR/../dependencies.txt $SP_DIR/map2loop/ 4 | cp $RECIPE_DIR/../LICENSE $SP_DIR/map2loop/ 5 | cp $RECIPE_DIR/../README.md $SP_DIR/map2loop/ 6 | $PYTHON -m pip install . -------------------------------------------------------------------------------- /conda/meta.yaml: -------------------------------------------------------------------------------- 1 | {% set name = "map2loop" %} 2 | 3 | package: 4 | name: "{{ name|lower }}" 5 | version: "{{ environ.get('GIT_DESCRIBE_TAG', '') }}" 6 | 7 | source: 8 | git_url: https://github.com/Loop3D/map2loop 9 | 10 | build: 11 | number: 0 12 | 13 | requirements: 14 | host: 15 | - pip 16 | - python 17 | - setuptools 18 | run: 19 | - loopprojectfile ==0.2.2 20 | - gdal 21 | - python 22 | - numpy 23 | - scipy 24 | - geopandas 25 | - shapely 26 | - networkx 27 | - owslib 28 | - map2model 29 | - beartype 30 | - pytest 31 | - scikit-learn 32 | 33 | about: 34 | home: "https://github.com/Loop3D/map2loop" 35 | license: MIT 36 | license_family: MIT 37 | license_file: ../LICENSE 38 | summary: "Generate 3D model data using 2D maps." 39 | 40 | extra: 41 | recipe-maintainers: 42 | - lachlangrose 43 | 44 | channels: 45 | - loop3d 46 | - conda-forge -------------------------------------------------------------------------------- /dependencies.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | scipy 3 | geopandas 4 | shapely 5 | networkx 6 | owslib 7 | map2model 8 | loopprojectfile==0.2.2 9 | beartype 10 | pytest 11 | scikit-learn -------------------------------------------------------------------------------- /docs/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM continuumio/miniconda3 2 | LABEL maintainer="lachlan.grose@monash.edu" 3 | #This docker image has been adapted from the lavavu dockerfile 4 | # install things 5 | 6 | RUN apt-get update -qq && \ 7 | DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ 8 | gcc \ 9 | g++ \ 10 | libc-dev \ 11 | make\ 12 | libgl1\ 13 | libtinfo5\ 14 | libtiff6\ 15 | libgl1-mesa-glx\ 16 | libarchive13 17 | 18 | COPY . /map2loop 19 | 20 | WORKDIR /map2loop 21 | 22 | COPY dependencies.txt dependencies.txt 23 | COPY dependencies.txt dependenciesdocs.txt 24 | 25 | RUN cat ./docs/requirements.txt >> dependenciesdocs.txt 26 | RUN conda install -c conda-forge -c loop3d --file dependenciesdocs.txt -y 27 | RUN conda install gdal -y 28 | 29 | RUN pip install . 30 | 31 | WORKDIR / 32 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/build_docs.sh: -------------------------------------------------------------------------------- 1 | pip install ./map2loop 2 | export DOCUMENTATION_TEST=True 3 | make -C ./map2loop/docs html -------------------------------------------------------------------------------- /docs/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | map2loop: 3 | build: 4 | dockerfile: docs/Dockerfile 5 | context: ../ 6 | volumes: 7 | - ../:/map2loop 8 | - ../../LoopStructural:/LoopStructural 9 | tty: true 10 | 11 | # command: sh map2loop/docs/build_docs.sh -------------------------------------------------------------------------------- /docs/examples/README.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== -------------------------------------------------------------------------------- /docs/examples/plot_data_checks_on_structure.py: -------------------------------------------------------------------------------- 1 | # %% 2 | import geopandas as gpd 3 | from map2loop.mapdata import MapData 4 | from map2loop import data_checks 5 | import shapely.geometry 6 | 7 | 8 | # Mock Datatype Enum 9 | class Datatype: 10 | GEOLOGY = 0 11 | STRUCTURE = 1 12 | 13 | 14 | # Mock Config class 15 | class MockConfig: 16 | def __init__(self): 17 | self.structure_config = { 18 | "dipdir_column": "DIPDIR", 19 | "dip_column": "DIP", 20 | "description_column": "DESCRIPTION", 21 | "overturned_column": "OVERTURNED", 22 | "objectid_column": "ID", 23 | } 24 | 25 | 26 | # Mock data for the structure dataset 27 | valid_structure_data = { 28 | "geometry": [shapely.geometry.Point(0, 0), shapely.geometry.Point(1, 1)], 29 | "DIPDIR": [45.0, 135.0], 30 | "DIP": [30.0, 45.0], 31 | "DESCRIPTION": ["Description1", "Description2"], 32 | "OVERTURNED": ["Yes", "No"], 33 | "ID": [1, 2], 34 | } 35 | 36 | # Create a GeoDataFrame for valid structure data 37 | valid_structure_gdf = gpd.GeoDataFrame(valid_structure_data, crs="EPSG:4326") 38 | 39 | 40 | # Instantiate the MapData class with the mock config and data 41 | map_data = MapData() 42 | map_data.config = MockConfig() 43 | 44 | # Test with valid structure data 45 | map_data.raw_data = [None] * len(Datatype.__dict__) 46 | map_data.raw_data[Datatype.STRUCTURE] = valid_structure_gdf 47 | validity_check, message = data_checks.check_structure_fields_validity(map_data) 48 | print("Test 1 - Valid Data:") 49 | print(f"Validity Check: {validity_check}, Message: {message}") 50 | 51 | # %% 52 | # Mock data with invalid geometry 53 | invalid_geometry_structure_data = valid_structure_data.copy() 54 | invalid_geometry_structure_data["geometry"] = [ 55 | shapely.geometry.Point(0, 0), 56 | shapely.geometry.Polygon([(0, 0), (1, 1), (1, 0), (0, 1), (0, 0)]), # Invalid geometry 57 | ] 58 | invalid_geometry_structure_gdf = gpd.GeoDataFrame(invalid_geometry_structure_data, crs="EPSG:4326") 59 | 60 | 61 | # Test with invalid geometry 62 | map_data.raw_data[Datatype.STRUCTURE] = invalid_geometry_structure_gdf 63 | validity_check, message = data_checks.check_structure_fields_validity(map_data) 64 | print("\nTest 3 - Invalid Geometry:") 65 | print(f"Validity Check: {validity_check}, Message: {message}") 66 | 67 | # %% 68 | # Mock data with missing required columns 69 | missing_column_structure_data = valid_structure_data.copy() 70 | del missing_column_structure_data["DIPDIR"] 71 | missing_column_structure_gdf = gpd.GeoDataFrame(missing_column_structure_data, crs="EPSG:4326") 72 | 73 | # Test with missing required column 74 | map_data.raw_data[Datatype.STRUCTURE] = missing_column_structure_gdf 75 | validity_check, message = data_checks.check_structure_fields_validity(map_data) 76 | print("\nTest 2 - Missing Required Column:") 77 | print(f"Validity Check: {validity_check}, Message: {message}") 78 | 79 | # %% 80 | # Mock data for the structure dataset 81 | invalid_structure_data = { 82 | "geometry": [shapely.geometry.Point(0, 0), shapely.geometry.Point(1, 1)], 83 | "DIPDIR": ["A", "B"], 84 | "DIP": [30.0, 45.0], 85 | "DESCRIPTION": ["Description1", "Description2"], 86 | "OVERTURNED": ["Yes", "No"], 87 | "ID": [1, 2], 88 | } 89 | 90 | map_data.raw_data[Datatype.STRUCTURE] = invalid_structure_data 91 | validity_check, message = data_checks.check_structure_fields_validity(map_data) 92 | print(f"Validity Check: {validity_check}, Message: {message}") 93 | 94 | # %% 95 | # Mock data for the structure dataset 96 | invalid_structure_data = gpd.GeoDataFrame( 97 | { 98 | "geometry": [shapely.geometry.Point(0, 0), shapely.geometry.Point(1, 1)], 99 | "DIPDIR": ["A", "B"], 100 | "DIP": [30.0, 45.0], 101 | "DESCRIPTION": ["Description1", "Description2"], 102 | "OVERTURNED": ["Yes", "No"], 103 | "ID": [1, 2], 104 | } 105 | ) 106 | 107 | map_data.raw_data[Datatype.STRUCTURE] = invalid_structure_data 108 | validity_check, message = data_checks.check_structure_fields_validity(map_data) 109 | print(f"Validity Check: {validity_check}, Message: {message}") 110 | 111 | # %% 112 | # Mock data for the structure dataset 113 | invalid_structure_data = gpd.GeoDataFrame( 114 | { 115 | "geometry": [shapely.geometry.Point(0, 0), shapely.geometry.Point(1, 1)], 116 | "DIPDIR": [None, 3], 117 | "DIP": [30.0, 45.0], 118 | "DESCRIPTION": ["Description1", "Description2"], 119 | "OVERTURNED": ["Yes", "No"], 120 | "ID": [1, 2], 121 | } 122 | ) 123 | 124 | map_data.raw_data[Datatype.STRUCTURE] = invalid_structure_data 125 | validity_check, message = data_checks.check_structure_fields_validity(map_data) 126 | print(f"Validity Check: {validity_check}, Message: {message}") 127 | 128 | # %% 129 | # Mock data for the structure dataset 130 | invalid_structure_data = gpd.GeoDataFrame( 131 | { 132 | "geometry": [shapely.geometry.Point(0, 0), shapely.geometry.Point(1, 1)], 133 | "DIPDIR": [5, 3], 134 | "DIP": [120.0, 45.0], 135 | "DESCRIPTION": ["Description1", "Description2"], 136 | "OVERTURNED": ["Yes", "No"], 137 | "ID": [1, 2], 138 | } 139 | ) 140 | 141 | map_data.raw_data[Datatype.STRUCTURE] = invalid_structure_data 142 | validity_check, message = data_checks.check_structure_fields_validity(map_data) 143 | print(f"Validity Check: {validity_check}, Message: {message}") 144 | 145 | # %% 146 | # Mock data for the structure dataset 147 | invalid_structure_data = gpd.GeoDataFrame( 148 | { 149 | "geometry": [shapely.geometry.Point(0, 0), shapely.geometry.Point(1, 1)], 150 | "DIPDIR": [5, 3], 151 | "DIP": [90, 45.0], 152 | "DESCRIPTION": [None, "Description2"], 153 | "OVERTURNED": ["Yes", "No"], 154 | "ID": [1, 2], 155 | } 156 | ) 157 | 158 | map_data.raw_data[Datatype.STRUCTURE] = invalid_structure_data 159 | validity_check, message = data_checks.check_structure_fields_validity(map_data) 160 | print(f"Validity Check: {validity_check}, Message: {message}") 161 | 162 | # %% 163 | # Mock data for the structure dataset 164 | invalid_structure_data = gpd.GeoDataFrame( 165 | { 166 | "geometry": [shapely.geometry.Point(0, 0), shapely.geometry.Point(1, 1)], 167 | "DIPDIR": [5, 3], 168 | "DIP": [90, 45.0], 169 | "DESCRIPTION": [None, "Description2"], 170 | "OVERTURNED": ["Yes", "No"], 171 | "ID": [1, 1], 172 | } 173 | ) 174 | 175 | map_data.raw_data[Datatype.STRUCTURE] = invalid_structure_data 176 | validity_check, message = data_checks.check_structure_fields_validity(map_data) 177 | print(f"Validity Check: {validity_check}, Message: {message}") 178 | 179 | # %% 180 | -------------------------------------------------------------------------------- /docs/examples/plot_hamersley.py: -------------------------------------------------------------------------------- 1 | """ 2 | ============================ 3 | Hamersley, Western Australia 4 | ============================ 5 | """ 6 | 7 | from map2loop.project import Project 8 | from map2loop.m2l_enums import VerboseLevel, Datatype 9 | from map2loop.sorter import SorterAlpha 10 | from map2loop.sampler import SamplerSpacing 11 | 12 | #################################################################### 13 | # Set the region of interest for the project 14 | # ------------------------------------------- 15 | # Define the bounding box for the ROI 16 | 17 | bbox_3d = { 18 | "minx": 515687.31005864, 19 | "miny": 7493446.76593407, 20 | "maxx": 562666.860106543, 21 | "maxy": 7521273.57407786, 22 | "base": -3200, 23 | "top": 3000, 24 | } 25 | 26 | # Specify minimum details (which Australian state, projection and bounding box 27 | # and output file) 28 | loop_project_filename = "wa_output.loop3d" 29 | proj = Project( 30 | use_australian_state_data="WA", 31 | working_projection="EPSG:28350", 32 | bounding_box=bbox_3d, 33 | verbose_level=VerboseLevel.NONE, 34 | loop_project_filename=loop_project_filename, 35 | overwrite_loopprojectfile=True, 36 | ) 37 | 38 | # Set the distance between sample points for arial and linestring geometry 39 | proj.set_sampler(Datatype.GEOLOGY, SamplerSpacing(200.0)) 40 | proj.set_sampler(Datatype.FAULT, SamplerSpacing(200.0)) 41 | 42 | # Choose which stratigraphic sorter to use or run_all with "take_best" flag to run them all 43 | proj.set_sorter(SorterAlpha()) 44 | # proj.set_sorter(SorterAgeBased()) 45 | # proj.set_sorter(SorterUseHint()) 46 | # proj.set_sorter(SorterUseNetworkx()) 47 | # proj.set_sorter(SorterMaximiseContacts()) 48 | # proj.set_sorter(SorterObservationProjections()) 49 | proj.run_all(take_best=True) 50 | 51 | #################################################################### 52 | # Visualise the map2loop results 53 | # ------------------------------------------- 54 | 55 | proj.map_data.basal_contacts.plot() 56 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-gallery 3 | pydata-sphinx-theme 4 | myst-parser 5 | sphinxcontrib-bibtex 6 | poppler 7 | LoopStructural -------------------------------------------------------------------------------- /docs/source/API.rst: -------------------------------------------------------------------------------- 1 | API 2 | --- 3 | 4 | .. autosummary:: 5 | :caption: API 6 | :toctree: _autosummary 7 | :template: custom-module-template.rst 8 | :recursive: 9 | 10 | map2loop 11 | -------------------------------------------------------------------------------- /docs/source/_static/HJSON_TEMPLATE.hjson: -------------------------------------------------------------------------------- 1 | #This is a template for a map2loop HJSON file, using the LEGACY code variable names (the left most 'term'). 2 | #The information after the hash on the right is a description of the required field. You can delete this from your own file if you'd like. 3 | #You'll need to change the attribute name in the second quotation to match your shapefile attribute names 4 | # (e.g. change "INSERT_DIP" to your attribute name). Ensure that the attribute name is between '' or "". 5 | 6 | #Any lines with a *opt in the description string, means that they are optional. You can just leave the attribute field blank 7 | # (e.g. "g": '', ) 8 | 9 | #For more information on the variables and map2loop requirements please see the documentation. 10 | 11 | { 12 | #ORIENTATION SHAPEFILE ATTRIBUTES 13 | "d": "INSERT_DIP", #attribute containing dip information 14 | "dd": "INSERT_DIP_DIRECTION", #attribute containing dip direction information 15 | "otype": 'dip direction', #Set the measurement convention used (either 'strike' or 'dip direction') 16 | "sf": 'INSERT_STRUCTURE_DESCRIPTION', #*opt attribute containing type of structure (eg. S0, S1) 17 | "bedding": 'INSERT_BEDDING_TEXT', #*opt text defining bedding measurements in the "sf" field (eg "Bedding" or "S0") 18 | "bo": 'INSERT_FOLIATION_DESCRIPTION', #*opt attribute containing type of foliation 19 | "btype": 'OVERTURNED_BEDDING_TEXT', #*opt text defining overturned bedding measurements in the "bo" field (eg. 'overturned') 20 | 21 | #LITHOLOGY SHAPEFILE ATTRIBUTES 22 | "c": 'INSERT_UNIT_NAME', #attribute containing stratigraphic unit name (most specific) 23 | "u": 'INSERT_ALT_UNIT_NAME', #attribute containing alternative stratigraphic unit name (eg unit code). Can be the same as 'c' 24 | "g": 'INSERT_GROUP', #*opt attribute containing stratigraphic group 25 | "g2": 'INSERT_SUPERGROUP', #*opt attribute containing stratigraphic supergroup (most coarse classification) 26 | "ds": 'INSERT_DESCRIPTION', #*opt general description field 27 | "r1": 'INSERT_ROCKTYPE', #*opt attribute containing extra lithology information (can indicate intrusions) 28 | "r2": 'INSERT_ROCKTYPE2', #*opt attribute containing secondary rocktype information 29 | "sill": 'INSERT_SILL_TEXT', #*opt text defining a sill in the "ds" field (eg 'sill') 30 | "intrusive": 'INSERT_INTRUSIVE_TEXT', #*opt text defining an intrusion in the "r1" field (eg 'intrusion') 31 | "volcanic": 'INSERT_VOLCANIC_TEXT', #*opt text defining volcanics in the "ds" field (eg 'volcanic') 32 | "min": 'INSERT_MIN_AGE', #*opt attribute containing minimum unit age 33 | "max": 'INSERT_MAX_AGE', #*opt attribute containing maximum unit age 34 | 35 | #LINEAR FEATURES SHAPEFILE ATTRIBUTES 36 | "f": 'INSERT_STRUCT_TYPE', #attribute containing linear structure type (e.g. fault) 37 | "fault": 'fault', #text defining faults in the "f" field (eg. 'fault') 38 | "fdip": 'INSERT_FAULT_DIP', #*opt attribute containing numeric fault dip value (defaults to fdipnull) 39 | "fdipnull": '0', #Default fault dip value, if 'fdip' field is empty 40 | "fdipdir": 'INSERT_FAULT_DIP_DIRECTION', #*opt attribute containing the fault dip direction (defaults to -999) 41 | "fdipdir_flag": 'num', #*opt specifies whether fdipdir is "num":numeric or other ("alpha") 42 | "fdipest": 'INSERT_DIP_EST_TEXT', #*opt field for text fault dip estimate value (defaults to none) 43 | "fdipest_vals": 'INSERT_DIP_EST_TERMS', #*opt text used to estimate dip in increasing steepness, in "fdipest" field 44 | "n": 'INSERT_FAULT_NAME', #*opt attribute containing the fault name 45 | 46 | #GENERAL IDS 47 | "o": 'INSERT_OBJ_ID', #*opt attribute containing unique object id (used in polygon and lineString shapefiles 48 | "gi": 'INSERT_ORI_ID', #*opt attribute containing unique id of structural points 49 | } 50 | -------------------------------------------------------------------------------- /docs/source/_static/images/csv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Loop3D/map2loop/1af43e4621a837db02252c05ff8cee0d5d8b3dd0/docs/source/_static/images/csv.png -------------------------------------------------------------------------------- /docs/source/_static/images/fault_attributes_table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Loop3D/map2loop/1af43e4621a837db02252c05ff8cee0d5d8b3dd0/docs/source/_static/images/fault_attributes_table.png -------------------------------------------------------------------------------- /docs/source/_static/images/litho_attributes_table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Loop3D/map2loop/1af43e4621a837db02252c05ff8cee0d5d8b3dd0/docs/source/_static/images/litho_attributes_table.png -------------------------------------------------------------------------------- /docs/source/_static/images/ori_attributes_table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Loop3D/map2loop/1af43e4621a837db02252c05ff8cee0d5d8b3dd0/docs/source/_static/images/ori_attributes_table.png -------------------------------------------------------------------------------- /docs/source/_templates/custom-class-template.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. autoclass:: {{ objname }} 6 | :members: 7 | :show-inheritance: 8 | :inherited-members: 9 | 10 | {% block methods %} 11 | .. automethod:: __init__ 12 | 13 | {% if methods %} 14 | .. rubric:: {{ _('Methods') }} 15 | 16 | .. autosummary:: 17 | {% for item in methods %} 18 | ~{{ name }}.{{ item }} 19 | {%- endfor %} 20 | {% endif %} 21 | {% endblock %} 22 | 23 | {% block attributes %} 24 | {% if attributes %} 25 | .. rubric:: {{ _('Attributes') }} 26 | 27 | .. autosummary:: 28 | {% for item in attributes %} 29 | ~{{ name }}.{{ item }} 30 | {%- endfor %} 31 | {% endif %} 32 | {% endblock %} -------------------------------------------------------------------------------- /docs/source/_templates/custom-module-template.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. automodule:: {{ fullname }} 4 | 5 | {% block attributes %} 6 | {% if attributes %} 7 | .. rubric:: Module Attributes 8 | 9 | .. autosummary:: 10 | :toctree: 11 | {% for item in attributes %} 12 | {{ item }} 13 | {%- endfor %} 14 | {% endif %} 15 | {% endblock %} 16 | 17 | {% block functions %} 18 | {% if functions %} 19 | .. rubric:: {{ _('Functions') }} 20 | 21 | .. autosummary:: 22 | :toctree: 23 | :recursive: 24 | {% for item in functions %} 25 | {{ item }} 26 | {%- endfor %} 27 | {% endif %} 28 | {% endblock %} 29 | 30 | {% block classes %} 31 | {% if classes %} 32 | .. rubric:: {{ _('Classes') }} 33 | 34 | .. autosummary:: 35 | :toctree: 36 | :template: custom-class-template.rst 37 | :recursive: 38 | {% for item in classes %} 39 | {{ item }} 40 | {%- endfor %} 41 | {% endif %} 42 | {% endblock %} 43 | 44 | {% block exceptions %} 45 | {% if exceptions %} 46 | .. rubric:: {{ _('Exceptions') }} 47 | 48 | .. autosummary:: 49 | :toctree: 50 | {% for item in exceptions %} 51 | {{ item }} 52 | {%- endfor %} 53 | {% endif %} 54 | {% endblock %} 55 | 56 | {% block modules %} 57 | {% if modules %} 58 | .. rubric:: Modules 59 | 60 | .. autosummary:: 61 | :toctree: 62 | :template: custom-module-template.rst 63 | :recursive: 64 | {% for item in modules %} 65 | {{ item }} 66 | {%- endfor %} 67 | {% endif %} 68 | {% endblock %} 69 | -------------------------------------------------------------------------------- /docs/source/_templates/custom.css: -------------------------------------------------------------------------------- 1 | .toggle .header { 2 | display: block; 3 | clear: both; 4 | } 5 | 6 | .toggle .header:after { 7 | content: " ▶"; 8 | } 9 | 10 | .toggle .header.open:after { 11 | content: " ▼"; 12 | } -------------------------------------------------------------------------------- /docs/source/_templates/page.html: -------------------------------------------------------------------------------- 1 | {% extends "!page.html" %} {% block footer %} 2 | 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | sys.path.insert(0, os.path.abspath("../../")) 17 | 18 | 19 | # -- Project information ----------------------------------------------------- 20 | import map2loop 21 | 22 | project = "map2loop" 23 | copyright = "2024, Loop development team" 24 | author = "Roy Thomson, Mark Jessell, Lachlan Grose, and others" 25 | 26 | # The full version, including alpha/beta/rc tags 27 | release = map2loop.__version__ 28 | 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | autoclass_content = "both" # include both class docstring and __init__ 36 | autodoc_default_flags = [ 37 | # Make sure that any autodoc declarations show the right members 38 | "members", 39 | "inherited-members", 40 | "private-members", 41 | "show-inheritance", 42 | ] 43 | autosummary_generate = True # Make _autosummary files and include them 44 | napoleon_numpy_docstring = True # False # Force consistency, leave only Google 45 | napoleon_use_rtype = False # More legible 46 | autosummary_imported_members = True 47 | autosummary_ignore_module_all = False 48 | # Add any Sphinx extension module names here, as strings. They can be 49 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 50 | # ones. 51 | extensions = [ 52 | # Need the autodoc and autosummary packages to generate our docs. 53 | "sphinx.ext.autodoc", 54 | "sphinx.ext.autosummary", 55 | # The Napoleon extension allows for nicer argument formatting. 56 | "sphinx.ext.napoleon", 57 | # add sphinx gallery 58 | "sphinx_gallery.gen_gallery", 59 | # citations 60 | "myst_parser", 61 | ] 62 | 63 | source_suffix = {".rst": "restructuredtext", ".txt": "markdown", ".md": "markdown"} 64 | 65 | # Add any paths that contain templates here, relative to this directory. 66 | templates_path = ["_templates"] 67 | 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | # This pattern also affects html_static_path and html_extra_path. 72 | exclude_patterns = [] 73 | 74 | 75 | # -- Options for HTML output ------------------------------------------------- 76 | html_theme_options = { 77 | "icon_links": [ 78 | { 79 | "name": "GitHub", 80 | "url": "https://github.com/loop3d/map2loop", 81 | "icon": "fab fa-github-square", 82 | }, 83 | {"name": "Twitter", "url": "https://twitter.com/loop3d", "icon": "fab fa-twitter-square"}, 84 | ], 85 | # "navbar_start": ["navbar-logo", "navbar-version"], 86 | # "use_edit_page_button": True, 87 | "collapse_navigation": True, 88 | "external_links": [{"name": "Loop3d", "url": "https://www.loop3d.org"}], 89 | } 90 | # The theme to use for HTML and HTML Help pages. See the documentation for 91 | # a list of builtin themes. 92 | # 93 | html_theme = "pydata_sphinx_theme" 94 | 95 | # Add any paths that contain custom static files (such as style sheets) here, 96 | # relative to this directory. They are copied after the builtin static files, 97 | # so a file named "default.css" will overwrite the builtin "default.css". 98 | # Add any paths that contain custom static files (such as style sheets) here, 99 | # relative to this directory. They are copied after the builtin static files, 100 | # so a file named "default.css" will overwrite the builtin "default.css". 101 | html_static_path = ["_static"] 102 | autosummary_generate = True 103 | 104 | autosummary_mock_imports = ["LoopStructural.interpolators._cython"] 105 | # Sphinx gallery examples 106 | 107 | 108 | # from LoopStructural.visualisation.sphinx_scraper import Scraper as LoopScraper 109 | from sphinx_gallery.sorting import ExampleTitleSortKey 110 | 111 | sphinx_gallery_conf = { 112 | "examples_dirs": ["../examples/"], 113 | "gallery_dirs": ["_auto_examples/"], # path to where to save gallery generated output 114 | "image_scrapers": ("matplotlib"), 115 | "within_subsection_order": ExampleTitleSortKey, 116 | "reference_url": {"LoopStructural": None}, 117 | } 118 | 119 | # def setup(app): 120 | # app.add_stylesheet('custom.css') 121 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. map2loop documentation master file, created by 2 | sphinx-quickstart on Wed Jan 17 15:48:56 2024. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Map2loop |release| 7 | ==================================== 8 | 9 | Generate 3D geological model inputs from geolocial maps — a high-level implementation and extension of the original map2loop code developed by Prof. Mark Jessell at UWA. To see an example interactive model built with map2loop and LoopStructural, follow this link: 10 | 11 | `Brockman Syncline model, WA `_ 12 | 13 | 14 | 15 | Usage 16 | ------ 17 | 18 | Our notebooks cover use cases in more detail, but here is an example of processing Loop's South Australia remote geospatial data in just 20 lines of Python. 19 | 20 | First, lets import map2loop and define a bounding box. You can use GIS software to find one. Remember what projection your coordinates are in! 21 | 22 | .. code-block:: 23 | 24 | from map2loop.project import Project 25 | from map2loop.m2l_enums import VerboseLevel 26 | 27 | # Note that this region is defined in the EPSG 28354 projection and 28 | # x and y represent easting and northing respectively 29 | bbox_3d = { 30 | 'minx': 250805.1529856466, 31 | 'miny': 6405084.328058686, 32 | 'maxx': 336682.921539395, 33 | 'maxy': 6458336.085975628, 34 | 'base': -3200, 35 | 'top': 1200 36 | } 37 | 38 | 39 | 40 | Then, specify: the state, directory for the output, the bounding box and projection from above - and hit go! That's it. 41 | 42 | .. code-block:: 43 | 44 | proj = Project(use_australian_state_data = "SA", 45 | working_projection = 'EPSG:28354', 46 | bounding_box = bbox_3d, 47 | loop_project_filename = "output.loop3d" 48 | ) 49 | 50 | proj.run_all() 51 | 52 | 53 | This is a minimal example and a small part of Loop. 54 | 55 | Our *documentation and other resources outline how to extend map2loop and port to the LoopStructural modelling engine. We are working to incorporate geophysical tools and best provide visualisation and workflow consolidation in the GUI.* 56 | 57 | *Loop is led by Laurent Ailleres (Monash University) with a team of Work Package leaders from:* 58 | 59 | - *Monash University: Roy Thomson, Lachlan Grose and Robin Armit* 60 | - *University of Western Australia: Mark Jessell, Jeremie Giraud, Mark Lindsay and Guillaume Pirot* 61 | - *Geological Survey of Canada: Boyan Brodaric and Eric de Kemp* 62 | 63 | 64 | 65 | Known Issues and FAQs 66 | ~~~~~~~~~~~~~~~~~~~~~~~ 67 | - Developing with docker on Windows means you won't have GPU passthrough and can’t use a discrete graphics card in the container even if you have one. 68 | - If Jupyter links require a token or password, it may mean port 8888 is already in use. To fix, either make docker map to another port on the host ie -p 8889:8888 or stop any other instances on 8888. 69 | 70 | Links 71 | ~~~~~~ 72 | 73 | `https://loop3d.github.io/ `_ 74 | 75 | `https://github.com/Loop3D/LoopStructural `_ 76 | 77 | 78 | 79 | .. toctree:: 80 | :hidden: 81 | 82 | user_guide/index 83 | _auto_examples/index 84 | CHANGLOG.md 85 | 86 | .. autosummary:: 87 | :caption: API 88 | :toctree: _autosummary 89 | :template: custom-module-template.rst 90 | :recursive: 91 | 92 | map2loop 93 | -------------------------------------------------------------------------------- /docs/source/user_guide/changing_colours.rst: -------------------------------------------------------------------------------- 1 | Changing Colours 2 | ================ 3 | Using a CSV file 4 | ---------------- 5 | The easiest way to set the colour of different units in your model is to create a csv file that contains the lithological unit names in one column and the hex colour code in another, as shown below: 6 | 7 | .. image:: _static/images/csv.png 8 | :width: 500 9 | 10 | Changing colours via your Jupyter notebook 11 | ------------------------------------------ 12 | You can also change unit and fault colours manually in your Jupyter notebook. There are examples of the code you can use to achieve this below. Importantly, if you decide to use this method, you’ll need to call this code after the creation of ‘model’ in your Jupyter notebook. 13 | The stratigraphy data is stored in a python dictionary, so to change the colour of specific elements you need to use the associated key. In the below cases you need to navigate through the nested dictionaries to find the colour value. To view the contents of the stratigraphic column dictionary in your own notebook you can just use the command: 14 | 15 | .. code-block:: python 16 | 17 | model. stratigraphic_column 18 | 19 | To view a dictionary nested within the stratigraphic_column dictionary you will need to specify the key, for example: 20 | 21 | .. code-block:: python 22 | 23 | model.stratigraphic_column['sg'] 24 | 25 | If you run this command, you’ll get an output showing the sg dictionary (which contains all of the rock units) as well as any dictionaries nested within it. 26 | 27 | 28 | Unit Colours 29 | ............. 30 | Following on from the above explanation, to access the unit colour you will need to navigate from the stratigraphic_column dictionary through the dictionaries nested within it. In the example below you’ll navigate to ‘sg’ then ‘unit_name’ then ‘colour’ where you can finally edit the hex colour value. 31 | 32 | Make sure to replace ‘unit_name’ with the name of the unit you want to change and the ‘#f71945’ with the hex colour code you desire. Remember you can check these values by running and inspecting the output of: model. stratigraphic_column 33 | 34 | .. code-block:: python 35 | 36 | model.stratigraphic_column['sg']['unit_name']['colour'] = '#f71945' 37 | 38 | Fault Colours 39 | .............. 40 | The code to change the colour of faults is very similar, where ‘fault_name’ is the name of the fault you’re editing. 41 | 42 | .. code-block:: python 43 | 44 | model.stratigraphic_column['faults']['fault_name’]['colour'] = '#f25d27' 45 | 46 | Please see the examples for further clarification. 47 | 48 | 49 | -------------------------------------------------------------------------------- /docs/source/user_guide/config_file.rst: -------------------------------------------------------------------------------- 1 | Mapping attributes to variables using a JSON file 2 | =================================================== 3 | Once you’ve completed your map in QGIS, you’ll need to map the attributes (with whatever names you’ve given them) to the variable names used in map2loop. You can map the attributes to the variable names used in version 2 or 3 of map2loop, as specified in the tables in the Setting up your Shapefiles section. 4 | An example json file is shown below, using map2loop-2 variable names (also known as Legacy code. If you decide to use map2loop-2 variable names, you will have to set the legacy flag to true in the map2loop calling code later on. If you use map2loop-3 variable names you'll need to set the legacy flag to false. 5 | 6 | Feel free to copy the attached template and fill in the required variables with the attribute names specific to your project. 7 | 8 | Config File Template 9 | --------------------- 10 | The templates below demonstrate how to setup a config file for map2loop. 11 | Explanations of the JSON file elements: 12 | 13 | * The left most 'term' is the map2loop variable name 14 | * The information after the hash on the right is a description of the required field. You can delete this from your own file if you'd like. 15 | * You'll need to change the attribute name in the second quotation to match your shapefile attribute names (e.g. change "INSERT_DIP" to your attribute name). Ensure that the attribute name is between '' or "". 16 | * Any lines with a *opt in the description string, means that they are optional. If you don't want to include them, just leave the attribute field blank (e.g. "g": '', ) 17 | 18 | For more information on the variables and map2loop requirements please see the documentation. 19 | 20 | Variable Names (map2loop-3) Template 21 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 22 | Note, there is an inbuilt converter within map2loop so you can use either of the config files with the most recent version of map2loop. 23 | `This <../_static/HJSON_TEMPLATE.hjson>`_ is an example of the legacy variables. 24 | 25 | .. code-block:: python 26 | 27 | { 28 | #ORIENTATION SHAPEFILE ATTRIBUTES 29 | "orientation_type": "dip direction", #attribute containing dip information 30 | "dipdir_column": "INSERT_DIP_DIRECTION", #attribute containing dip direction information 31 | "dip_column": "INSERT_DIP", #attribute containing dip information 32 | "description_column": "INSERT_STRUCTURE_DESCRIPTION", #*opt attribute containing type of structure (eg. S0, S1) 33 | "bedding_text": "INSERT_BEDDING_TEXT", #*opt text defining bedding measurements in the "sf" field (eg "Bedding" or "S0") 34 | "overturned_column": "INSERT_OVERTURNED", #*opt attribute containing type of foliation 35 | "overturned_text": "INSERT_OVERTURNED_DESCRIPTION", #*opt text defining overturned bedding measurements (eg. 'overturned') 36 | "objectid_column": "INSERT_ID", #*opt attribute containing unique id of structural points 37 | #LITHOLOGY SHAPEFILE ATTRIBUTES 38 | "unitname_column": "INSERT_UNITNAME", #attribute containing stratigraphic unit name (most specific) 39 | "alt_unitname_column": "INSERT_ALTERNATIVE_UNITNAME_CODE", #attribute containing alternative stratigraphic unit name (eg unit code). Can be the same as "unitname_column" 40 | "group_column": "INSERT_GROUP", #*opt attribute containing stratigraphic group 41 | "supergroup_column": "INSERT_SUPERGROUP", #*opt attribute containing stratigraphic supergroup (most coarse classification) 42 | "description_column": "INSERT_DESCRIPTION", #*opt general description field 43 | "minage_column": "INSERT_MIN_AGE", #*opt attribute containing minimum unit age 44 | "maxage_column": "INSERT_MAX_AGE", #*opt attribute containing maximum unit age 45 | "rocktype_column": "INSERT_ROCKTYPE1", #*opt attribute containing extra lithology information (can indicate intrusions) 46 | "alt_rocktype_column": "INSERT_ALTERNATIVE_ROCKTYPE", #*opt attribute containing secondary rocktype information 47 | "sill_text": "INSERT_SILL_TEXT", #*opt text defining a sill in the "ds" field (eg 'sill') 48 | "intrusive_text": "INSERT_INTRUSIVE_TEXT", #*opt text defining an intrusion in the "r1" field (eg 'intrusion') 49 | "volcanic_text": "INSERT_VOLCANIC_TEXT", #*opt text defining volcanics in the "ds" field (eg 'volcanic') 50 | "objectid_column": "INSERT_ID", #*opt attribute containing unique object id (used in polygon and lineString shapefiles) 51 | "ignore_codes": ["INSERT_COVER_UNIT_CODES_TO_IGNORE"], #*opt attribute containing codes to ignore 52 | #LINEAR FEATURES SHAPEFILE ATTRIBUTES 53 | "structtype_column": "INSERT_FEATURE_TYPE", #attribute containing linear structure type (e.g. fault) 54 | "fault_text": "INSERT_FAULT_TEXT", #text defining faults in the "f" field (eg. 'fault') 55 | "dip_null_value": "-999", #Default fault dip value, if 'fdip' field is empty 56 | "dipdir_flag": "num", #*opt specifies whether fdipdir is "num":numeric or other ("alpha") 57 | "dipdir_column": "INSERT_DIP_DIRECTION", #*opt attribute containing the fault dip direction (defaults to -999) 58 | "dip_column": "INSERT_DIP", #*opt attribute containing numeric fault dip value (defaults to fdipnull) 59 | "orientation_type": "dip direction", #Set the measurement convention used (either 'strike' or 'dip direction') 60 | "dipestimate_column": "INSERT_DIP_ESTIMATE", #*opt field for text fault dip estimate value (defaults to none) 61 | "dipestimate_text": "'NORTH_EAST','NORTH',,'NOT ACCESSED'", #*opt text used to estimate dip in increasing steepness, in "fdipest" field 62 | "name_column": "INSERT_FAULT_NAME", #*opt attribute containing the fault name 63 | "objectid_column": "INSERT_ID", #*opt attribute containing unique object id (used in polygon and lineString shapefiles) 64 | #FOLD FEATURES SHAPEFILE ATTRIBUTES 65 | "structtype_column": "INSERT_FEATURE_TYPE", #attribute containing linear structure type (e.g. fault) 66 | "fold_text": "INSERT_FOLD_TEXT", #text defining folds in the "f" field (eg. 'fold') 67 | "description_column": "INSERT_FOLD_DESCRIPTION", #*opt attribute containing type of fold 68 | "synform_text": "INSERT_SYNCLINE_TEXT", #*opt text defining synclines in the "f" field (eg. 'syncline') 69 | "foldname_column": "INSERT_FOLD_NAME", #*opt attribute containing the fold name 70 | "objectid_column": "INSERT_ID", #*opt attribute containing unique object id (used in polygon and lineString shapefiles) 71 | } 72 | 73 | The following is an example filled out of a JSON config file: 74 | 75 | .. code-block:: python 76 | 77 | { 78 | "structure" : { 79 | "orientation_type": "strike", 80 | "dipdir_column": "strike", 81 | "dip_column": "dip", 82 | "description_column": "feature", 83 | "bedding_text": "Bed", 84 | "overturned_column": "structypei", 85 | "overturned_text": "BEOI", 86 | "objectid_column": "geopnt_id", 87 | }, 88 | "geology" : { 89 | "unitname_column": "unitname", 90 | "alt_unitname_column": "code", 91 | "group_column": "group_", 92 | "supergroup_column": "supersuite", 93 | "description_column": "descriptn", 94 | "minage_column": "min_age_ma", 95 | "maxage_column": "max_age_ma", 96 | "rocktype_column": "rocktype1", 97 | "alt_rocktype_column": "rocktype2", 98 | "sill_text": "is a sill", 99 | "intrusive_text": "intrusive", 100 | "volcanic_text": "volcanic", 101 | "objectid_column": "objectid", 102 | "ignore_codes": ["cover"], 103 | }, 104 | "fault" : { 105 | "structtype_column": "feature", 106 | "fault_text": "Fault", 107 | "dip_null_value": "0", 108 | "dipdir_flag": "num", 109 | "dipdir_column": "dip_dir", 110 | "dip_column": "dip", 111 | "orientation_type": "dip direction", 112 | "dipestimate_column": "dip_est", 113 | "dipestimate_text": "gentle,moderate,steep", 114 | "name_column": "name", 115 | "objectid_column": "objectid", 116 | }, 117 | "fold" : { 118 | "structtype_column": "feature", 119 | "fold_text": "Fold axial trace", 120 | "description_column": "type", 121 | "synform_text": "syncline", 122 | "foldname_column": "name", 123 | "objectid_column": "objectid", 124 | } 125 | } 126 | 127 | map2loop-3 variable names JSON File Template 128 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 129 | This is a template with the most up-to date variable names. -------------------------------------------------------------------------------- /docs/source/user_guide/explanation.rst: -------------------------------------------------------------------------------- 1 | What map2loop does 2 | ================== 3 | -------------------------------------------------------------------------------- /docs/source/user_guide/exporting.rst: -------------------------------------------------------------------------------- 1 | Exporting 2 | ========== 3 | 4 | Geological map 5 | -------------- -------------------------------------------------------------------------------- /docs/source/user_guide/fault_offset.rst: -------------------------------------------------------------------------------- 1 | Fault offset calculation 2 | ------------------------ 3 | 4 | -------------------------------------------------------------------------------- /docs/source/user_guide/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | map2loop is a python library that improves the ease and accessibility of 3D modelling with Loop. It enables you to create 3D geological models from digital maps produced in programs such as GIS. map2loop's role within the Loop3D ecosystem is to automatically convert digital map data into a useable format. The generated ‘.loop3d’ output file can then be transformed into a 3D geological model using the LoopStructural library. 4 | 5 | map2loop Requirements 6 | ...................... 7 | In order to run map2loop you will need the following input files: 8 | 9 | #. Polygon shapefile containing your lithologies 10 | #. LineString shapefile containing your linear features (e.g. faults) 11 | #. Point data shapefile containing orientation data 12 | #. hjson config file, used to map the attribute names used in your shapefiles to map2loop's variables 13 | 14 | Additional useful inputs: 15 | 16 | * DEM or DTM file for your map region 17 | * Optional CSV file specifying unit colours 18 | 19 | If you need help setting up these files please refer to the relevant map2loop user pages or to the examples which provide a step-by-step guide. 20 | -------------------------------------------------------------------------------- /docs/source/user_guide/index.rst: -------------------------------------------------------------------------------- 1 | User Guide 2 | =========== 3 | 4 | .. toctree:: 5 | :caption: User Guide 6 | 7 | getting_started 8 | installation 9 | geomodelling 10 | config_file 11 | setup_jupyter 12 | changing_colours 13 | stratigraphic_thickness 14 | stratigraphic_order 15 | fault_offset 16 | m2l_code_template 17 | exporting 18 | -------------------------------------------------------------------------------- /docs/source/user_guide/installation.rst: -------------------------------------------------------------------------------- 1 | Installing map2loop 2 | =================== 3 | 4 | Install 5 | ------------- 6 | 7 | You will need some flavour of conda (a python package manager, `see here `_), as well as Python ≥ 3.8 8 | 9 | In addition, map2loop installation may run smoother if ```conda-forge``` is added to the channels. 10 | To check for that, run: 11 | 12 | .. code-block:: 13 | 14 | conda config --show channels 15 | 16 | 17 | if conda-forge is not in the output, the channel can be added with: 18 | 19 | .. code-block:: 20 | 21 | conda config --add channels conda-forge 22 | 23 | 24 | 25 | Express install: 26 | ~~~~~~~~~~~~~~~~ 27 | 28 | To just install map2loop run the following command 29 | 30 | .. code-block:: 31 | 32 | conda install -c conda-forge -c loop3d map2loop -y 33 | 34 | 35 | Development 36 | ~~~~~~~~~~~~ 37 | 38 | If you want to tinker yourself/contribute, clone the source code with 39 | 40 | .. code-block:: 41 | 42 | git clone https://github.com/Loop3D/map2loop.git 43 | 44 | Or get the source + example notebooks with 45 | 46 | .. code-block:: 47 | 48 | git clone https://github.com/Loop3D/map2loop.git 49 | git clone https://github.com/Loop3D/map2loop-3-notebooks 50 | 51 | 52 | Navigate into the map2loop folder, and issue the following to install map2loop and its dependencies. 53 | 54 | .. code-block:: 55 | 56 | conda install -c conda-forge -c loop3d --file dependencies.txt 57 | 58 | To install map2loop in an editable environment run the following command: 59 | 60 | .. code-block:: 61 | 62 | pip install -e . 63 | 64 | 65 | Building with Docker 66 | --------------------- 67 | 68 | Fair warning, we recommend conda to almost everyone. With great software development power comes great environment setup inconvenience. 69 | You'll need to download and install the `docker containerisation software `_, and the docker and docker-compose CLI. 70 | 71 | Development with docker 72 | ~~~~~~~~~~~~~~~~~~~~~~~ 73 | 74 | 1. Clone this repo and navigate inside as per above 75 | 2. Run the following and click on the Jupyter server forwarded link to access and edit the notebooks 76 | 77 | .. code-block:: 78 | 79 | docker-compose up --build 80 | 81 | 82 | 3. To hop into a bash shell in a running container, open a terminal and issue 83 | 84 | .. code-block:: 85 | 86 | docker ps 87 | 88 | 89 | Find the container name or ID and then run 90 | 91 | .. code-block:: 92 | 93 | docker exec -it bash 94 | # Probably -> docker exec -it map2loop_dev_1 bash -------------------------------------------------------------------------------- /docs/source/user_guide/setup_jupyter.rst: -------------------------------------------------------------------------------- 1 | Setting up your Jupyter Notebook 2 | ================================ 3 | Now that you’ve created all of the required inputs for map2loop, you can set up a Jupyter notebook to run the code. In order to do this, you’ll need to create an Anaconda (or similar) environment with all the required dependencies. If you need help setting up your virtual environment, please refer to the Installation page. 4 | 5 | Map2loop calling code: 6 | --------------------- 7 | In order to ‘run’ map2loop, you need to pass the program several pieces of information, including: 8 | * Shape file paths 9 | * Csv colour file path (optional) 10 | * DEM / DTM path 11 | * hjson config file that maps the attributes from your QGIS project onto the map2loop variables. 12 | 13 | An example code template for processing your QGIS map data using map2loop is provided under the Examples section on the map2loop website. The output from this code is a .loop3d file which can then be passed to LoopStructural to create your 3d model. 14 | 15 | Depending on the sorter algorithm you choose to use with map2loop (as described in the template), you’ll receive different outputs. If you use the take best sorter algorithm, you can expect an output similar to: 16 | 17 | **Best sorter SorterAgeBased calculated contact length of 29632.67023438857** 18 | 19 | 20 | Inspecting the .loop3d output file 21 | ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, 22 | You can inspect your map2loop output file using the ncdump command via the terminal (e.g. Anaconda Prompt). In order to use this tool, you’ll need to ensure that you have netcdf4 installed in your environment. You can check this by using the command: ''**conda list**'' within your Anaconda environment. This will list all of the installed applications and dependencies. You’ll need to scroll through these to see if netcdf4 is installed. 23 | If you are able to use this tool, you can run it with the following command, where local_source.loop3d is the name of the output file that you named in the map2loop calling code (see the explanation in the Map2Loop template notebook). 24 | **ncdump local_source.loop3d** 25 | 26 | Note: this command will only work if you are in the correct working directory (i.e wherever you saved the loop3d file). You can change directories using the **cd** command, followed by the directory path that you’d like to change to. 27 | The expected output of the ncdump command, is a series of text representing the tabulated data generated by map2loop. It will be output directly onto the terminal console. 28 | 29 | LoopStructural calling code 30 | --------------------------- 31 | Once you have run map2loop successfully, you can pass the produced .loop3d file to apt LoopStructural calling code, to produce a 3D model. 32 | There is an example of this code on the map2loop documentation page. It is also explained in the Loop Structural Calling Code Template. 33 | 34 | 35 | -------------------------------------------------------------------------------- /docs/source/user_guide/stratigraphic_order.rst: -------------------------------------------------------------------------------- 1 | Identifying stratigraphic order 2 | =============================== 3 | 4 | Calculating stratigraphic order in map2loop can be difficult depending on the 5 | stratigraphy of your region of interest and how much data has been collected in 6 | the field. Because of this map2loop has been written so that a python-capable user 7 | can create their own stratigraphic column sorter and use it within the map2loop process. 8 | 9 | Below is the structure of a stratigraphic column sorter and an example of how to implement 10 | your own and use it within map2loop. 11 | 12 | In order to make a plugin sorter map2loop has a template that is extendable. The whole 13 | template is shown below: 14 | 15 | .. code-block:: 16 | 17 | class Sorter(ABC): 18 | """ 19 | Base Class of Sorter used to force structure of Sorter 20 | 21 | Args: 22 | ABC (ABC): Derived from Abstract Base Class 23 | """ 24 | def __init__(self): 25 | """ 26 | Initialiser of for Sorter 27 | """ 28 | self.sorter_label = "SorterBaseClass" 29 | 30 | def type(self): 31 | """ 32 | Getter for subclass type label 33 | 34 | Returns: 35 | str: Name of subclass 36 | """ 37 | return self.sorter_label 38 | 39 | @beartype.beartype 40 | @abstractmethod 41 | def sort(self, units: pandas.DataFrame, unit_relationships: pandas.DataFrame, stratigraphic_order_hint: list, contacts: pandas.DataFrame, map_data: MapData) -> list: 42 | """ 43 | Execute sorter method (abstract method) 44 | 45 | Args: 46 | units (pandas.DataFrame): the data frame to sort (columns must contain ["layerId", "name", "minAge", "maxAge", "group"]) 47 | units_relationships (pandas.DataFrame): the relationships between units (columns must contain ["Index1", "Unitname1", "Index2", "Unitname2"]) 48 | stratigraphic_order_hint (list): a list of unit names to be used as a hint to sorting the units 49 | contacts (geopandas.GeoDataFrame): unit contacts with length of the contacts in metres 50 | map_data (map2loop.MapData): a catchall so that access to all map data is available 51 | 52 | Returns: 53 | list: sorted list of unit names 54 | """ 55 | pass 56 | 57 | Using this abstract base class a new class can be created by taking that base class and 58 | replacing the __init__ and sort functions, the simplest example is shown below: 59 | 60 | .. code-block:: 61 | from map2loop.sorter import Sorter 62 | from map2loop.mapdata import MapData 63 | import pandas 64 | import geopandas 65 | 66 | class mySorter(Sorter): 67 | def __init__(self): 68 | self.sorter_label = "mySorter" 69 | 70 | def sort(self, 71 | units: pandas.DataFrame, 72 | unit_relationships: pandas.DataFrame, 73 | stratigraphic_order_hint: list, 74 | contacts: geopandas.GeoDataFrame, 75 | map_data: MapData 76 | ) -> list: 77 | unitnames = sorted(units['name']) 78 | return unitnames 79 | 80 | This example will sort the units into alphabetical order based on name and return the 81 | stratigraphic column order in a list of unit names. 82 | 83 | To use this new sorter in the map2loop project one final line needs to 84 | be added after the Project has been initialised: 85 | 86 | .. code-block:: 87 | 88 | proj = map2loop.Project( ... ) 89 | 90 | proj.set_sorter(mySorter()) 91 | 92 | Notes 93 | ----- 94 | You need to set the sorter as an instance of mySorter (with the ()s) rather than the definition. 95 | 96 | The sorter takes the existing units dataframe and must return a list containing all the 97 | unitnames present in that dataframe. If some are added or missing map2loop with raise an 98 | exception. Also while you have control of this dataframe you have the power to add or 99 | remove units, and change features of any unit but if you do this there is no longer any 100 | guarantee that map2loop will still process your maps or even finish. 101 | 102 | As the base class Sorter contains abstract methods and is parsed through beartype the 103 | structure of the sort function must remain the same. If there is reason to access more 104 | map2loop data that isn't in the map_data structure raise an issue with in the map2loop 105 | github repo and we can address that. 106 | 107 | Parameters 108 | ---------- 109 | As seen in the template and the sort abstract method you have access to other data 110 | from within the map2loop process. Below is a brief description of each and a potential 111 | use for them in your thickness calculator: 112 | 113 | units - this is the data frame that contains the units and fields such as group, supergroup and 114 | min/max ages. If the age data is present it can be useful in sorting the units. Also 115 | group and supergroup information could be used to ensure that all units within the 116 | same group/supergroup are contiguous. 117 | 118 | unit_relationships - this data frame contains a list of adjacent units within the shapefile. 119 | The format is ['Index1', 'Unitname1', 'Index2', 'Unitname2'] and each row is a single 120 | adjacency that was found. Note that some of these contacts might have been across a fault 121 | so take that into account when using this data. 122 | 123 | stratigraphic_order_hint - this is a first pass attempt at the stratigraphic column 124 | calculated by map2model which looks at unit adjacency in the shapefile. 125 | 126 | contacts - this geometric data frame contains linear data of where adjacent 127 | units are and the length of that contact. Using this data you might prioritise 128 | longer contacts as more likely to be adjacent in the stratigraphic column. 129 | 130 | map_data - this catch-all gives you complete access to the shapefiles used in map2loop. 131 | If you need access to the structural orientation data you can use 132 | map_data.get_map_data(Datatype.STRUCTURE) or if you want the geology map 133 | map_data.get_map_data(Datatype.GEOLOGY) and you have access to those shapefiles. Note 134 | that changing information or using setter function from map_data is likely to cause 135 | problems within the map2loop workflow. 136 | 137 | -------------------------------------------------------------------------------- /docs/source/user_guide/stratigraphic_thickness.rst: -------------------------------------------------------------------------------- 1 | Calculating stratigraphic thicknesses 2 | ===================================== 3 | 4 | Calculating unit thicknesses in map2loop is both an important and a difficult 5 | task. There is no 'best' way to determine thicknesses because it depends on the 6 | stratigraphy of your region of interest and how much data has been collected in 7 | the field. Because of this map2loop has been written so that a python-capable user 8 | can create their own thickness calculator and use it within the map2loop process. 9 | 10 | Below is the structure of a thickness calculator and an example of how to implement 11 | your own and use it within map2loop. 12 | 13 | In order to make a plugin thickness calculator map2loop has a template that is 14 | extendable. The whole template is shown below: 15 | 16 | .. code-block:: 17 | 18 | class ThicknessCalculator(ABC): 19 | """ 20 | Base Class of Thickness Calculator used to force structure of ThicknessCalculator 21 | 22 | Args: 23 | ABC (ABC): Derived from Abstract Base Class 24 | """ 25 | 26 | def __init__(self): 27 | """ 28 | Initialiser of for ThicknessCalculator 29 | """ 30 | self.thickness_calculator_label = "ThicknessCalculatorBaseClass" 31 | 32 | def type(self): 33 | """ 34 | Getter for subclass type label 35 | 36 | Returns: 37 | str: Name of subclass 38 | """ 39 | return self.thickness_calculator_label 40 | 41 | @beartype.beartype 42 | @abstractmethod 43 | def compute( 44 | self, 45 | units: pandas.DataFrame, 46 | stratigraphic_order: list, 47 | basal_contacts: geopandas.GeoDataFrame, 48 | map_data: MapData, 49 | ) -> pandas.DataFrame: 50 | """ 51 | Execute thickness calculator method (abstract method) 52 | 53 | Args: 54 | units (pandas.DataFrame): the data frame of units to add thicknesses to 55 | stratigraphic_order (list): a list of unit names sorted from youngest to oldest 56 | basal_contacts (geopandas.GeoDataFrame): basal contact geo data with locations and unit names of the contacts (columns must contain ["ID","basal_unit","type","geometry"]) 57 | map_data (map2loop.MapData): a catchall so that access to all map data is available 58 | 59 | Returns: 60 | pandas.DataFrame: units dataframe with added thickness column for calculated thickness values 61 | """ 62 | pass 63 | 64 | Using this abstract base class a new class can be created by taking that base class and 65 | replacing the __init__ and compute functions, the simplest example is shown below: 66 | 67 | .. code-block:: 68 | from map2loop.thickness_calculator import ThicknessCalculator 69 | from map2loop.mapdata import MapData 70 | import pandas 71 | import geopandas 72 | 73 | class myThicknessCalculator(ThicknessCalculator): 74 | def __init__(self, thickness=100): 75 | self.thickness_calculator_label = "myThicknessCalculator" 76 | self.default_thickness = thickness 77 | 78 | def compute( 79 | self, 80 | units: pandas.DataFrame, 81 | stratigraphic_order: list, 82 | basal_contacts: pandas.DataFrame, 83 | map_data: MapData, 84 | ) -> pandas.DataFrame: 85 | output_units = units.copy() 86 | output_units['thickness'] = default_thickness 87 | return output_units 88 | 89 | This example will set all unit thicknesses to 100m. 90 | 91 | To use this new thickness calculator in the map2loop project one final line needs to 92 | be added after the Project has been initialised: 93 | 94 | .. code-block:: 95 | 96 | proj = map2loop.Project( ... ) 97 | 98 | proj.set_thickness_calculator(myThicknessCalculator(50)) 99 | 100 | Notes 101 | ----- 102 | You need to set the thickness calculator as an instance of myThicknessCalculator 103 | (with the ()s) rather than the definition. If you want to set the default thickness using 104 | this class you can create the class with the thickness parameter as above 105 | (myThicknessCalculator(50)). 106 | 107 | The thickness calculator takes the existing units dataframe, changes the values in the 108 | thickness column and then returns the modified dataframe. While you have control of 109 | this dataframe you have the power to add or remove units, and change features 110 | of any unit but if you do this there is no longer any guarantee that map2loop will still 111 | process your maps or even finish. 112 | 113 | Parameters 114 | ---------- 115 | As seen in the template and the compute abstract method you have access to other data 116 | from within the map2loop process. Below is a brief description of each and a potential 117 | use for them in your thickness calculator: 118 | 119 | units - while this is the data frame that you need to return it also contains fields 120 | such as group, supergroup and min/max ages. If you have coarser information about the 121 | thickness of a group this information could be used to ensure that the sum of the unit 122 | thicknesses in your region that are within the same group matches your information. 123 | 124 | stratigraphic_order - this is likely the most useful parameter as it tells you which 125 | units are adjacent. In combination with the basal_contacts parameter apparent thicknesses 126 | can be calculated. 127 | 128 | basal_contacts - this geometric data frame contains linear data of where adjacent 129 | contacts are. By comparing the contacts on both sides of a unit you can calculated the 130 | apparent thickness of a unit 131 | 132 | map_data - this catch-all gives you complete access to the shapefiles used in map2loop. 133 | If you need access to the structural orientation data you can use 134 | map_data.get_map_data(Datatype.STRUCTURE) and you have access to the shapefile. Note 135 | that changing information or using setter function from map_data is likely to cause 136 | problems within the map2loop workflow. 137 | -------------------------------------------------------------------------------- /map2loop/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | loggers = {} 4 | ch = logging.StreamHandler() 5 | formatter = logging.Formatter("%(levelname)s: %(asctime)s: %(filename)s:%(lineno)d -- %(message)s") 6 | ch.setFormatter(formatter) 7 | ch.setLevel(logging.WARNING) 8 | from .project import Project 9 | from .version import __version__ 10 | 11 | import warnings # TODO: convert warnings to logging 12 | from packaging import ( 13 | version as pkg_version, 14 | ) # used instead of importlib.version because adheres to PEP 440 using pkg_version.parse 15 | import pathlib 16 | import re 17 | from importlib.metadata import version as get_installed_version, PackageNotFoundError 18 | 19 | 20 | class DependencyChecker: 21 | ''' 22 | A class to check installation and version compatibility of each package in dependencies.txt 23 | 24 | Attributes: 25 | package_name (str): Name of the package 26 | dependency_file (str): path to dependencies.txt 27 | required_version (str or None): required version of the package as in dependencies.txt 28 | installed_version (str or None): installed version of the package in the current environment 29 | ''' 30 | 31 | def __init__(self, package_name, dependency_file="dependencies.txt"): 32 | self.package_name = package_name 33 | self.dependency_file = pathlib.Path(__file__).parent / dependency_file 34 | self.required_version = self.get_required_version() 35 | self.installed_version = self.get_installed_version() 36 | 37 | def get_required_version(self): 38 | ''' 39 | Get the required package version for each package from dependencies.txt; 40 | 41 | Returns: 42 | str or None: The required version of the package (if specified), otherwise None. 43 | ''' 44 | try: 45 | with self.dependency_file.open("r") as file: 46 | for line in file: 47 | if line.startswith(f"{self.package_name}=="): 48 | match = re.match(rf"^{self.package_name}==([\d\.]+)", line.strip()) 49 | if match: 50 | return match.group(1) 51 | elif line.strip() == self.package_name: 52 | return None 53 | print(f"{self.package_name} version not found in {self.dependency_file}.") 54 | except FileNotFoundError: 55 | warnings.warn( 56 | f"{self.dependency_file} not found. Unable to check {self.package_name} version compatibility.", 57 | UserWarning, 58 | ) 59 | return None 60 | 61 | def get_installed_version(self): 62 | ''' 63 | Get the installed version of the package. 64 | 65 | Returns: 66 | str: The installed version of the package. 67 | ''' 68 | try: 69 | # Use importlib.metadata to get the installed version of the package 70 | return get_installed_version(self.package_name) 71 | except PackageNotFoundError: 72 | raise ImportError( 73 | f"{self.package_name} is not installed. Please install {self.package_name}." 74 | ) 75 | 76 | def check_version(self): 77 | ''' 78 | Checks if the installed version of the package matches the required version in the dependencies.txt file. 79 | ''' 80 | if self.required_version is None: 81 | if self.installed_version is None: 82 | raise ImportError( 83 | f"{self.package_name} is not installed. Please install {self.package_name} before using map2loop." 84 | ) 85 | else: 86 | if self.installed_version is None or pkg_version.parse( 87 | self.installed_version 88 | ) != pkg_version.parse(self.required_version): 89 | raise ImportError( 90 | f"Installed version of {self.package_name}=={self.installed_version} does not match required version=={self.required_version}. " 91 | f"Please install the correct version of {self.package_name}." 92 | ) 93 | 94 | 95 | def check_all_dependencies(dependency_file="dependencies.txt"): 96 | dependencies_path = pathlib.Path(__file__).parent / dependency_file 97 | try: 98 | with dependencies_path.open("r") as file: 99 | for line in file: 100 | line = line.strip() 101 | if line.startswith("gdal"): 102 | continue 103 | if line: 104 | if "==" in line: 105 | package_name, _ = line.split("==") 106 | else: 107 | package_name = line 108 | 109 | checker = DependencyChecker(package_name, dependency_file=dependency_file) 110 | checker.check_version() 111 | 112 | except FileNotFoundError: 113 | warnings.warn( 114 | f"{dependency_file} not found. No dependencies checked for map2loop.", 115 | UserWarning 116 | ) 117 | 118 | 119 | # Run check for all dependencies listed in dependencies.txt 120 | check_all_dependencies() 121 | -------------------------------------------------------------------------------- /map2loop/_datasets/clut_files/SA_clut.csv: -------------------------------------------------------------------------------- 1 | UNITNAME,code,colour 2 | test_all,PolyStyle00,#e6ffcc 3 | Unnamed_GIS_Unit___see_description,PolyStyle00,#e6ffcc 4 | Birksgate_Complex,PolyStyle01,#66b3b3 5 | Pitjantjatjara_Supersuite,PolyStyle07,#ff4d4d 6 | Manuka_Subgroup,PolyStyle022,#b3ffe6 7 | Eyre_Formation,PolyStyle043,#ffe600 8 | Bulldog_Shale,PolyStyle051,#ccff99 9 | Alcurra_Dolerite,PolyStyle054,#00ff00 10 | Amata_Dolerite,PolyStyle054,#00ff00 11 | Oodnadatta_Formation,PolyStyle059,#b3ff4d 12 | Coorikiana_Sandstone,PolyStyle059,#b3ff4d 13 | Algebuckina_Sandstone,PolyStyle076,#b3ffb3 14 | Polda_Formation,PolyStyle076,#b3ffb3 15 | Giles_Complex,PolyStyle095,#9900ff 16 | Waitoona_beds,PolyStyle0764,#b3ffff 17 | Radium_Ridge_Breccias,PolyStyle0764,#b3ffff 18 | Wataru_Gneiss,PolyStyle0767,#cc0000 19 | Dutton_Suite,PolyStyle0767,#cc0000 20 | Doonbara_Formation,PolyStyle060,#ffe6e6 21 | Yerelina_Subgroup,PolyStyle060,#ffe6e6 22 | Koolunga_Gravel,PolyStyle060,#ffe6e6 23 | Punkerri_Sandstone,PolyStyle0774,#ffe6cc 24 | Garford_Formation,PolyStyle0774,#ffe6cc 25 | Pound_Subgroup,PolyStyle0774,#ffe6cc 26 | Trainor_Hill_Sandstone,PolyStyle0875,#ff4dff 27 | Pindyin_Sandstone,PolyStyle0878,#b34d66 28 | Callanna_Group,PolyStyle0878,#b34d66 29 | Mimili_Formation,PolyStyle0916,#4d9999 30 | Munda_Group,PolyStyle0919,#990080 31 | Chambers_Bluff_Tillite,PolyStyle0952,#cc9980 32 | Murnaroo_Formation,PolyStyle0963,#ffe699 33 | Simmens_Quartzite_Member,PolyStyle0963,#ffe699 34 | ABC_Range_Quartzite,PolyStyle0963,#ffe699 35 | Table_Hill_Volcanics,PolyStyle01005,#ff99e6 36 | Bollaparudda_Subgroup,PolyStyle01005,#ff99e6 37 | Wright_Hill_beds,PolyStyle01007,#cc9999 38 | Wallaroo_Group,PolyStyle01007,#cc9999 39 | Wantapella_Volcanics,PolyStyle01328,#66ff00 40 | Varavaranha_Formation,PolyStyle01328,#66ff00 41 | Wirrildar_beds,PolyStyle0874,#ff6699 42 | Wilpena_Group,PolyStyle01690,#ffe6b3 43 | Rodda_beds,PolyStyle01690,#ffe6b3 44 | Peake_Metamorphics,PolyStyle01866,#ccccb3 45 | Wirriecurrie_Granite,PolyStyle02046,#ff6600 46 | Burra_Group,PolyStyle02352,#b39966 47 | Emeroo_Subgroup,PolyStyle02361,#994d4d 48 | Yudnamutana_Subgroup,PolyStyle03325,#b39999 49 | Nullarbor_Limestone,PolyStyle03642,#ff994d 50 | Rowland_Flat_Sand,PolyStyle03642,#ff994d 51 | Umberatana_Group,PolyStyle03661,#e6ccb3 52 | Mount_Woods_Complex,PolyStyle03685,#999900 53 | Warrow_Quartzite,PolyStyle03685,#999900 54 | Hutchison_Group,PolyStyle03685,#999900 55 | Barossa_Complex,PolyStyle03685,#999900 56 | Mulgathing_Complex,PolyStyle03807,#804d99 57 | Glenloth_Granite,PolyStyle03807,#804d99 58 | Hiltaba_Suite,PolyStyle02394,#ff0000 59 | Bimbowrie_Suite,PolyStyle02394,#ff0000 60 | Crocker_Well_Suite,PolyStyle02394,#ff0000 61 | Cultana_Subsuite,PolyStyle02394,#ff0000 62 | Billeroo_Intrusive_Complex,PolyStyle02394,#ff0000 63 | Eba_Formation,PolyStyle03869,#e6994d 64 | Ooldea_Sand,PolyStyle0556,#ffe680 65 | Barton_Sand,PolyStyle0556,#ffe680 66 | Loxton_Sand,PolyStyle0556,#ffe680 67 | Norwest_Bend_Formation,PolyStyle0556,#ffe680 68 | Hallett_Cove_Sandstone,PolyStyle0556,#ffe680 69 | Wilgena_Hill_Jaspilite,PolyStyle03876,#00ffff 70 | Coulthard_Suite,PolyStyle04592,#ff8000 71 | Peter_Pan_Supersuite,PolyStyle04592,#ff8000 72 | Willawortina_Formation,PolyStyle0188,#ffff00 73 | Tarcoola_Formation,PolyStyle04771,#cccccc 74 | Mobella_Tonalite,PolyStyle04924,#ff4d80 75 | Labyrinth_Formation,PolyStyle04991,#4d0000 76 | Mentor_Formation,PolyStyle05149,#e6b380 77 | Corunna_Conglomerate,PolyStyle05149,#e6b380 78 | Blue_Range_beds,PolyStyle05149,#e6b380 79 | Leigh_Creek_Coal_Measures,PolyStyle05293,#4dffcc 80 | Pepegoona_Porphyry,PolyStyle05355,#9966ff 81 | Wisanger_Basalt,PolyStyle05355,#9966ff 82 | Moolawatana_Suite,PolyStyle05414,#e64dff 83 | Petermorra_Volcanics,PolyStyle05440,#ccffff 84 | Saint_Kilda_Formation,PolyStyle05531,#cccc99 85 | Pandurra_Formation,PolyStyle05583,#ccb300 86 | Gairdner_Dolerite,PolyStyle06020,#00cc66 87 | Engenina_Monzogranite,PolyStyle06079,#ff9999 88 | St_Peter_Suite,PolyStyle06079,#ff9999 89 | Tunkillia_Suite,PolyStyle06198,#ff8099 90 | Basso_Suite,PolyStyle06225,#ff4d00 91 | Donington_Suite,PolyStyle06225,#ff4d00 92 | Nancatee_Granite,PolyStyle06225,#ff4d00 93 | Munjeela_Suite,PolyStyle06226,#800000 94 | Sleaford_Complex,PolyStyle06463,#664d99 95 | Muckanippie_Suite,PolyStyle06478,#00ffb3 96 | Bumbumbie_Suite,PolyStyle06538,#0099b3 97 | Bridgewater_Formation,PolyStyle038,#ffff4d 98 | Glanville_Formation,PolyStyle038,#ffff4d 99 | Lady_Louise_Suite,PolyStyle06703,#00804d 100 | Coolanie_Gneiss,PolyStyle06871,#0000b3 101 | St_Francis_Suite,PolyStyle06924,#ff6666 102 | Cooyerdoo_Granite,PolyStyle07130,#4d0066 103 | Price_Metasediments,PolyStyle07363,#80ff00 104 | Normanville_Group,PolyStyle07365,#b3ccff 105 | Willyama_Supergroup,PolyStyle07417,#cccc00 106 | Silver_City_Suite,PolyStyle07430,#ff8066 107 | Moola_Suite,PolyStyle07430,#ff8066 108 | Eucarro_Rhyolite,PolyStyle07643,#4de6e6 109 | Pinbong_Suite,PolyStyle07914,#ffb300 110 | _,PolyStyle07983,#e6ffff 111 | Spilsby_Suite,PolyStyle08147,#ff00cc 112 | Wertigo_Granite,PolyStyle08197,#e66666 113 | Boucaut_Volcanics,PolyStyle08245,#66b3ff 114 | Miltalie_Gneiss,PolyStyle08248,#ff4db3 115 | Corny_Point_Paragneiss,PolyStyle08248,#ff4db3 116 | Gawler_Range_Volcanics,PolyStyle08251,#6666e6 117 | Tip_Top_Granite,PolyStyle08379,#cc4d80 118 | Coomandook_Formation,PolyStyle05936,#ffffe6 119 | Kangaroo_Island_Group,PolyStyle08947,#b399cc 120 | Moody_Suite,PolyStyle05511,#e69999 121 | Nuyts_Volcanics,PolyStyle09386,#b34db3 122 | Keynes_Subgroup,PolyStyle09419,#ffcce6 123 | Kanmantoo_Group,PolyStyle09419,#ffcce6 124 | Padthaway_Formation,PolyStyle09439,#e6ffb3 125 | Bosanquet_Formation,PolyStyle09625,#ff99ff 126 | Truro_Volcanics,PolyStyle09905,#b399ff 127 | Broken_Hill_Group,PolyStyle010060,#00ff80 128 | Fulham_Sand,PolyStyle05,#ffffb3 129 | Mount_Gee_Sinter,PolyStyle010830,#e6ff00 130 | Poodla_Granodiorite,PolyStyle011275,#4d00ff 131 | Coonta_Gabbro,PolyStyle011336,#00e666 132 | Mount_Crawford_Granite_Gneiss,PolyStyle04272,#e69900 -------------------------------------------------------------------------------- /map2loop/_datasets/config_files/NSW.json: -------------------------------------------------------------------------------- 1 | { 2 | "structure": { 3 | "orientation_type": "dip direction", 4 | "dipdir_column": "azimuth", 5 | "dip_column": "dip", 6 | "description_column": "DESCRIPTION", 7 | "bedding_text": "bedding", 8 | "overturned_column": "deform", 9 | "overturned_text": "BEOI", 10 | "objectid_column": "unique_id", 11 | "desciption_column": "codedescpt" 12 | }, 13 | "geology": { 14 | "unitname_column": "unit_name", 15 | "alt_unitname_column": "nsw_code", 16 | "group_column": "sub_provin", 17 | "supergroup_column": "province", 18 | "description_column": "deposition", 19 | "minage_column": "top_end_ag", 20 | "maxage_column": "base_start", 21 | "rocktype_column": "class", 22 | "alt_rocktype_column": "igneous_ty", 23 | "sill_text": "sill", 24 | "intrusive_text": "intrusive", 25 | "volcanic_text": "volcanic", 26 | "objectid_column": "ID", 27 | "ignore_lithology_codes": ["cover"]}, 28 | "fault": { 29 | "structtype_column": "boundaryty", 30 | "fault_text": "Fault", 31 | "dip_null_value": "0", 32 | "dipdir_flag": "num", 33 | "dipdir_column": "faultdipdi", 34 | "dip_column": "dip", 35 | "orientation_type": "dip direction", 36 | "dipestimate_column": "faultdipan", 37 | "dipestimate_text": "Moderate,Listric,Steep,Vertical", 38 | "name_column": "name", 39 | "objectid_column": "unique_id", 40 | }, 41 | "fold": { 42 | "structtype_column": "codedescpt", 43 | "fold_text": "cline", 44 | "description_column": "codedescpt", 45 | "synform_text": "Syncline", 46 | "foldname_column": "NAME", 47 | "objectid_column": "unique_id" 48 | } 49 | } -------------------------------------------------------------------------------- /map2loop/_datasets/config_files/QLD.json: -------------------------------------------------------------------------------- 1 | { 2 | "structure": { 3 | "orientation_type": "dip direction", 4 | "dipdir_column": "azimuth", 5 | "dip_column": "dip_plunge", 6 | "description_column": "DESCRIPTION", 7 | "bedding_text": "BEDDING", 8 | "overturned_column": "facing", 9 | "overturned_text": "DOWN", 10 | "objectid_column": "Feature ID", 11 | "desciption_column": "structure" 12 | }, 13 | "geology": { 14 | "unitname_column": "stratigrap", 15 | "alt_unitname_column": "ru_name", 16 | "group_column": "parent_na", 17 | "supergroup_column": "parent_na", 18 | "description_column": "rock_type", 19 | "minage_column": "top_minim", 20 | "maxage_column": "base_maxi", 21 | "rocktype_column": "intrusive", 22 | "alt_rocktype_column": "lith_summ", 23 | "sill_text": "sill", 24 | "intrusive_text": "Y", 25 | "volcanic_text": "VOLCANIC", 26 | "objectid_column": "ID", 27 | "ignore_lithology_codes": ["cover"] 28 | }, 29 | "fault": { 30 | "structtype_column": "type", 31 | "fault_text": "Fault", 32 | "dip_null_value": "0", 33 | "dipdir_flag": "num", 34 | "dipdir_column": "dip_dir", 35 | "dip_column": "dip", 36 | "orientation_type": "dip direction", 37 | "dipestimate_column": "dip_est", 38 | "dipestimate_text": "gentle,moderate,steep", 39 | "name_column": "name", 40 | "objectid_column": "Feature ID" 41 | }, 42 | "fold": { 43 | "structtype_column": "type", 44 | "fold_text": "Fold", 45 | "description_column": "id_desc", 46 | "synform_text": "Syncline", 47 | "foldname_column": "NAME", 48 | "objectid_column": "Feature ID" 49 | } 50 | } -------------------------------------------------------------------------------- /map2loop/_datasets/config_files/SA.json: -------------------------------------------------------------------------------- 1 | { 2 | "structure": { 3 | "orientation_type": "dip direction", 4 | "dipdir_column": "azimuth_tr", 5 | "dip_column": "inclinatio", 6 | "description_column": "DESCRIPTION", 7 | "bedding_text": "bedding", 8 | "overturned_column": "younging", 9 | "overturned_text": "-", 10 | "objectid_column": "ID", 11 | "desciption_column": "structure_" 12 | }, 13 | "geology": { 14 | "unitname_column": "stratname", 15 | "alt_unitname_column": "mainunit", 16 | "group_column": "parentname", 17 | "supergroup_column": "province", 18 | "description_column": "stratdesc", 19 | "minage_column": "min", 20 | "maxage_column": "max", 21 | "rocktype_column": "no_col", 22 | "alt_rocktype_column": "no_col", 23 | "sill_text": "sill", 24 | "intrusive_text": "intrusive", 25 | "volcanic_text": "volc", 26 | "objectid_column": "ID", 27 | "ignore_lithology_codes": ["cover"] 28 | }, 29 | "fault": { 30 | "structtype_column": "descriptio", 31 | "fault_text": "fault", 32 | "dip_null_value": "0", 33 | "dipdir_flag": "num", 34 | "dipdir_column": "no_col", 35 | "dip_column": "no_col", 36 | "orientation_type": "dip direction", 37 | "dipestimate_column": "no_col", 38 | "dipestimate_text": "no_col", 39 | "name_column": "no_col", 40 | "objectid_column": "ID" 41 | }, 42 | "fold": { 43 | "structtype_column": "descriptio", 44 | "fold_text": "fold", 45 | "description_column": "no_col", 46 | "synform_text": "syn", 47 | "foldname_column": "NAME", 48 | "objectid_column": "ID" 49 | } 50 | } -------------------------------------------------------------------------------- /map2loop/_datasets/config_files/TAS.json: -------------------------------------------------------------------------------- 1 | { 2 | "structure": { 3 | "orientation_type": "dip direction", 4 | "dipdir_column": "AZIMUTH", 5 | "dip_column": "DIP", 6 | "description_column": "DESCRIPTION", 7 | "bedding_text": "bedding", 8 | "overturned_column": "DESCRIPT", 9 | "overturned_text": "overturned", 10 | "objectid_column": "No_col", 11 | "desciption_column": "DESCRIPT" 12 | }, 13 | "geology": { 14 | "unitname_column": "SYMBOL", 15 | "alt_unitname_column": "SYMBOL", 16 | "group_column": "SUPERGROUP", 17 | "supergroup_column": "GRP", 18 | "description_column": "DESCRIPT", 19 | "minage_column": "MINAGE", 20 | "maxage_column": "MAXAGE", 21 | "rocktype_column": "No_col", 22 | "alt_rocktype_column": "No_col", 23 | "sill_text": "sill", 24 | "intrusive_text": "granit", 25 | "volcanic_text": "volc", 26 | "objectid_column": "ID", 27 | "ignore_lithology_codes": ["cover"] 28 | }, 29 | "fault": { 30 | "structtype_column": "TYPE", 31 | "fault_text": "ault", 32 | "dip_null_value": "0", 33 | "dipdir_flag": "num", 34 | "dipdir_column": "No_col", 35 | "dip_column": "No_col", 36 | "orientation_type": "dip direction", 37 | "dipestimate_column": "No_col", 38 | "dipestimate_text": "No_col", 39 | "name_column": "No_col", 40 | "objectid_column": "No_col" 41 | }, 42 | "fold": { 43 | "structtype_column": "TYPE", 44 | "fold_text": "form", 45 | "description_column": "TYPE", 46 | "synform_text": "syn", 47 | "foldname_column": "NAME", 48 | "objectid_column": "No_col" 49 | } 50 | } -------------------------------------------------------------------------------- /map2loop/_datasets/config_files/VIC.json: -------------------------------------------------------------------------------- 1 | { 2 | "structure": { 3 | "orientation_type": "dip direction", 4 | "dipdir_column": "azimuth", 5 | "dip_column": "inclinatn", 6 | "description_column": "DESCRIPTION", 7 | "bedding_text": "bed", 8 | "overturned_column": "no_col", 9 | "overturned_text": "blah", 10 | "objectid_column": "geographic", 11 | "desciption_column": "sub_type" 12 | }, 13 | "geology": { 14 | "unitname_column": "formatted_", 15 | "alt_unitname_column": "abbreviate", 16 | "group_column": "no_col", 17 | "supergroup_column": "interpreta", 18 | "description_column": "text_descr", 19 | "minage_column": "no_col", 20 | "maxage_column": "no_col", 21 | "rocktype_column": "rank", 22 | "alt_rocktype_column": "type", 23 | "sill_text": "sill", 24 | "intrusive_text": "intrusion", 25 | "volcanic_text": "volc", 26 | "objectid_column": "ID", 27 | "ignore_lithology_codes": ["cover"] 28 | }, 29 | "fault": { 30 | "structtype_column": "featuretyp", 31 | "fault_text": "s", 32 | "dip_null_value": "0", 33 | "dipdir_flag": "num", 34 | "dipdir_column": "no_col", 35 | "dip_column": "no_col", 36 | "orientation_type": "dip direction", 37 | "dipestimate_column": "no_col", 38 | "dipestimate_text": "no_col", 39 | "name_column": "no_col", 40 | "objectid_column": "geographic" 41 | }, 42 | "fold": { 43 | "structtype_column": "featuretyp", 44 | "fold_text": "fold", 45 | "description_column": "no_col", 46 | "synform_text": "syn", 47 | "foldname_column": "NAME", 48 | "objectid_column": "geographic" 49 | } 50 | } -------------------------------------------------------------------------------- /map2loop/_datasets/config_files/WA.json: -------------------------------------------------------------------------------- 1 | { 2 | "structure": { 3 | "orientation_type": "strike", 4 | "dipdir_column": "strike", 5 | "dip_column": "dip", 6 | "description_column": "feature", 7 | "bedding_text": "Bed", 8 | "overturned_column": "structypei", 9 | "overturned_text": "BEOI", 10 | "objectid_column": "objectid" 11 | }, 12 | "geology": { 13 | "unitname_column": "unitname", 14 | "alt_unitname_column": "code", 15 | "group_column": "group_", 16 | "supergroup_column": "supersuite", 17 | "description_column": "descriptn", 18 | "minage_column": "min_age_ma", 19 | "maxage_column": "max_age_ma", 20 | "rocktype_column": "rocktype1", 21 | "alt_rocktype_column": "rocktype2", 22 | "sill_text": "is a sill", 23 | "intrusive_text": "intrusive", 24 | "volcanic_text": "volcanic", 25 | "objectid_column": "ID", 26 | "ignore_lithology_codes": ["cover"] 27 | }, 28 | "fault": { 29 | "structtype_column": "feature", 30 | "fault_text": "Fault", 31 | "dip_null_value": "0", 32 | "dipdir_flag": "num", 33 | "dipdir_column": "dip_dir", 34 | "dip_column": "dip", 35 | "orientation_type": "dip direction", 36 | "dipestimate_column": "dip_est", 37 | "dipestimate_text": "gentle,moderate,steep", 38 | "name_column": "name", 39 | "objectid_column": "objectid" 40 | }, 41 | "fold": { 42 | "structtype_column": "feature", 43 | "fold_text": "Fold axial trace", 44 | "description_column": "type", 45 | "synform_text": "syncline", 46 | "foldname_column": "NAME", 47 | "objectid_column": "objectid" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /map2loop/_datasets/geodata_files/hamersley/dtm_rp.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Loop3D/map2loop/1af43e4621a837db02252c05ff8cee0d5d8b3dd0/map2loop/_datasets/geodata_files/hamersley/dtm_rp.tif -------------------------------------------------------------------------------- /map2loop/_datasets/geodata_files/load_map2loop_data.py: -------------------------------------------------------------------------------- 1 | import geopandas 2 | from importlib.resources import files 3 | from osgeo import gdal 4 | gdal.UseExceptions() 5 | 6 | def load_hamersley_geology(): 7 | """ 8 | Loads Hamersley geology data from a shapefile 9 | 10 | Args: 11 | path (str): 12 | The path to the shapefile 13 | 14 | Returns: 15 | geopandas.GeoDataFrame: The geology data 16 | """ 17 | stream = files("map2loop._datasets.geodata_files.hamersley").joinpath("geology.geojson") 18 | return geopandas.read_file(stream) 19 | 20 | 21 | def load_hamersley_structure(): 22 | """ 23 | Loads Hamersley structure data from a shapefile 24 | 25 | Args: 26 | path (str): 27 | The path to the shapefile 28 | 29 | Returns: 30 | geopandas.GeoDataFrame: The structure data 31 | """ 32 | 33 | path = files("map2loop._datasets.geodata_files.hamersley").joinpath("structure.geojson") 34 | return geopandas.read_file(path) 35 | 36 | 37 | def load_hamersley_dtm(): 38 | """ 39 | Load DTM data from a raster file 40 | 41 | Returns: 42 | gdal.Dataset: The DTM data 43 | """ 44 | path = files("map2loop._datasets.geodata_files.hamersley").joinpath("dtm_rp.tif") 45 | return gdal.Open(path) 46 | -------------------------------------------------------------------------------- /map2loop/aus_state_urls.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | 4 | module_path = os.path.dirname(__file__) 5 | 6 | 7 | def load_clut(state): 8 | stream = ( 9 | pathlib.Path(module_path) 10 | / pathlib.Path('_datasets') 11 | / pathlib.Path('clut_files') 12 | / pathlib.Path(f'{state}_clut.csv') 13 | ) 14 | return stream 15 | 16 | 17 | def load_config(state): 18 | stream = ( 19 | pathlib.Path(module_path) 20 | / pathlib.Path('_datasets') 21 | / pathlib.Path('config_files') 22 | / pathlib.Path(f'{state}.json') 23 | ) 24 | return stream 25 | 26 | 27 | class AustraliaStateUrls: 28 | aus_geology_urls = { 29 | "WA": "http://13.211.217.129:8080/geoserver/loop/wfs?service=WFS&version=1.0.0&request=GetFeature&typeName=loop:500k_geol_28350&bbox={BBOX_STR}&srs=EPSG:28350&outputFormat=shape-zip", 30 | "QLD": "http://13.211.217.129:8080/geoserver/QLD/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=QLD:qld_geol_asud&bbox={BBOX_STR}&srsName=EPSG:28355&outputFormat=shape-zip", 31 | "SA": "http://13.211.217.129:8080/geoserver/SA/wfs?service=WFS&version=1.0.0&request=GetFeature&typeName=SA:2m_surface_geology_28354_relage&bbox={BBOX_STR}&srs=EPSG:28354&outputFormat=shape-zip", 32 | "VIC": "http://13.211.217.129:8080/geoserver/VIC/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=VIC:geolunit_250k_py_28355&bbox={BBOX_STR}&srs=EPSG:28355&outputFormat=shape-zip", 33 | "NSW": "http://13.211.217.129:8080/geoserver/NSW/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=NSW:ge_rockunit_lao2&bbox={BBOX_STR}&srs=EPSG:28355&outputFormat=shape-zip", 34 | "ACT": "", 35 | "TAS": "http://13.211.217.129:8080/geoserver/TAS/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=TAS:geol_poly_250_28355&bbox={BBOX_STR}&srs=EPSG:28355&outputFormat=shape-zip", 36 | "NT": "", 37 | } 38 | aus_structure_urls = { 39 | "WA": "http://13.211.217.129:8080/geoserver/loop/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=loop:waroxi_wa_28350&bbox={BBOX_STR}&srs=EPSG:28350&outputFormat=shape-zip", 40 | "QLD": "http://13.211.217.129:8080/geoserver/QLD/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=QLD:outcrops_28355&bbox={BBOX_STR}&srsName=EPSG:28355&outputFormat=shape-zip", 41 | "SA": "http://13.211.217.129:8080/geoserver/SA/wfs?service=WFS&version=1.0.0&request=GetFeature&typeName=SA:sth_flinders_28354&bbox={BBOX_STR}&srs=EPSG:28354&outputFormat=shape-zip", 42 | "VIC": "http://13.211.217.129:8080/geoserver/VIC/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=VIC:struc_28355&bbox={BBOX_STR}&srs=EPSG:28355&outputFormat=shape-zip", 43 | "NSW": "http://13.211.217.129:8080/geoserver/NSW/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=NSW:lao_struct_pt&bbox={BBOX_STR}&srs=EPSG:28355&outputFormat=shape-zip", 44 | "ACT": "", 45 | "TAS": "http://13.211.217.129:8080/geoserver/TAS/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=TAS:geol_struc_250_28355&bbox={BBOX_STR}&srs=EPSG:28355&outputFormat=shape-zip", 46 | "NT": "", 47 | } 48 | aus_fault_urls = { 49 | "WA": "http://13.211.217.129:8080/geoserver/loop/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=loop:500k_faults_28350&bbox={BBOX_STR}&srs=EPSG:28350&outputFormat=shape-zip", 50 | "QLD": "http://13.211.217.129:8080/geoserver/QLD/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=QLD:qld_faults_folds_28355&bbox={BBOX_STR}&srsName=EPSG:28355&outputFormat=shape-zip", 51 | "SA": "http://13.211.217.129:8080/geoserver/SA/wfs?service=WFS&version=1.0.0&request=GetFeature&typeName=SA:2m_linear_structures&bbox={BBOX_STR}&srs=EPSG:28354&outputFormat=shape-zip", 52 | "VIC": "http://13.211.217.129:8080/geoserver/VIC/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=VIC:geolstructure_250k_ln_28355&bbox={BBOX_STR}&srs=EPSG:28355&outputFormat=shape-zip", 53 | "NSW": "http://13.211.217.129:8080/geoserver/NSW/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=NSW:faults_joined_left_contains_drop_dups&bbox={BBOX_STR}&srs=EPSG:28355&outputFormat=shape-zip", 54 | "ACT": "", 55 | "TAS": "http://13.211.217.129:8080/geoserver/TAS/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=TAS:geol_line_250_28355&bbox={BBOX_STR}&srs=EPSG:28355&outputFormat=shape-zip", 56 | "NT": "", 57 | } 58 | aus_fold_urls = { 59 | "WA": "http://13.211.217.129:8080/geoserver/loop/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=loop:500k_faults_28350&bbox={BBOX_STR}&srs=EPSG:28350&outputFormat=shape-zip", 60 | "QLD": "http://13.211.217.129:8080/geoserver/QLD/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=QLD:qld_faults_folds_28355&bbox={BBOX_STR}&srsName=EPSG:28355&outputFormat=shape-zip", 61 | "SA": "http://13.211.217.129:8080/geoserver/SA/wfs?service=WFS&version=1.0.0&request=GetFeature&typeName=SA:2m_linear_structures&bbox={BBOX_STR}&srs=EPSG:28354&outputFormat=shape-zip", 62 | "VIC": "http://13.211.217.129:8080/geoserver/VIC/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=VIC:geolstructure_250k_ln_28355&bbox={BBOX_STR}&srs=EPSG:28355&outputFormat=shape-zip", 63 | "NSW": "http://13.211.217.129:8080/geoserver/NSW/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=NSW:folds_lao&bbox={BBOX_STR}&srs=EPSG:28355&outputFormat=shape-zip", 64 | "ACT": "", 65 | "TAS": "http://13.211.217.129:8080/geoserver/TAS/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=TAS:geol_line_250_28355&bbox={BBOX_STR}&srs=EPSG:28355&outputFormat=shape-zip", 66 | "NT": "", 67 | } 68 | aus_mindep_loopdata = { 69 | "WA": "http://13.211.217.129:8080/geoserver/loop/wfs?service=WFS&version=1.0.0&request=GetFeature&typeName=loop:mindeps_2018_28350&bbox={BBOX_STR}&srs=EPSG:28350&outputFormat=shape-zip", 70 | "QLD": "http://13.211.217.129:8080/geoserver/QLD/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=qld_mindeps_28355&bbox={BBOX_STR}&srsName=EPSG:28355&outputFormat=shape-zip", 71 | "SA": "", 72 | "VIC": "http://13.211.217.129:8080/geoserver/VIC/wfs?service=WFS&version=1.0.0&request=GetFeature&typeName=VIC:mindeps_2018_28350&bbox={BBOX_STR}&srs=EPSG:28350&outputFormat=shape-zip", 73 | "NSW": "http://13.211.217.129:8080/geoserver/NSW/wfs?service=WFS&version=1.1.0&request=GetFeature&typeName=NSW:nsw_mindeps&bbox={BBOX_STR}&srs=EPSG:28355&outputFormat=shape-zip", 74 | "ACT": "", 75 | "TAS": "http://13.211.217.129:8080/geoserver/VIC/wfs?service=WFS&version=1.0.0&request=GetFeature&typeName=VIC:mindeps_2018_28350&bbox={BBOX_STR}&srs=EPSG:28350&outputFormat=shape-zip", 76 | "NT": "", 77 | } 78 | aus_config_urls = { 79 | "WA": load_config('WA'), 80 | "QLD": load_config('QLD'), 81 | "SA": load_config('SA'), 82 | "VIC": load_config('VIC'), 83 | "NSW": load_config('NSW'), 84 | "ACT": "", 85 | "TAS": load_config('TAS'), 86 | "NT": "", 87 | } 88 | aus_clut_urls = { 89 | "WA": load_clut('WA'), 90 | "QLD": load_clut('QLD'), 91 | "SA": load_clut('SA'), 92 | "VIC": "", 93 | "NSW": "", 94 | "ACT": "", 95 | "TAS": "", 96 | "NT": "", 97 | } 98 | -------------------------------------------------------------------------------- /map2loop/config.py: -------------------------------------------------------------------------------- 1 | import urllib.error 2 | import beartype 3 | import json 4 | import urllib 5 | import time 6 | import pathlib 7 | from typing import Union 8 | 9 | from .logging import getLogger 10 | 11 | logger = getLogger(__name__) 12 | 13 | 14 | class Config: 15 | """ 16 | A data structure containing column name mappings for files and keywords 17 | 18 | Attributes 19 | ---------- 20 | structure_config: dict 21 | column names and keywords for structure mappings 22 | geology_config: dict 23 | column names and keywords for geology mappings 24 | fault_config: dict 25 | column names and keywords for fault mappings 26 | fold_config: dict 27 | column names and keywords for fold mappings 28 | """ 29 | 30 | def __init__(self): 31 | self.structure_config = { 32 | "orientation_type": "dip direction", 33 | "dipdir_column": "DIPDIR", 34 | "dip_column": "DIP", 35 | "description_column": "DESCRIPTION", 36 | "bedding_text": "bedding", 37 | "overturned_column": "DESCRIPTION", 38 | "overturned_text": "overturned", 39 | "objectid_column": "ID", 40 | } 41 | self.geology_config = { 42 | "unitname_column": "UNITNAME", 43 | "alt_unitname_column": "CODE", 44 | "group_column": "GROUP", 45 | "supergroup_column": "SUPERGROUP", 46 | "description_column": "DESCRIPTION", 47 | "minage_column": "MIN_AGE", 48 | "maxage_column": "MAX_AGE", 49 | "rocktype_column": "ROCKTYPE1", 50 | "alt_rocktype_column": "ROCKTYPE2", 51 | "sill_text": "sill", 52 | "intrusive_text": "intrusive", 53 | "volcanic_text": "volcanic", 54 | "objectid_column": "ID", 55 | "ignore_lithology_codes": ["cover"], 56 | } 57 | self.fault_config = { 58 | "structtype_column": "FEATURE", 59 | "fault_text": "fault", 60 | "dip_null_value": "-999", 61 | "dipdir_flag": "num", 62 | "dipdir_column": "DIPDIR", 63 | "dip_column": "DIP", 64 | "orientation_type": "dip direction", 65 | "dipestimate_column": "DIP_ESTIMATE", 66 | "dipestimate_text": "'NORTH_EAST','NORTH',,'NOT ACCESSED'", 67 | "name_column": "NAME", 68 | "objectid_column": "ID", 69 | "minimum_fault_length": -1.0, # negative -1 means not set 70 | "ignore_fault_codes": [None], 71 | } 72 | self.fold_config = { 73 | "structtype_column": "FEATURE", 74 | "fold_text": "fold", 75 | "description_column": "DESCRIPTION", 76 | "synform_text": "syncline", 77 | "foldname_column": "NAME", 78 | "objectid_column": "ID", 79 | } 80 | 81 | def to_dict(self): 82 | """ 83 | Convert the config dictionary to a dictionary 84 | 85 | Returns: 86 | dict: The dictionary representation of the config 87 | """ 88 | return { 89 | "structure": self.structure_config, 90 | "geology": self.geology_config, 91 | "fault": self.fault_config, 92 | "fold": self.fold_config, 93 | } 94 | 95 | @beartype.beartype 96 | def update_from_dictionary(self, dictionary: dict, lower: bool = True): 97 | """ 98 | Update the config dictionary from a provided dict 99 | 100 | Args: 101 | dictionary (dict): The dictionary to update from 102 | """ 103 | 104 | if "structure" in dictionary: 105 | self.structure_config.update(dictionary["structure"]) 106 | for key in dictionary["structure"].keys(): 107 | if key not in self.structure_config: 108 | logger.warning( 109 | f"Config dictionary structure segment contained {key} which is not used" 110 | ) 111 | dictionary.pop("structure") 112 | 113 | if "geology" in dictionary: 114 | self.geology_config.update(dictionary["geology"]) 115 | for key in dictionary["geology"].keys(): 116 | if key not in self.geology_config: 117 | logger.warning( 118 | f"Config dictionary geology segment contained {key} which is not used" 119 | ) 120 | dictionary.pop("geology") 121 | if "fault" in dictionary: 122 | self.fault_config.update(dictionary["fault"]) 123 | for key in dictionary["fault"].keys(): 124 | if key not in self.fault_config: 125 | logger.warning( 126 | f"Config dictionary fault segment contained {key} which is not used" 127 | ) 128 | dictionary.pop("fault") 129 | if "fold" in dictionary: 130 | self.fold_config.update(dictionary["fold"]) 131 | for key in dictionary["fold"].keys(): 132 | if key not in self.fold_config: 133 | logger.warning( 134 | f"Config dictionary fold segment contained {key} which is not used" 135 | ) 136 | dictionary.pop("fold") 137 | if len(dictionary): 138 | logger.warning(f"Unused keys from config format {list(dictionary.keys())}") 139 | 140 | 141 | @beartype.beartype 142 | def update_from_file( 143 | self, filename: Union[pathlib.Path, str], lower: bool = False 144 | ): 145 | """ 146 | Update the config dictionary from the provided json filename or url 147 | 148 | Args: 149 | filename (Union[pathlib.Path, str]): Filename or URL of the JSON config file 150 | lower (bool, optional): convert keys to lowercase. Defaults to False. 151 | """ 152 | func = self.update_from_dictionary 153 | 154 | try: 155 | filename = str(filename) 156 | 157 | # if url, open the url 158 | if filename.startswith("http") or filename.startswith("ftp"): 159 | try_count = 5 160 | success = False 161 | # try 5 times to access the URL 162 | while try_count > 0 and not success: 163 | try: 164 | with urllib.request.urlopen(filename) as url_data: 165 | data = json.load(url_data) 166 | func(data, lower) 167 | success = True 168 | 169 | # case 1. handle url error 170 | except urllib.error.URLError as e: 171 | # wait 0.25 seconds before trying again 172 | time.sleep(0.25) 173 | # decrease the number of tries by 1 174 | try_count -= 1 175 | # if no more tries left, raise the error 176 | if try_count <= 0: 177 | raise urllib.error.URLError( 178 | f"Failed to access URL after multiple attempts: {filename}" 179 | ) from e 180 | 181 | # case 2. handle json error 182 | except json.JSONDecodeError as e: 183 | raise json.JSONDecodeError( 184 | f"Error decoding JSON data from URL: {filename}" 185 | ) from e 186 | else: 187 | try: 188 | with open(filename) as file_data: 189 | data = json.load(file_data) 190 | func(data, lower) 191 | except FileNotFoundError as e: 192 | err_string = f"The specified config file does not exist ({filename}).\n" 193 | err_string += ( 194 | "Please check the file exists and is accessible, then try again.\n" 195 | ) 196 | raise FileNotFoundError(err_string) from e 197 | except json.JSONDecodeError as e: 198 | raise json.JSONDecodeError( 199 | f"Error decoding JSON data from file: {filename}" 200 | ) from e 201 | 202 | except FileNotFoundError: 203 | raise 204 | 205 | except Exception: 206 | err_string = f"There is a problem parsing the config file ({filename}).\n" 207 | if filename.startswith("http"): 208 | err_string += "Please check the file is accessible online and then\n" 209 | else: 210 | err_string += "Please check the file exists and is accessible then\n" 211 | err_string += "Check the contents for mismatched quotes or brackets!" 212 | raise Exception(err_string) -------------------------------------------------------------------------------- /map2loop/fault_orientation.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import beartype 3 | import pandas 4 | import geopandas 5 | from .mapdata import MapData 6 | import numpy as np 7 | 8 | from .logging import getLogger 9 | 10 | logger = getLogger(__name__) 11 | 12 | 13 | class FaultOrientation(ABC): 14 | """ 15 | Base Class of Fault Orientation assigner to force structure of FaultOrientation 16 | 17 | Args: 18 | ABC (ABC): Derived from Abstract Base Class 19 | """ 20 | 21 | def __init__(self): 22 | """ 23 | Initialiser of for orientation assigner 24 | """ 25 | self.label = "FaultOrientationBaseClass" 26 | 27 | def type(self): 28 | """ 29 | Getter for subclass type label 30 | 31 | Returns: 32 | str: Name of subclass 33 | """ 34 | return self.label 35 | 36 | @beartype.beartype 37 | @abstractmethod 38 | def calculate( 39 | self, 40 | fault_trace: geopandas.GeoDataFrame, 41 | fault_orientations: pandas.DataFrame, 42 | map_data: MapData, 43 | ) -> pandas.DataFrame: 44 | """ 45 | Execute fault orientation assigned method (abstract method) 46 | 47 | Args: 48 | faults (pandas.DataFrame): the data frame of the faults to add throw values to 49 | fault_orientation (pandas.DataFrame): data frame with fault orientations to assign to faults 50 | map_data (map2loop.MapData): a catchall so that access to all map data is available 51 | 52 | Returns: 53 | pandas.DataFrame: fault orientations assigned to a fault label 54 | """ 55 | pass 56 | 57 | 58 | class FaultOrientationNearest(FaultOrientation): 59 | """ 60 | FaultOrientation class which estimates fault orientation based on nearest orientation 61 | """ 62 | 63 | def __init__(self): 64 | """ 65 | Initialiser for nearest version of the fault orientation assigner 66 | """ 67 | self.label = "FaultOrientationNearest" 68 | 69 | @beartype.beartype 70 | def calculate( 71 | self, 72 | fault_trace: geopandas.GeoDataFrame, 73 | fault_orientations: pandas.DataFrame, 74 | map_data: MapData, 75 | ) -> pandas.DataFrame: 76 | """ 77 | Assigns the nearest fault orientation to a fault 78 | 79 | Args: 80 | fault_trace (geopandas.GeoDataFrame): the data frame of the fault traces 81 | fault_orientation (pandas.DataFrame): data frame with fault orientations to assign to faults 82 | map_data (map2loop.MapData): a catchall so that access to all map data is available 83 | 84 | Returns: 85 | pandas.DataFrame: fault orientations assigned to a fault label 86 | """ 87 | logger.info("Assigning fault orientations to fault traces from nearest orientation") 88 | orientations = fault_orientations.copy() 89 | logger.info(f'There are {len(orientations)} fault orientations to assign') 90 | 91 | orientations["ID"] = -1 92 | 93 | for i in orientations.index: 94 | p = orientations.loc[i, :].geometry 95 | orientations.loc[i, "ID"] = fault_trace.loc[ 96 | fault_trace.index[np.argmin(fault_trace.distance(p))], "ID" 97 | ] 98 | orientations.loc[i, "X"] = p.x 99 | orientations.loc[i, "Y"] = p.y 100 | 101 | return orientations.drop(columns="geometry") 102 | -------------------------------------------------------------------------------- /map2loop/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import map2loop 3 | 4 | 5 | def get_levels(): 6 | """dict for converting to logger levels from string 7 | 8 | 9 | Returns 10 | ------- 11 | dict 12 | contains all strings with corresponding logging levels. 13 | """ 14 | return { 15 | "info": logging.INFO, 16 | "warning": logging.WARNING, 17 | "error": logging.ERROR, 18 | "debug": logging.DEBUG, 19 | } 20 | 21 | 22 | def getLogger(name: str): 23 | """Get a logger object with a specific name 24 | 25 | 26 | Parameters 27 | ---------- 28 | name : str 29 | name of the logger object 30 | 31 | Returns 32 | ------- 33 | logging.Logger 34 | logger object 35 | """ 36 | if name in map2loop.loggers: 37 | return map2loop.loggers[name] 38 | logger = logging.getLogger(name) 39 | logger.addHandler(map2loop.ch) 40 | logger.propagate = False 41 | map2loop.loggers[name] = logger 42 | return logger 43 | 44 | 45 | logger = getLogger(__name__) 46 | 47 | 48 | def set_level(level: str): 49 | """Set the level of the logging object 50 | 51 | 52 | Parameters 53 | ---------- 54 | level : str 55 | level of the logging object 56 | """ 57 | levels = get_levels() 58 | level = levels.get(level, logging.WARNING) 59 | map2loop.ch.setLevel(level) 60 | 61 | for name in map2loop.loggers: 62 | logger = logging.getLogger(name) 63 | logger.setLevel(level) 64 | logger.info(f"Logging level set to {level}") 65 | -------------------------------------------------------------------------------- /map2loop/m2l_enums.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | class Datatype(IntEnum): 5 | GEOLOGY = 0 6 | STRUCTURE = 1 7 | FAULT = 2 8 | FOLD = 3 9 | DTM = 4 10 | FAULT_ORIENTATION = 5 11 | 12 | 13 | class Datastate(IntEnum): 14 | UNNAMED = 0 15 | UNLOADED = 1 16 | LOADED = 2 17 | REPROJECTED = 3 18 | CLIPPED = 4 19 | COMPLETE = 5 20 | ERRORED = 9 21 | 22 | 23 | class ErrorState(IntEnum): 24 | NONE = 0 25 | URLERROR = 1 26 | CONFIGERROR = 2 27 | 28 | 29 | class VerboseLevel(IntEnum): 30 | NONE = 0 31 | TEXTONLY = 1 32 | ALL = 2 33 | -------------------------------------------------------------------------------- /map2loop/sampler.py: -------------------------------------------------------------------------------- 1 | # internal imports 2 | from .m2l_enums import Datatype 3 | from .mapdata import MapData 4 | 5 | # external imports 6 | from abc import ABC, abstractmethod 7 | import beartype 8 | import geopandas 9 | import pandas 10 | import shapely 11 | import numpy 12 | from typing import Optional 13 | 14 | 15 | class Sampler(ABC): 16 | """ 17 | Base Class of Sampler used to force structure of Sampler 18 | 19 | Args: 20 | ABC (ABC): Derived from Abstract Base Class 21 | """ 22 | 23 | def __init__(self): 24 | """ 25 | Initialiser of for Sampler 26 | """ 27 | self.sampler_label = "SamplerBaseClass" 28 | 29 | def type(self): 30 | """ 31 | Getter for subclass type label 32 | 33 | Returns: 34 | str: Name of subclass 35 | """ 36 | return self.sampler_label 37 | 38 | @beartype.beartype 39 | @abstractmethod 40 | def sample( 41 | self, spatial_data: geopandas.GeoDataFrame, map_data: Optional[MapData] = None 42 | ) -> pandas.DataFrame: 43 | """ 44 | Execute sampling method (abstract method) 45 | 46 | Args: 47 | spatial_data (geopandas.GeoDataFrame): data frame to sample 48 | 49 | Returns: 50 | pandas.DataFrame: data frame containing samples 51 | """ 52 | pass 53 | 54 | 55 | class SamplerDecimator(Sampler): 56 | """ 57 | Decimator sampler class which decimates the geo data frame based on the decimation value 58 | ie. decimation = 10 means take every tenth point 59 | Note: This only works on data frames with lists of points with columns "X" and "Y" 60 | """ 61 | 62 | @beartype.beartype 63 | def __init__(self, decimation: int = 1): 64 | """ 65 | Initialiser for decimator sampler 66 | 67 | Args: 68 | decimation (int, optional): stride of the points to sample. Defaults to 1. 69 | """ 70 | self.sampler_label = "SamplerDecimator" 71 | decimation = max(decimation, 1) 72 | self.decimation = decimation 73 | 74 | @beartype.beartype 75 | def sample( 76 | self, spatial_data: geopandas.GeoDataFrame, map_data: Optional[MapData] = None 77 | ) -> pandas.DataFrame: 78 | """ 79 | Execute sample method takes full point data, samples the data and returns the decimated points 80 | 81 | Args: 82 | spatial_data (geopandas.GeoDataFrame): the data frame to sample 83 | 84 | Returns: 85 | pandas.DataFrame: the sampled data points 86 | """ 87 | data = spatial_data.copy() 88 | data["X"] = data.geometry.x 89 | data["Y"] = data.geometry.y 90 | data["Z"] = map_data.get_value_from_raster_df(Datatype.DTM, data)["Z"] 91 | data["layerID"] = geopandas.sjoin( 92 | data, map_data.get_map_data(Datatype.GEOLOGY), how='left' 93 | )['index_right'] 94 | data.reset_index(drop=True, inplace=True) 95 | 96 | return pandas.DataFrame(data[:: self.decimation].drop(columns="geometry")) 97 | 98 | 99 | class SamplerSpacing(Sampler): 100 | """ 101 | Spacing based sampler which decimates the geo data frame based on the distance between points along a line or 102 | in the case of a polygon along the boundary of that polygon 103 | ie. spacing = 500 means take a sample every 500 metres 104 | Note: This only works on data frames that contain MultiPolgon, Polygon, MultiLineString and LineString geometry 105 | """ 106 | 107 | @beartype.beartype 108 | def __init__(self, spacing: float = 50.0): 109 | """ 110 | Initialiser for spacing sampler 111 | 112 | Args: 113 | spacing (float, optional): The distance between samples. Defaults to 50.0. 114 | """ 115 | self.sampler_label = "SamplerSpacing" 116 | spacing = max(spacing, 1.0) 117 | self.spacing = spacing 118 | 119 | @beartype.beartype 120 | def sample( 121 | self, spatial_data: geopandas.GeoDataFrame, map_data: Optional[MapData] = None 122 | ) -> pandas.DataFrame: 123 | """ 124 | Execute sample method takes full point data, samples the data and returns the sampled points 125 | 126 | Args: 127 | spatial_data (geopandas.GeoDataFrame): the data frame to sample (must contain column ["ID"]) 128 | 129 | Returns: 130 | pandas.DataFrame: the sampled data points 131 | """ 132 | schema = {"ID": str, "X": float, "Y": float, "featureId": str} 133 | df = pandas.DataFrame(columns=schema.keys()).astype(schema) 134 | for _, row in spatial_data.iterrows(): 135 | if type(row.geometry) is shapely.geometry.multipolygon.MultiPolygon: 136 | targets = row.geometry.boundary.geoms 137 | elif type(row.geometry) is shapely.geometry.polygon.Polygon: 138 | targets = [row.geometry.boundary] 139 | elif type(row.geometry) is shapely.geometry.multilinestring.MultiLineString: 140 | targets = row.geometry.geoms 141 | elif type(row.geometry) is shapely.geometry.linestring.LineString: 142 | targets = [row.geometry] 143 | else: 144 | targets = [] 145 | 146 | # For the main cases Polygon and LineString the list 'targets' has one element 147 | for a, target in enumerate(targets): 148 | df2 = pandas.DataFrame(columns=schema.keys()).astype(schema) 149 | distances = numpy.arange(0, target.length, self.spacing)[:-1] 150 | points = [target.interpolate(distance) for distance in distances] 151 | df2["X"] = [point.x for point in points] 152 | df2["Y"] = [point.y for point in points] 153 | 154 | # # account for holes//rings in polygons 155 | df2["featureId"] = str(a) 156 | # 1. check if line is "closed" 157 | if target.is_ring: 158 | target_polygon = shapely.geometry.Polygon(target) 159 | if target_polygon.exterior.is_ccw: # if counterclockwise --> hole 160 | for j, target2 in enumerate(targets): 161 | # skip if line or point 162 | if len(target2.coords) >= 2: 163 | continue 164 | # which poly is the hole in? assign featureId of the same poly 165 | t2_polygon = shapely.geometry.Polygon(target2) 166 | if target.within(t2_polygon): # 167 | df2['featureId'] = str(j) 168 | 169 | df2["ID"] = row["ID"] if "ID" in spatial_data.columns else 0 170 | df = df2 if len(df) == 0 else pandas.concat([df, df2]) 171 | 172 | df.reset_index(drop=True, inplace=True) 173 | return df 174 | -------------------------------------------------------------------------------- /map2loop/stratigraphic_column.py: -------------------------------------------------------------------------------- 1 | import pandas 2 | import numpy 3 | import geopandas 4 | 5 | 6 | class StratigraphicColumn: 7 | """ 8 | A class containing all the fault and fold summaries and relationships 9 | 10 | Attributes 11 | ---------- 12 | column: list 13 | List of stratigraphic units in time order 14 | groups: list 15 | List of stratigraphic groups in time order 16 | stratigraphicUnitColumns: numpy.dtype 17 | Column names and types for stratigraphic unit summary 18 | stratigraphicUnits: pandas.DataFrame 19 | The stratigraphic units 20 | lithologyUnitColumns: numpy.dtype 21 | Column names and types for lithology layer summary 22 | lithologyUnits: pandas.DataFrame 23 | The lithology units 24 | 25 | """ 26 | 27 | def __init__(self): 28 | """ 29 | The initialiser for the stratigraphic units. All attributes are defaulted 30 | """ 31 | self.column = [] 32 | self.groups = [] 33 | 34 | # Create empty dataframes for units 35 | self.stratigraphicUnitColumns = numpy.dtype( 36 | [ 37 | ("layerId", int), 38 | ("name", str), 39 | ("minAge", float), 40 | ("maxAge", float), 41 | ("group", str), 42 | ("supergroup", str), 43 | ("colour", str), 44 | ] 45 | ) 46 | self.stratigraphicUnits = pandas.DataFrame( 47 | numpy.empty(0, dtype=self.stratigraphicUnitColumns) 48 | ) 49 | self.stratigraphicUnits = self.stratigraphicUnits.set_index("name") 50 | 51 | self.lithologyUnitColumns = numpy.dtype( 52 | [ 53 | ("layerId", int), 54 | ("name", str), 55 | ("minAge", float), 56 | ("maxAge", float), 57 | ("group", str), 58 | ("colour", str), 59 | ] 60 | ) 61 | self.lithologyUnits = pandas.DataFrame(numpy.empty(0, dtype=self.lithologyUnitColumns)) 62 | self.lithologyUnits = self.lithologyUnits.set_index("name") 63 | 64 | def findStratigraphicUnit(self, id): 65 | """ 66 | Find the unit in the units list based on its layerId or name 67 | 68 | Args: 69 | id (int or str): 70 | The layerId or name to look for 71 | 72 | Returns: 73 | pandas.DataFrame: The sliced data frame containing the requested unit 74 | """ 75 | if issubclass(type(id), int): 76 | return self.stratigraphicUnits[self.stratigraphicUnits["layerId"] == id] 77 | elif issubclass(type(id), str): 78 | return self.stratigraphicUnits[self.stratigraphicUnits["name"] == id] 79 | else: 80 | print("ERROR: Unknown identifier type used to find stratigraphic unit") 81 | 82 | def findLithologyUnit(self, id): 83 | """ 84 | Find the lithology unit in the units list based on its layerId or name 85 | 86 | Args: 87 | id (int or str): 88 | The layerId or name to look for 89 | 90 | Returns: 91 | pandas.DataFrame: The sliced data frame containing the requested unit 92 | """ 93 | if issubclass(type(id), int): 94 | return self.lithologyUnits[self.lithologyUnits["layerId"] == id] 95 | elif issubclass(type(id), str): 96 | return self.lithologyUnits[self.lithologyUnits["name"] == id] 97 | else: 98 | print("ERROR: Unknown identifier type used to find lithology unit") 99 | 100 | def addStratigraphicUnit(self, unit): 101 | """ 102 | Add stratigraphic unit to the units list 103 | 104 | Args: 105 | fault (pandas.DataFrame or dict): 106 | The unit information to add 107 | """ 108 | if issubclass(type(unit), pandas.DataFrame) or issubclass(type(unit), dict): 109 | if "name" in unit.keys(): 110 | if unit["name"] in self.stratigraphicUnits.index: 111 | print("Replacing stratigraphic unit", unit["name"]) 112 | self.stratigraphicUnits.loc[unit["name"]] = unit 113 | else: 114 | print("No name field in stratigraphic unit", unit) 115 | else: 116 | print("Cannot add unit to dataframe with type", type(unit)) 117 | 118 | def addLithologyUnit(self, unit): 119 | """ 120 | Add lithology unit to the units list 121 | 122 | Args: 123 | fault (pandas.DataFrame or dict): 124 | The unit information to add 125 | """ 126 | if issubclass(type(unit), pandas.DataFrame) or issubclass(type(unit), dict): 127 | if "name" in unit.keys(): 128 | if unit["name"] in self.lithologyUnits.index: 129 | print("Replacing lithology unit", unit["name"]) 130 | self.lithologyUnits.loc[unit["name"]] = unit 131 | else: 132 | print("No name field in lithology unit", unit) 133 | else: 134 | print("Cannot add unit to dataframe with type", type(unit)) 135 | 136 | def populate(self, geology_map_data: geopandas.GeoDataFrame): 137 | """ 138 | Parse the geodataframe data into the stratigraphic units list 139 | 140 | Args: 141 | geology_map_data (geopandas.GeoDataFrame): 142 | The geodataframe with the unit data 143 | """ 144 | if geology_map_data.shape[0] == 0: 145 | return 146 | geology_data = geology_map_data.copy() 147 | geology_data = geology_data.drop_duplicates(subset=["UNITNAME"]) 148 | geology_data = geology_data.reset_index(drop=True) 149 | # geology_data = geology_data.dropna(subset=["UNITNAME"]) 150 | 151 | self.stratigraphicUnits = pandas.DataFrame( 152 | numpy.empty(geology_data.shape[0], dtype=self.stratigraphicUnitColumns) 153 | ) 154 | self.stratigraphicUnits["layerId"] = numpy.arange(geology_data.shape[0]) 155 | self.stratigraphicUnits["name"] = geology_data["UNITNAME"] 156 | self.stratigraphicUnits["minAge"] = geology_data["MIN_AGE"] 157 | self.stratigraphicUnits["maxAge"] = geology_data["MAX_AGE"] 158 | self.stratigraphicUnits["group"] = geology_data["GROUP"] 159 | self.stratigraphicUnits["supergroup"] = geology_data["SUPERGROUP"] 160 | self.stratigraphicUnits["colour"] = "#000000" 161 | # self.stratigraphicUnits["indexInGroup"] = -1 162 | 163 | self.groups = list(self.stratigraphicUnits['group'].unique()) 164 | 165 | def set_stratigraphic_unit_parameter_by_name(self, name: str, parameter: str, value): 166 | """ 167 | Set a specific parameter on a specific stratigraphic unit 168 | 169 | Args: 170 | name (str): The name of the stratigraphic unit 171 | parameter (str): The colmn name of the parameters 172 | value (str or int or float): The value to set 173 | """ 174 | self.stratigraphicUnits.iloc[self.stratigraphicUnits["name"] == name][parameter] = value 175 | 176 | def sort_from_relationship_list(self, relationshipList: list): 177 | """ 178 | Sort the stratigraphic column based on the list of name 179 | 180 | Args: 181 | relationshipList (list): 182 | The order of the units by name 183 | """ 184 | sorter = dict(zip(relationshipList, range(len(relationshipList)))) 185 | self.stratigraphicUnits["stratigraphic_Order"] = self.stratigraphicUnits["name"].map(sorter) 186 | self.stratigraphicUnits.sort_values(["stratigraphic_Order"], inplace=True) 187 | -------------------------------------------------------------------------------- /map2loop/throw_calculator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import beartype 3 | import pandas 4 | import geopandas 5 | from .mapdata import MapData 6 | 7 | 8 | class ThrowCalculator(ABC): 9 | """ 10 | Base Class of Throw Calculator used to force structure of ThrowCalculator 11 | 12 | Args: 13 | ABC (ABC): Derived from Abstract Base Class 14 | """ 15 | 16 | def __init__(self): 17 | """ 18 | Initialiser of for Sorter 19 | """ 20 | self.throw_calculator_label = "ThrowCalculatorBaseClass" 21 | 22 | def type(self): 23 | """ 24 | Getter for subclass type label 25 | 26 | Returns: 27 | str: Name of subclass 28 | """ 29 | return self.throw_calculator_label 30 | 31 | @beartype.beartype 32 | @abstractmethod 33 | def compute( 34 | self, 35 | faults: pandas.DataFrame, 36 | stratigraphic_order: list, 37 | basal_contacts: geopandas.GeoDataFrame, 38 | map_data: MapData, 39 | ) -> pandas.DataFrame: 40 | """ 41 | Execute throw calculator method (abstract method) 42 | 43 | Args: 44 | faults (pandas.DataFrame): the data frame of the faults to add throw values to 45 | stratigraphic_order (list): a list of unit names sorted from youngest to oldest 46 | basal_contacts (geopandas.GeoDataFrame): basal contact geo data with locations and unit names of the contacts (columns must contain ["ID","basal_unit","type","geometry"]) 47 | map_data (map2loop.MapData): a catchall so that access to all map data is available 48 | 49 | Returns: 50 | pandas.DataFrame: fault data frame with throw values (avgDisplacement and avgDownthrowDir) filled in 51 | """ 52 | pass 53 | 54 | 55 | class ThrowCalculatorAlpha(ThrowCalculator): 56 | """ 57 | ThrowCalculator class which estimates fault throw values based on units, basal_contacts and stratigraphic order 58 | """ 59 | 60 | def __init__(self): 61 | """ 62 | Initialiser for alpha version of the throw calculator 63 | """ 64 | self.throw_calculator_label = "ThrowCalculatorAlpha" 65 | 66 | @beartype.beartype 67 | def compute( 68 | self, 69 | faults: pandas.DataFrame, 70 | stratigraphic_order: list, 71 | basal_contacts: pandas.DataFrame, 72 | map_data: MapData, 73 | ) -> pandas.DataFrame: 74 | """ 75 | Execute throw calculator method takes fault data, basal_contacts and stratigraphic order and attempts to estimate fault throw. 76 | 77 | Args: 78 | faults (pandas.DataFrame): the data frame of the faults to add throw values to 79 | stratigraphic_order (list): a list of unit names sorted from youngest to oldest 80 | basal_contacts (geopandas.GeoDataFrame): basal contact geo data with locations and unit names of the contacts (columns must contain ["ID","basal_unit","type","geometry"]) 81 | map_data (map2loop.MapData): a catchall so that access to all map data is available 82 | 83 | Returns: 84 | pandas.DataFrame: fault data frame with throw values (avgDisplacement and avgDownthrowDir) filled in 85 | """ 86 | # For each fault take the geometric join of all contact lines and that fault line 87 | 88 | # For each contact join take the length of that join as an approximation of the minimum throw of the fault 89 | 90 | # Take all the min throw approximations and set the largest one as the avgDisplacement 91 | 92 | # If a fault has no contact lines the maximum throw should be less than the thickness of the containing 93 | # unit (if we exclude map height changes and fault angle) 94 | 95 | # Set any remaining displacement values to default value 96 | faults["avgDisplacement"] = faults.apply( 97 | lambda row: 100 if row["avgDisplacement"] == -1 else row["avgDisplacement"], axis=1 98 | ) 99 | return faults 100 | -------------------------------------------------------------------------------- /map2loop/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "3.2.2" 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['setuptools'] 3 | build-backend = 'setuptools.build_meta' 4 | [project] 5 | name = 'map2loop' 6 | description = 'Generate 3D model data from 2D maps.' 7 | authors = [{name = 'Loop team'}] 8 | readme = 'README.md' 9 | requires-python = '>=3.8' 10 | keywords = [ "earth sciences", 11 | "geology", 12 | "3-D modelling", 13 | "structural geology", 14 | "uncertainty",] 15 | license = {text = 'MIT'} 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Intended Audience :: Science/Research", 19 | "Natural Language :: English", 20 | "Programming Language :: Python", 21 | "Programming Language :: Python :: 3", 22 | "License :: OSI Approved :: MIT License", 23 | "Operating System :: OS Independent", 24 | "Topic :: Scientific/Engineering", 25 | "Topic :: Scientific/Engineering :: GIS", 26 | ] 27 | dynamic = ['version','dependencies'] 28 | 29 | [project.urls] 30 | Documentation = 'https://Loop3d.org/map2loop/' 31 | "Bug Tracker" = 'https://github.com/Loop3D/map2loop/issues' 32 | "Source Code" = 'https://github.com/Loop3D/map2loop' 33 | 34 | [tool.setuptools.dynamic] 35 | dependencies = { file = ["dependencies.txt"]} 36 | version = { attr = "map2loop.version.__version__" } 37 | 38 | [tool.setuptools.packages.find] 39 | include = ['map2loop', 'map2loop.*'] 40 | 41 | [tool.setuptools.package-data] 42 | map2loop = [ 43 | "dependencies.txt", 44 | "_datasets/geodata_files/hamersley/*" 45 | ] 46 | 47 | [tool.black] 48 | line-length = 100 49 | skip-string-normalization = true 50 | target-version = ['py39'] 51 | exclude = '\.eggs|\.git|\.mypy_cache|\.tox|\.venv|_build|buck-out|build|dist|node_modules' 52 | skip-magic-trailing-comma = true 53 | 54 | # Add an option to remove the trailing comma of separated lists 55 | # but it doesn't exist because python devs don't like beautiful code. 56 | # black specifically adds trailing commas due to git diff lengths 57 | # which is an insufficient reason to destroy beautiful code making 58 | # it look like an unfinished thought or an incomplete 59 | [tool.blackdoc] 60 | # From https://numpydoc.readthedocs.io/en/latest/format.html 61 | # The length of docstring lines should be kept to 75 characters to facilitate 62 | # reading the docstrings in text terminals. 63 | line-length = 75 64 | 65 | [tool.build_sphinx] 66 | source-dir = 'doc' 67 | build-dir = './doc/build' 68 | all_files = 1 69 | 70 | [tool.upload_sphinx] 71 | upload-dir = 'doc/build/html' 72 | 73 | [tool.pydocstyle] 74 | match = '(?!coverage).*.py' 75 | convention = "numpy" 76 | add-ignore = ["D404"] 77 | 78 | [tool.codespell] 79 | skip = '*.pyc,*.txt,*.gif,*.png,*.jpg,*.ply,*.vtk,*.vti,*.vtu,*.js,*.html,*.doctree,*.ttf,*.woff,*.woff2,*.eot,*.mp4,*.inv,*.pickle,*.ipynb,flycheck*,./.git/*,./.hypothesis/*,*.yml,doc/_build/*,./doc/images/*,./dist/*,*~,.hypothesis*,./doc/examples/*,*.mypy_cache/*,*cover,./tests/tinypages/_build/*,*/_autosummary/*' 80 | quiet-level = 3 81 | 82 | 83 | [tool.ruff] 84 | exclude = ['.git', 'pycache__', 'build', 'dist', 'doc/examples', 'doc/_build'] 85 | line-length = 100 86 | indent-width = 4 87 | target-version = 'py39' 88 | 89 | [tool.ruff.lint] 90 | external = ["E131", "D102", "D105"] 91 | ignore = [ 92 | # whitespace before ':' 93 | "E203", 94 | # line break before binary operator 95 | # "W503", 96 | # line length too long 97 | "E501", 98 | # do not assign a lambda expression, use a def 99 | "E731", 100 | # too many leading '#' for block comment 101 | "E266", 102 | # ambiguous variable name 103 | "E741", 104 | # module level import not at top of file 105 | "E402", 106 | # Quotes (temporary) 107 | "Q0", 108 | # bare excepts (temporary) 109 | # "B001", "E722", 110 | "E722", 111 | # we already check black 112 | # "BLK100", 113 | # 'from module import *' used; unable to detect undefined names 114 | "F403", 115 | ] 116 | fixable = ["ALL"] 117 | unfixable = [] 118 | extend-select = ["B007", "B010", "C4", "F", "NPY", "PGH004", "RSE", "RUF100"] 119 | 120 | [tool.ruff.lint.flake8-comprehensions] 121 | allow-dict-calls-with-keyword-arguments = true 122 | [tool.ruff.lint.per-file-ignores] 123 | "__init__.py" = ["F401"] 124 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | "map2loop": { 4 | "release-type": "python", 5 | "types": ["feat", "fix"], 6 | "bump-minor-pre-major": true, 7 | "bump-minor-pre-major-pattern": "feat", 8 | "bump-patch-for-minor-pre-major": true, 9 | "bump-patch-for-minor-pre-major-pattern": "fix", 10 | "include-v-in-tag": true 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools.command.sdist import sdist as _sdist 2 | import shutil 3 | from setuptools import setup, find_packages 4 | from pathlib import Path 5 | 6 | # Resolve the absolute path to the directory containing this file: 7 | package_root = Path(__file__).resolve().parent 8 | 9 | # Get the version from the version.py file 10 | version = {} 11 | version_file = package_root / "map2loop" / "version.py" 12 | with version_file.open() as fp: 13 | exec(fp.read(), version) 14 | 15 | # Read dependencies from dependencies.txt 16 | requirements_file = package_root / "dependencies.txt" 17 | with requirements_file.open("r") as f: 18 | install_requires = [line.strip() for line in f if line.strip()] 19 | 20 | 21 | class CustomSDist(_sdist): 22 | 23 | def make_release_tree(self, base_dir, files): 24 | # 1) Let the normal sdist process run first. 25 | super().make_release_tree(base_dir, files) 26 | map2loop_dir = Path(base_dir) / "map2loop" 27 | 28 | # 2) Specify which files to move from the root to map2loop/. 29 | top_level_files = ["dependencies.txt", "LICENSE", "README.md"] 30 | 31 | for filename in top_level_files: 32 | src = Path(base_dir) / filename 33 | dst = map2loop_dir / filename 34 | 35 | # If the source file exists in base_dir, move it to map2loop/. 36 | if src.exists(): 37 | shutil.copy(str(src), str(dst)) 38 | 39 | setup( 40 | name="map2loop", 41 | install_requires=install_requires, 42 | packages=find_packages(exclude=["tests", "tests.*"]), 43 | package_data={"": ['dependencies.txt']}, 44 | include_package_data=True, 45 | license="MIT", 46 | cmdclass={ 47 | "sdist": CustomSDist, 48 | }, 49 | ) 50 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Loop3D/map2loop/1af43e4621a837db02252c05ff8cee0d5d8b3dd0/tests/__init__.py -------------------------------------------------------------------------------- /tests/data_checks/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from map2loop.data_checks import validate_config_dictionary 3 | 4 | 5 | @pytest.fixture 6 | def valid_config(): 7 | return { 8 | "structure": { 9 | "orientation_type": "dip direction", 10 | "dipdir_column": "azimuth", 11 | "dip_column": "inclinatn", 12 | "description_column": "DESCRIPTION", 13 | "bedding_text": "bed", 14 | "overturned_column": "no_col", 15 | "overturned_text": "blah", 16 | "objectid_column": "geographic", 17 | "desciption_column": "sub_type" 18 | }, 19 | "geology": { 20 | "unitname_column": "formatted_", 21 | "alt_unitname_column": "abbreviate", 22 | "group_column": "no_col", 23 | "supergroup_column": "interpreta", 24 | "description_column": "text_descr", 25 | "minage_column": "no_col", 26 | "maxage_column": "no_col", 27 | "rocktype_column": "rank", 28 | "alt_rocktype_column": "type", 29 | "sill_text": "sill", 30 | "intrusive_text": "intrusion", 31 | "volcanic_text": "volc", 32 | "objectid_column": "ID", 33 | "ignore_lithology_codes": ["cover"] 34 | }, 35 | "fault": { 36 | "structtype_column": "featuretyp", 37 | "fault_text": "s", 38 | "dip_null_value": "0", 39 | "dipdir_flag": "num", 40 | "dipdir_column": "no_col", 41 | "dip_column": "no_col", 42 | "orientation_type": "dip direction", 43 | "dipestimate_column": "no_col", 44 | "dipestimate_text": "no_col", 45 | "name_column": "no_col", 46 | "objectid_column": "geographic", 47 | "minimum_fault_length": 100.0, 48 | "ignore_fault_codes": [] 49 | }, 50 | "fold": { 51 | "structtype_column": "featuretyp", 52 | "fold_text": "fold", 53 | "description_column": "no_col", 54 | "synform_text": "syn", 55 | "foldname_column": "NAME", 56 | "objectid_column": "geographic" 57 | } 58 | } 59 | 60 | 61 | def test_valid_config_no_errors(valid_config): 62 | # Should not raise any error 63 | validate_config_dictionary(valid_config) 64 | 65 | 66 | def test_missing_required_section(valid_config): 67 | 68 | config_missing_structure = dict(valid_config) 69 | del config_missing_structure["structure"] # remove required section 70 | 71 | with pytest.raises(ValueError) as exc_info: 72 | validate_config_dictionary(config_missing_structure) 73 | assert "Missing required section 'structure'" in str(exc_info.value) 74 | 75 | 76 | def test_missing_required_key(valid_config): 77 | 78 | config_missing_dip = dict(valid_config) 79 | 80 | del config_missing_dip["structure"]["dip_column"] # remove required key 81 | 82 | with pytest.raises(ValueError) as exc_info: 83 | validate_config_dictionary(config_missing_dip) 84 | assert "Missing required key 'dip_column' for 'structure'" in str(exc_info.value) 85 | 86 | 87 | def test_unrecognized_section(valid_config): 88 | 89 | config_extra_section = dict(valid_config) 90 | config_extra_section["random_section"] = {"random_key": "random_value"} 91 | 92 | with pytest.raises(ValueError) as exc_info: 93 | validate_config_dictionary(config_extra_section) 94 | assert "Unrecognized section 'random_section'" in str(exc_info.value) 95 | 96 | 97 | def test_unrecognized_key_in_section(valid_config): 98 | 99 | config_extra_key = dict(valid_config) 100 | config_extra_key["structure"]["random_key"] = "random_value" 101 | 102 | with pytest.raises(ValueError) as exc_info: 103 | validate_config_dictionary(config_extra_key) 104 | assert "Key 'random_key' is not an allowed key in the 'structure' section." in str(exc_info.value) 105 | 106 | 107 | def test_legacy_key_detected(valid_config): 108 | 109 | config_with_legacy = dict(valid_config) 110 | config_with_legacy["structure"]["otype"] = "legacy_value" # 'otype' --> legacy key 111 | with pytest.raises(ValueError) as exc_info: 112 | validate_config_dictionary(config_with_legacy) 113 | assert "Legacy key found in config - 'otype'" in str(exc_info.value) 114 | 115 | 116 | def test_minimum_fault_length_wrong_type(valid_config): 117 | 118 | config_wrong_mfl = dict(valid_config) 119 | config_wrong_mfl["fault"]["minimum_fault_length"] = "one_hundred" # invalid type 120 | 121 | with pytest.raises(ValueError) as exc_info: 122 | validate_config_dictionary(config_wrong_mfl) 123 | assert "minimum_fault_length must be a number" in str(exc_info.value) 124 | 125 | 126 | def test_minimum_fault_length_missing(valid_config): 127 | """ 128 | Remove minimum_fault_length entirely. That should be fine (None -> no check). 129 | """ 130 | config_no_mfl = dict(valid_config) 131 | del config_no_mfl["fault"]["minimum_fault_length"] 132 | 133 | # Should not raise any error, as it's optional 134 | validate_config_dictionary(config_no_mfl) 135 | 136 | -------------------------------------------------------------------------------- /tests/data_checks/test_input_data_faults.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import geopandas as gpd 3 | import shapely.geometry 4 | from map2loop.mapdata import MapData 5 | from map2loop.m2l_enums import Datatype 6 | from map2loop.data_checks import check_fault_fields_validity 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "fault_data, fault_config, expected_validity, expected_message", 11 | [ 12 | # Valid data 13 | ( 14 | { 15 | "geometry": [ 16 | shapely.geometry.LineString([(0, 0), (1, 1)]), 17 | shapely.geometry.MultiLineString([[(0, 0), (1, 1)], [(1, 1), (2, 2)]]), 18 | ], 19 | "FEATURE": ["Fault A", "Fault B"], 20 | "ID": [1, 2], 21 | }, 22 | {"structtype_column": "FEATURE", "fault_text": "Fault", "objectid_column": "ID"}, 23 | False, 24 | "", 25 | ), 26 | # Invalid geometry 27 | ( 28 | { 29 | "geometry": [ 30 | shapely.geometry.LineString([(0, 0), (1, 1)]), 31 | shapely.geometry.Polygon( 32 | [(0, 0), (1, 1), (1, 0), (0, 1), (0, 0)] 33 | ), # Invalid geometry 34 | ], 35 | "FEATURE": ["Fault A", "Fault B"], 36 | "ID": [1, 2], 37 | }, 38 | {"structtype_column": "FEATURE", "fault_text": "Fault", "objectid_column": "ID"}, 39 | True, 40 | "Invalid geometry types found in datatype FAULT. All geometries must be LineString, MultiLineString.", 41 | ), 42 | # Non-string FEATURE column 43 | ( 44 | { 45 | "geometry": [ 46 | shapely.geometry.LineString([(0, 0), (1, 1)]), 47 | shapely.geometry.MultiLineString([[(0, 0), (1, 1)], [(1, 1), (2, 2)]]), 48 | ], 49 | "FEATURE": [5, 2], 50 | "ID": [1, 2], 51 | }, 52 | {"structtype_column": "FEATURE", "fault_text": "Fault", "objectid_column": "ID"}, 53 | True, 54 | "Datatype FAULT: Column 'FEATURE' (config key: 'structtype_column') contains non-string values. Please ensure all values in this column are strings.", 55 | ), 56 | # Invalid values in DIP estimate column 57 | ( 58 | { 59 | "geometry": [ 60 | shapely.geometry.LineString([(0, 0), (1, 1)]), 61 | shapely.geometry.MultiLineString([[(0, 0), (1, 1)], [(1, 1), (2, 2)]]), 62 | ], 63 | "FEATURE": ["Fault", "Fault"], 64 | "NAME": ["Zuleika", "Zuleika"], 65 | "ID": [1, 2], 66 | "DIP": [70, 50], 67 | "STRIKE": [150, None], 68 | "DEC": ["north_east", "southt"], 69 | }, 70 | { 71 | "structtype_column": "FEATURE", 72 | "fault_text": "Fault", 73 | "objectid_column": "ID", 74 | "name_column": "NAME", 75 | "dip_column": "DIP", 76 | "dipdir_column": "STRIKE", 77 | "dip_estimate_column": "DEC", 78 | }, 79 | True, 80 | "Datatype FAULT: Column 'DEC' contains invalid values. Allowed values: ['north_east', 'south_east', 'south_west', 'north_west', 'north', 'east', 'south', 'west'].", 81 | ), 82 | ], 83 | ids=[ 84 | "Valid fault data", 85 | "Invalid geometry", 86 | "Non-string FEATURE column", 87 | "Invalid DIP estimate column", 88 | ], 89 | ) 90 | def test_check_fault_fields_validity(fault_data, fault_config, expected_validity, expected_message): 91 | # Dynamically create the mock config for this test case 92 | class MockConfig: 93 | def __init__(self, config): 94 | self.fault_config = config 95 | 96 | # Create a GeoDataFrame 97 | fault_gdf = gpd.GeoDataFrame(fault_data, crs="EPSG:4326") 98 | 99 | # Instantiate the MapData class with the dynamic mock config and data 100 | map_data = MapData() 101 | map_data.config = MockConfig(fault_config) 102 | map_data.raw_data = [None] * len(Datatype.__dict__) 103 | map_data.raw_data[Datatype.FAULT] = fault_gdf 104 | 105 | # Test the check_fault_fields_validity function 106 | validity_check, message = check_fault_fields_validity(map_data) 107 | assert validity_check == expected_validity 108 | assert message == expected_message 109 | -------------------------------------------------------------------------------- /tests/data_checks/test_input_data_fold.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import geopandas as gpd 3 | import shapely.geometry 4 | from map2loop.mapdata import MapData 5 | from map2loop.m2l_enums import Datatype 6 | from map2loop.data_checks import check_fold_fields_validity 7 | 8 | @pytest.mark.parametrize( 9 | "fold_data, fold_config, expected_validity, expected_message", 10 | [ 11 | # Valid data 12 | ( 13 | { 14 | "geometry": [ 15 | shapely.geometry.LineString([(0, 0), (1, 1)]), 16 | shapely.geometry.MultiLineString([[(0, 0), (1, 1)], [(1, 1), (2, 2)]]) 17 | ], 18 | "FEATURE": ["fold A", "fold B"], 19 | "ID": [1, 2], 20 | "description": ["desc1", "desc2"] 21 | }, 22 | {"structtype_column": "FEATURE", "fold_text": "fold", "objectid_column": "ID", "description_column": "description"}, 23 | False, 24 | "" 25 | ), 26 | # Missing geometry 27 | ( 28 | { 29 | "geometry": [ 30 | shapely.geometry.LineString([(0,0), (0,0)]), # Invalid type 31 | shapely.geometry.LineString([(0, 0), (1, 1)]) 32 | ], 33 | "FEATURE": ["fold A", "fold B"], 34 | "ID": [1, 2], 35 | "description": ["desc1", "desc2"] 36 | }, 37 | {"structtype_column": "FEATURE", "fold_text": "fold", "objectid_column": "ID", "description_column": "description"}, 38 | True, 39 | "Invalid geometry types found in datatype FOLD. All geometries must be LineString, MultiLineString." 40 | ), 41 | # Non-string FEATURE column 42 | ( 43 | { 44 | "geometry": [ 45 | shapely.geometry.LineString([(0, 0), (1, 1)]), 46 | shapely.geometry.MultiLineString([[(0, 0), (1, 1)], [(1, 1), (2, 2)]]) 47 | ], 48 | "FEATURE": [123, 456], # Invalid type 49 | "ID": [1, 2], 50 | "description": ["desc1", "desc2"] 51 | }, 52 | {"structtype_column": "FEATURE", "fold_text": "fold", "objectid_column": "ID", "description_column": "description"}, 53 | True, 54 | "Datatype FOLD: Column 'FEATURE' (config key: 'structtype_column') contains non-string values. Please ensure all values in this column are strings." 55 | ), 56 | # Missing ID column 57 | ( 58 | { 59 | "geometry": [ 60 | shapely.geometry.LineString([(0, 0), (1, 1)]), 61 | shapely.geometry.MultiLineString([[(0, 0), (1, 1)], [(1, 1), (2, 2)]]) 62 | ], 63 | "FEATURE": ["fold A", "fold B"], 64 | "description": ["desc1", "desc2"] 65 | }, 66 | {"structtype_column": "FEATURE", "fold_text": "fold", "objectid_column": "ID", "description_column": "description"}, 67 | False, 68 | "" 69 | ), 70 | # Duplicate ID values 71 | ( 72 | { 73 | "geometry": [ 74 | shapely.geometry.LineString([(0, 0), (1, 1)]), 75 | shapely.geometry.MultiLineString([[(0, 0), (1, 1)], [(1, 1), (2, 2)]]) 76 | ], 77 | "FEATURE": ["fold A", "fold B"], 78 | "ID": [1, 1], # Duplicate values 79 | "description": ["desc1", "desc2"] 80 | }, 81 | {"structtype_column": "FEATURE", "fold_text": "fold", "objectid_column": "ID", "description_column": "description"}, 82 | True, 83 | "Datatype FOLD: Column 'ID' (config key: 'objectid_column') contains duplicate values." 84 | ), 85 | ], 86 | ids=[ 87 | "Valid fold data", 88 | "Invalid geometry", 89 | "Non-string FEATURE column", 90 | "Missing ID column", 91 | "Duplicate ID values" 92 | ] 93 | ) 94 | def test_check_fold_fields_validity(fold_data, fold_config, expected_validity, expected_message): 95 | # Dynamically create the mock config for this test case 96 | class MockConfig: 97 | def __init__(self, config): 98 | self.fold_config = config 99 | 100 | # Create a GeoDataFrame 101 | fold_gdf = gpd.GeoDataFrame(fold_data, crs="EPSG:4326") 102 | 103 | # Instantiate the MapData class with the dynamic mock config and data 104 | map_data = MapData() 105 | map_data.config = MockConfig(fold_config) 106 | map_data.raw_data = [None] * len(Datatype.__dict__) 107 | map_data.raw_data[Datatype.FOLD] = fold_gdf 108 | 109 | # Test the check_fold_fields_validity function 110 | validity_check, message = check_fold_fields_validity(map_data) 111 | assert validity_check == expected_validity 112 | assert message == expected_message 113 | -------------------------------------------------------------------------------- /tests/data_checks/test_input_data_geology.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import geopandas as gpd 3 | import shapely.geometry 4 | from map2loop.mapdata import MapData 5 | from map2loop.data_checks import check_geology_fields_validity 6 | 7 | # Datatype Enum 8 | class Datatype: 9 | GEOLOGY = 0 10 | 11 | # Config 12 | class MockConfig: 13 | def __init__(self): 14 | self.geology_config = { 15 | "unitname_column": "UNITNAME", 16 | "alt_unitname_column": "CODE", 17 | "group_column": "GROUP", 18 | "supergroup_column": "SUPERGROUP", 19 | "description_column": "DESCRIPTION", 20 | "rocktype_column": "ROCKTYPE1", 21 | "alt_rocktype_column": "ROCKTYPE2", 22 | "minage_column": "MIN_AGE", 23 | "maxage_column": "MAX_AGE", 24 | "objectid_column": "ID", 25 | "ignore_lithology_codes": [], 26 | } 27 | 28 | @pytest.mark.parametrize( 29 | "geology_data, expected_validity, expected_message", 30 | [ 31 | # Valid data 32 | ( 33 | { 34 | "geometry": [shapely.geometry.Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])], 35 | "UNITNAME": ["Sandstone"], 36 | "CODE": ["SST"], 37 | "GROUP": ["Sedimentary"], 38 | "SUPERGROUP": ["Mesozoic"], 39 | "DESCRIPTION": ["A type of sandstone"], 40 | "ROCKTYPE1": ["Clastic"], 41 | "ROCKTYPE2": ["Quartz"], 42 | "MIN_AGE": [150.0], 43 | "MAX_AGE": [200.0], 44 | "ID": [1], 45 | }, 46 | False, 47 | "", 48 | ), 49 | # Invalid geometry 50 | ( 51 | { 52 | "geometry": [shapely.geometry.Polygon([(0, 0), (1, 1), (1, 0), (0, 1), (0, 0)])], 53 | "UNITNAME": ["Sandstone"], 54 | "CODE": ["SST"], 55 | "GROUP": ["Sedimentary"], 56 | "SUPERGROUP": ["Mesozoic"], 57 | "DESCRIPTION": ["A type of sandstone"], 58 | "ROCKTYPE1": ["Clastic"], 59 | "ROCKTYPE2": ["Quartz"], 60 | "MIN_AGE": [150.0], 61 | "MAX_AGE": [200.0], 62 | "ID": [1], 63 | }, 64 | False, 65 | "", 66 | ), 67 | # Missing required column 68 | ( 69 | { 70 | "geometry": [shapely.geometry.Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])], 71 | "UNITNAME": ["Sandstone"], 72 | # "CODE": ["SST"], # Missing required column 73 | "GROUP": ["Sedimentary"], 74 | "SUPERGROUP": ["Mesozoic"], 75 | "DESCRIPTION": ["A type of sandstone"], 76 | "ROCKTYPE1": ["Clastic"], 77 | "ROCKTYPE2": ["Quartz"], 78 | "MIN_AGE": [150.0], 79 | "MAX_AGE": [200.0], 80 | "ID": [1], 81 | }, 82 | True, 83 | "Datatype GEOLOGY: Required column with config key 'alt_unitname_column' (column: 'CODE') is missing from the data.", 84 | ), 85 | # Non-string value in required column 86 | ( 87 | { 88 | "geometry": [shapely.geometry.Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])], 89 | "UNITNAME": ["Sandstone"], 90 | "CODE": [2], # Non-string value 91 | "GROUP": ["Sedimentary"], 92 | "SUPERGROUP": ["Mesozoic"], 93 | "DESCRIPTION": ["A type of sandstone"], 94 | "ROCKTYPE1": ["Clastic"], 95 | "ROCKTYPE2": ["Quartz"], 96 | "MIN_AGE": [150.0], 97 | "MAX_AGE": [200.0], 98 | "ID": [1], 99 | }, 100 | True, 101 | "Datatype GEOLOGY: Column 'alt_unitname_column' (column: 'CODE') must contain only values.", 102 | ), 103 | # NaN or blank value in required column 104 | ( 105 | { 106 | "geometry": [shapely.geometry.Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])], 107 | "UNITNAME": [""], # Blank value 108 | "CODE": ["SST"], 109 | "GROUP": ["Sedimentary"], 110 | "SUPERGROUP": ["Mesozoic"], 111 | "DESCRIPTION": ["A type of sandstone"], 112 | "ROCKTYPE1": ["Clastic"], 113 | "ROCKTYPE2": ["Quartz"], 114 | "MIN_AGE": [150.0], 115 | "MAX_AGE": [200.0], 116 | "ID": [1], 117 | }, 118 | True, 119 | "Datatype GEOLOGY: Column 'unitname_column' (column: 'UNITNAME') contains blank (empty) values. Please ensure all values are populated.", 120 | ), 121 | # Duplicate ID values 122 | ( 123 | { 124 | "geometry": [ 125 | shapely.geometry.Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]), 126 | shapely.geometry.Polygon([(0, 0), (10, 0), (1, 1), (0, 10)]), 127 | ], 128 | "UNITNAME": ["fr", "df"], 129 | "CODE": ["SST", "FGH"], 130 | "GROUP": ["Sedimentary", "Ign"], 131 | "SUPERGROUP": ["Mesozoic", "Arc"], 132 | "DESCRIPTION": ["A", "B"], 133 | "ROCKTYPE1": ["A", "B"], 134 | "ROCKTYPE2": ["Quartz", "FDS"], 135 | "MIN_AGE": [150.0, 200], 136 | "MAX_AGE": [200.0, 250], 137 | "ID": [1, 1], # Duplicate ID 138 | }, 139 | True, 140 | "Datatype GEOLOGY: Column 'ID' (config key: 'objectid_column') contains duplicate values.", 141 | ), 142 | # nan in id 143 | ( 144 | { 145 | "geometry": [ 146 | shapely.geometry.Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]), 147 | shapely.geometry.Polygon([(0, 0), (10, 0), (1, 1), (0, 10)]), 148 | ], 149 | "UNITNAME": ["fr", "df"], 150 | "CODE": ["SST", "FGH"], 151 | "GROUP": ["Sedimentary", "Ign"], 152 | "SUPERGROUP": ["Mesozoic", "Arc"], 153 | "DESCRIPTION": ["A", "B"], 154 | "ROCKTYPE1": ["A", "B"], 155 | "ROCKTYPE2": ["Quartz", "FDS"], 156 | "MIN_AGE": [150.0, 200], 157 | "MAX_AGE": [200.0, 250], 158 | "ID": [1, None], 159 | }, 160 | True, 161 | "Datatype GEOLOGY: Column 'ID' (config key: 'objectid_column') contains non-numeric or NaN values. Please rectify the values, or remove this key from the config dictionary to let map2loop assign IDs.", 162 | ), 163 | # nan in unit name 164 | ( 165 | { 166 | "geometry": [ 167 | shapely.geometry.Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]), 168 | shapely.geometry.Polygon([(0, 0), (10, 0), (1, 1), (0, 10)]), 169 | ], 170 | "UNITNAME": ["fr", None], 171 | "CODE": ["SST", "FGH"], 172 | "GROUP": ["Sedimentary", "Ign"], 173 | "SUPERGROUP": ["Mesozoic", "Arc"], 174 | "DESCRIPTION": ["A", "B"], 175 | "ROCKTYPE1": ["A", "B"], 176 | "ROCKTYPE2": ["Quartz", "FDS"], 177 | "MIN_AGE": [150.0, 200], 178 | "MAX_AGE": [200.0, 250], 179 | "ID": [1, 1], # Duplicate ID 180 | }, 181 | True, 182 | "Datatype GEOLOGY: Column 'unitname_column' (column: 'UNITNAME') must contain only values.", 183 | ), 184 | ], 185 | ) 186 | 187 | 188 | 189 | def test_check_geology_fields_validity(geology_data, expected_validity, expected_message): 190 | # Create a GeoDataFrame 191 | geology_gdf = gpd.GeoDataFrame(geology_data, crs="EPSG:4326") 192 | 193 | # Instantiate the MapData class with the mock config and data 194 | map_data = MapData() 195 | map_data.config = MockConfig() 196 | map_data.raw_data = [None] * len(Datatype.__dict__) 197 | map_data.raw_data[Datatype.GEOLOGY] = geology_gdf 198 | 199 | # Test the check_geology_fields_validity function 200 | validity_check, message = check_geology_fields_validity(map_data) 201 | 202 | assert validity_check == expected_validity 203 | assert message == expected_message -------------------------------------------------------------------------------- /tests/data_checks/test_input_data_structure.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import geopandas as gpd 3 | import shapely.geometry 4 | from map2loop.mapdata import MapData 5 | from map2loop.data_checks import check_structure_fields_validity 6 | 7 | # Datatype Enum 8 | class Datatype: 9 | STRUCTURE = 1 10 | 11 | # Config 12 | class MockConfig: 13 | def __init__(self): 14 | self.structure_config = { 15 | "dipdir_column": "DIPDIR", 16 | "dip_column": "DIP", 17 | "description_column": "DESCRIPTION", 18 | "overturned_column": "OVERTURNED", 19 | "objectid_column": "ID", 20 | } 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "structure_data, expected_validity, expected_message", 25 | [ 26 | # Valid data 27 | ( 28 | { 29 | "geometry": [shapely.geometry.Point(0, 0), shapely.geometry.Point(1, 1)], 30 | "DIPDIR": [45.0, 135.0], 31 | "DIP": [30.0, 45.0], 32 | "DESCRIPTION": ["Description1", "Description2"], 33 | "OVERTURNED": ["Yes", "No"], 34 | "ID": [1, 2], 35 | }, 36 | False, 37 | "", 38 | ), 39 | # Invalid geometry 40 | ( 41 | { 42 | "geometry": [ 43 | shapely.geometry.Point(0, 0), 44 | shapely.geometry.Polygon( 45 | [(0, 0), (1, 1), (1, 0), (0, 1), (0, 0)] 46 | ), # Invalid geometry 47 | ], 48 | "DIPDIR": [45.0, 135.0], 49 | "DIP": [30.0, 45.0], 50 | "DESCRIPTION": ["Description1", "Description2"], 51 | "OVERTURNED": ["Yes", "No"], 52 | "ID": [1, 2], 53 | }, 54 | True, 55 | "Invalid geometry types found in datatype STRUCTURE. All geometries must be Point, MultiPoint.", 56 | ), 57 | # Missing required column 58 | ( 59 | { 60 | "geometry": [shapely.geometry.Point(0, 0), shapely.geometry.Point(1, 1)], 61 | # "DIPDIR": [45.0, 135.0], # Missing required column 62 | "DIP": [30.0, 45.0], 63 | "DESCRIPTION": ["Description1", "Description2"], 64 | "OVERTURNED": ["Yes", "No"], 65 | "ID": [1, 2], 66 | }, 67 | True, 68 | "Datatype STRUCTURE: Required column with config key 'dipdir_column' (column: 'DIPDIR') is missing from the data.", 69 | ), 70 | # Non-numeric value in numeric column 71 | ( 72 | { 73 | "geometry": [shapely.geometry.Point(0, 0), shapely.geometry.Point(1, 1)], 74 | "DIPDIR": ["A", "B"], # Non-numeric value 75 | "DIP": [30.0, 45.0], 76 | "DESCRIPTION": ["Description1", "Description2"], 77 | "OVERTURNED": ["Yes", "No"], 78 | "ID": [1, 2], 79 | }, 80 | True, 81 | "Datatype STRUCTURE: Column 'dipdir_column' (column: 'DIPDIR') must contain only numeric values.", 82 | ), 83 | # NaN or blank value in required column 84 | ( 85 | { 86 | "geometry": [shapely.geometry.Point(0, 0), shapely.geometry.Point(1, 1)], 87 | "DIPDIR": [None, 3], # NaN value 88 | "DIP": [30.0, 45.0], 89 | "DESCRIPTION": ["Description1", "Description2"], 90 | "OVERTURNED": ["Yes", "No"], 91 | "ID": [1, 2], 92 | }, 93 | True, 94 | "Datatype STRUCTURE: Column 'dipdir_column' (column: 'DIPDIR') contains null values. Please ensure all values are present.", 95 | ), 96 | # Duplicate ID column 97 | ( 98 | { 99 | "geometry": [shapely.geometry.Point(0, 0), shapely.geometry.Point(1, 1)], 100 | "DIPDIR": [45.0, 135.0], 101 | "DIP": [30.0, 45.0], 102 | "DESCRIPTION": ["Description1", "Description2"], 103 | "OVERTURNED": ["Yes", "No"], 104 | "ID": [1, 1], # Duplicate ID 105 | }, 106 | True, 107 | "Datatype STRUCTURE: Column 'ID' (config key: 'objectid_column') contains duplicate values.", 108 | ), 109 | ], 110 | ) 111 | def test_check_structure_fields_validity(structure_data, expected_validity, expected_message): 112 | # Create a GeoDataFrame 113 | structure_gdf = gpd.GeoDataFrame(structure_data, crs="EPSG:4326") 114 | 115 | # Instantiate the MapData class with the mock config and data 116 | map_data = MapData() 117 | map_data.config = MockConfig() 118 | map_data.raw_data = [None] * len(Datatype.__dict__) 119 | map_data.raw_data[Datatype.STRUCTURE] = structure_gdf 120 | 121 | # Test the check_structure_fields_validity function 122 | validity_check, message = check_structure_fields_validity(map_data) 123 | assert validity_check == expected_validity 124 | assert message == expected_message 125 | -------------------------------------------------------------------------------- /tests/mapdata/test_mapdata_assign_random_colours_to_units.py: -------------------------------------------------------------------------------- 1 | ### This file tests the function colour_units() in map2loop/mapdata.py 2 | ### Two main test cases are considered: cases in which there is clut file and cases in which there is no clut file 3 | 4 | import pandas as pd 5 | from map2loop.mapdata import MapData 6 | 7 | 8 | # are random colours being assigned to stratigraphic units in cases where no clut file is provided? 9 | def test_colour_units_no_clut_file(): 10 | # Create a sample DataFrame with missing 'colour' values 11 | data = {"name": ["Unit1", "Unit2", "Unit3"], "colour": [None, None, None]} 12 | stratigraphic_units = pd.DataFrame(data) 13 | 14 | # Instantiate the class and call the method 15 | md = MapData() 16 | md.colour_filename = None # Ensure no file is used 17 | result = md.colour_units(stratigraphic_units) 18 | 19 | # check that there are no duplicates in the 'unit' column 20 | assert result['name'].is_unique, "colour_units() in mapdata.py producing duplicate units" 21 | 22 | # Check that the 'colour' column has been assigned random colors 23 | assert ( 24 | len(result["colour"].dropna()) == 3 25 | ), "function MapData.colour_units not assigning the right len of random colours" 26 | 27 | # are the generated colours valid hex colours? 28 | colours = result["colour"] 29 | 30 | assert colours.apply( 31 | isinstance, args=(str,) 32 | ).all(), ( 33 | "function MapData.colour_units without clut file not assigning random hex colours as str" 34 | ) 35 | assert colours.str.startswith( 36 | "#" 37 | ).all(), "function MapData.colour_units without clut file not generating the right hex codes with # at the start" 38 | assert ( 39 | colours.str.len().isin([7, 4]).all() 40 | ), "function MapData.colour_units without clut file not generating the right hex codes with 7 or 4 characters" 41 | 42 | 43 | def test_colour_units_with_colour_file(tmp_path): 44 | # Create a strati units df with missing 'colour' values 45 | data = {"name": ["Unit1", "Unit2", "Unit3"], "colour": [None, None, None]} 46 | stratigraphic_units = pd.DataFrame(data) 47 | 48 | # Create a temp clut file 49 | colour_data = {"UNITNAME": ["Unit1", "Unit2"], "colour": ["#112233", "#445566"]} 50 | colour_lookup_df = pd.DataFrame(colour_data) 51 | colour_filename = tmp_path / "colour_lookup.csv" 52 | colour_lookup_df.to_csv(colour_filename, index=False) 53 | 54 | # Instantiate the class and call the method 55 | md = MapData() 56 | md.colour_filename = str(colour_filename) 57 | result = md.colour_units(stratigraphic_units) 58 | 59 | # check that there are no duplicates in the 'unit' column 60 | assert result['name'].is_unique, "colour_units() in mapdata.py producing duplicate units" 61 | 62 | # Check that the 'colour' column has been merged correctly and missing colors are assigned 63 | expected_colors = ["#112233", "#445566"] 64 | assert ( 65 | result["colour"].iloc[0] == expected_colors[0] 66 | ), "function MapData.colour_units with clut file not assigning the right colour from the lookup file" 67 | assert ( 68 | result["colour"].iloc[1] == expected_colors[1] 69 | ), "function MapData.colour_units with clut file not assigning the right colour from the lookup file" 70 | assert isinstance( 71 | result["colour"].iloc[2], str 72 | ), "function MapData.colour_units with clut file not assigning random hex colours as str" 73 | assert ( 74 | result["colour"].iloc[2].startswith("#") 75 | ), "function MapData.colour_units with clut file not generating the right hex codes with # at the start" 76 | assert len(result["colour"].iloc[2]) in { 77 | 7, 78 | 4, 79 | }, "function MapData.colour_units with clut file not generating the right hex codes with 7 or 4 characters" 80 | -------------------------------------------------------------------------------- /tests/mapdata/test_mapdata_dipdir.py: -------------------------------------------------------------------------------- 1 | ### This file tests the function parse_structure_map() in map2loop/mapdata.py 2 | ### at the moment only tests for DIPDIR values lower than 360 degrees 3 | ### TODO: add more tests for this function 4 | 5 | import pytest 6 | import geopandas 7 | import shapely 8 | from map2loop.mapdata import MapData 9 | from map2loop.m2l_enums import Datatype 10 | 11 | 12 | def test_if_m2l_returns_all_sampled_structures_with_DIPDIR_lower_than_360(): 13 | 14 | # call the class 15 | md = MapData() 16 | 17 | # add config definition 18 | md.config.structure_config = { 19 | "dipdir_column": "DIPDIR", 20 | "dip_column": "DIP", 21 | "description_column": "DESCRIPTION", 22 | "bedding_text": "Bedding", 23 | "objectid_column": "ID", 24 | "overturned_column": "facing", 25 | "overturned_text": "DOWN", 26 | "orientation_type": "strike", 27 | } 28 | 29 | # create mock data 30 | data = { 31 | 'geometry': [shapely.Point(1, 1), shapely.Point(2, 2), shapely.Point(3, 3)], 32 | 'DIPDIR': [45.0, 370.0, 420.0], 33 | 'DIP': [30.0, 60.0, 50], 34 | 'OVERTURNED': ["False", "True", "True"], 35 | 'DESCRIPTION': ["Bedding", "Bedding", "Bedidng"], 36 | 'ID': [1, 2, 3], 37 | } 38 | 39 | # build geodataframe to hold the data 40 | data = geopandas.GeoDataFrame(data) 41 | 42 | # set it as the raw_data 43 | md.raw_data[Datatype.STRUCTURE] = data 44 | 45 | # make it parse the structure map and raise exception if error in parse_structure_map 46 | 47 | try: 48 | md.parse_structure_map() 49 | except Exception as e: 50 | pytest.fail(f"parse_structure_map raised an exception: {e}") 51 | 52 | # check if all values below 360 53 | assert ( 54 | md.data[Datatype.STRUCTURE]['DIPDIR'].all() < 360 55 | ), "MapData.STRUCTURE is producing DIPDIRs > 360 degrees" 56 | 57 | 58 | # 59 | -------------------------------------------------------------------------------- /tests/mapdata/test_minimum_fault_length.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import geopandas as gpd 3 | import shapely 4 | import numpy 5 | 6 | from map2loop.mapdata import MapData 7 | from map2loop.m2l_enums import VerboseLevel, Datatype 8 | 9 | 10 | @pytest.fixture 11 | def setup_map_data(): 12 | # Create a mock MapData object with verbose level set to ALL 13 | map_data = MapData(verbose_level=VerboseLevel.ALL) 14 | 15 | # Simulate config with no minimum_fault_length set 16 | map_data.config.fault_config['minimum_fault_length'] = -1.0 17 | 18 | return map_data 19 | 20 | 21 | # for cases when there is no minimum_fault_length set in the config by user - does it update from map? 22 | def test_update_minimum_fault_length_from_faults(setup_map_data): 23 | map_data = setup_map_data 24 | 25 | # Define a test bounding box (in meters) 26 | bounding_box = { 27 | "minx": 0, 28 | "miny": 0, 29 | "maxx": 10000, # width = 10,000 meters 30 | "maxy": 10000, # height = 10,000 meters 31 | } 32 | 33 | # Set the bounding box in the map data 34 | map_data.set_bounding_box(bounding_box) 35 | 36 | # update config 37 | map_data.config.fault_config['name_column'] = 'NAME' 38 | map_data.config.fault_config['dip_column'] = 'DIP' 39 | 40 | # Define a dummy fault GeoDataFrame with faults of varying lengths 41 | faults = gpd.GeoDataFrame( 42 | { 43 | 'geometry': [ 44 | shapely.geometry.LineString( 45 | [(0, 0), (50, 50)] 46 | ), # Fault 1 (small, length ~70.7 meters) 47 | shapely.geometry.LineString( 48 | [(0, 0), (3000, 3000)] 49 | ), # Fault 2 (length ~4242 meters) 50 | shapely.geometry.LineString( 51 | [(0, 0), (7000, 7000)] 52 | ), # Fault 3 (length ~9899 meters) 53 | ], 54 | 'NAME': ['Fault_1', 'Fault_2', 'Fault_3'], 55 | 'DIP': [60, 45, 30], 56 | } 57 | ) 58 | 59 | faults.crs = "EPSG: 7850" 60 | 61 | # get the cropped fault dataset from parse_fault_map 62 | map_data.raw_data[Datatype.FAULT] = faults 63 | map_data.parse_fault_map() 64 | cropped_faults = map_data.data[Datatype.FAULT] 65 | 66 | # calculate 5% length of the bounding box area 67 | expected_minimum_fault_length = numpy.sqrt( 68 | 0.05 69 | * (bounding_box['maxx'] - bounding_box['minx']) 70 | * (bounding_box['maxy'] - bounding_box['miny']) 71 | ) 72 | 73 | # Verify that the minimum_fault_length was calculated correctly 74 | assert map_data.minimum_fault_length == pytest.approx(expected_minimum_fault_length, rel=1e-3) 75 | 76 | # There should only be two faults remaining (the second and third ones) 77 | assert len(cropped_faults) == 2 78 | 79 | # Ensure that the remaining faults are the correct ones 80 | remaining_lengths = cropped_faults.geometry.length 81 | assert all(remaining_lengths >= expected_minimum_fault_length) 82 | assert cropped_faults.geometry.equals( 83 | faults.iloc[1:]['geometry'] 84 | ) # Faults 2 and 3 geometries should be the same in the faults raw and faults cropped 85 | 86 | 87 | # are faults with length less than minimum_fault_length removed from the dataset? 88 | def test_cropping_faults_by_minimum_fault_length(setup_map_data): 89 | map_data = setup_map_data 90 | 91 | # Set minimum_fault_length in the config to 10 92 | map_data.config.fault_config['minimum_fault_length'] = 10.0 93 | 94 | map_data.config.fault_config['name_column'] = 'NAME' 95 | map_data.config.fault_config['dip_column'] = 'DIP' 96 | 97 | # Create a mock faults dataset with lengths < 10 and > 10 98 | faults = gpd.GeoDataFrame( 99 | { 100 | 'geometry': [ 101 | shapely.geometry.LineString([(0, 0), (2, 2)]), # Length ~2.83 (should be cropped) 102 | shapely.geometry.LineString([(0, 0), (5, 5)]), # Length ~7.07 (should be cropped) 103 | shapely.geometry.LineString([(0, 0), (10, 10)]), # Length ~14.14 (should remain) 104 | ], 105 | 'NAME': ['Fault_1', 'Fault_2', 'Fault_3'], 106 | 'DIP': [60, 45, 30], 107 | 'DIPDIR': [90, 120, 150], 108 | } 109 | ) 110 | 111 | # Set the raw data in the map_data object to simulate loaded fault data 112 | map_data.raw_data[Datatype.FAULT] = faults 113 | map_data.parse_fault_map() 114 | cropped_faults = map_data.data[Datatype.FAULT] 115 | 116 | # Assert that only 1 fault remains (the one with length ~14.14) 117 | assert ( 118 | len(cropped_faults) == 1 119 | ), f"Expected only 1 fault remaining after cropping, but found {len(cropped_faults)}" 120 | 121 | # Optionally, check that the remaining fault has the correct geometry (the long one) 122 | assert cropped_faults.iloc[0].geometry.length == pytest.approx( 123 | 14.14, 0.01 124 | ), f"Expected remaining fault length to be 14.14, but got {cropped_faults.iloc[0].geometry.length}" 125 | -------------------------------------------------------------------------------- /tests/mapdata/test_set_get_recreate_bounding_box.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from map2loop.mapdata import MapData 3 | import geopandas 4 | import shapely 5 | 6 | 7 | @pytest.fixture 8 | def md(): 9 | return MapData() 10 | 11 | 12 | def test_set_bounding_box_with_tuple(md): 13 | bounding_box = (0, 10, 0, 10) 14 | md.set_bounding_box(bounding_box) 15 | 16 | assert md.bounding_box == { 17 | "minx": 0, 18 | "maxx": 10, 19 | "miny": 0, 20 | "maxy": 10, 21 | "top": 0, 22 | "base": 2000, 23 | }, "MapData.set_bounding_box() not working as expected" 24 | 25 | 26 | def test_set_bounding_box_with_dict(md): 27 | bounding_box = {"minx": 0, "maxx": 10, "miny": 0, "maxy": 10, "top": 5, "base": 15} 28 | md.set_bounding_box(bounding_box) 29 | 30 | assert md.bounding_box == bounding_box, "MapData.set_bounding_box() not working as expected" 31 | 32 | 33 | def test_bounding_box_keys(md): 34 | bounding_box = (0, 10, 0, 10) 35 | md.set_bounding_box(bounding_box) 36 | 37 | for key in ["minx", "maxx", "miny", "maxy", "top", "base"]: 38 | assert key in md.bounding_box, f"MapData.bounding_box missing key: {key}" 39 | 40 | 41 | def test_bounding_box_polygon(md): 42 | bounding_box = (0, 10, 0, 10) 43 | md.set_bounding_box(bounding_box) 44 | 45 | minx, miny, maxx, maxy = 0, 0, 10, 10 46 | lat_point_list = [miny, miny, maxy, maxy, miny] 47 | lon_point_list = [minx, maxx, maxx, minx, minx] 48 | expected_polygon = geopandas.GeoDataFrame( 49 | index=[0], 50 | crs=md.working_projection, 51 | geometry=[shapely.Polygon(zip(lon_point_list, lat_point_list))], 52 | ) 53 | 54 | assert md.bounding_box_polygon.equals( 55 | expected_polygon 56 | ), "MapData.bounding_box_polygon not returning the correct GeoDataFrame" 57 | 58 | 59 | def test_get_bounding_box_as_dict(md): 60 | bounding_box = {"minx": 0, "maxx": 10, "miny": 0, "maxy": 10, "top": 5, "base": 15} 61 | md.set_bounding_box(bounding_box) 62 | result = md.get_bounding_box() 63 | 64 | assert ( 65 | result == bounding_box 66 | ), "MapData.get_bounding_box() not returning the correct bounding box" 67 | 68 | 69 | def test_get_bounding_box_as_polygon(md): 70 | bounding_box = (0, 10, 0, 10) 71 | md.set_bounding_box(bounding_box) 72 | result = md.get_bounding_box(polygon=True) 73 | 74 | assert isinstance( 75 | result, geopandas.GeoDataFrame 76 | ), "MapData.get_bounding_box(polygon=True) not returning a GeoDataFrame" 77 | assert result.equals( 78 | md.bounding_box_polygon 79 | ), "MapData.get_bounding_box(polygon=True) not returning the correct GeoDataFrame" 80 | 81 | 82 | def test_recreate_bounding_box_str(md): 83 | bounding_box = (0, 10, 0, 10) 84 | md.set_working_projection("EPSG:4326") 85 | md.set_bounding_box(bounding_box) 86 | md.recreate_bounding_box_str() 87 | 88 | expected_str = "0,0,10,10,EPSG:4326" 89 | assert ( 90 | md.bounding_box_str == expected_str 91 | ), "MapData.recreate_bounding_box_str() not working as expected" 92 | 93 | 94 | def test_set_bounding_box_with_missing_keys(md): 95 | bounding_box = { 96 | "minx": 0, 97 | "maxx": 10, 98 | "miny": 0 99 | # Missing "maxy", "top", "base" 100 | } 101 | with pytest.raises(KeyError): 102 | md.set_bounding_box( 103 | bounding_box 104 | ), "MapData.set_bounding_box accepting wrong argument, but should raise KeyError" 105 | -------------------------------------------------------------------------------- /tests/mapdata/test_set_get_working_projection.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from map2loop.mapdata import MapData 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "projection, expected_projection, bounding_box, expected_warning", 7 | [ 8 | (4326, "EPSG:4326", None, None), # happy path with int projection 9 | ("EPSG:3857", "EPSG:3857", None, None), # happy path with str projection 10 | (9999, "EPSG:9999", None, None), # edge case with high int projection 11 | ("EPSG:9999", "EPSG:9999", None, None), # edge case with high str projection 12 | (None, None, None, True), # error case with None 13 | ([], None, None, True), # error case with list 14 | ({}, None, None, True), # error case with dict 15 | ], 16 | ids=[ 17 | "int_projection", 18 | "str_projection", 19 | "high_int_projection", 20 | "high_str_projection", 21 | "none_projection", 22 | "list_projection", 23 | "dict_projection", 24 | ], 25 | ) 26 | def test_set_working_projection(projection, expected_projection, bounding_box, expected_warning): 27 | # Set up MapData object 28 | map_data = MapData() 29 | map_data.bounding_box = bounding_box 30 | 31 | # Call the method 32 | map_data.set_working_projection(projection) 33 | 34 | # Assert the working projection is correctly set 35 | assert map_data.working_projection == expected_projection, ( 36 | f"Expected working_projection to be {expected_projection}, but got {map_data.working_projection}" 37 | ) 38 | 39 | # Check for the presence of warnings via side effects (if applicable) 40 | if expected_warning: 41 | assert map_data.working_projection is None, ( 42 | "Expected working_projection to remain None when an invalid projection is provided" 43 | ) 44 | -------------------------------------------------------------------------------- /tests/project/test_config_arguments.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pathlib 3 | from map2loop.project import Project 4 | import map2loop 5 | 6 | # ------------------------------------------------------------------------------ 7 | # Common fixtures or helper data (bounding box, minimal filenames, etc.) 8 | # ------------------------------------------------------------------------------ 9 | 10 | @pytest.fixture 11 | def minimal_bounding_box(): 12 | return { 13 | "minx": 515687.31005864, 14 | "miny": 7493446.76593407, 15 | "maxx": 562666.860106543, 16 | "maxy": 7521273.57407786, 17 | "base": -3200, 18 | "top": 3000, 19 | } 20 | 21 | @pytest.fixture 22 | def geology_file(): 23 | return str( 24 | pathlib.Path(map2loop.__file__).parent 25 | / pathlib.Path('_datasets/geodata_files/hamersley/geology.geojson') 26 | ) 27 | 28 | @pytest.fixture 29 | def structure_file(): 30 | return str( 31 | pathlib.Path(map2loop.__file__).parent 32 | / pathlib.Path('_datasets/geodata_files/hamersley/structure.geojson') 33 | ) 34 | 35 | @pytest.fixture 36 | def dtm_file(): 37 | return str( 38 | pathlib.Path(map2loop.__file__).parent 39 | / pathlib.Path('_datasets/geodata_files/hamersley/dtm_rp.tif') 40 | ) 41 | 42 | @pytest.fixture 43 | def valid_config_dictionary(): 44 | """ 45 | A valid config dictionary that meets the 'structure' and 'geology' requirements 46 | """ 47 | return { 48 | "structure": { 49 | "dipdir_column": "azimuth2", 50 | "dip_column": "dip" 51 | }, 52 | "geology": { 53 | "unitname_column": "unitname", 54 | "alt_unitname_column": "code", 55 | } 56 | } 57 | 58 | 59 | 60 | # 1) config_filename and config_dictionary both present should raise ValueError 61 | def test_config_filename_and_dictionary_raises_error( 62 | minimal_bounding_box, geology_file, dtm_file, structure_file, valid_config_dictionary 63 | ): 64 | 65 | with pytest.raises(ValueError, match="Both 'config_filename' and 'config_dictionary' were provided"): 66 | Project( 67 | bounding_box=minimal_bounding_box, 68 | working_projection="EPSG:28350", 69 | geology_filename=geology_file, 70 | dtm_filename=dtm_file, 71 | structure_filename=structure_file, 72 | config_filename="dummy_config.json", 73 | config_dictionary=valid_config_dictionary, 74 | ) 75 | 76 | # 2) No config_filename or config_dictionary should raise ValueError 77 | def test_no_config_provided_raises_error( 78 | minimal_bounding_box, geology_file, dtm_file, structure_file 79 | ): 80 | 81 | with pytest.raises(ValueError, match="A config file is required to run map2loop"): 82 | Project( 83 | bounding_box=minimal_bounding_box, 84 | working_projection="EPSG:28350", 85 | geology_filename=geology_file, 86 | dtm_filename=dtm_file, 87 | structure_filename=structure_file, 88 | ) 89 | 90 | # 3) Passing an unexpected argument should raise TypeError 91 | def test_unexpected_argument_raises_error( 92 | minimal_bounding_box, geology_file, dtm_file, structure_file, valid_config_dictionary 93 | ): 94 | 95 | with pytest.raises(TypeError, match="unexpected keyword argument 'config_file'"): 96 | Project( 97 | bounding_box=minimal_bounding_box, 98 | working_projection="EPSG:28350", 99 | geology_filename=geology_file, 100 | dtm_filename=dtm_file, 101 | structure_filename=structure_file, 102 | config_dictionary=valid_config_dictionary, 103 | config_file="wrong_kwarg.json", 104 | ) 105 | 106 | # 4) Dictionary missing a required key should raise ValueError 107 | 108 | def test_dictionary_missing_required_key_raises_error( 109 | minimal_bounding_box, geology_file, dtm_file, structure_file 110 | ): 111 | 112 | invalid_dictionary = { 113 | "structure": {"dipdir_column": "azimuth2", "dip_column": "dip"}, 114 | "geology": {"unitname_column": "unitname"} # alt_unitname_column missing 115 | } 116 | 117 | with pytest.raises(ValueError, match="Missing required key 'alt_unitname_column' for 'geology'"): 118 | Project( 119 | bounding_box=minimal_bounding_box, 120 | working_projection="EPSG:28350", 121 | geology_filename=geology_file, 122 | dtm_filename=dtm_file, 123 | structure_filename=structure_file, 124 | config_dictionary=invalid_dictionary, 125 | ) 126 | 127 | # 5) All good => The Project should be created without errors 128 | def test_good_config_runs_successfully( 129 | minimal_bounding_box, geology_file, dtm_file, structure_file, valid_config_dictionary 130 | ): 131 | project = None 132 | try: 133 | project = Project( 134 | bounding_box=minimal_bounding_box, 135 | working_projection="EPSG:28350", 136 | geology_filename=geology_file, 137 | dtm_filename=dtm_file, 138 | structure_filename=structure_file, 139 | config_dictionary=valid_config_dictionary, 140 | ) 141 | except Exception as e: 142 | pytest.fail(f"Project initialization raised an unexpected exception: {e}") 143 | 144 | assert project is not None, "Project was not created." 145 | assert project.map_data.config.structure_config["dipdir_column"] == "azimuth2" 146 | assert project.map_data.config.structure_config["dip_column"] == "dip" 147 | assert project.map_data.config.geology_config["unitname_column"] == "unitname" 148 | assert project.map_data.config.geology_config["alt_unitname_column"] == "code" -------------------------------------------------------------------------------- /tests/project/test_ignore_codes_setters_getters.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from map2loop.project import Project 3 | from map2loop.m2l_enums import Datatype 4 | import map2loop 5 | from unittest.mock import patch 6 | 7 | 8 | # Sample test function for lithology and fault ignore codes 9 | def test_set_get_ignore_codes(): 10 | # Set up a sample bounding box and other required data 11 | bbox_3d = { 12 | "minx": 515687.31005864, 13 | "miny": 7493446.76593407, 14 | "maxx": 562666.860106543, 15 | "maxy": 7521273.57407786, 16 | "base": -3200, 17 | "top": 3000, 18 | } 19 | 20 | # Set up the config dictionary 21 | config_dictionary = { 22 | "structure": {"dipdir_column": "azimuth2", "dip_column": "dip"}, 23 | "geology": {"unitname_column": "unitname", "alt_unitname_column": "code"}, 24 | "fault": {'structtype_column': 'feature', 'fault_text': 'Fault'}, 25 | } 26 | with patch.object(Project, 'validate_required_inputs', return_value=None): 27 | project = Project( 28 | working_projection='EPSG:28350', 29 | bounding_box=bbox_3d, 30 | geology_filename=str( 31 | pathlib.Path(map2loop.__file__).parent 32 | / pathlib.Path('_datasets/geodata_files/hamersley/geology.geojson') 33 | ), 34 | fault_filename=str( 35 | pathlib.Path(map2loop.__file__).parent 36 | / pathlib.Path('_datasets/geodata_files/hamersley/faults.geojson') 37 | ), 38 | dtm_filename=str( 39 | pathlib.Path(map2loop.__file__).parent 40 | / pathlib.Path('_datasets/geodata_files/hamersley/dtm_rp.tif') 41 | ), 42 | config_dictionary=config_dictionary, 43 | structure_filename="", 44 | ) 45 | 46 | # Define test ignore codes for lithology and faults 47 | lithology_codes = ["cover", "Fortescue_Group", "A_FO_od"] 48 | fault_codes = ['Fault_9', "NotAFault"] 49 | 50 | # Test Lithology ignore codes 51 | project.set_ignore_lithology_codes(lithology_codes) 52 | assert ( 53 | project.map_data.get_ignore_lithology_codes() == lithology_codes 54 | ), "Lithology ignore codes mismatch" 55 | 56 | # Test Fault ignore codes 57 | project.set_ignore_fault_codes(fault_codes) 58 | assert project.map_data.get_ignore_fault_codes() == fault_codes, "Fault ignore codes mismatch" 59 | 60 | project.map_data.parse_fault_map() 61 | # check if the skipped fault has been removed now: 62 | assert not project.map_data.get_map_data(Datatype.FAULT)['NAME'].str.contains('Fault_9').any() 63 | 64 | project.map_data.parse_geology_map() 65 | # check if the skipped lithology has been removed now: 66 | assert ( 67 | not project.map_data.get_map_data(Datatype.GEOLOGY)['UNITNAME'] 68 | .str.contains('Fortescue_Group') 69 | .any() 70 | ) 71 | assert ( 72 | not project.map_data.get_map_data(Datatype.GEOLOGY)['UNITNAME'].str.contains('cover').any() 73 | ) 74 | -------------------------------------------------------------------------------- /tests/project/test_plot_hamersley.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from map2loop.project import Project 3 | from map2loop.m2l_enums import VerboseLevel 4 | from unittest.mock import patch 5 | from pyproj.exceptions import CRSError 6 | import requests 7 | import os 8 | 9 | # Define constants for common parameters 10 | bbox_3d = { 11 | "minx": 515687.31005864, 12 | "miny": 7493446.76593407, 13 | "maxx": 562666.860106543, 14 | "maxy": 7521273.57407786, 15 | "base": -3200, 16 | "top": 3000, 17 | } 18 | loop_project_filename = "wa_output.loop3d" 19 | 20 | # create a project function 21 | def create_project(state_data="WA", projection="EPSG:28350"): 22 | return Project( 23 | use_australian_state_data=state_data, 24 | working_projection=projection, 25 | bounding_box=bbox_3d, 26 | verbose_level=VerboseLevel.NONE, 27 | loop_project_filename=loop_project_filename, 28 | overwrite_loopprojectfile=True, 29 | ) 30 | 31 | 32 | # is the project running? 33 | def test_project_execution(): 34 | 35 | proj = create_project() 36 | try: 37 | proj.run_all(take_best=True) 38 | # if there's a timeout: 39 | except requests.exceptions.ReadTimeout: 40 | print("Timeout occurred, skipping the test.") # Debugging line 41 | pytest.skip( 42 | "Skipping the project test from server data due to timeout while attempting to run proj.run_all" 43 | ) 44 | 45 | # if no timeout: 46 | # is there a project? 47 | assert proj is not None, "Plot Hamersley Basin failed to execute" 48 | # is there a LPF? 49 | assert os.path.exists( 50 | loop_project_filename 51 | ), f"Expected file {loop_project_filename} was not created" 52 | 53 | 54 | # Is the test_project_execution working - ie, is the test skipped on timeout? 55 | def test_timeout_handling(): 56 | # Mock `openURL` in `owslib.util` to raise a ReadTimeout directly 57 | with patch("owslib.util.openURL"): 58 | # Run `test_project_execution` and check if the skip occurs 59 | result = pytest.main( 60 | ["-q", "--tb=short", "--disable-warnings", "-k", "test_project_execution"] 61 | ) 62 | assert ( 63 | result.value == pytest.ExitCode.OK 64 | ), "The test was not skipped as expected on timeout." 65 | 66 | 67 | # does the project fail when the CRS is invalid? 68 | def test_expect_crs_error(): 69 | try: 70 | with pytest.raises(CRSError): 71 | create_project(projection="InvalidCRS") 72 | print("CRSError was raised as expected.") 73 | except requests.exceptions.ReadTimeout: 74 | print("Timeout occurred, skipping test_expect_crs_error.") 75 | pytest.skip("Skipping test_expect_crs_error due to a timeout.") 76 | 77 | 78 | # does the project fail when the Aus state name is invalid? 79 | def test_expect_state_error(): 80 | try: 81 | with pytest.raises(ValueError): 82 | create_project(state_data="InvalidState") 83 | print("ValueError was raised as expected.") 84 | except requests.exceptions.ReadTimeout: 85 | print("Timeout occurred, skipping test_expect_state_error.") 86 | pytest.skip("Skipping test_expect_state_error due to a timeout.") 87 | 88 | 89 | # does the project fail when a config file is invalid? 90 | def test_expect_config_error(): 91 | try: 92 | with pytest.raises(Exception): 93 | create_project(config_file='InvalidConfig.csv') 94 | print("FileNotFoundError//Exception by catchall in project.py was raised as expected.") 95 | except requests.exceptions.ReadTimeout: 96 | print("Timeout occurred, skipping test_expect_config_error.") 97 | pytest.skip("Skipping test_expect_config_error due to a timeout.") 98 | -------------------------------------------------------------------------------- /tests/sampler/test_SamplerSpacing.py: -------------------------------------------------------------------------------- 1 | import pandas 2 | from map2loop.sampler import SamplerSpacing 3 | from beartype.roar import BeartypeCallHintParamViolation 4 | import pytest 5 | import shapely 6 | import geopandas 7 | import numpy 8 | 9 | # add test for SamplerSpacing specifically 10 | 11 | 12 | @pytest.fixture 13 | def sampler_spacing(): 14 | return SamplerSpacing(spacing=1.0) 15 | 16 | 17 | @pytest.fixture 18 | def correct_geodata(): 19 | data = { 20 | 'geometry': [ 21 | shapely.LineString([(0, 0), (1, 1), (2, 2)]), 22 | shapely.Polygon([(0, 0), (1, 1), (1, 0), (0, 0)]), 23 | shapely.MultiLineString( 24 | [shapely.LineString([(0, 0), (1, 1)]), shapely.LineString([(2, 2), (3, 3)])] 25 | ), 26 | ], 27 | 'ID': ['1', '2', '3'], 28 | } 29 | return geopandas.GeoDataFrame(data, geometry='geometry') 30 | 31 | 32 | @pytest.fixture 33 | def incorrect_geodata(): 34 | data = {'geometry': [shapely.Point(0, 0), "Not a geometry"], 'ID': ['1', '2']} 35 | return pandas.DataFrame(data) 36 | 37 | 38 | # test if correct outputs are generated from the right input 39 | def test_sample_function_correct_data(sampler_spacing, correct_geodata): 40 | result = sampler_spacing.sample(correct_geodata) 41 | assert isinstance(result, pandas.DataFrame) 42 | assert 'X' in result.columns 43 | assert 'Y' in result.columns 44 | assert 'featureId' in result.columns 45 | 46 | 47 | # add test for incorrect inputs - does it raise a BeartypeCallHintParamViolation error? 48 | def test_sample_function_incorrect_data(sampler_spacing, incorrect_geodata): 49 | with pytest.raises(BeartypeCallHintParamViolation): 50 | sampler_spacing.sample(spatial_data=incorrect_geodata) 51 | 52 | 53 | # for a specific >2 case 54 | def test_sample_function_target_less_than_or_equal_to_2(): 55 | sampler_spacing = SamplerSpacing(spacing=1.0) 56 | data = { 57 | 'geometry': [shapely.LineString([(0, 0), (0, 1)]), shapely.LineString([(0, 0), (1, 0)])], 58 | 'ID': ['1', '2'], 59 | } 60 | gdf = geopandas.GeoDataFrame(data, geometry='geometry') 61 | result = sampler_spacing.sample(spatial_data=gdf) 62 | assert len(result) == 0 # No points should be sampled from the linestring 63 | 64 | 65 | # Test if the extracted points are correct 66 | def test_sample_function_extracted_points(sampler_spacing, correct_geodata): 67 | 68 | result = sampler_spacing.sample(correct_geodata) 69 | 70 | expected_points = [ 71 | (0.0, 0.0), 72 | (0.707107, 0.707107), 73 | (0.0, 0.0), 74 | (0.707107, 0.707107), 75 | (1.0, 0.414214), 76 | (0.0, 0.0), 77 | [2.0, 2.0], 78 | ] 79 | 80 | sampled_points = list(zip(result['X'], result['Y'])) 81 | 82 | distances = [ 83 | shapely.geometry.Point(xy).distance(shapely.geometry.Point(ex)) 84 | for xy, ex in zip(sampled_points, expected_points) 85 | ] 86 | 87 | assert numpy.absolute(distances).all() == 0.0 88 | -------------------------------------------------------------------------------- /tests/sampler/test_SamplerSpacing_featureId.py: -------------------------------------------------------------------------------- 1 | import pandas 2 | from map2loop.sampler import SamplerSpacing 3 | import shapely 4 | import geopandas 5 | 6 | geology_original = pandas.read_csv("tests/sampler/geo_test.csv") 7 | geology_original['geometry'] = geology_original['geometry'].apply(shapely.wkt.loads) 8 | geology_original = geopandas.GeoDataFrame(geology_original, crs='epsg:7854') 9 | 10 | sampler_space = 700.0 11 | 12 | sampler = SamplerSpacing(spacing=sampler_space) 13 | geology_samples = sampler.sample(geology_original) 14 | 15 | 16 | # the actual test: 17 | def test_featureId(): 18 | for _, poly in geology_original.iterrows(): 19 | # check if one polygon, only 0 in featureId 20 | multipolygon = poly['geometry'] 21 | corresponding_rows = geology_samples[geology_samples['ID'] == poly['ID']] 22 | 23 | if poly['geometry'].geom_type == 'Polygon': 24 | if poly.geometry.area < sampler_space: 25 | continue 26 | # check if zero featureId 27 | assert ( 28 | corresponding_rows['featureId'].unique() == '0' 29 | ), "Polygon with featureId 0 is not sampled." 30 | 31 | # check if in the right place 32 | for _, sample in corresponding_rows.iterrows(): 33 | point = shapely.Point(sample['X'], sample['Y']).buffer(1) 34 | assert point.intersects( 35 | poly.geometry 36 | ), f"Point from featureId 0 is not in the correct polygon segment of ID {poly['ID']}." 37 | 38 | if poly['geometry'].geom_type == 'MultiPolygon': 39 | if any(geom.area < sampler_space for geom in multipolygon.geoms): 40 | continue # skip tiny little polys 41 | 42 | # # is the number of segs the same as the geology polygon? 43 | assert len(multipolygon.geoms) == len( 44 | corresponding_rows.featureId.unique() 45 | ), "Mismatch in the number of geo_polygons and featureId" 46 | 47 | for i, polygon in enumerate(poly.geometry.geoms): 48 | polygon_samples = corresponding_rows[corresponding_rows['featureId'] == str(i)] 49 | print(polygon_samples) 50 | for _, sample in polygon_samples.iterrows(): 51 | point = shapely.Point(sample['X'], sample['Y']).buffer( 52 | 1 53 | ) # buffer just to make sure 54 | assert point.intersects( 55 | polygon 56 | ), f"Point from featureId {i} is not in the correct polygon segment of ID {poly['ID']}." 57 | -------------------------------------------------------------------------------- /tests/test_import.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def test_import_map2loop(): 5 | try: 6 | import map2loop 7 | 8 | map2loop.__version__ 9 | except ImportError: 10 | pytest.fail("Failed to import map2loop module") 11 | -------------------------------------------------------------------------------- /tests/utils/test_rgb_and_hex_functions.py: -------------------------------------------------------------------------------- 1 | ### This file tests the function generate_random_hex_colors() and hex_to_rgba() in map2loop/utils.py 2 | 3 | import pytest 4 | import re 5 | from map2loop.utils import generate_random_hex_colors, hex_to_rgb 6 | 7 | # does it return the right number of colors? 8 | def test_generate_random_hex_colors_length(): 9 | n = 5 10 | colors = generate_random_hex_colors(n) 11 | assert ( 12 | len(colors) == n 13 | ), f"utils function generate_random_hex_colors not returning the right number of hex codes.Expected {n} colors, got {len(colors)}" 14 | 15 | 16 | # are the returned hex strings the right format? 17 | def test_generate_random_hex_colors_format(): 18 | n = 10 19 | hex_pattern = re.compile(r'^#[0-9A-Fa-f]{6}$') 20 | colors = generate_random_hex_colors(n) 21 | for color in colors: 22 | assert hex_pattern.match( 23 | color 24 | ), f"utils function generate_random_hex_colors not returning hex strings in the right format. Got {color} instead." 25 | 26 | 27 | # is hex conversion to rgba working as expected? 28 | def test_hex_to_rgba_long_hex(): 29 | hex_color = "#1a2b3c" # long hex versions 30 | expected_output = (0.10196078431372549, 0.16862745098039217, 0.23529411764705882, 1.0) 31 | assert ( 32 | hex_to_rgb(hex_color) == expected_output 33 | ), f"utils function hex_to_rgba not doing hex to rgba conversion correctly. Expected {expected_output}, got {hex_to_rgb(hex_color)}" 34 | 35 | 36 | def test_hex_to_rgba_short_hex(): 37 | hex_color = "#abc" # short hex versions 38 | expected_output = (0.6666666666666666, 0.7333333333333333, 0.8, 1.0) 39 | assert hex_to_rgb(hex_color) == expected_output 40 | 41 | 42 | # does it handle invalid inputs correctly? 43 | def test_hex_to_rgba_invalid_hex(): 44 | with pytest.raises(ValueError): 45 | hex_to_rgb( 46 | "12FF456" 47 | ), "utils function hex_to_rgba is expected to raise a ValueError when an invalid hex string is passed, but it did not." 48 | --------------------------------------------------------------------------------