├── .github ├── ISSUE_TEMPLATE.md ├── pull_request_template.md └── workflows │ ├── lint.yml │ └── python-test.yml ├── .gitignore ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── Code-Of-Conduct.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── THIRDPARTYLICENSES.rst ├── docs ├── Makefile ├── authors.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── index.rst ├── modules.rst ├── readme.rst ├── synthterrain.crater.rst ├── synthterrain.rock.rst ├── synthterrain.rst ├── synthterrain_ARC-18971-1_Corporate_CLA.pdf └── synthterrain_ARC-18971-1_Individual_CLA.pdf ├── environment.yml ├── environment_dev.yml ├── package.xml ├── pyproject.toml ├── requirements-opt.txt ├── requirements.txt ├── requirements_dev.txt ├── resource └── synthterrain ├── setup.cfg ├── src └── python │ └── synthterrain │ ├── __init__.py │ ├── cli.py │ ├── crater │ ├── __init__.py │ ├── age.py │ ├── cli.py │ ├── cli_convert.py │ ├── cli_plot.py │ ├── diffusion.py │ ├── functions.py │ └── profile.py │ ├── rock │ ├── __init__.py │ ├── cli.py │ └── functions.py │ └── util.py ├── tests └── python │ ├── crater │ ├── test_age.py │ ├── test_functions.py │ └── test_init.py │ └── test_util.py └── tox.ini /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * synthterrain version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Related Issue 7 | 8 | 9 | 10 | 11 | ## Motivation and Context 12 | 13 | 14 | 15 | ## How Has This Been Tested? 16 | 17 | 18 | 19 | 20 | 21 | ## Types of changes 22 | 23 | - Bug fix (non-breaking change which fixes an issue) 24 | - New feature (non-breaking change which adds functionality) 25 | - Breaking change (fix or feature that would cause existing functionality to change) 26 | 27 | ## Checklist: 28 | 29 | 30 | - My change requires a change to the documentation. 31 | - I have updated the documentation accordingly. 32 | - I have added tests to cover my changes. 33 | - All new and existing tests passed. 34 | 35 | ## Licensing: 36 | 37 | This project is released under the [LICENSE](https://github.com/NeoGeographyToolkit/synthterrain/blob/master/LICENSE). 38 | 39 | 40 | - I claim copyrights on my contributions in this pull request, and I provide those contributions via this pull request under the same license terms that the synthterrain project uses, and I have submitted a CLA. 41 | - I dedicate any and all copyright interest in my contributions in this pull request to the public domain. I make this dedication for the benefit of the public at large and to the detriment of my heirs and successors. I intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this contribution under copyright law. 42 | 43 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | black: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Check out source repository 10 | uses: actions/checkout@v4 11 | - name: Black Version 12 | uses: psf/black@stable 13 | with: 14 | options: "--version" 15 | - name: Black Check 16 | uses: psf/black@stable 17 | 18 | flake8: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Check out source repository 22 | uses: actions/checkout@v4 23 | - name: Set up Python environment 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: "3.8" 27 | - name: flake8 Lint 28 | uses: py-actions/flake8@v2 29 | with: 30 | path: "src/python/synthterrain tests/python" 31 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python Testing 5 | 6 | on: [push] 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ['3.8', '3.9', '3.10', '3.11'] 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | ref: ${{ github.event.pull_request.head.sha }} 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Display Python version 26 | run: python -c "import sys; print(sys.version)" 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install pytest-cov 31 | python -m pip install -r requirements.txt 32 | - name: Install 33 | # run: python -m pip install -e ${{ matrix.install-target }} 34 | run: python -m pip install -e . 35 | - name: Test with pytest and generate coverage report 36 | run: | 37 | pytest --cov=./src/python/synthterrain --cov-report=xml 38 | 39 | # - name: Upload coverage to Codecov 40 | # uses: codecov/codecov-action@v4 41 | # with: 42 | # verbose: true 43 | # env: 44 | # CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Ross Beyer , SETI Institute & NASA Ames 9 | 10 | Contributors 11 | ------------ 12 | 13 | * Mark Allan, NASA Ames & KBR 14 | * Scott McMichael, NASA Ames & KBR 15 | * Audrow Nash, NASA Ames & KBR 16 | * Jennifer Nguyen, NASA Ames & KBR 17 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | All notable changes to this project will be documented in this file. 6 | 7 | The format is based on `Keep a Changelog `_, 8 | and this project adheres to `Semantic Versioning `_. 9 | 10 | When updating this file, please add an entry for your change under 11 | Unreleased_ and one of the following headings: 12 | 13 | - Added - for new features. 14 | - Changed - for changes in existing functionality. 15 | - Deprecated - for soon-to-be removed features. 16 | - Removed - for now removed features. 17 | - Fixed - for any bug fixes. 18 | - Security - in case of vulnerabilities. 19 | 20 | If the heading does not yet exist under Unreleased_, then add it 21 | as a 3rd level heading, underlined with pluses (see examples below). 22 | 23 | When preparing for a public release add a new 2nd level heading, 24 | underlined with dashes under Unreleased_ with the version number 25 | and the release date, in year-month-day format (see examples below). 26 | 27 | 28 | Unreleased 29 | ---------- 30 | 31 | 0.2.0 (2025-03-27) 32 | ------------------ 33 | 34 | Changed 35 | ^^^^^^^ 36 | 37 | - Made scikit-image an optional dependency and put in error messages if someone 38 | tries to use the single function in which it is used without it being installed. 39 | 40 | 41 | 42 | 0.1.0 (2024-07-05) 43 | ------------------ 44 | 45 | - First release. 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | For a high-level overview of the philosophy of contributions, please see 11 | https://github.com/planetarypy/TC/blob/master/Contributing.md. 12 | 13 | You can contribute in many ways: 14 | 15 | Types of Contributions 16 | ---------------------- 17 | 18 | Report Bugs 19 | ~~~~~~~~~~~ 20 | 21 | Report bugs at https://github.com/NeoGeographyToolkit/synthterrain/issues . 22 | 23 | If you are reporting a bug, please include: 24 | 25 | * Your operating system name and version. 26 | * Any details about your local setup that might be helpful in troubleshooting. 27 | * Detailed steps to reproduce the bug. 28 | 29 | Fix Bugs 30 | ~~~~~~~~ 31 | 32 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 33 | wanted" is open to whoever wants to implement it. 34 | 35 | Implement Features 36 | ~~~~~~~~~~~~~~~~~~ 37 | 38 | Look through the GitHub issues for features. Anything tagged with "enhancement" 39 | and "help wanted" is open to whoever wants to implement it. 40 | 41 | Write Documentation 42 | ~~~~~~~~~~~~~~~~~~~ 43 | 44 | This software could always use more documentation, whether as part of the 45 | official docs, in docstrings, or even on the web in blog posts, 46 | articles, and such. 47 | 48 | Submit Feedback 49 | ~~~~~~~~~~~~~~~ 50 | 51 | The best way to send feedback is to file an issue at https://github.com/NeoGeographyToolkit/synthterrain/issues 52 | 53 | If you are proposing a feature: 54 | 55 | * Explain in detail how it would work. 56 | * Keep the scope as narrow as possible, to make it easier to implement. 57 | * Remember that this is a volunteer-driven project, and that contributions 58 | are welcome :) 59 | 60 | Get Started! 61 | ------------ 62 | 63 | Ready to contribute? Here's how to set up `synthterrain` for local development. 64 | 65 | 1. Fork the `synthterrain` repo on GitHub. 66 | 2. Clone your fork locally:: 67 | 68 | $> git clone git@github.com:your_name_here/synthterrain.git 69 | 70 | 3. Install your local copy into a virtual environment of your choice (there are many to choose from like conda, etc.). We will assume conda here, but any should work:: 71 | 72 | $> cd synthterrain/ 73 | $> conda env create -n synthterrain 74 | $> conda activate synthterrain 75 | $> mamba env update --file environment_dev.yml 76 | $> mamba env update --file environment.yml 77 | $> pip install --no-deps -e . 78 | 79 | The last ``pip install`` installs synthterrain in "editable" mode which facilitates using the programs and testing. 80 | 81 | 4. Create a branch for local development:: 82 | 83 | $> git checkout -b name-of-your-bugfix-or-feature 84 | 85 | Now you can make your changes locally. 86 | 87 | 5. When you're done making changes, check that your changes pass flake8 and the 88 | tests. 89 | 90 | $> make lint 91 | $> make test 92 | 93 | 94 | 6. Commit your changes and push your branch to GitHub:: 95 | 96 | $> git add . 97 | $> git commit -m "Your detailed description of your changes." 98 | $> git push origin name-of-your-bugfix-or-feature 99 | 100 | 7. Submit a pull request through the GitHub website. 101 | 102 | Pull Request Guidelines 103 | ----------------------- 104 | 105 | Before you submit a pull request, check that it meets these guidelines: 106 | 107 | 1. The pull request should include tests. 108 | 2. If the pull request adds functionality, the docs should be updated. Put 109 | your new functionality into a function with a docstring, and add the 110 | feature to the list in CHANGELOG.rst. 111 | 3. The pull request should work for Python 3.8, 3.9, 3.10, 3.11 and optionally for PyPy. 112 | And make sure that the tests pass for all supported Python versions. 113 | 114 | What to expect 115 | -------------- 116 | 117 | Our development of synthterrain is not particularly continuous, 118 | and it is entirely possible that when you submit a PR 119 | (pull request), none of us will have the time to evaluate or integrate 120 | your PR. If we don't, we'll try and communicate that with you via the 121 | PR. 122 | 123 | For large contributions, it is likely that you, or your employer, 124 | will be retaining your copyrights, but releasing the contributions 125 | via an open-source license. It must be compatible with the Apache-2 126 | license that synthterrain is distributed with, so that we can redistribute 127 | that contribution with synthterrain, give you credit, and make synthterrain even 128 | better! Please contact us if you have a contribution of that nature, 129 | so we can be sure to get all of the details right. 130 | 131 | For smaller contributions, where you (or your employer) are not 132 | concerned about retaining copyright (but we will give you credit!), 133 | you will need to fill out a Contributor License Agreement (CLA) 134 | before we can accept your PR. The CLA assigns your copyright in 135 | your contribution to NASA, so that our NASA copyright statement 136 | remains true: 137 | 138 | Copyright (c) YEAR, United States Government as represented by the 139 | Administrator of the National Aeronautics and Space Administration. 140 | All rights reserved. 141 | 142 | There is an `Individual CLA 143 | `_ and a 144 | `Corporate CLA 145 | `_. 146 | 147 | synthterrain People 148 | ------------------- 149 | 150 | - A synthterrain **Contributor** is any individual creating or commenting 151 | on an issue or pull request. Anyone who has authored a PR that was 152 | merged should be listed in the AUTHORS.rst file. 153 | 154 | - A synthterrain **Committer** is a subset of contributors, typically NASA 155 | employees or contractors, who have been given write access to the 156 | repository. 157 | 158 | Rules for Merging Pull Requests 159 | ------------------------------- 160 | 161 | Any change to resources in this repository must be through pull 162 | requests (PRs). This applies to all changes to documentation, code, 163 | binary files, etc. Even long term committers must use pull requests. 164 | 165 | In general, the submitter of a PR is responsible for making changes 166 | to the PR. Any changes to the PR can be suggested by others in the 167 | PR thread (or via PRs to the PR), but changes to the primary PR 168 | should be made by the PR author (unless they indicate otherwise in 169 | their comments). In order to merge a PR, it must satisfy these conditions: 170 | 171 | 1. Have been open for 24 hours. 172 | 2. Have one approval. 173 | 3. If the PR has been open for 2 days without approval or comment, then it 174 | may be merged without any approvals. 175 | 176 | Pull requests should sit for at least 24 hours to ensure that 177 | contributors in other timezones have time to review. Consideration 178 | should also be given to weekends and other holiday periods to ensure 179 | active committers all have reasonable time to become involved in 180 | the discussion and review process if they wish. 181 | 182 | In order to encourage involvement and review, we encourage at least 183 | one explicit approval from committers that are not the PR author. 184 | 185 | However, in order to keep development moving along with our low number of 186 | active contributors, if a PR has been open for 2 days without comment, then 187 | it could be committed without an approval. 188 | 189 | The default for each contribution is that it is accepted once no 190 | committer has an objection, and the above requirements are 191 | satisfied. 192 | 193 | In the case of an objection being raised in a pull request by another 194 | committer, all involved committers should seek to arrive at a 195 | consensus by way of addressing concerns being expressed by discussion, 196 | compromise on the proposed change, or withdrawal of the proposed 197 | change. 198 | 199 | Exceptions to the above are minor typo fixes or cosmetic changes 200 | that don't alter the meaning of a document. Those edits can be made 201 | via a PR and the requirement for being open 24 h is waived in this 202 | case. 203 | 204 | 205 | .. Deploying 206 | --------- 207 | 208 | A reminder for the maintainers on how to deploy. 209 | Make sure all your changes are committed (including an entry in CHANGELOG.rst). 210 | Then run:: 211 | 212 | $ bump2version patch # possible: major / minor / patch 213 | $ git push 214 | $ git push --tags 215 | -------------------------------------------------------------------------------- /Code-Of-Conduct.rst: -------------------------------------------------------------------------------- 1 | ==================================================================== 2 | synthterrain Contributor Covenant Code of Conduct 3 | ==================================================================== 4 | 5 | The PlanetaryPy Project `Code of `Conduct`_ applies to 6 | synthterrain. 7 | 8 | .. _Code of Conduct: https://github.com/planetarypy/TC/blob/master/Code-Of-Conduct.md 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CHANGELOG.rst 3 | include CONTRIBUTING.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | from urllib.request import pathname2url 8 | 9 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 10 | endef 11 | export BROWSER_PYSCRIPT 12 | 13 | define PRINT_HELP_PYSCRIPT 14 | import re, sys 15 | 16 | for line in sys.stdin: 17 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 18 | if match: 19 | target, help = match.groups() 20 | print("%-20s %s" % (target, help)) 21 | endef 22 | export PRINT_HELP_PYSCRIPT 23 | 24 | define DOWNLOAD_PYSCRIPT 25 | import urllib.request, sys 26 | 27 | urllib.request.urlretrieve(sys.argv[1],sys.argv[2]) 28 | endef 29 | export DOWNLOAD_PYSCRIPT 30 | 31 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 32 | DOWNLOAD := python -c "$$DOWNLOAD_PYSCRIPT" 33 | 34 | help: 35 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 36 | 37 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 38 | 39 | clean-build: ## remove build artifacts 40 | rm -fr build/ 41 | rm -fr dist/ 42 | rm -fr .eggs/ 43 | find . -name '*.egg-info' -exec rm -fr {} + 44 | find . -name '*.egg' -exec rm -f {} + 45 | 46 | clean-pyc: ## remove Python file artifacts 47 | find . -name '*.pyc' -exec rm -f {} + 48 | find . -name '*.pyo' -exec rm -f {} + 49 | find . -name '*~' -exec rm -f {} + 50 | find . -name '__pycache__' -exec rm -fr {} + 51 | 52 | clean-test: ## remove test and coverage artifacts 53 | rm -fr .tox/ 54 | rm -f .coverage 55 | rm -fr htmlcov/ 56 | rm -fr .pytest_cache 57 | rm -fr test-resources 58 | 59 | lint/flake8: ## check style with flake8 60 | flake8 src/python/synthterrain tests/python/ 61 | 62 | lint/black: ## check style with black 63 | black --check src/python/synthterrain tests 64 | 65 | lint/ufmt: ## check format with ufmt 66 | ufmt check src/python/synthterrain 67 | ufmt check tests/python 68 | 69 | lint: lint/flake8 lint/black lint/ufmt 70 | 71 | test: ## run tests quickly with the default Python 72 | pytest -s 73 | 74 | test-all: ## run tests on every Python version with tox 75 | tox 76 | 77 | coverage: ## check code coverage quickly with the default Python 78 | coverage run --source src/python/synthterrain -m pytest 79 | coverage report -m 80 | coverage html 81 | $(BROWSER) htmlcov/index.html 82 | 83 | # docs: ## generate Sphinx HTML documentation, including API docs 84 | # rm -f docs/hiproc.rst 85 | # rm -f docs/modules.rst 86 | # sphinx-apidoc -o docs/ synthterrain 87 | # $(MAKE) -C docs clean 88 | # $(MAKE) -C docs html 89 | # $(BROWSER) docs/_build/html/index.html 90 | 91 | # servedocs: docs ## compile the docs watching for changes 92 | # watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 93 | 94 | release-check: dist ## check state of distribution 95 | twine check dist/* 96 | 97 | release: dist ## package and upload a release 98 | twine upload dist/* 99 | 100 | dist: clean ## builds source and wheel package 101 | python -m build 102 | ls -l dist 103 | 104 | develop: clean ## install the package in an editable format for development 105 | pip install --no-deps -e . 106 | 107 | install: clean ## install the package to the active Python's site-packages 108 | pip install 109 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | synthterrain 3 | ============ 4 | 5 | .. image:: https://github.com/NeoGeographyToolkit/synthterrain/actions/workflows/python-test.yml/badge.svg 6 | :target: https://github.com/NeoGeographyToolkit/synthterrain/actions 7 | 8 | The synthterrain package is software to support the creation of synthetic 9 | terrain on worlds in the solar system. 10 | 11 | At the moment, this repo is under significant development and change as we 12 | attempt to craft various pieces of code. It is very messy and a work-in-process. 13 | Nothing is guaranteed about structure until we pass the 1.0 version. 14 | 15 | It currently contains only Python code, but we anticipate the addition of C++ 16 | for certain functionality. 17 | 18 | 19 | Features 20 | -------- 21 | 22 | The synthterrain package currently offers these command-line programs 23 | when it is installed (see below). Arguments 24 | can be found by running any program with a ``-h`` flag. 25 | 26 | ``synthcraters`` 27 | This program generates synthetic crater populations. 28 | 29 | ``synthrocks`` 30 | This program generates synthetic rock populations (generally run after 31 | ``synthcraters`` so that rocks can be placed relative to crater ejecta 32 | fields. 33 | 34 | ``synthterrain`` 35 | This program mostly just runs ``synthcraters`` and then immediately runs 36 | ``synthrocks``. Also allows a set of pre-existing craters to be added 37 | to the probabiliy maps that ``synthrocks`` uses to place rocks. 38 | 39 | ``synthcraterconvert`` 40 | Converts between the current crater CSV and old XML MATLAB formats. 41 | 42 | ``synthcraterplot`` 43 | Generates a set of plots from the CSV output of ``synthcraters``. 44 | 45 | 46 | The command lines for these programs can get long, and if you would prefer to 47 | write a text file pre-loaded with the arguments, you can do so with ampersand-arguments. 48 | 49 | For example, you could write a text file ``test1.txt`` that looks like this:: 50 | 51 | # This is an arguments file, lines that start with octothorpes are ignored. 52 | -v 53 | --bbox 0 200 200 0 54 | --cr_maxd 30 55 | --cr_mind 0.5 56 | --rk_maxd 10 57 | --rk_mind 0.1 58 | --probability_map_gsd 0.5 59 | --cr_outfile test_cr.csv 60 | --rk_outfile test_rk.csv 61 | 62 | And then you could call ``synthterrain`` like this:: 63 | 64 | $> synthterrain @test1.txt 65 | 66 | You can mix regular arguments and ampersand-arguments if you wish. 67 | 68 | 69 | Installation 70 | ------------ 71 | 72 | Clone or download this repository. 73 | 74 | It is highly suggested to install this into a virtual Python environment. 75 | 76 | Change directory to where you have downloaded this repository after you have 77 | set up your virtual environment, just do this:: 78 | 79 | $> pip install 80 | 81 | 82 | or:: 83 | 84 | $> make install 85 | 86 | If you use conda for your virtual environment and install dependencies via conda, you can do this:: 87 | 88 | $> conda create -n synthterrain 89 | $> conda activate synthterrain 90 | $> conda env update --file environment.yml 91 | $> pip install --no-deps . 92 | 93 | 94 | Contributing 95 | ------------ 96 | 97 | Feedback, issues, and contributions are always gratefully welcomed. See the 98 | contributing guide for details on how to help and setup a development 99 | environment. 100 | 101 | 102 | Credits 103 | ------- 104 | 105 | synthterrain was developed in the open at NASA's Ames Research Center. 106 | 107 | See the `AUTHORS ` 108 | file for a complete list of developers. 109 | 110 | 111 | License 112 | ------- 113 | Copyright © 2024-2025, United States Government, as represented by the 114 | Administrator of the National Aeronautics and Space Administration. 115 | All rights reserved. 116 | 117 | The "synthterrain" software is licensed under the Apache License, 118 | Version 2.0 (the "License"); you may not use this file except in 119 | compliance with the License. You may obtain a copy of the License 120 | at http://www.apache.org/licenses/LICENSE-2.0. 121 | 122 | Unless required by applicable law or agreed to in writing, software 123 | distributed under the License is distributed on an "AS IS" BASIS, 124 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 125 | implied. See the License for the specific language governing 126 | permissions and limitations under the License. 127 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /THIRDPARTYLICENSES.rst: -------------------------------------------------------------------------------- 1 | The synthterrain module would not be possible with out the 2 | use of third party software that is also open source. The following 3 | software is used by synthterrain when run on your system, but is not distributed 4 | with it: 5 | 6 | ======================== ============== ===== 7 | Title License URL 8 | ======================== ============== ===== 9 | matplotlib custom https://matplotlib.org/stable/users/project/license.html 10 | numpy BSD-3-Clause https://github.com/numpy/numpy/blob/main/LICENSE.txt 11 | opensimplex MIT https://github.com/lmas/opensimplex/blob/master/LICENSE 12 | pandas BSD-3-Clause https://github.com/pandas-dev/pandas/blob/main/LICENSE 13 | rasterio BSD-3-Clause https://github.com/rasterio/rasterio/blob/main/LICENSE.txt 14 | scipy BSD-3-Clause https://github.com/scipy/scipy/blob/main/LICENSE.txt 15 | shapely BSD-3-Clause https://github.com/shapely/shapely/blob/main/LICENSE.txt 16 | ======================== ============== ===== 17 | -------------------------------------------------------------------------------- /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 = . 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/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | import os 7 | import sys 8 | 9 | sys.path.insert(0, os.path.abspath("../src/python")) 10 | 11 | # -- Project information ----------------------------------------------------- 12 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 13 | 14 | project = "synthterrain" 15 | copyright = "2024, United States Government, as represented by the Administrator of " 16 | " the National Aeronautics and Space Administration." 17 | author = "Ross Beyer" 18 | 19 | # -- General configuration --------------------------------------------------- 20 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 21 | 22 | extensions = ["sphinx.ext.autodoc", "sphinxcontrib.apidoc"] 23 | 24 | apidoc_module_dir = "../src/python/synthterrain" 25 | apidoc_output_dir = "." 26 | apidoc_excluded_paths = ["tests"] 27 | 28 | templates_path = ["_templates"] 29 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 30 | 31 | 32 | # -- Options for HTML output ------------------------------------------------- 33 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 34 | 35 | html_theme = "alabaster" 36 | html_static_path = ["_static"] 37 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. synthterrain documentation master file, created by 2 | sphinx-quickstart on Mon Jul 1 15:58:03 2024. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to synthterrain's documentation! 7 | ======================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | readme 14 | modules 15 | contributing 16 | authors 17 | changelog 18 | 19 | 20 | Indices and tables 21 | ================== 22 | 23 | * :ref:`genindex` 24 | * :ref:`modindex` 25 | * :ref:`search` 26 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | synthterrain 2 | ============ 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | synthterrain 8 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/synthterrain.crater.rst: -------------------------------------------------------------------------------- 1 | synthterrain.crater package 2 | =========================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | synthterrain.crater.age module 8 | ------------------------------ 9 | 10 | .. automodule:: synthterrain.crater.age 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | synthterrain.crater.cli module 16 | ------------------------------ 17 | 18 | .. automodule:: synthterrain.crater.cli 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | synthterrain.crater.cli\_convert module 24 | --------------------------------------- 25 | 26 | .. automodule:: synthterrain.crater.cli_convert 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | synthterrain.crater.cli\_plot module 32 | ------------------------------------ 33 | 34 | .. automodule:: synthterrain.crater.cli_plot 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | synthterrain.crater.diffusion module 40 | ------------------------------------ 41 | 42 | .. automodule:: synthterrain.crater.diffusion 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | synthterrain.crater.functions module 48 | ------------------------------------ 49 | 50 | .. automodule:: synthterrain.crater.functions 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | synthterrain.crater.profile module 56 | ---------------------------------- 57 | 58 | .. automodule:: synthterrain.crater.profile 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | Module contents 64 | --------------- 65 | 66 | .. automodule:: synthterrain.crater 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | -------------------------------------------------------------------------------- /docs/synthterrain.rock.rst: -------------------------------------------------------------------------------- 1 | synthterrain.rock package 2 | ========================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | synthterrain.rock.cli module 8 | ---------------------------- 9 | 10 | .. automodule:: synthterrain.rock.cli 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | synthterrain.rock.functions module 16 | ---------------------------------- 17 | 18 | .. automodule:: synthterrain.rock.functions 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | Module contents 24 | --------------- 25 | 26 | .. automodule:: synthterrain.rock 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | -------------------------------------------------------------------------------- /docs/synthterrain.rst: -------------------------------------------------------------------------------- 1 | synthterrain package 2 | ==================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | synthterrain.crater 11 | synthterrain.rock 12 | 13 | Submodules 14 | ---------- 15 | 16 | synthterrain.cli module 17 | ----------------------- 18 | 19 | .. automodule:: synthterrain.cli 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | 24 | synthterrain.util module 25 | ------------------------ 26 | 27 | .. automodule:: synthterrain.util 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | 32 | Module contents 33 | --------------- 34 | 35 | .. automodule:: synthterrain 36 | :members: 37 | :undoc-members: 38 | :show-inheritance: 39 | -------------------------------------------------------------------------------- /docs/synthterrain_ARC-18971-1_Corporate_CLA.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NeoGeographyToolkit/synthterrain/e10ba36e9aa5b923bc7e6746ef089859af9ccaf8/docs/synthterrain_ARC-18971-1_Corporate_CLA.pdf -------------------------------------------------------------------------------- /docs/synthterrain_ARC-18971-1_Individual_CLA.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NeoGeographyToolkit/synthterrain/e10ba36e9aa5b923bc7e6746ef089859af9ccaf8/docs/synthterrain_ARC-18971-1_Individual_CLA.pdf -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | # If you only wish to "use" synthterrain with conda, you can just 2 | # > conda env create -n synthterrain -f environment.yml 3 | # 4 | # If you want to develop synthterrain, you should do this: 5 | # > conda create -n synthterrain 6 | # > conda activate synthterrain 7 | # > conda env update --file environment_dev.yml 8 | # > conda env update --file environment.yml 9 | # If either of the "update" steps takes forever, consider using "mamba" instead of "conda". 10 | # 11 | dependencies: 12 | - matplotlib 13 | - numpy 14 | - pandas 15 | - rasterio 16 | - scikit-image 17 | - scipy 18 | - shapely 19 | - pip 20 | - pip: 21 | - opensimplex 22 | -------------------------------------------------------------------------------- /environment_dev.yml: -------------------------------------------------------------------------------- 1 | # Please read comments in environment.yml 2 | channels: 3 | - conda-forge 4 | - defaults 5 | dependencies: 6 | - black 7 | - bump-my-version 8 | - flake8 9 | - icecream 10 | - ipympl 11 | - jupyterlab 12 | - pytest 13 | - pytest-cov 14 | - sphinx 15 | - sphinxcontrib 16 | - sphinxcontrib-apidoc 17 | - sphinxcontrib-autoprogram 18 | - twine 19 | - pip 20 | - pip: 21 | - ufmt 22 | -------------------------------------------------------------------------------- /package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | synthterrain 5 | 0.1.0 6 | 7 | The synthterrain package is software to support the creation of synthetic terrain on worlds in the solar system. 8 | 9 | Copyright (c) 2024-2025 United States Government as represented by the 10 | Administrator of the National Aeronautics and Space Administration. 11 | All Rights Reserved. 12 | 13 | Ross Beyer 14 | Apache 2.0 15 | 16 | python3-shapely 17 | python3-matplotlib 18 | python3-numpy 19 | python3-pandas 20 | python3-scipy 21 | python3-lark-parser 22 | 23 | ament_copyright 24 | ament_flake8 25 | ament_pep257 26 | python3-pytest 27 | 28 | 29 | ament_python 30 | 31 | 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.0"] 3 | build-backend = "setuptools.build_meta" 4 | [project] 5 | name = "synthterrain" 6 | version = "0.2.0" 7 | # dynamic = ["version", "dependencies"] 8 | dynamic = ["dependencies", "optional-dependencies"] 9 | description = "The synthterrain package is software to support the creation of synthetic terrain in the solar system." 10 | maintainers = [ 11 | {name = "Ross Beyer", email = "Ross.A.Beyer@nasa.gov"} 12 | ] 13 | readme = "README.rst" 14 | requires-python = ">=3.8" 15 | 16 | classifiers = [ 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Intended Audience :: Science/Research", 20 | "License :: OSI Approved :: Apache Software License", 21 | "Development Status :: 2 - Pre-Alpha", 22 | "Natural Language :: English", 23 | ] 24 | 25 | [project.scripts] 26 | synthcraters = "synthterrain.crater.cli:main" 27 | synthrocks = "synthterrain.rock.cli:main" 28 | synthterrain = "synthterrain.cli:main" 29 | synthcraterplot = "synthterrain.crater.cli_plot:main" 30 | synthcraterconvert = "synthterrain.crater.cli_convert:main" 31 | 32 | [project.urls] 33 | Repository = "https://github.com/NeoGeographyToolkit/synthterrain" 34 | 35 | [tools.setup.dynamic] 36 | # version = {attr = "synthterrain.__version__"} 37 | dependencies = {file = ["requirements.txt"]} 38 | optional-dependencies = {opt = {file = ["requirements-opt.txt"]}} 39 | 40 | [tool.setuptools] 41 | package-dir = {"" = "src/python"} 42 | 43 | [tool.bumpversion] 44 | current_version = "0.2.0" 45 | parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(?:-(?P[a-z]+))?" 46 | serialize = ["{major}.{minor}.{patch}-{release}", "{major}.{minor}.{patch}"] 47 | search = "{current_version}" 48 | replace = "{new_version}" 49 | regex = false 50 | ignore_missing_version = false 51 | ignore_missing_files = false 52 | tag = false 53 | sign_tags = false 54 | tag_name = "v{new_version}" 55 | tag_message = "Bump version: {current_version} → {new_version}" 56 | allow_dirty = false 57 | commit = false 58 | message = "Bump version: {current_version} → {new_version}" 59 | commit_args = "" 60 | 61 | [tool.bumpversion.parts.release] 62 | values = ["dev", "released"] 63 | optional_value = "released" 64 | 65 | [[tool.bumpversion.files]] 66 | filename = "src/python/synthterrain/__init__.py" 67 | search = '__version__ = "{current_version}"' 68 | replace = '__version__ = "{new_version}"' 69 | -------------------------------------------------------------------------------- /requirements-opt.txt: -------------------------------------------------------------------------------- 1 | scikit-image 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib 2 | numpy 3 | opensimplex 4 | pandas 5 | rasterio 6 | scipy 7 | shapely 8 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | coverage 3 | pip 4 | bump-my-version 5 | flake8 6 | pytest 7 | Sphinx 8 | twine 9 | ufmt 10 | -------------------------------------------------------------------------------- /resource/synthterrain: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NeoGeographyToolkit/synthterrain/e10ba36e9aa5b923bc7e6746ef089859af9ccaf8/resource/synthterrain -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = docs 3 | max_line_length = 88 4 | ignore = E203,E701,W503 5 | -------------------------------------------------------------------------------- /src/python/synthterrain/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for synthterrain.""" 2 | 3 | __author__ = """synthterrain Developers""" 4 | __email__ = "Ross.A.Beyer@nasa.gov" 5 | __version__ = "0.2.0" 6 | -------------------------------------------------------------------------------- /src/python/synthterrain/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Generates synthetic crater and rock populations. 3 | """ 4 | 5 | # Copyright © 2024, United States Government, as represented by the 6 | # Administrator of the National Aeronautics and Space Administration. 7 | # All rights reserved. 8 | # 9 | # The “synthterrain” software is licensed under the Apache License, 10 | # Version 2.0 (the "License"); you may not use this file except in 11 | # compliance with the License. You may obtain a copy of the License 12 | # at http://www.apache.org/licenses/LICENSE-2.0. 13 | 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 17 | # implied. See the License for the specific language governing 18 | # permissions and limitations under the License. 19 | 20 | import logging 21 | import sys 22 | from pathlib import Path 23 | 24 | import pandas as pd 25 | from shapely.geometry import box 26 | 27 | import synthterrain.crater as cr 28 | import synthterrain.rock as rk 29 | from synthterrain import util 30 | from synthterrain.crater import functions as cr_dist 31 | from synthterrain.crater.cli import csfd_dict 32 | from synthterrain.rock import functions as rk_func 33 | 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | 38 | def arg_parser(): 39 | parser = util.FileArgumentParser( 40 | description=__doc__, 41 | parents=[util.parent_parser()], 42 | ) 43 | parser.add_argument( 44 | "--bbox", 45 | nargs=4, 46 | type=float, 47 | default=[0, 1000, 1000, 0], 48 | metavar=("MINX", "MAXY", "MAXX", "MINY"), 49 | help="The coordinates of the bounding box, expressed in meters, to " 50 | "evaluate in min-x, max-y, max-x, min-y order (which is ulx, " 51 | "uly, lrx, lry, the GDAL pattern). " 52 | "Default: %(default)s", 53 | ) 54 | parser.add_argument( 55 | "-c", 56 | "--craters", 57 | type=Path, 58 | help="Crater CSV or XML file of pre-existing craters. This option is usually " 59 | "used as follows: A set of 'real' craters are identified from a target " 60 | "area above a certain diameter (say 5 m/pixel) and given to this option. " 61 | "Then --cr-mind and --cr_maxd are set to some range less than 5 m/pixel. " 62 | "This generates synthetic craters in the specified range, and then uses " 63 | "those synthetic craters in addition to the craters from --craters when " 64 | "building the rock probability map.", 65 | ) 66 | parser.add_argument( 67 | "--csfd", 68 | default="VIPER_Env_Spec", 69 | choices=csfd_dict.keys(), 70 | help="The name of the crater size-frequency distribution to use. " 71 | f"Options are: {', '.join(csfd_dict.keys())}. " 72 | "Default: %(default)s", 73 | ) 74 | parser.add_argument( 75 | "--cr_maxd", 76 | default=1000, 77 | type=float, 78 | help="Maximum crater diameter in meters. Default: %(default)s", 79 | ) 80 | parser.add_argument( 81 | "--cr_mind", 82 | default=1, 83 | type=float, 84 | help="Minimum crater diameter in meters. Default: %(default)s", 85 | ) 86 | parser.add_argument( 87 | "--rk_maxd", 88 | default=2, 89 | type=float, 90 | help="Maximum crater diameter in meters. Default: %(default)s", 91 | ) 92 | parser.add_argument( 93 | "--rk_mind", 94 | default=0.1, 95 | type=float, 96 | help="Minimum crater diameter in meters. Default: %(default)s", 97 | ) 98 | parser.add_argument( 99 | "-p", 100 | "--plot", 101 | action="store_true", 102 | help="This will cause a matplotlib windows to open with some summary " 103 | "plots after the program has generated craters and then rocks.", 104 | ) 105 | parser.add_argument( 106 | "-x", 107 | "--xml", 108 | action="store_true", 109 | help="Default output is in CSV format, but if given this will result " 110 | "in XML output that conforms to the old MATLAB code.", 111 | ) 112 | parser.add_argument( 113 | "--probability_map_gsd", 114 | type=float, 115 | default=1, 116 | help="This program builds a probability map to generate locations, and this " 117 | "sets the ground sample distance in the units of --bbox for that map.", 118 | ) 119 | group = parser.add_mutually_exclusive_group(required=True) 120 | group.add_argument( 121 | "--csfd_info", 122 | action=util.PrintDictAction, 123 | dict=csfd_dict, 124 | help="If given, will list detailed information about each of the " 125 | "available CSFDs and exit.", 126 | ) 127 | group.add_argument("--cr_outfile", type=Path, help="Path to crater output file.") 128 | parser.add_argument( 129 | "--rk_outfile", type=Path, required=True, help="Path to crater output file." 130 | ) 131 | return parser 132 | 133 | 134 | def main(): 135 | args = arg_parser().parse_args() 136 | 137 | util.set_logger(args.verbose) 138 | 139 | poly = box(args.bbox[0], args.bbox[3], args.bbox[2], args.bbox[1]) 140 | 141 | logger.info("Synthesizing Craters.") 142 | crater_dist = getattr(cr_dist, args.csfd)(a=args.cr_mind, b=args.cr_maxd) 143 | 144 | crater_df = cr.synthesize( 145 | crater_dist, polygon=poly, min_d=args.cr_mind, max_d=args.cr_maxd 146 | ) 147 | cr.to_file(crater_df, args.cr_outfile, args.xml) 148 | if args.plot: 149 | cr.plot(crater_df) 150 | 151 | if args.craters is not None: 152 | precraters = cr.from_file(args.craters) 153 | logger.info(f"Adding {precraters.shape[0]} craters from {args.craters}") 154 | crater_df = pd.concat([crater_df, precraters], ignore_index=True) 155 | 156 | logger.info("Synthesizing Rocks.") 157 | df, pmap = rk.synthesize( 158 | rk_func.VIPER_Env_Spec(a=args.rk_mind, b=args.rk_maxd), 159 | polygon=poly, 160 | pmap_gsd=args.probability_map_gsd, 161 | crater_frame=crater_df, 162 | min_d=args.rk_mind, 163 | max_d=args.rk_maxd, 164 | ) 165 | 166 | rk.to_file(df, args.rk_outfile, args.xml) 167 | if args.plot: 168 | rk.plot( 169 | df, pmap, [poly.bounds[0], poly.bounds[2], poly.bounds[1], poly.bounds[3]] 170 | ) 171 | 172 | 173 | if __name__ == "__main__": 174 | sys.exit(main()) 175 | -------------------------------------------------------------------------------- /src/python/synthterrain/crater/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Generates synthetic crater populations. 3 | """ 4 | 5 | # Copyright © 2024, United States Government, as represented by the 6 | # Administrator of the National Aeronautics and Space Administration. 7 | # All rights reserved. 8 | # 9 | # The “synthterrain” software is licensed under the Apache License, 10 | # Version 2.0 (the "License"); you may not use this file except in 11 | # compliance with the License. You may obtain a copy of the License 12 | # at http://www.apache.org/licenses/LICENSE-2.0. 13 | 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 17 | # implied. See the License for the specific language governing 18 | # permissions and limitations under the License. 19 | 20 | import logging 21 | import math 22 | import random 23 | from pathlib import Path 24 | 25 | import matplotlib.pyplot as plt 26 | import numpy as np 27 | import pandas as pd 28 | 29 | from matplotlib.collections import PatchCollection 30 | from matplotlib.patches import Circle 31 | from matplotlib.ticker import ScalarFormatter 32 | from shapely.geometry import Point, Polygon 33 | 34 | from synthterrain.crater import functions 35 | from synthterrain.crater.age import equilibrium_age 36 | from synthterrain.crater.diffusion import diffuse_d_over_D, diffuse_d_over_D_by_bin 37 | 38 | 39 | logger = logging.getLogger(__name__) 40 | 41 | 42 | def synthesize( 43 | crater_dist: functions.Crater_rv_continuous, 44 | polygon: Polygon, 45 | production_fn=None, 46 | by_bin=True, 47 | min_d=None, 48 | max_d=None, 49 | return_surfaces=False, 50 | ): 51 | """Return a pandas DataFrame which contains craters and their properties 52 | synthesized from the input parameters. 53 | """ 54 | if production_fn is None: 55 | production_fn = determine_production_function(crater_dist.a, crater_dist.b) 56 | logger.info(f"Production function is {production_fn.__class__}") 57 | 58 | # Get craters 59 | if min_d is None and max_d is None: 60 | diameters = crater_dist.rvs(area=polygon.area) 61 | elif min_d is not None and max_d is not None: 62 | diameters = generate_diameters(crater_dist, polygon.area, min_d, max_d) 63 | else: 64 | raise ValueError( 65 | f"One of min_d, max_d ({min_d}, {max_d}) was None, they must " 66 | "either both be None or both have a value." 67 | ) 68 | logger.info(f"In {polygon.area} m^2, generated {len(diameters)} craters.") 69 | 70 | # Generate ages and start working with a dataframe. 71 | df = generate_ages(diameters, production_fn.csfd, crater_dist.csfd) 72 | logger.info( 73 | f"Generated ages from {math.floor(df['age'].min()):,} to " 74 | f"{math.ceil(df['age'].max()):,} years." 75 | ) 76 | 77 | # Randomly generate positions within the polygon for the locations of 78 | # the craters. 79 | logger.info("Generating center positions.") 80 | # positions = random_points(polygon, len(diameters)) 81 | xlist, ylist = random_points(polygon, len(diameters)) 82 | # Add x and y positional information to the dataframe 83 | df["x"] = xlist 84 | df["y"] = ylist 85 | 86 | # Generate depth to diameter ratio 87 | if by_bin: 88 | df = diffuse_d_over_D_by_bin( 89 | df, start_dd_mean="Stopar step", return_surfaces=return_surfaces 90 | ) 91 | else: 92 | if return_surfaces: 93 | df["surface"] = None 94 | df["surface"].astype(object) 95 | df["d/D", "surface"] = df.apply( 96 | lambda crater: diffuse_d_over_D( 97 | crater["diameter"], crater["age"], return_surface=True 98 | ), 99 | axis=1, 100 | result_type="expand", 101 | ) 102 | else: 103 | df["d/D"] = df.apply( 104 | lambda crater: diffuse_d_over_D(crater["diameter"], crater["age"]), 105 | axis=1, 106 | ) 107 | 108 | return df 109 | 110 | 111 | def determine_production_function(a: float, b: float): 112 | if a >= 10: 113 | return functions.NPF(a, b) 114 | if b <= 2.76: 115 | return functions.Grun(a, b) 116 | 117 | return functions.GNPF(a, b) 118 | 119 | 120 | def random_points(poly: Polygon, num_points: int): 121 | """Returns two lists, the first being the x coordinates, and the second 122 | being the y coordinates, each *num_points* long that represent 123 | random locations within the provided *poly*. 124 | """ 125 | # We could have returned a list of shapely Point objects, but that's 126 | # not how we need the data later. 127 | min_x, min_y, max_x, max_y = poly.bounds 128 | # points = [] 129 | x_list = [] 130 | y_list = [] 131 | # while len(points) < num_points: 132 | while len(x_list) < num_points: 133 | random_point = Point( 134 | [random.uniform(min_x, max_x), random.uniform(min_y, max_y)] 135 | ) 136 | if random_point.within(poly): 137 | # points.append(random_point) 138 | x_list.append(random_point.x) 139 | y_list.append(random_point.y) 140 | 141 | # return points 142 | return x_list, y_list 143 | 144 | 145 | def generate_diameters(crater_dist, area, min, max): 146 | """ 147 | Returns a numpy array with diameters selected from the *crater_dist* 148 | function based on the *area*, and no craters smaller than *min* or 149 | larger than *max* will be returned. 150 | """ 151 | size = crater_dist.count(area, min) - crater_dist.count(area, max) 152 | diameters = [] 153 | 154 | while len(diameters) != size: 155 | d = crater_dist.rvs(size=(size - len(diameters))) 156 | diameters += d[np.logical_and(min <= d, d <= max)].tolist() 157 | 158 | return np.array(diameters) 159 | 160 | 161 | def generate_ages(diameters, pd_csfd, eq_csfd): 162 | """ 163 | Returns a pandas DataFrame which contains "diameters" and "ages" 164 | columns with "diameters" being those provided via *diameters* and 165 | "ages" determined randomly from the range computed based on the 166 | provided equilibrium cumulative size frequency function, *eq_csfd*, 167 | and the the provided production cumulative size frequency function, 168 | *pd_csfd*. 169 | 170 | Both of these functions, when given a diameter, should return an actual 171 | cumulative count of craters per square meter (eq_csfd), and a rate of 172 | cratering in craters per square meter per Gigayear at that 173 | diameter (pd_csfd). 174 | """ 175 | yrs_to_equilibrium = equilibrium_age(diameters, pd_csfd, eq_csfd) 176 | # print(yrs_to_equilibrium) 177 | 178 | ages = np.random.default_rng().uniform(0, yrs_to_equilibrium) 179 | 180 | return pd.DataFrame(data={"diameter": diameters, "age": ages}) 181 | 182 | 183 | def plot(df): 184 | """ 185 | Generates a plot display with a variety of subplots for the provided 186 | pandas DataFrame, consistent with the columns in the DataFrame output 187 | by synthesize(). 188 | """ 189 | # Plots are: 190 | # CSFD, Age 191 | # d/D, location 192 | 193 | plt.ioff() 194 | fig, ((ax_csfd, ax_age), (ax_dd, ax_location)) = plt.subplots(2, 2) 195 | 196 | ax_csfd.hist( 197 | df["diameter"], 198 | cumulative=-1, 199 | log=True, 200 | bins=50, 201 | histtype="stepfilled", 202 | label="Craters", 203 | ) 204 | ax_csfd.set_ylabel("Count") 205 | ax_csfd.yaxis.set_major_formatter(ScalarFormatter()) 206 | ax_csfd.set_xlabel("Diameter (m)") 207 | ax_csfd.legend(loc="best", frameon=False) 208 | 209 | ax_age.scatter(df["diameter"], df["age"], alpha=0.2, edgecolors="none", s=10) 210 | ax_age.set_xscale("log") 211 | ax_age.xaxis.set_major_formatter(ScalarFormatter()) 212 | ax_age.set_yscale("log") 213 | ax_age.set_ylabel("Age (yr)") 214 | ax_age.set_xlabel("Diameter (m)") 215 | 216 | ax_dd.scatter(df["diameter"], df["d/D"], alpha=0.2, edgecolors="none", s=10) 217 | ax_dd.set_xscale("log") 218 | ax_dd.xaxis.set_major_formatter(ScalarFormatter()) 219 | ax_dd.set_ylabel("depth / Diameter") 220 | ax_dd.set_xlabel("Diameter (m)") 221 | 222 | patches = [ 223 | Circle((x_, y_), s_) 224 | for x_, y_, s_ in np.broadcast(df["x"], df["y"], df["diameter"] / 2) 225 | ] 226 | collection = PatchCollection(patches) 227 | collection.set_array(df["d/D"]) # Sets circle color to this data property. 228 | ax_location.add_collection(collection) 229 | ax_location.autoscale_view() 230 | ax_location.set_aspect("equal") 231 | fig.colorbar(collection, ax=ax_location) 232 | 233 | plt.show() 234 | 235 | 236 | def to_file(df: pd.DataFrame, outfile: Path, xml=False): 237 | if xml: 238 | # Write out the dataframe in the XML style of the old MATLAB 239 | # program. 240 | df["rimRadius"] = df["diameter"] / 2 241 | 242 | # freshness float: 0 is an undetectable crater and 1 is "fresh" 243 | # The mapping from d/D to "freshness" is just the fraction of 244 | # d/D versus 0.2731, although I'm not sure why that value was selected. 245 | df["freshness"] = df["d/D"] / 0.2731 246 | 247 | # This indicates whether a crater in the listing was synthetically 248 | # generated or not, at this time, all are. 249 | df["isGenerated"] = 1 250 | 251 | df.to_xml( 252 | outfile, 253 | index=False, 254 | root_name="CraterList", 255 | row_name="CraterData", 256 | parser="etree", 257 | attr_cols=["x", "y", "rimRadius", "freshness", "isGenerated"], 258 | ) 259 | else: 260 | df.to_csv( 261 | outfile, 262 | index=False, 263 | columns=["x", "y", "diameter", "age", "d/D"], 264 | ) 265 | 266 | 267 | def from_file(infile: Path): 268 | """Load previously written crater information from disk""" 269 | if infile.suffix == ".xml": 270 | df = pd.read_xml(infile) 271 | else: 272 | df = pd.read_csv(infile) 273 | return df 274 | -------------------------------------------------------------------------------- /src/python/synthterrain/crater/age.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Functions for estimating crater ages. 4 | """ 5 | 6 | # Copyright © 2024, United States Government, as represented by the 7 | # Administrator of the National Aeronautics and Space Administration. 8 | # All rights reserved. 9 | # 10 | # The “synthterrain” software is licensed under the Apache License, 11 | # Version 2.0 (the "License"); you may not use this file except in 12 | # compliance with the License. You may obtain a copy of the License 13 | # at http://www.apache.org/licenses/LICENSE-2.0. 14 | 15 | # Unless required by applicable law or agreed to in writing, software 16 | # distributed under the License is distributed on an "AS IS" BASIS, 17 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 18 | # implied. See the License for the specific language governing 19 | # permissions and limitations under the License. 20 | 21 | import bisect 22 | import logging 23 | 24 | import numpy as np 25 | import pandas as pd 26 | 27 | from synthterrain.crater.diffusion import diffuse_d_over_D 28 | from synthterrain.crater.profile import stopar_fresh_dd 29 | 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | 34 | def equilibrium_age(diameters, pd_csfd, eq_csfd): 35 | """ 36 | Returns a numpy array which contains the equilibrium ages 37 | which correspond to the craters provided via *diameters* 38 | computed based on the provided equilibrium cumulative size frequency function, 39 | *eq_csfd*, and the the provided production cumulative size frequency function, 40 | *pd_csfd*. 41 | 42 | Both of these functions, when given a diameter, should return an actual 43 | cumulative count of craters per square meter (eq_csfd), and a rate of 44 | cratering in craters per square meter per Gigayear at that 45 | diameter (pd_csfd). 46 | """ 47 | upper_diameters = np.float_power(10, np.log10(diameters) + 0.1) 48 | eq = eq_csfd(diameters) - eq_csfd(upper_diameters) 49 | pf = pd_csfd(diameters) - pd_csfd(upper_diameters) 50 | 51 | return 1e9 * eq / pf 52 | 53 | 54 | def estimate_age(diameter, dd, max_age): 55 | """ 56 | Estimates the age of a crater in years given its diameter in meters and the 57 | d/D value by attempting to match the given d/D value to the diffusion shape 58 | of a crater of the given *diameter* and the *max_age* of that crater, which 59 | could be estimated via the equilibrium_ages() function. 60 | 61 | This function returns estimated ages in multiples of a million years. More 62 | precision than that is not accurate for this approach. 63 | """ 64 | fresh_dd = stopar_fresh_dd(diameter) 65 | 66 | if dd > fresh_dd: 67 | return 0 68 | 69 | dd_rev_list = list( 70 | reversed( 71 | diffuse_d_over_D( 72 | diameter, max_age, start_dd_adjust=fresh_dd, return_steps=True 73 | ) 74 | ) 75 | ) 76 | nsteps = len(dd_rev_list) 77 | 78 | age_step = bisect.bisect_left(dd_rev_list, dd) 79 | 80 | years_per_step = max_age / nsteps 81 | 82 | age = (nsteps - age_step) * years_per_step 83 | 84 | return round(age / 1e6) * 1e6 85 | 86 | 87 | def estimate_age_by_bin( 88 | df, 89 | num=50, 90 | pd_csfd=None, 91 | eq_csfd=None, 92 | ) -> pd.DataFrame: 93 | """ 94 | Returns a pandas DataFrame identical to the input *df* but with the 95 | addition of two columns: "diameter_bin" and "age". The ages are in years 96 | and are estimated from the "diameter" and "d/D" columns 97 | in the provided pandas DataFrame, *df*. 98 | 99 | For lage numbers of craters, running a estimate_age() for each can be 100 | computationally intensive. This function generates *num* bins with log 101 | scale boundaries (using the numpy geomspace() function) between the maximum 102 | and minimum diameter values. 103 | 104 | Then, the center diameter of each bin has diffuse_d_over_D() 105 | run to evaluate the d/D ratio of that crater over the lifetime 106 | of the solar system (if *pd_csfd* and *eq_csfd* are both None), or 107 | the diffusion calculation is run for the appropriate equilibrium age 108 | of each crater diameter. 109 | 110 | Then, for each crater in the bin, its d/D is compared to the d/D ratios from the 111 | diffusion run, and an estimated age is assigned. 112 | """ 113 | if df.empty: 114 | raise ValueError("The provided dataframe has no rows.") 115 | 116 | logger.info("estimate_age_by_bin start.") 117 | if df["diameter"].min() == df["diameter"].max(): 118 | bin_edges = 1 119 | total_bins = 1 120 | logger.info("The craters are all the same size.") 121 | else: 122 | bin_edges = np.geomspace( 123 | df["diameter"].min(), df["diameter"].max(), num=num + 1 124 | ) 125 | total_bins = num 126 | # logger.info(f"{df.shape[0]} craters") 127 | logger.info( 128 | f"Divided the craters into {num} diameter bins (not all bins may have " 129 | "craters)" 130 | ) 131 | 132 | df["diameter_bin"] = pd.cut( 133 | df["diameter"], 134 | bins=bin_edges, 135 | include_lowest=True, 136 | ) 137 | 138 | # df["equilibrium_age"] = equilibrium_ages(df["diameter"], pd_csfd, eq_csfd) 139 | df["age"] = 0 140 | 141 | for i, (interval, count) in enumerate( 142 | df["diameter_bin"].value_counts(sort=False).items() 143 | ): 144 | logger.info( 145 | f"Processing bin {i + 1}/{total_bins}, interval: {interval}, count: {count}" 146 | ) 147 | 148 | if count == 0: 149 | continue 150 | 151 | fresh_dd = stopar_fresh_dd(interval.mid) 152 | 153 | if pd_csfd is not None and eq_csfd is not None: 154 | age = equilibrium_age(interval.mid, pd_csfd, eq_csfd) 155 | else: 156 | age = 4.5e9 157 | 158 | dd_rev_list = list( 159 | reversed( 160 | diffuse_d_over_D( 161 | interval.mid, age, start_dd_adjust=fresh_dd, return_steps=True 162 | ) 163 | ) 164 | ) 165 | nsteps = len(dd_rev_list) 166 | years_per_step = age / nsteps 167 | 168 | def guess_age(dd): 169 | age_step = bisect.bisect_left(dd_rev_list, dd) 170 | return round(int((nsteps - age_step) * years_per_step) / 1e6) * 1e6 171 | 172 | df.loc[df["diameter_bin"] == interval, "age"] = df.loc[ 173 | df["diameter_bin"] == interval 174 | ].apply(lambda row: guess_age(row["d/D"]), axis=1) 175 | 176 | df["age"] = df["age"].astype("int64") 177 | 178 | logger.info("estimate_age_by_bin complete.") 179 | 180 | return df 181 | -------------------------------------------------------------------------------- /src/python/synthterrain/crater/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Generates synthetic crater populations. 3 | """ 4 | 5 | # Copyright © 2024, United States Government, as represented by the 6 | # Administrator of the National Aeronautics and Space Administration. 7 | # All rights reserved. 8 | # 9 | # The “synthterrain” software is licensed under the Apache License, 10 | # Version 2.0 (the "License"); you may not use this file except in 11 | # compliance with the License. You may obtain a copy of the License 12 | # at http://www.apache.org/licenses/LICENSE-2.0. 13 | 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 17 | # implied. See the License for the specific language governing 18 | # permissions and limitations under the License. 19 | 20 | import logging 21 | import math 22 | import sys 23 | from pathlib import Path 24 | 25 | import numpy as np 26 | import rasterio 27 | from rasterio.transform import from_origin 28 | from rasterio.windows import from_bounds 29 | from shapely.geometry import box 30 | 31 | from synthterrain import crater, util 32 | from synthterrain.crater import functions 33 | from synthterrain.crater.diffusion import make_crater_field 34 | 35 | 36 | logger = logging.getLogger(__name__) 37 | 38 | # Assemble this global variable value. 39 | csfd_dict = {} 40 | for fn in functions.equilibrium_functions: 41 | csfd_dict[fn.__name__] = fn.__doc__ 42 | 43 | 44 | def arg_parser(): 45 | parser = util.FileArgumentParser( 46 | description=__doc__, 47 | parents=[util.parent_parser()], 48 | # formatter_class=argparse.RawDescriptionHelpFormatter 49 | ) 50 | # parser.add_argument( 51 | # "--area", 52 | # type=float, 53 | # help="Area in square kilometers to evaluate." 54 | # ) 55 | parser.add_argument( 56 | "--bbox", 57 | nargs=4, 58 | type=float, 59 | default=[0, 1000, 1000, 0], 60 | metavar=("MINX", "MAXY", "MAXX", "MINY"), 61 | help="The coordinates of the bounding box, expressed in meters, to " 62 | "evaluate in min-x, max-y, max-x, min-y order (which is ulx, " 63 | "uly, lrx, lry, the GDAL pattern). " 64 | "Default: %(default)s", 65 | ) 66 | parser.add_argument( 67 | "--csfd", 68 | default="VIPER_Env_Spec", 69 | choices=csfd_dict.keys(), 70 | help="The name of the crater size-frequency distribution to use. " 71 | f"Options are: {', '.join(csfd_dict.keys())}. " 72 | "Default: %(default)s", 73 | ) 74 | parser.add_argument( 75 | "--maxd", 76 | default=1000, 77 | type=float, 78 | help="Maximum crater diameter in meters. Default: %(default)s", 79 | ) 80 | parser.add_argument( 81 | "--mind", 82 | default=1, 83 | type=float, 84 | help="Minimum crater diameter in meters. Default: %(default)s", 85 | ) 86 | parser.add_argument( 87 | "-p", 88 | "--plot", 89 | action="store_true", 90 | help="This will cause a matplotlib window to open with some summary " 91 | "plots after the program has generated the data.", 92 | ) 93 | parser.add_argument( 94 | "-proj", 95 | help="If -t is given this is needed to determine the proj string for the " 96 | "output GeoTIFF. e.g. '+proj=eqc +R=1737400 +units=m'", 97 | ) 98 | parser.add_argument( 99 | "--run_individual", 100 | # Inverted the action to typically be set to true as it will be 101 | # given to the by_bin parameter of synthesize. 102 | action="store_false", 103 | help="If given, this will run a diffusion model for each synthetic " 104 | "crater individually and depending on the area provided and the " 105 | "crater range could cause this program to run for many hours as " 106 | "it tried to calculate tens of thousands of diffusion models. " 107 | "The default behavior is to gather the craters into diameter bins " 108 | "and only run a few representative diffusion models to span the " 109 | "parameter space.", 110 | ) 111 | parser.add_argument( 112 | "-t", 113 | "--terrain_gsd", 114 | type=float, 115 | help="If provided, will trigger creation of a terrain model based on bbox, and " 116 | "the value given here will set the ground sample distance (GSD) of that model. " 117 | "The terrain model output file will be the same as --outfile, but with a .tif " 118 | "ending.", 119 | ) 120 | parser.add_argument( 121 | "-x", 122 | "--xml", 123 | action="store_true", 124 | help="Default output is in CSV format, but if given this will result " 125 | "in XML output that conforms to the old MATLAB code.", 126 | ) 127 | group = parser.add_mutually_exclusive_group(required=True) 128 | group.add_argument( 129 | "--csfd_info", 130 | action=util.PrintDictAction, 131 | dict=csfd_dict, 132 | help="If given, will list detailed information about each of the " 133 | "available CSFDs and exit.", 134 | ) 135 | group.add_argument("-o", "--outfile", type=Path, help="Path to output file.") 136 | return parser 137 | 138 | 139 | def main(): 140 | args = arg_parser().parse_args() 141 | 142 | util.set_logger(args.verbose) 143 | 144 | # if args.csfd_info: 145 | # print(csfd_dict) 146 | # return 147 | 148 | # This could more generically take an arbitrary polygon 149 | # bbox argument takes: 'MINX', 'MAXY', 'MAXX', 'MINY' 150 | # the box() function takes: (minx, miny, maxx, maxy) 151 | poly = box(args.bbox[0], args.bbox[3], args.bbox[2], args.bbox[1]) 152 | 153 | crater_dist = getattr(functions, args.csfd)(a=args.mind, b=args.maxd) 154 | 155 | df = crater.synthesize( 156 | crater_dist, 157 | polygon=poly, 158 | by_bin=args.run_individual, 159 | min_d=args.mind, 160 | max_d=args.maxd, 161 | return_surfaces=bool(args.terrain_gsd), 162 | ) 163 | 164 | if args.plot: 165 | crater.plot(df) 166 | 167 | # Write out results. 168 | crater.to_file(df, args.outfile, args.xml) 169 | 170 | if args.terrain_gsd is not None: 171 | transform = from_origin( 172 | poly.bounds[0], poly.bounds[3], args.terrain_gsd, args.terrain_gsd 173 | ) 174 | window = from_bounds(*poly.bounds, transform=transform) 175 | 176 | tm = make_crater_field( 177 | df, np.zeros((math.ceil(window.height), math.ceil(window.width))), transform 178 | ) 179 | 180 | with rasterio.open( 181 | args.outfile.with_suffix(".tif"), 182 | "w", 183 | driver="GTiff", 184 | height=tm.shape[0], 185 | width=tm.shape[1], 186 | count=1, 187 | dtype=tm.dtype, 188 | crs=args.proj, 189 | transform=transform, 190 | ) as dst: 191 | dst.write(tm, 1) 192 | 193 | 194 | if __name__ == "__main__": 195 | sys.exit(main()) 196 | -------------------------------------------------------------------------------- /src/python/synthterrain/crater/cli_convert.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Converts between the crater CSV and XML formats. 3 | """ 4 | 5 | # Copyright © 2024, United States Government, as represented by the 6 | # Administrator of the National Aeronautics and Space Administration. 7 | # All rights reserved. 8 | # 9 | # The “synthterrain” software is licensed under the Apache License, 10 | # Version 2.0 (the "License"); you may not use this file except in 11 | # compliance with the License. You may obtain a copy of the License 12 | # at http://www.apache.org/licenses/LICENSE-2.0. 13 | 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 17 | # implied. See the License for the specific language governing 18 | # permissions and limitations under the License. 19 | 20 | import argparse 21 | import logging 22 | import sys 23 | from pathlib import Path 24 | 25 | import pandas as pd 26 | 27 | import synthterrain.crater.functions as crater_func 28 | 29 | from synthterrain import crater, util 30 | from synthterrain.crater.age import estimate_age_by_bin 31 | 32 | 33 | logger = logging.getLogger(__name__) 34 | 35 | 36 | def arg_parser(): 37 | parser = argparse.ArgumentParser( 38 | description=__doc__, 39 | parents=[util.parent_parser()], 40 | ) 41 | parser.add_argument( 42 | "--estimate_ages", 43 | action="store_true", 44 | help="When given, craters in the input file with no age specified, or an age " 45 | "of zero, will have an age estimated based on their diameter and d/D " 46 | "ratio using the Grun/Neukum production function and the VIPER " 47 | "Environmental Specification equilibrium crater function. Some resulting " 48 | "craters may still yield a zero age if the d/D ratio was large relative " 49 | "to the diameter, indicating a very fresh crater.", 50 | ) 51 | parser.add_argument( 52 | "--full_age", 53 | action="store_true", 54 | help="Ignored unless --estimate_ages is also given. When provided, it will " 55 | "cause the diffusion calculation to run for the age of the solar system " 56 | "instead of just the equilibrium age for each crater size. This may " 57 | "provide improved age estimates, but could also cause longer run times. " 58 | "Please use with caution.", 59 | ) 60 | parser.add_argument( 61 | "infile", type=Path, help="A CSV or XML file produced by synthcraters." 62 | ) 63 | parser.add_argument( 64 | "outfile", 65 | type=Path, 66 | help="The output file an XML or CSV file (whatever the opposite of the " 67 | "first argument is).", 68 | ) 69 | return parser 70 | 71 | 72 | def main(): 73 | parser = arg_parser() 74 | args = parser.parse_args() 75 | 76 | util.set_logger(args.verbose) 77 | 78 | if args.infile.suffix.casefold() == ".csv": 79 | df = pd.read_csv(args.infile) 80 | 81 | elif args.infile.suffix.casefold() == ".xml": 82 | df = pd.read_xml(args.infile, parser="etree", xpath=".//CraterData") 83 | 84 | # Create the columns that the CSV output is expecting. 85 | df["diameter"] = df["rimRadius"] * 2 86 | df["d/D"] = df["freshness"] * 0.2731 87 | df["age"] = 0 # No age information from XML file format. 88 | 89 | else: 90 | parser.error(f"The input file {args.infile} did not end in .csv or .xml.") 91 | 92 | if args.estimate_ages: 93 | if args.full_age: 94 | pd_csfd = None 95 | eq_csfd = None 96 | else: 97 | a = df["diameter"].min() 98 | b = df["diameter"].max() 99 | pd_csfd = crater.determine_production_function(a=a, b=b).csfd 100 | eq_csfd = crater_func.VIPER_Env_Spec(a=a, b=b).csfd 101 | 102 | try: 103 | if "age" in df.columns: 104 | df[df["age"] == 0] = estimate_age_by_bin( 105 | df[df["age"] == 0], 50, pd_csfd, eq_csfd 106 | ) 107 | else: 108 | df = estimate_age_by_bin(df, 50, pd_csfd, eq_csfd) 109 | except ValueError: 110 | logger.error("The provided file has no craters with an age of zero.") 111 | return 1 112 | 113 | crater.to_file(df, args.outfile, xml=(args.outfile.suffix.casefold() == ".xml")) 114 | 115 | return None 116 | 117 | 118 | if __name__ == "__main__": 119 | sys.exit(main()) 120 | -------------------------------------------------------------------------------- /src/python/synthterrain/crater/cli_plot.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Generates plots from .csv files. 3 | """ 4 | 5 | # Copyright © 2024, United States Government, as represented by the 6 | # Administrator of the National Aeronautics and Space Administration. 7 | # All rights reserved. 8 | # 9 | # The “synthterrain” software is licensed under the Apache License, 10 | # Version 2.0 (the "License"); you may not use this file except in 11 | # compliance with the License. You may obtain a copy of the License 12 | # at http://www.apache.org/licenses/LICENSE-2.0. 13 | 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 17 | # implied. See the License for the specific language governing 18 | # permissions and limitations under the License. 19 | 20 | import argparse 21 | import logging 22 | import sys 23 | from pathlib import Path 24 | 25 | import pandas as pd 26 | 27 | from synthterrain import crater, util 28 | 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | def arg_parser(): 34 | parser = argparse.ArgumentParser( 35 | description=__doc__, 36 | parents=[util.parent_parser()], 37 | ) 38 | parser.add_argument( 39 | "csv", 40 | type=Path, 41 | help="A CSV file with a header row, and the following columns: " 42 | "x, y, diameter, age, d/D.", 43 | ) 44 | return parser 45 | 46 | 47 | def main(): 48 | args = arg_parser().parse_args() 49 | 50 | util.set_logger(args.verbose) 51 | 52 | df = pd.read_csv(args.csv) 53 | 54 | crater.plot(df) 55 | 56 | 57 | if __name__ == "__main__": 58 | sys.exit(main()) 59 | -------------------------------------------------------------------------------- /src/python/synthterrain/crater/diffusion.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Performs diffusion of a crater shape. 4 | 5 | A descriptive guide to the approach used here can be found in 6 | Chapter 7 of Learning Scientific Programming with Python by 7 | Christian Hill, 2nd Edition, 2020. An online version can be found 8 | at 9 | https://scipython.com/book2/chapter-7-matplotlib/examples/the-two-dimensional-diffusion-equation/ 10 | """ 11 | 12 | # Copyright © 2024, United States Government, as represented by the 13 | # Administrator of the National Aeronautics and Space Administration. 14 | # All rights reserved. 15 | # 16 | # The “synthterrain” software is licensed under the Apache License, 17 | # Version 2.0 (the "License"); you may not use this file except in 18 | # compliance with the License. You may obtain a copy of the License 19 | # at http://www.apache.org/licenses/LICENSE-2.0. 20 | 21 | # Unless required by applicable law or agreed to in writing, software 22 | # distributed under the License is distributed on an "AS IS" BASIS, 23 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 24 | # implied. See the License for the specific language governing 25 | # permissions and limitations under the License. 26 | 27 | import logging 28 | import math 29 | import statistics 30 | from typing import Union 31 | 32 | import numpy as np 33 | import numpy.typing as npt 34 | import pandas as pd 35 | from numpy.polynomial import Polynomial 36 | from rasterio.transform import rowcol 37 | from rasterio.windows import get_data_window, intersect, Window 38 | 39 | from synthterrain.crater.profile import FTmod_Crater, stopar_fresh_dd 40 | 41 | 42 | logger = logging.getLogger(__name__) 43 | 44 | 45 | def diffusion_length_scale(diameter: float, domain_size: int) -> float: 46 | """ 47 | Returns the appropriate "diffusion length scale" based on the provided 48 | *diameter* and the *domain_size*. Where *domain_size* is the width of a 49 | square grid upon which the diffusion calculation is performed. 50 | 51 | In the equations and code detailed in Hill (2020), the diffusion 52 | coefficient is denoted as D, and the time step as 𝚫t or dt. 53 | 54 | The equation for dt is: 55 | 56 | dt = (dx dy)^2 / (2 * D * (dx^2 + dy^2)) 57 | 58 | In the case where dx = dy, this simplifies to: 59 | 60 | dt = dx^2 / (4 * D) 61 | 62 | For our approach, it is useful to define a "diffusion length scale" which 63 | we'll denote as dls, such that 64 | 65 | dt = dls / D 66 | 67 | This value of dls can then be easily used in other functions in this 68 | module. 69 | """ 70 | return math.pow(diameter * 2 / domain_size, 2) / 4 71 | 72 | 73 | def kappa_diffusivity(diameter: float) -> float: 74 | """ 75 | Returns the diffusivity for a crater with a *diameter* in meters. The 76 | returned diffusivity is in units of m^2 per year. 77 | 78 | This calculation is based on Fassett and Thomson (2014, 79 | https://doi.org/10.1002/2014JE004698) and Fassett and Thomson (2015, 80 | https://ui.adsabs.harvard.edu/abs/2015LPI....46.1120F/abstract), 81 | but modeling done by Caleb Fassett since then has influenced this 82 | function. 83 | """ 84 | # Fassett and Thomson (2014) don't actually define this functional 85 | # form, this was provided to me by Caleb. 86 | # The *kappa0* value is the diffusivity at 1 km, and *kappa_corr* is the 87 | # power law exponent for correcting this to small sizes. 88 | # Fassett and Thomson (2015) indicates *kappa0* is 5.5e-6 m^2 / year 89 | # (the default). 90 | # kappa0=5.5e-6, kappa_corr=0.9 91 | # return kappa0 * math.pow(diameter / 1000, kappa_corr) 92 | 93 | # The logic below replaced the above simple logic on 94 | # 2022-05-22 by Caleb. 95 | if diameter <= 11.2: 96 | k = 0.0155 # m2/myr 97 | elif diameter < 45: 98 | k = 1.55e-3 * math.pow(diameter, 0.974) 99 | elif diameter < 125: 100 | k = 1.23e-3 * math.pow(diameter, 0.8386) 101 | else: # UNCONSTRAINED BY EQUILIBRIUM ABOVE 125m!!!!!!! 102 | k = 5.2e-3 * math.pow(diameter, 1.3) 103 | 104 | return k / 1.0e6 # m2/yr 105 | 106 | 107 | def diffuse_d_over_D( 108 | diameter, 109 | age, 110 | domain_size=200, 111 | start_dd_adjust=False, 112 | start_dd_mean=0.15, 113 | start_dd_std=0.02, 114 | return_steps=False, 115 | return_surface: Union[bool, str] = False, 116 | crater_cls=FTmod_Crater, 117 | ): 118 | """ 119 | Returns a depth to diameter ratio of a crater of *diameter* in meters 120 | and *age* in years after the model in Fassett and Thomson (2014, 121 | https://doi.org/10.1002/2014JE004698). 122 | 123 | The *domain_size* is the width of a square grid upon which the 124 | diffusion calculation is performed. 125 | 126 | If *start_dd_adjust* is True, the initial d/D ratio of the 127 | crater will be determined by randomly selecting a value from 128 | a normal distribution with a mean of *start_dd_mean* and 129 | and standard deviation of *start_dd_std*. 130 | 131 | If *start_dd_adjust* is numeric, then the starting depth to Diameter 132 | ratio will be set to this specific value (and *start_dd_mean* and 133 | *start_dd_std* will be ignored). 134 | 135 | In order to make no changes to the initial crater shape, set 136 | *start_dd_adjust* to False (the default). 137 | 138 | If *return_steps* is True, instead of a single depth to diameter ratio 139 | returned, a list of depth to diameter ratios is returned, one for each 140 | time step in the diffusion process, with the last list item being identical 141 | to what would be returned when *return_steps* is False (the default). 142 | 143 | If *return_surface* is True, instead of a single object returned, a tuple 144 | will be returned, where the nature of the zeroth element is based on 145 | *return_steps* and the final element is the numpy array of elevations 146 | which represents the final diffused surface relative to a starting flat 147 | surface of zero. If "all" is given for *return_surface*, the final tuple 148 | element will be a list of numpy arrays, one for each time step. 149 | 150 | If a different crater profile is desired, pass a subclass (not an instance) 151 | of crater.profile.Crater to *crater_cls* that takes a depth parameter, 152 | otherwise defaults to crater.profile.FTmod_Crater. 153 | """ 154 | # Set up grid and initialize crater shape. 155 | 156 | # sscale = diameter / 50 157 | dx = diameter * 2 / domain_size # Could define dy, but would be identical 158 | dx2 = dx * dx 159 | 160 | # The array rr contains radius fraction values from the center of the 161 | # domain. 162 | x = np.linspace(-2, 2, domain_size) # spans a space 2x the diameter. 163 | xx, yy = np.meshgrid(x, x, sparse=True) # square domain 164 | rr = np.sqrt(xx**2 + yy**2) 165 | 166 | # Create crater with the right depth 167 | if isinstance(start_dd_adjust, bool): 168 | if start_dd_adjust: 169 | # u *= np.random.normal(start_dd_mean, start_dd_std) / crater_dd 170 | s_dd = np.random.normal(start_dd_mean, start_dd_std) 171 | crater = crater_cls(diameter, depth=(s_dd * diameter)) 172 | else: 173 | crater = crater_cls(diameter) 174 | else: 175 | # u *= start_dd_adjust / crater_dd 176 | crater = crater_cls(diameter, depth=(start_dd_adjust * diameter)) 177 | 178 | # Now create starting height field: 179 | u = crater.profile(rr) 180 | 181 | # This commented block is a structure from Caleb's code, which was mostly 182 | # meant for larger craters, but for small craters (<~ 10 m), the d/D 183 | # basically gets set to 0.04 to start and then diffuses to nothing. 184 | # 185 | # This text was in the docstring: 186 | # If the *diameter* is <= *d_mag_thresh* the topographic amplitude 187 | # of the starting crater shape will be randomly magnified by a factor 188 | # equal to 0.214 * variability * (diameter^(0.22)), where the variability 189 | # is randomly selected from a normal distribution with a mean of 1 and 190 | # a standard deviation of 0.1. 191 | # 192 | # # Magnify the topographic amplitude for smaller craters. 193 | # # Caleb indicates this is from Mahanti et al. (2018), but I can't 194 | # # quite find these numbers 195 | # if diameter <= d_mag_thresh: 196 | # variability = np.random.normal(1.0, 0.1) 197 | # # print(f"variability {0.214 * variability}") 198 | # u *= 0.214 * variability * (diameter**(0.22)) # modified from Mahanti 199 | 200 | # Set up diffusion calculation parameters. 201 | 202 | kappaT = kappa_diffusivity(diameter) * age 203 | # print(f"kappaT: {kappaT}") 204 | 205 | dls = diffusion_length_scale(diameter, domain_size) 206 | 207 | nsteps = math.ceil(kappaT / dls) 208 | 209 | # D * dt appears in the Hill (2020) diffusion calculation, but 210 | # we have formulated dls, such that D * dt = dls 211 | 212 | dd_for_each_step = [ 213 | (np.max(u) - np.min(u)) / diameter, 214 | ] 215 | u_for_each_step = [ 216 | u, 217 | ] 218 | un = np.copy(u) 219 | for step in range(nsteps): 220 | un[1:-1, 1:-1] = u[1:-1, 1:-1] + dls * ( 221 | (u[2:, 1:-1] - 2 * u[1:-1, 1:-1] + u[:-2, 1:-1]) / dx2 222 | + (u[1:-1, 2:] - 2 * u[1:-1, 1:-1] + u[1:-1, :-2]) / dx2 223 | ) 224 | dd_for_each_step.append((np.max(un) - np.min(un)) / diameter) 225 | u = np.copy(un) 226 | u_for_each_step.append(u) 227 | 228 | # Final depth to diameter: 229 | if return_steps: 230 | dd = dd_for_each_step 231 | else: 232 | dd = dd_for_each_step[-1] 233 | 234 | if return_surface == "all": 235 | return dd, u_for_each_step 236 | 237 | if return_surface: 238 | return dd, u_for_each_step[-1] 239 | 240 | return dd 241 | 242 | 243 | def diffuse_d_over_D_by_bin( 244 | df, 245 | num=50, 246 | domain_size=200, 247 | start_dd_mean=0.15, 248 | start_dd_std=0.02, 249 | return_surfaces=False, 250 | ) -> pd.DataFrame: 251 | """ 252 | Returns a pandas DataFrame identical to the input *df* but with the 253 | addition of two columns: "diameter_bin" and "d/D". The depth to Diameter 254 | ratios in column "d/D" are estimated from the "diameter" and "age" columns 255 | in the provided pandas DataFrame, *df*. 256 | 257 | For lage numbers of craters, running a diffusion model via 258 | diffuse_d_over_D() for each can be computationally intensive. This 259 | function generates *num* bins with log scale boundaries (using the numpy 260 | geomspace() function) between the maximum and minimum diameter values. 261 | 262 | If there are three or fewer craters in a size bin, then diffuse_d_over_D() 263 | is run with start_dd_adjust=True, and the *start_dd_mean*, and 264 | *start_dd_std* set as specified for each individual crater. 265 | 266 | If there are more than three craters in a size bin, then diffuse_d_over_D() 267 | is run three times, once with start_dd_adjust=*start_dd_mean*, once with 268 | *start_dd_mean* - *start_dd_std*, and once with *start_dd_mean* + 269 | *start_dd_std*. These three models then essentially provide three values 270 | for d/D for each time step that represent a "high", "mean", and "middle" 271 | d/D ratio. 272 | 273 | Then, for each crater in the bin, at the age specified, a d/D value is 274 | determined by selecting from a normal distribution with a mean d/D provided 275 | by the "mean" d/D ratio curve, and a standard deviation specified by the 276 | mean of the difference of the "high" and "low" diffiusion model values with 277 | the "mean" d/D model value at that time step. 278 | 279 | If *return_surfaces* is True, there will be an additional column, "surface", in the 280 | returned dataframe that contains a 2D numpy array which represents the height field 281 | of the crater in each row. 282 | """ 283 | logger.info("diffuse_d_over_D_by_bin start.") 284 | 285 | bin_edges = np.geomspace(df["diameter"].min(), df["diameter"].max(), num=num + 1) 286 | # logger.info(f"{df.shape[0]} craters") 287 | logger.info( 288 | f"Divided the craters into {num} diameter bins (not all bins may have " 289 | "craters)" 290 | ) 291 | 292 | if start_dd_mean == "Stopar fit": 293 | # This is a 3-degree fit to the data from Stopar et al. (2017) 294 | # The resulting function, stopar_dD() will return d/D ratios when 295 | # given a diameter in meters. 296 | stopar_poly = Polynomial( 297 | [1.23447427e-01, 1.49135061e-04, -6.16681361e-08, 7.08449143e-12] 298 | ) 299 | 300 | def start_dd(diameter): 301 | if diameter < 850: 302 | return stopar_poly(diameter) 303 | return 0.2 304 | 305 | def start_std(diameter): 306 | if diameter < 10: 307 | return start_dd_std + 0.01 308 | return start_dd_std 309 | 310 | elif start_dd_mean == "Stopar step": 311 | # Stopar et al. (2017) define a set of graduate d/D categories 312 | # defined down to 40 m. This creates two extrapolated categories: 313 | # def start_dd(diameter): 314 | # # The last two elements are extrapolated 315 | # d_lower_bounds = (400, 200, 100, 40, 10, 0) 316 | # dds = (0.21, 0.17, 0.15, 0.13, 0.11, 0.10) 317 | # for d, dd in zip(d_lower_bounds, dds): 318 | # if diameter > d: 319 | # return dd 320 | # else: 321 | # raise ValueError("Diameter was less than zero.") 322 | start_dd = stopar_fresh_dd 323 | 324 | def start_std(diameter): 325 | # if diameter < 10: 326 | # return start_dd_std + 0.01 327 | # else: 328 | return start_dd_std 329 | 330 | else: 331 | 332 | def start_dd(diameter): 333 | return start_dd_mean 334 | 335 | def start_std(diameter): 336 | return start_dd_std 337 | 338 | df["diameter_bin"] = pd.cut(df["diameter"], bins=bin_edges, include_lowest=True) 339 | df["d/D"] = 0.0 340 | df["surface"] = None 341 | df["surface"].astype(object) 342 | 343 | # Need to convert this loop to multiprocessing. 344 | for i, (interval, count) in enumerate( 345 | df["diameter_bin"].value_counts(sort=False).items() 346 | ): 347 | logger.info(f"Processing bin {i}/{num}, interval: {interval}, count: {count}") 348 | 349 | if count == 0: 350 | continue 351 | 352 | if 0 < count <= 3: 353 | # Run individual models for each crater. 354 | if return_surfaces: 355 | df.loc[df["diameter_bin"] == interval, ["d/D", "surface"]] = df.loc[ 356 | df["diameter_bin"] == interval 357 | ].apply( 358 | lambda row: pd.Series( 359 | diffuse_d_over_D( 360 | row["diameter"], 361 | row["age"], 362 | domain_size=domain_size, 363 | start_dd_adjust=True, 364 | start_dd_mean=start_dd(row["diameter"]), 365 | start_dd_std=start_std(row["diameter"]), 366 | return_surface=True, 367 | ), 368 | index=["d/D", "surface"], 369 | ), 370 | axis=1, 371 | result_type="expand", 372 | ) 373 | else: 374 | df.loc[df["diameter_bin"] == interval, "d/D"] = df.loc[ 375 | df["diameter_bin"] == interval 376 | ].apply( 377 | lambda row: diffuse_d_over_D( 378 | row["diameter"], 379 | row["age"], 380 | domain_size=domain_size, 381 | start_dd_adjust=True, 382 | start_dd_mean=start_dd(row["diameter"]), 383 | start_dd_std=start_std(row["diameter"]), 384 | ), 385 | axis=1, 386 | ) 387 | else: 388 | # Run three representative models for this "bin" 389 | oldest_age = df.loc[df["diameter_bin"] == interval, "age"].max() 390 | 391 | kappa = kappa_diffusivity(interval.mid) 392 | dls = diffusion_length_scale(interval.mid, domain_size) 393 | 394 | start = start_dd(interval.mid) 395 | std = start_std(interval.mid) 396 | 397 | all_args = [interval.mid, oldest_age] 398 | all_kwargs = dict( 399 | domain_size=domain_size, 400 | return_steps=True, 401 | ) 402 | mid_kwargs = all_kwargs.copy() 403 | mid_kwargs["start_dd_adjust"] = start 404 | 405 | high_kwargs = all_kwargs.copy() 406 | high_kwargs["start_dd_adjust"] = start + std 407 | 408 | low_kwargs = all_kwargs.copy() 409 | low_kwargs["start_dd_adjust"] = start - std 410 | 411 | if return_surfaces: 412 | mid_kwargs["return_surface"] = "all" 413 | 414 | middle_dds, middle_surfs = diffuse_d_over_D(*all_args, **mid_kwargs) 415 | high_dds = diffuse_d_over_D(*all_args, **high_kwargs) 416 | low_dds = diffuse_d_over_D(*all_args, **low_kwargs) 417 | 418 | # Defining this in-place since it really isn't needed outside 419 | # this function. 420 | def dd_surf_from_rep(age, diam): 421 | age_step = math.floor(age * kappa / dls) 422 | dd = np.random.normal( 423 | middle_dds[age_step], 424 | statistics.mean( 425 | [ 426 | middle_dds[age_step] - low_dds[age_step], 427 | high_dds[age_step] - middle_dds[age_step], 428 | ] 429 | ), 430 | ) 431 | surf = middle_surfs[age_step] 432 | surf_depth = np.max(surf) - np.min(surf) 433 | d_mult = dd * diam / surf_depth 434 | 435 | return dd, surf * d_mult 436 | 437 | df.loc[df["diameter_bin"] == interval, ["d/D", "surface"]] = df.loc[ 438 | df["diameter_bin"] == interval 439 | ].apply( 440 | lambda row: pd.Series( 441 | dd_surf_from_rep(row["age"], row["diameter"]), 442 | index=["d/D", "surface"], 443 | ), 444 | axis=1, 445 | result_type="expand", 446 | ) 447 | 448 | else: 449 | middle_dds = diffuse_d_over_D(*all_args, **mid_kwargs) 450 | high_dds = diffuse_d_over_D(*all_args, **high_kwargs) 451 | low_dds = diffuse_d_over_D(*all_args, **low_kwargs) 452 | 453 | # Defining this in-place since it really isn't needed outside 454 | # this function. 455 | def dd_from_rep(age): 456 | age_step = math.floor(age * kappa / dls) 457 | return np.random.normal( 458 | middle_dds[age_step], 459 | statistics.mean( 460 | [ 461 | middle_dds[age_step] - low_dds[age_step], 462 | high_dds[age_step] - middle_dds[age_step], 463 | ] 464 | ), 465 | ) 466 | 467 | df.loc[df["diameter_bin"] == interval, "d/D"] = df.loc[ 468 | df["diameter_bin"] == interval 469 | ].apply(lambda row: dd_from_rep(row["age"]), axis=1) 470 | 471 | logger.info("diffuse_d_over_D_by_bin complete.") 472 | 473 | return df 474 | 475 | 476 | def make_crater_field( 477 | df, 478 | terrain_model, 479 | transform, 480 | ) -> npt.NDArray: 481 | """ 482 | Returns a 2D numpy array whose values are heights. 483 | 484 | The *df* should have a "surface" column which contains 2D numpy arrays, 485 | presumably the output of diffuse_d_over_D_by_bin() with return_surfaces=True. 486 | 487 | The *terrain_model* argument can either be an initial 2D Numpy Array which 488 | contains elevation values for a surface which the craters in *df* will be 489 | applied to and diffused over, or it can be a two-element sequence that contains 490 | the number of rows and columns that an initial flat 2D Numpy Array will be 491 | generated from. 492 | 493 | """ 494 | try: 495 | from skimage.transform import rescale 496 | except ImportError as err: 497 | raise ImportError( 498 | "The scikit-image library is not present, so the make_crater_field() " 499 | "function is not available. In order to use it, please install the " 500 | "scikit-image library.", 501 | ImportWarning, 502 | ) from err 503 | 504 | logger.info("make_crater_field start.") 505 | 506 | # # Establish initial height field: 507 | # if len(terrain_model.shape) == 2: 508 | # u = terrain_model 509 | # else: 510 | # u = np.zeros(terrain_model) 511 | 512 | if abs(transform.a) != abs(transform.e): 513 | raise ValueError("The transform does not have even spacing in X and Y.") 514 | 515 | gsd = transform.a 516 | 517 | tm_window = get_data_window(terrain_model) 518 | 519 | for row in df.itertuples(index=False): 520 | surf = row.surface 521 | if surf is None: 522 | logger.info(f"There is no surface for this row: {row}") 523 | continue 524 | # So the below assumes that surf is square, and that it was built with 525 | # diffuse_d_over_D(): 526 | surf_gsd = row.diameter * 2 / surf.shape[0] 527 | 528 | new_surf = rescale( 529 | surf, surf_gsd / gsd, preserve_range=True, anti_aliasing=True 530 | ) 531 | 532 | r, c = rowcol(transform, row.x, row.y) 533 | 534 | surf_window = Window( 535 | c - int(new_surf.shape[1] / 2), 536 | r - int(new_surf.shape[0] / 2), 537 | new_surf.shape[1], 538 | new_surf.shape[0], 539 | ) 540 | tm_slices, surf_slices = to_relative_slices(tm_window, surf_window) 541 | terrain_model[tm_slices] += new_surf[surf_slices] 542 | 543 | return terrain_model 544 | 545 | 546 | def to_relative_slices(w1: Window, w2: Window): 547 | if not intersect(w1, w2): 548 | raise ValueError("The two windows do not intersect.") 549 | 550 | w1_r1 = Window(0, 0, w1.width, w1.height) 551 | w2_r1 = Window( 552 | w2.col_off - w1.col_off, w2.row_off - w1.row_off, w2.width, w2.height 553 | ) 554 | 555 | w1_r2 = Window( 556 | w1.col_off - w2.col_off, w1.row_off - w2.row_off, w1.width, w1.height 557 | ) 558 | w2_r2 = Window(0, 0, w2.width, w2.height) 559 | 560 | return w1_r1.intersection(w2_r1).toslices(), w2_r2.intersection(w1_r2).toslices() 561 | -------------------------------------------------------------------------------- /src/python/synthterrain/crater/functions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """This module contains abstract and concrete classes for representing 3 | crater size-frequency distributions as probability distributions. 4 | """ 5 | 6 | # Copyright © 2024, United States Government, as represented by the 7 | # Administrator of the National Aeronautics and Space Administration. 8 | # All rights reserved. 9 | # 10 | # The “synthterrain” software is licensed under the Apache License, 11 | # Version 2.0 (the "License"); you may not use this file except in 12 | # compliance with the License. You may obtain a copy of the License 13 | # at http://www.apache.org/licenses/LICENSE-2.0. 14 | 15 | # Unless required by applicable law or agreed to in writing, software 16 | # distributed under the License is distributed on an "AS IS" BASIS, 17 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 18 | # implied. See the License for the specific language governing 19 | # permissions and limitations under the License. 20 | 21 | import copy 22 | import logging 23 | import math 24 | from abc import ABC, abstractmethod 25 | from numbers import Number 26 | 27 | import numpy as np 28 | from numpy.polynomial import Polynomial 29 | from scipy.interpolate import interp1d 30 | from scipy.stats import rv_continuous 31 | 32 | logger = logging.getLogger(__name__) 33 | 34 | # If new equilibrium functions are added, add them to the equilibrium_functions 35 | # list below the class definitions to expose them to users. 36 | 37 | 38 | class Crater_rv_continuous(ABC, rv_continuous): 39 | """Base class for crater continuous distributions. Provides 40 | some convenience functions common to all crater distributions. 41 | 42 | Crater distribution terminology can be reviewed in Robbins, et al. (2018, 43 | https://doi.org/10.1111/maps.12990), which in its 44 | terminology section states "In crater work, the CSFD can be thought 45 | of as a scaled version of "1−CDF." 46 | 47 | CSFD is the crater size-frequency distribution, a widely used 48 | representation in the scientific literature. CDF is the statistical 49 | cumulative distribution function. 50 | 51 | Both the CSFD and the CDF are functions of d (the diameter of craters), 52 | and are related thusly, according to Robbins et al.: 53 | 54 | CSFD(d) ~ 1 - CDF(d) 55 | 56 | For any particular count of craters, the smallest crater value 57 | measured (d_min) gives the total number of craters in the set per 58 | unit area, CSFD(d_min). Which implies this relation 59 | 60 | CDF(d) = 1 - (CSFD(d) / CSFD(d_min)) 61 | 62 | If you scale by CSFC(d_min) which is the total number of craters, 63 | then you get a statistical CDF. 64 | 65 | When creating concrete classes that descend from Crater_rv_continuous, 66 | the csfd() function must be implemented. There is a default 67 | implementation of ._cdf(), but it is advised that it be implemented 68 | directly for speed and efficiency. It is also heavily advised 69 | (echoing the advice from scipy.stats.rv_continuous itself) that _ppf() 70 | also be directly implemented. 71 | 72 | It is assumed that the units of diameters (d) are in meters, and 73 | that the resulting CSFD(d) is in units of number per square meter. 74 | Implementing functions should support this. 75 | """ 76 | 77 | def __init__(self, a, **kwargs): 78 | if a <= 0: 79 | raise ValueError( 80 | "The lower bound of the support of the distribution, a, must be > 0." 81 | ) 82 | 83 | kwargs["a"] = a 84 | super().__init__(**kwargs) 85 | 86 | @abstractmethod 87 | def csfd(self, d): 88 | """ 89 | Implementing classes must define this function which is the 90 | crater size-frequency distribution (CSFD, e.g. defined in 91 | Robbins, et al. 2018, https://doi.org/10.1111/maps.12990). 92 | 93 | The argument *d* should could be a single value, a collection 94 | of values, or a numpy array representing diameters in meters. 95 | 96 | Returns a numpy array of the result of applying the CSFD to 97 | the diameters provided in *d*. 98 | """ 99 | pass 100 | 101 | # Experiments with ISFD formulated in this manner produce results that 102 | # indicate there is something that I'm not formulating correctly, so 103 | # I'm commenting out these functions for now. 104 | # 105 | # def isfd(self, d): 106 | # """ 107 | # The incremental size frequency distribution (ISFD, e. g. 108 | # defined in Robbins, et al. 2018, https://doi.org/10.1111/maps.12990) 109 | # is typically the actual, discrete counts, which are then used to 110 | # construct a CSFD. For the purposes of this class, since the CSFD 111 | # is the primary definition, and is a continuous function, there is 112 | # a continuous function that describes the ISFD, which is the negative 113 | # derivative of the CSFD function. And this is how classes that descend 114 | # from this one should implement this function. 115 | # """ 116 | # raise NotImplementedError( 117 | # f"The isfd() function is not implemented for {_class__.__name__}.") 118 | 119 | def _cdf(self, d): 120 | """Returns an array-like which is the result of applying the 121 | cumulative density function to *d*, the input array-like of 122 | diameters. 123 | 124 | If the crater size-frequency distribution (CSFD) is C(d) (typically 125 | also expressed as N_cumulative(d) ), then 126 | 127 | cdf(d) = 1 - (C(d) / C(d_min)) 128 | 129 | In the context of this class, d_min is a, the lower bound of the 130 | support of the distribution when this class is instantiated. 131 | 132 | As with the parent class, rv_continuous, implementers of derived 133 | classes are strongly encouraged to override this with more 134 | efficient implementations, also possibly implementing _ppf(). 135 | """ 136 | return np.ones_like(d, dtype=np.dtype(float)) - ( 137 | self.csfd(d) / self.csfd(self.a) 138 | ) 139 | 140 | def count(self, area, diameter=None) -> int: 141 | """Returns the number of craters based on the *area* provided 142 | in square meters. If *diameter* is None (the default), the 143 | calculation will be based on the cumulative number of craters 144 | at the minimum support, a, of this distribution. Otherwise, the 145 | returned size will be the value of this distribution's CSFD 146 | at *diameter* multiplied by the *area*. 147 | """ 148 | if diameter is None: 149 | d = self.a 150 | else: 151 | d = diameter 152 | return int(self.csfd(d) * area) 153 | 154 | def rvs(self, *args, **kwargs): 155 | """Overrides the parent rvs() function by adding an *area* 156 | parameter, all other arguments are identical. 157 | 158 | If an *area* parameter is provided, it is interpreted as the 159 | area in square meters which has accumulated craters. 160 | 161 | Specifying it will cause the *size* parameter (if given) to 162 | be overridden such that 163 | 164 | size = CSDF(d_min) * area 165 | 166 | and then the parent rvs() function will be called. 167 | 168 | Since the CSDF interpreted at the minimum crater size is 169 | the total number of craters per square meter, multiplying 170 | it by the desired area results in the desired number of craters. 171 | """ 172 | if "area" in kwargs: 173 | kwargs["size"] = self.count(kwargs["area"]) 174 | del kwargs["area"] 175 | 176 | return super().rvs(*args, **kwargs) 177 | 178 | 179 | class Test_Distribution(Crater_rv_continuous): 180 | """This is testing a simple function.""" 181 | 182 | def csfd(self, d): 183 | """Returns the crater cumulative size frequency distribution function 184 | such that 185 | CSFD(d) = N_cum(d) = 29174 / d^(1.92) 186 | """ 187 | return 29174 * np.float_power(d, -1.92) 188 | 189 | def _cdf(self, d): 190 | """Override of parent function to eliminate unnecessary division 191 | of 29174 by itself. 192 | """ 193 | return np.ones_like(d, dtype=np.dtype(float)) - ( 194 | np.float_power(d, -1.92) / np.float_power(self.a, -1.92) 195 | ) 196 | 197 | 198 | class VIPER_Env_Spec(Crater_rv_continuous): 199 | """ 200 | This distribution is from the VIPER Environmental Specification, 201 | VIPER-MSE-SPEC-001 (2021-09-16). Sadly, no citation is provided 202 | for it in that document. 203 | 204 | The equation there is written such that diameters are in meters, 205 | but that the CSFD(d) is in number per square kilometer. This class 206 | returns CSFD(d) in number per square meter. 207 | """ 208 | 209 | def csfd(self, d): 210 | """ 211 | CSFD( d <= 80 ) = (29174 / d^(1.92)) / (1000^2) 212 | CSFD( d > 80 ) = (156228 / d^(2.389)) / (1000^2) 213 | 214 | """ 215 | if isinstance(d, Number): 216 | # Convert to numpy array, if needed. 217 | diam = np.array( 218 | [ 219 | d, 220 | ] 221 | ) 222 | else: 223 | diam = d 224 | c = np.empty_like(diam, dtype=np.dtype(float)) 225 | c[diam <= 80] = 29174 * np.float_power(diam[diam <= 80], -1.92) 226 | c[diam > 80] = 156228 * np.float_power(diam[diam > 80], -2.389) 227 | out = c / (1000 * 1000) 228 | if isinstance(d, Number): 229 | return out.item() 230 | 231 | return out 232 | 233 | # See comment on commented out parent isfd() function. 234 | # def isfd(self, d): 235 | # """ 236 | # Returns the incremental size frequency distribution for diameters, *d*. 237 | # """ 238 | # if isinstance(d, Number): 239 | # # Convert to numpy array, if needed. 240 | # d = np.array([d, ]) 241 | # c = np.empty_like(d) 242 | # c[d <= 80] = 29174 * 1.92 * np.float_power(d[d <= 80], -2.92) 243 | # c[d > 80] = 156228 * 2.389 * np.float_power(d[d > 80], -3.389) 244 | # return c / (1000 * 1000) 245 | 246 | def _cdf(self, d): 247 | """Override parent function to eliminate unnecessary division 248 | by constants. 249 | """ 250 | c = np.empty_like(d, dtype=np.dtype(float)) 251 | c[d <= 80] = np.float_power(d[d <= 80], -1.92) / np.float_power(self.a, -1.92) 252 | c[d > 80] = np.float_power(d[d > 80], -2.389) / np.float_power(self.a, -2.389) 253 | return np.ones_like(d, dtype=np.dtype(float)) - c 254 | 255 | def _ppf(self, q): 256 | """Override parent function to make things faster for .rvs().""" 257 | q80 = float( 258 | self._cdf( 259 | np.array( 260 | [ 261 | 80, 262 | ] 263 | ) 264 | ) 265 | ) 266 | ones = np.ones_like(q, dtype=np.dtype(float)) 267 | p = np.empty_like(q, dtype=np.dtype(float)) 268 | p[q <= q80] = np.float_power( 269 | (ones[q <= q80] / (ones[q <= q80] - q[q <= q80])), (1 / 1.92) 270 | ) 271 | p[q > q80] = np.float_power( 272 | (ones[q > q80] / (ones[q > q80] - q[q > q80])), (1 / 2.389) 273 | ) 274 | 275 | return self.a * p 276 | 277 | 278 | class Trask(Crater_rv_continuous): 279 | """ 280 | This describes an equilibrium function based on Trask's 281 | contribution, "Size and Spatial Distribution of Craters Estimated 282 | From the Ranger Photographs," in E. M. Shoemaker et al. (1966) 283 | Ranger VIII and IX: Part II. Experimenters’ Analyses and 284 | Interpretations. JPL Technical Report 32-800, 382 pages. 285 | Available at 286 | https://www.lpi.usra.edu/lunar/documents/RangerVIII_and_IX.pdf 287 | Also described as "Standard lunar equilibrium (Trask, 1966)" in 288 | the craterstats package. 289 | 290 | In the 1966 work, it is written 291 | 292 | N = 10^(10.9) * d^(-2) 293 | 294 | Where N is cumulative number of craters per 10^6 km^2 and diameters 295 | greater than d (in meters). 296 | 297 | It is uncertain what range of crater diameters this simple relation 298 | holds for. 299 | """ 300 | 301 | def csfd(self, d): 302 | """ 303 | Returns the crater cumulative size frequency distribution function 304 | such that 305 | CSFD(d) = N_cum(d) = 10^(-1.1) * d^(-2) 306 | 307 | The exponent has been adjusted from 10.9 to -1.1 so this function 308 | returns counts per square meter. 309 | """ 310 | return math.pow(10, -1.1) * np.float_power(d, -2) 311 | 312 | # See comment on commented out parent isfd() function. 313 | # def isfd(self, d): 314 | # """ 315 | # Returns the value of the incremental size frequency distribution 316 | # function such that the ISFD is the negative derivative of the CSFD: 317 | 318 | # ISFD(d) = 10^(-1.1) * 2 * d^(-3) 319 | 320 | # """ 321 | # return math.pow(10, -1.1) * 2 * np.float_power(d, -3) 322 | 323 | # def isfd_experimental(self, d): 324 | # """ 325 | # Experimental 326 | # """ 327 | # # Rather than attempt to do fancy math (which I may have well screwed 328 | # # up) to derive a continuous function for isfd, let's just 329 | # # "unintegrate" the csfd maybe ? 330 | # i = self.csfd(d) 331 | # i[:-1] = i[:-1] - self.csfd(d[1:]) 332 | # return i 333 | 334 | 335 | class Coef_Distribution(Crater_rv_continuous): 336 | """This class instantiates a continuous crater distribution based 337 | on a polynomial. This notation for a crater distribution is 338 | used by Neukum et al. (2001, https://doi.org/10.1023/A:1011989004263) 339 | and in the craterstats package. 340 | 341 | The coefficients generally assume that the diameter values are in 342 | kilometers, and the math here is based on that, but only matters for 343 | the specification of the coefficients. The diameter values passed 344 | to csfd() are expected to be in meters, and the returned value 345 | is number per square meter. 346 | """ 347 | 348 | def __init__(self, *args, coef=None, poly=None, **kwargs): 349 | if coef is None and poly is None: 350 | raise ValueError( 351 | "A Coef_Distribution object must be initiated with a " 352 | "*coef* array-like of coefficients from which a polynomial " 353 | "will be constructed, or a *poly* object which must be a " 354 | "numpy Polynomial object." 355 | ) 356 | super().__init__(*args, **kwargs) 357 | 358 | if coef is not None: 359 | poly = Polynomial(coef) 360 | 361 | self.poly = poly 362 | 363 | def csfd(self, d): 364 | """Returns the crater cumulative size frequency distribution function 365 | such that 366 | CSFD(d) = N_cum(d) = 10^x / (1000 * 1000) 367 | 368 | where x is the summation of j from zero to n (typically ~ 11) of 369 | a_j * ( lg(d/1000) )^j 370 | 371 | where lg() is the base 10 logarithm, and the values a_j are the 372 | coefficients provided via the constructor. 373 | 374 | Since published coefficients are typically provided for diameter 375 | values in kilometers and areas in square kilometers, the equation 376 | for CSFD(d) is adjusted so that diameters can be provided in units 377 | of meters, and CSFD(d) is returned in counts per square meter. 378 | """ 379 | # The 1000s are to take diameters in meters, convert to kilometers 380 | # for application by the polynomial, and then division by a square 381 | # kilometer to get the number per square meter. 382 | # lg(d / 1000) = lg( d * (1/1000)) = lg(d) + lg(1/1000) = lg(d) - 3 383 | # return np.power(10, self.poly(np.log10(d / 1000))) / (1000 * 1000) 384 | return np.float_power(10, self.poly(np.log10(d / 1000))) / (1000 * 1000) 385 | 386 | # See comment on commented out parent isfd() function. 387 | # def isfd(self, d): 388 | # """ 389 | # Returns the incremental size frequency distribution or count for 390 | # diameter *d*. 391 | 392 | # This requires properly taking the derivative of the csfd() function 393 | # which is: 394 | 395 | # CSFD(d) = 10^x / (1000 * 1000) 396 | 397 | # where "x" is really a polynomial function of d: 398 | 399 | # x(d) = a_0 + a_1 * ( lg(d/1000) ) + a_2 * ( lg(d/1000) )^2 + ... 400 | 401 | # So this means that CSFD(d) can be formulated as CSFD(x(d)), and the 402 | # derivative of CSFD with respect to d is denoted using Lagrange's 403 | # "prime" notation: 404 | 405 | # ISFD(d) = CSFD'(d) = CSFD'(x(d)) * x'(d) 406 | 407 | # CSFD'(x) = [x * 10^(x-1)] / (1000 * 1000) 408 | 409 | # x'(d) = [a_1 / (d * ln(10))] + [2 * a_2 / (d * ln(10))] + 410 | # [3 * a_3 / (d * ln(10))^2] + ... 411 | 412 | # For compactness in x'(d), assume the d values on the right-hand side 413 | # have been pre-divided by 1000. The natural log is denoted by "ln()". 414 | 415 | # Assuming I've done the differentiation correctly, x'(d) is a 416 | # polynomial with coefficients a_1, 2 * a_2, 3 * a_3, 417 | # etc. that we can construct from the original polynomial, and the 418 | # values of the variables are 1 / (d/1000) * ln(10). 419 | # """ 420 | # # x = self.poly(np.log10(d) - 3) 421 | # # csfd_prime = x * np.power(10, x - 1) / (1000 * 1000) 422 | 423 | # # new_coefs = list() 424 | # # for i, a in enumerate(self.poly.coef[1:], start=1): 425 | # # new_coefs.append(i * a) 426 | 427 | # # x_prime = Polynomial(new_coefs) 428 | 429 | # # return csfd_prime * x_prime( 430 | # # np.reciprocal( 431 | # # (d / 1000) * np.log(10) 432 | # # ) 433 | # # ) 434 | 435 | # # Okay, Neukum indicates that differentiating the CSFD by d is 436 | # # ISFD(d) = -1 * CSFD'(d) = -1 * [CSFD(d) / d] * x(d) 437 | # # where x(d) = a_1 + a_2 * ( lg(d) ) + a_3 * ( lg(d) )^2 438 | 439 | # # new_coefs = list() 440 | # # for i, a in enumerate(self.poly.coef[1:], start=1): 441 | # # new_coefs.append(i * a) 442 | # # x_prime = Polynomial(new_coefs) 443 | # x_prime = Polynomial(self.poly.coef[1:]) 444 | 445 | # # return -1 * (self.csfd(d) / (d / 1000)) * x_prime(np.log10(d / 1000)) 446 | # return np.absolute( 447 | # (self.csfd(d) / (d / 1000)) * x_prime(np.log10(d / 1000)) 448 | # ) 449 | 450 | def _cdf(self, d): 451 | """Override parent function to speed up.""" 452 | return np.ones_like(d, dtype=np.dtype(float)) - np.float_power( 453 | 10, self.poly(np.log10(d / 1000)) - self.poly(np.log10(self.a / 1000)) 454 | ) 455 | 456 | 457 | class NPF(Coef_Distribution): 458 | """ 459 | This describes the Neukum et al. (2001, 460 | https://doi.org/10.1023/A:1011989004263) production function (NPF) 461 | defined in their equation 2 and with coefficients from the '"New" N(D)' 462 | column in their table 1. 463 | 464 | The craterstats package notes indicate that the published a0 value 465 | (-3.0876) is a typo, and uses -3.0768, which we use here. 466 | 467 | In this case, CSFD(N) is the cumulative number of craters per square 468 | area per Gyr. So outputs from csfd() and isfd() must be multiplied 469 | by 10**9 to get values per year. 470 | 471 | Note that equation 2 is valid for diameters from 0.01 km to 300 km, 472 | set the *a* and *b* parameters appropriately (>=10, <= 300,000). 473 | """ 474 | 475 | def __init__(self, a, b, **kwargs): 476 | if a < 10: 477 | raise ValueError( 478 | "The lower bound of the support of the distribution, a, must " 479 | "be >= 10." 480 | ) 481 | if b > 300000: 482 | raise ValueError( 483 | "The upper bound of the support of the distribution, b, must " 484 | "be <= 300,000." 485 | ) 486 | 487 | kwargs["a"] = a 488 | kwargs["b"] = b 489 | super().__init__( 490 | coef=[ 491 | -3.076756, 492 | # -3.0768, 493 | -3.557528, 494 | 0.781027, 495 | 1.021521, 496 | -0.156012, 497 | -0.444058, 498 | 0.019977, 499 | 0.086850, 500 | -0.005874, 501 | -0.006809, 502 | 8.25e-04, 503 | 5.54e-05, 504 | ], 505 | **kwargs, 506 | ) 507 | 508 | 509 | class Interp_Distribution(Crater_rv_continuous): 510 | """This class instantiates a continuous crater distribution based 511 | on interpolation of a set of data points. 512 | 513 | The input arrays assume that the diameter values are in meters 514 | and the cumulative size frequency distribution values are in 515 | counts per square meter. 516 | """ 517 | 518 | def __init__(self, *args, diameters=None, csfds=None, func=None, **kwargs): 519 | if diameters is None and func is None: 520 | raise ValueError( 521 | "An Interp_Distribution object must be initiated with " 522 | "*diameters* and *csfds* array-likes of data from which an " 523 | "interpolated function will be constructed, or a *func* object " 524 | "which must be callable with diameter values and will return " 525 | "csfd values." 526 | ) 527 | super().__init__(*args, **kwargs) 528 | 529 | if diameters is not None: 530 | func = interp1d(np.log10(diameters), np.log10(csfds)) 531 | 532 | self.func = func 533 | 534 | def csfd(self, d): 535 | """Returns the crater cumulative size frequency distribution function 536 | value for *d*. 537 | """ 538 | return np.float_power(10, self.func(np.log10(d))) 539 | 540 | def _cdf(self, d): 541 | """Override parent function to speed up.""" 542 | return np.ones_like(d, dtype=np.dtype(float)) - np.float_power( 543 | 10, self.func(np.log10(d)) - self.func(np.log10(self.a)) 544 | ) 545 | 546 | 547 | class Grun(Interp_Distribution): 548 | """ 549 | Grun et al. (1985, https://doi.org/10.1016/0019-1035(85)90121-6) 550 | describe a small particle impact flux, which can be converted into 551 | a production function for small craters, that matches well with the 552 | value of the Neukum et al. (2001) production function at d=10 m. 553 | """ 554 | 555 | def __init__(self, **kwargs): 556 | # This method for using Grun et al. (1985) to "simulate" a crater 557 | # distribution is from Caleb Fassett, pers. comm. 558 | diameters, fluxes = self.parameters() 559 | 560 | if "b" in kwargs: 561 | if kwargs["b"] > max(diameters): 562 | raise ValueError( 563 | "The upper bound of the support of the distribution, b, " 564 | f" must be <= {max(diameters)}." 565 | ) 566 | else: 567 | kwargs["b"] = max(diameters) 568 | 569 | if "a" not in kwargs: 570 | kwargs["a"] = min(diameters) 571 | 572 | kwargs["diameters"] = diameters 573 | kwargs["csfds"] = fluxes 574 | 575 | super().__init__(**kwargs) 576 | 577 | # These comments temporarily preserve the Coef_Distribution model 578 | # # The Coef_Distribution polynomial needs diameters 579 | # # in kilometers, so divide by 1000. And fluxes need 580 | # # to be in /km^2 /Gyr, so multiply by a million. 581 | # p = Polynomial.fit( 582 | # np.log10(diameters / 1000), np.log10(fluxes * 1e6), 11 583 | # ) 584 | 585 | # super().__init__( 586 | # poly=p, 587 | # **kwargs 588 | # ) 589 | 590 | @staticmethod 591 | def parameters(): 592 | """ 593 | This function returns a set of diameters and fluxes which are used 594 | by the Grun() class. This function (and its comments) exists to 595 | demonstrate the math for how the values were arrived at. 596 | """ 597 | 598 | # Grun et al. equation A2 describes fluxes in terms of mass. 599 | 600 | # We'll generate "craters" based on masses from 10^-18 to the upper 601 | # valid limit of 10^2 grams for these equations. 602 | # masses = np.logspace(-18, 2, 21) # in grams 603 | masses = np.logspace(-18, 2, 201) # in grams 604 | # masses = np.logspace(-18, 0, 19) # in grams 605 | # print(masses) 606 | 607 | # Constants indicated below equation A2 from Grun et al. (1985) 608 | # First element here is arbitrarily zero so that indexes match 609 | # with printed A2 equation for easier comparison. 610 | c = (0, 4e29, 1.5e44, 1.1e-2, 2.2e3, 15.0) 611 | gamma = (0, 1.85, 3.7, -0.52, 0.306, -4.38) 612 | 613 | def a_elem(mass, i): 614 | return c[i] * np.float_power(mass, gamma[i]) 615 | 616 | def a2(mass): 617 | # Returns flux in m^-2 s^-1 618 | return np.float_power( 619 | a_elem(mass, 1) + a_elem(mass, 2) + c[3], gamma[3] 620 | ) + np.float_power(a_elem(mass, 4) + c[5], gamma[5]) 621 | 622 | # fluxes = a2(masses) * 86400.0 * 365.25 # convert to m^-2 yr^-1 623 | # fluxes = a2(masses) * 86400.0 * 365.25 * 1e6 # convert to km^-2 yr^-1 624 | fluxes = a2(masses) * 86400.0 * 365.25 * 1e9 # convert to m^-2 Gyr^-1 625 | # fluxes = a2(masses) * 1e6 * 86400.0 * 365.25 * 1e9 # to /km^2 /Gyr 626 | 627 | # To convert mass, m, of a particle to diameter of crater, we first 628 | # assume a density, rho, of 2.5 g/cm^-3, and calculate a particle radius 629 | # by assuming spherical particles: 630 | # 631 | # m / rho = (4 / 3) * pi * r^3 632 | # 633 | # r = [ (3 * m) / (4 * pi * rho) ] ^(1/3) 634 | # 635 | rho = 2.5e6 # g/m^-3 636 | radii = np.float_power( 637 | (3 * masses) / (4 * math.pi * rho), 1 / 3 638 | ) # should be radii in meters. 639 | 640 | # Now these "impactor" radii need to be converted to crater size via 641 | # Housen & Holsapple (2011) scaling. 642 | diameters = Grun.hoho_diameter(radii, masses / 1000, rho / 1000) 643 | 644 | # # The above largest diameter only gets you 2.7636 m diameter craters. And 645 | # # Neukum doesn't start until 10 m, so we're going to pick out some 646 | # # diameters from Neukum to add to these so that the polynomial in Grun() 647 | # # spans the space. 648 | # npf = NPF(10, 100) 649 | # n_diams = np.array([10, 15, 20, 30, 50, 100]) 650 | # n_fluxes = npf.csfd(n_diams) 651 | # 652 | # diameters = np.append(diameters, n_diams) 653 | # fluxes = np.append(fluxes, n_fluxes) 654 | 655 | # diameters in meters, and fluxes in m^-2 Gyr^-1 656 | return diameters, fluxes 657 | 658 | @staticmethod 659 | def hoho_diameter( 660 | radii, # numpy array in meters 661 | masses, # numpy array in kg 662 | rho, # impactor density in kg m^-3 663 | gravity=1.62, # m s^-2 664 | strength=1.0e4, # Pa 665 | targdensity=1500.0, # kg/m3 (rho) 666 | velocity=20000.0, # m/s 667 | alpha=45.0, # impact angle degrees 668 | nu=(1.0 / 3.0), # ~1/3 to 0.4 669 | mu=0.43, # ~0.4 to 0.55 670 | K1=0.132, 671 | K2=0.26, 672 | Kr=(1.1 * 1.3), # Kr and KrRim 673 | ): 674 | # This function is adapted from Caleb's research code, but is based 675 | # on Holsapple (1993, 676 | # https://www.annualreviews.org/doi/epdf/10.1146/annurev.ea.21.050193.002001 677 | # ). He says: # Varying mu makes a big difference in scaling, 0.41 from 678 | # Williams et al. would predict lower fluxes / longer equilibrium times 679 | # and a discontinuity with Neukum 680 | 681 | effvelocity = velocity * math.sin(math.radians(alpha)) 682 | densityratio = targdensity / rho 683 | 684 | # impmass=((4.0*math.pi)/3.0)*impdensity*(impradius**3.0) #impactormass 685 | pi2 = (gravity * radii) / math.pow(effvelocity, 2.0) 686 | 687 | pi3 = strength / (targdensity * math.pow(effvelocity, 2.0)) 688 | 689 | expone = (6.0 * nu - 2.0 - mu) / (3.0 * mu) 690 | exptwo = (6.0 * nu - 2.0) / (3.0 * mu) 691 | expthree = (2.0 + mu) / 2.0 692 | expfour = (-3.0 * mu) / (2.0 + mu) 693 | piV = K1 * np.float_power( 694 | (pi2 * np.float_power(densityratio, expone)) 695 | + np.float_power(K2 * pi3 * np.float_power(densityratio, exptwo), expthree), 696 | expfour, 697 | ) 698 | V = (masses * piV) / targdensity # m3 for crater 699 | rim_radius = Kr * np.float_power(V, (1 / 3)) 700 | 701 | return 2 * rim_radius 702 | 703 | 704 | class GNPF_old(NPF): 705 | """ 706 | This describes a combination function such that it functions as a Neukum 707 | Production Function (NPF) for the size ranges where NPF is appropriate, 708 | and as a Grun function where that is appropriate. 709 | """ 710 | 711 | def __init__(self, a, b, interp="extendGrun", **kwargs): 712 | if b <= 2.5: 713 | raise ValueError( 714 | f"The upper bound, b, is {b}, you should use Grun, not GNPF." 715 | ) 716 | 717 | if a >= 10: 718 | raise ValueError( 719 | f"The lower bound, a, is {a}, you should use NPF, not GNPF." 720 | ) 721 | 722 | interp_types = ("extendGrun", "linear", "interp") 723 | if interp in interp_types: 724 | self.interp = interp 725 | else: 726 | raise ValueError( 727 | f"The interpolation method, {interp} " f"is not one of {interp_types}." 728 | ) 729 | 730 | # Will now construct *this* as an NPF with a Grun hidden inside. 731 | npf_kwargs = copy.deepcopy(kwargs) 732 | npf_kwargs["a"] = 10 733 | npf_kwargs["b"] = b 734 | super().__init__(**npf_kwargs) # Calls NPF __init__() 735 | 736 | grun_kwargs = copy.deepcopy(kwargs) 737 | grun_kwargs["a"] = a 738 | if self.interp == "extendGrun": 739 | grun_kwargs["b"] = 10 740 | grun_d, grun_f = Grun.parameters() 741 | # The above largest diameter only gets you 2.5 m diameter craters. 742 | # And Neukum doesn't start until 10 m, so we're going to pick out 743 | # some # diameters from Neukum to add to these so that the 744 | # polynomial spans the space. 745 | npf = NPF(10, 100) 746 | n_diams = np.array([10, 15, 20, 30, 50, 100]) 747 | n_fluxes = npf.csfd(n_diams) 748 | 749 | diameters = np.append(grun_d, n_diams) 750 | fluxes = np.append(grun_f, n_fluxes) 751 | 752 | p = Polynomial.fit(np.log10(diameters / 1000), np.log10(fluxes * 1e6), 11) 753 | 754 | self.grun = Coef_Distribution(poly=p, **grun_kwargs) 755 | elif self.interp == "interp": 756 | grun_kwargs["b"] = 10 757 | grun_d, grun_f = Grun.parameters() 758 | npf = NPF(10, 100) 759 | n_diam = 10 760 | n_flux = npf.csfd(n_diam) 761 | diameters = np.append(grun_d, n_diam) 762 | fluxes = np.append(grun_f, n_flux) 763 | grun_kwargs["diameters"] = diameters 764 | grun_kwargs["csfds"] = fluxes 765 | self.grun = Interp_Distribution(**grun_kwargs) 766 | else: 767 | grun_kwargs["b"] = 2.5 768 | self.grun = Grun(**grun_kwargs) 769 | 770 | self.grunstop = 2.5 771 | 772 | def csfd(self, d): 773 | if isinstance(d, Number): 774 | # Convert to numpy array, if needed. 775 | diam = np.array( 776 | [ 777 | float(d), 778 | ] 779 | ) 780 | else: 781 | diam = d 782 | c = np.empty_like(diam, dtype=np.dtype(float)) 783 | 784 | c[diam >= 10] = super().csfd(diam[diam >= 10]) 785 | 786 | if self.interp == "extendGrun" or self.interp == "interp": 787 | c[diam < 10] = self.grun.csfd(diam[diam < 10]) 788 | elif self.interp == "linear": 789 | d_interp = np.log10((self.grunstop, 10)) 790 | c_interp = np.log10((self.grun.csfd(self.grunstop), super().csfd(10))) 791 | # cs = CubicSpline(d_interp, c_interp) 792 | f = interp1d(d_interp, c_interp) 793 | 794 | overlap = np.logical_and(diam > self.grunstop, diam < 10) 795 | # c[diam < 2.5] = self.grun.csfd(diam[diam < 2.5]) 796 | # c[overlap] = np.power(10, np.interp( 797 | # np.log10(diam[overlap]), 798 | # [np.log10(self.grunstop), np.log10(10)], 799 | # [ 800 | # np.log10(self.grun.csfd(self.grunstop)), 801 | # np.log10(super().csfd(10)) 802 | # ] 803 | # )) 804 | c[overlap] = np.float_power(10, f(np.log10(diam[overlap]))) 805 | c[diam <= self.grunstop] = self.grun.csfd(diam[diam <= self.grunstop]) 806 | else: 807 | raise ValueError( 808 | f"The interpolation method, {self.interp}, is not recognized." 809 | ) 810 | 811 | if isinstance(d, Number): 812 | return c.item() 813 | 814 | return c 815 | 816 | # def isfd(self, d): 817 | # if isinstance(d, Number): 818 | # # Convert to numpy array, if needed. 819 | # d = np.array([d, ]) 820 | # i = np.empty_like(d) 821 | # i[d >= 10] = super().isfd(d[d >= 10]) 822 | # i[d < 10] = self.grun.isfd(d[d < 10]) 823 | # return i 824 | 825 | def _cdf(self, d): 826 | if isinstance(d, Number): 827 | # Convert to numpy array, if needed. 828 | diam = np.array( 829 | [ 830 | d, 831 | ] 832 | ) 833 | else: 834 | diam = d 835 | c = np.empty_like(diam, dtype=np.dtype(float)) 836 | 837 | c[diam >= 10] = super()._cdf(diam[diam >= 10]) 838 | if self.interp == "extendGrun": 839 | c[diam < 10] = self.grun._cdf(diam[diam < 10]) 840 | elif self.interp == "linear": 841 | d_interp = np.log10((self.grunstop, 10)) 842 | c_interp = np.log10((self.grun._cdf(self.grunstop), super()._cdf(10))) 843 | # cs = CubicSpline(d_interp, c_interp) 844 | f = interp1d(d_interp, c_interp) 845 | 846 | overlap = np.logical_and(diam > self.grunstop, diam < 10) 847 | # c[overlap] = np.power(10, np.interp( 848 | # np.log10(d[overlap]), 849 | # [np.log10(self.grunstop), np.log10(10)], 850 | # [ 851 | # np.log10(self.grun._cdf(self.grunstop)), 852 | # np.log10(super()._cdf(10)) 853 | # ] 854 | # )) 855 | c[overlap] = np.float_power(10, f(np.log10(diam[overlap]))) 856 | c[diam <= self.grunstop] = self.grun._cdf(diam[diam <= self.grunstop]) 857 | 858 | if isinstance(d, Number): 859 | return c.item() 860 | 861 | return c 862 | 863 | 864 | class GNPF(NPF): 865 | """ 866 | This describes a combination function such that it functions as a Neukum 867 | Production Function (NPF) for the size ranges where NPF is appropriate, 868 | and as a Grun function where that is appropriate. 869 | """ 870 | 871 | def __init__(self, a, b, **kwargs): 872 | if b <= 2.76: 873 | raise ValueError( 874 | f"The upper bound, b, is {b}, you should use Grun, not GNPF." 875 | ) 876 | 877 | if a >= 10: 878 | raise ValueError( 879 | f"The lower bound, a, is {a}, you should use NPF, not GNPF." 880 | ) 881 | 882 | # Will now construct *this* as an NPF with a Grun hidden inside. 883 | npf_kwargs = copy.deepcopy(kwargs) 884 | npf_kwargs["a"] = 10 885 | npf_kwargs["b"] = b 886 | super().__init__(**npf_kwargs) # Calls NPF __init__() 887 | 888 | grun_kwargs = copy.deepcopy(kwargs) 889 | grun_kwargs["a"] = a 890 | grun_kwargs["b"] = 10 891 | 892 | # Need to get Grun data points and extend to the first NPF point: 893 | grun_diams, grun_fluxes = Grun.parameters() 894 | npf = NPF(10, b, **kwargs) 895 | n_diam = 10 896 | n_flux = npf.csfd(n_diam) 897 | diameters = np.append(grun_diams, n_diam) 898 | fluxes = np.append(grun_fluxes, n_flux) 899 | grun_kwargs["diameters"] = diameters 900 | grun_kwargs["csfds"] = fluxes 901 | self.grun = Interp_Distribution(**grun_kwargs) 902 | 903 | def csfd(self, d): 904 | if isinstance(d, Number): 905 | # Convert to numpy array, if needed. 906 | diam = np.array( 907 | [ 908 | float(d), 909 | ] 910 | ) 911 | else: 912 | diam = d 913 | c = np.empty_like(diam, dtype=np.dtype(float)) 914 | 915 | c[diam >= 10] = super().csfd(diam[diam >= 10]) 916 | c[diam < 10] = self.grun.csfd(diam[diam < 10]) 917 | 918 | if isinstance(d, Number): 919 | return c.item() 920 | 921 | return c 922 | 923 | def _cdf(self, d): 924 | if isinstance(d, Number): 925 | # Convert to numpy array, if needed. 926 | diam = np.array( 927 | [ 928 | d, 929 | ] 930 | ) 931 | else: 932 | diam = d 933 | c = np.empty_like(diam, dtype=np.dtype(float)) 934 | 935 | c[diam >= 10] = super()._cdf(diam[diam >= 10]) 936 | c[diam < 10] = self.grun._cdf(diam[diam < 10]) 937 | 938 | if isinstance(d, Number): 939 | return c.item() 940 | else: 941 | return c 942 | 943 | 944 | class GNPF_fit(Coef_Distribution): 945 | """ 946 | This describes a combination function such that it functions as a Neukum 947 | Production Function (NPF) for the size ranges where NPF is appropriate, 948 | and as a Grun function where that is appropriate. Rather than being 949 | piecewise correct (which can cause unrealistic behavior where the two 950 | functions meet at 10 m diameter, this fits a new, single 11-degree 951 | polynomial across the span of both functions. This resulting function 952 | is similar but not equal to Grun or Neukum in the ranges where they are 953 | appropriate, but does join together smoothly at 10 m diameters. 954 | """ 955 | 956 | def __init__(self, a, b, **kwargs): 957 | if b <= 10: 958 | raise ValueError( 959 | f"The upper bound, b, is {b}, you should use Grun, not GNPF." 960 | ) 961 | 962 | if a >= 10: 963 | raise ValueError( 964 | f"The lower bound, a, is {a}, you should use NPF, not GNPF." 965 | ) 966 | 967 | # Need to get Grun data points: 968 | grun_diams, grun_fluxes = Grun.parameters() 969 | 970 | # Now need to get Neukum and sample points: 971 | npf = NPF(10, b, **kwargs) 972 | npf_diams = np.geomspace(10, 300000, 1000) 973 | npf_fluxes = np.float_power(10, npf.poly(np.log10(npf_diams / 1000))) 974 | 975 | # The Coef_Distribution polynomial needs diameters 976 | # in kilometers, so divide by 1000. And fluxes need 977 | # to be in /km^2 /Gyr, so multiply by a million. 978 | diameters = np.append(grun_diams, npf_diams) / 1000 979 | fluxes = np.append(grun_fluxes * 1e6, npf_fluxes) 980 | 981 | # import sys 982 | # np.set_printoptions(threshold=sys.maxsize) 983 | # print(np.log10(diameters)) 984 | # print(grun_fluxes) 985 | # print(npf_fluxes) 986 | # print(fluxes) 987 | # print(np.log10(fluxes)) 988 | # np.set_printoptions(threshold=False) 989 | 990 | p = Polynomial.fit(np.log10(diameters), np.log10(fluxes), 11) 991 | 992 | kwargs["a"] = a 993 | kwargs["b"] = b 994 | super().__init__(poly=p, **kwargs) 995 | 996 | 997 | # If new equilibrium functions are added, add them to this list to expose them 998 | # to users. This must be defined here *after* the classes are defined. 999 | equilibrium_functions = (Trask, VIPER_Env_Spec) 1000 | -------------------------------------------------------------------------------- /src/python/synthterrain/crater/profile.py: -------------------------------------------------------------------------------- 1 | """This module contains functions described by 2 | Martin, Parkes, and Dunstan (2014), 3 | https:doi.org/10.1109/TAES.2014.120282 4 | .""" 5 | 6 | # Copyright © 2024, United States Government, as represented by the 7 | # Administrator of the National Aeronautics and Space Administration. 8 | # All rights reserved. 9 | # 10 | # The “synthterrain” software is licensed under the Apache License, 11 | # Version 2.0 (the "License"); you may not use this file except in 12 | # compliance with the License. You may obtain a copy of the License 13 | # at http://www.apache.org/licenses/LICENSE-2.0. 14 | 15 | # Unless required by applicable law or agreed to in writing, software 16 | # distributed under the License is distributed on an "AS IS" BASIS, 17 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 18 | # implied. See the License for the specific language governing 19 | # permissions and limitations under the License. 20 | 21 | # Consider attempting to implement the Mahanti et al. (2014, 22 | # https://doi.org/10.1016/j.icarus.2014.06.023) Chebyshev polynomial approach. 23 | 24 | 25 | import math 26 | 27 | import numpy as np 28 | from numpy.polynomial import Polynomial 29 | 30 | 31 | class Crater: 32 | """A base class for establishing characteristics for a crater, in order 33 | to query its elevation at particular radial distances.""" 34 | 35 | def __init__( 36 | self, 37 | diameter, 38 | ): 39 | self.diameter = diameter 40 | 41 | def r(self): 42 | return self.diameter / 2 43 | 44 | def profile(self, r): 45 | """Implementing classes must override this function. 46 | This function returns a numpy array of the same shape as 47 | *r*. 48 | This function returns the elevation of the crater profile 49 | at the radial distance *r* where *r* is a fraction of the 50 | crater radius. Such that *r* = 0 is at the center, *r* = 1 51 | is at the crater diameter, and values of *r* greater than 1 52 | are distances outside the crater rim. 53 | 54 | Values returned in the numpy array are elevation values 55 | in the distance units of whatever units the self.diameter 56 | parameter is in. Values of zero are considered pre-existing 57 | surface elevations. 58 | """ 59 | raise NotImplementedError( 60 | f"The class {self.__name__} has not implemented elevation() as " "required." 61 | ) 62 | 63 | 64 | class FT_Crater(Crater): 65 | """A crater whose profile is defined by functions described in 66 | Fassett and Thomson (2014, https://doi.org/10.1002/2014JE004698), 67 | equation 4. 68 | """ 69 | 70 | def profile(self, r, radius_fix=True): 71 | """Returns a numpy array of elevation values based in the input numpy 72 | array of radius fraction values, such that a radius fraction value 73 | of 1 is at the rim, less than that interior to the crater, etc. 74 | 75 | A ValueError will be thrown if any values in r are < 0. 76 | 77 | The Fassett and Thomson (2014) paper defined equations which 78 | placed the rim point at a radius fraction of 0.98, but that 79 | results in a crater with a smaller diameter than specifed. 80 | If radius_fix is True (the default) the returned profile will 81 | extend the interior slope and place the rim at radius fraction 82 | 1.0, but this may cause a discontinuity at the rim. If you 83 | would like a profile with the original behavior, set radius_fix 84 | to False. 85 | """ 86 | 87 | if not isinstance(r, np.ndarray): 88 | r = np.ndarray(r) 89 | 90 | out_arr = np.zeros_like(r) 91 | 92 | if np.any(r < 0): 93 | raise ValueError("The radius fraction value can't be less than zero.") 94 | 95 | # In F&T (2014) the boundary between inner and outer was at 0.98 96 | # which put the rim not at r=1, Caleb's subsequent code revised 97 | # this position to 1.0. 98 | if radius_fix: 99 | rim = 1.0 100 | else: 101 | rim = 0.98 102 | 103 | flat_idx = np.logical_and(0 <= r, r <= 0.2) 104 | inner_idx = np.logical_and(0.2 < r, r <= rim) 105 | outer_idx = np.logical_and(rim < r, r <= 1.5) 106 | 107 | inner_poly = Polynomial([-0.228809953, 0.227533882, 0.083116795, -0.039499407]) 108 | outer_poly = Polynomial([0.188253307, -0.187050452, 0.01844746, 0.01505647]) 109 | 110 | out_arr[flat_idx] = self.diameter * -0.181 111 | out_arr[inner_idx] = self.diameter * inner_poly(r[inner_idx]) 112 | out_arr[outer_idx] = self.diameter * outer_poly(r[outer_idx]) 113 | 114 | return out_arr 115 | 116 | 117 | class FTmod_Crater(Crater): 118 | """ 119 | This crater profile is based on Fassett and Thomson 120 | (2014, https://doi.org/10.1002/2014JE004698), equation 4, but modified 121 | in 2022 by Caleb Fassett (pers. comm). 122 | 123 | An optional *depth* parameter can be given to the constructor which 124 | specifies the initial depth of the crater. If no *depth* is given, 125 | the "Stopar step" value for fresh craters of the given diameter is used. 126 | 127 | The modifications to the Fassett and Thomson (2014) shape are at the rim 128 | and at the floor. Rather than being a sharp transition from crater 129 | interior to crater exterior, there is now a "flat" rim from 0.98 relative 130 | crater diameter to 1.02. 131 | 132 | The specification of the "depth" parameter will set the level of the flat 133 | floor in the middle of the crater. In practice, this means that for 134 | smaller craters, the flat floor will have a larger relative radius than 135 | larger craters, because the d/D ratios for smaller craters are smaller, 136 | thus shallower, thus larger (relative) flat floors. 137 | """ 138 | 139 | def __init__(self, diameter, depth=None): 140 | super().__init__(diameter) 141 | if depth is None: 142 | self.depth = stopar_fresh_dd(self.diameter) * self.diameter 143 | else: 144 | self.depth = depth 145 | 146 | def profile(self, r): 147 | """Returns a numpy array of elevation values based in the input numpy 148 | array of radius fraction values, such that a radius fraction value 149 | of 1 is at the rim, less than that interior to the crater, etc. 150 | 151 | A ValueError will be thrown if any values in r are < 0. 152 | """ 153 | 154 | if not isinstance(r, np.ndarray): 155 | r = np.ndarray(r) 156 | 157 | out_arr = np.zeros_like(r) 158 | 159 | if np.any(r < 0): 160 | raise ValueError("The radius fraction value can't be less than zero.") 161 | 162 | inner_idx = np.logical_and(0 <= r, r <= 0.98) 163 | rim_idx = np.logical_and(0.98 < r, r <= 1.02) 164 | outer_idx = np.logical_and(1.02 < r, r <= 1.5) 165 | 166 | inner_poly = Polynomial([-0.228809953, 0.227533882, 0.083116795, -0.039499407]) 167 | outer_poly = Polynomial([0.188253307, -0.187050452, 0.01844746, 0.01505647]) 168 | 169 | rim_hoverd = 0.036822095 170 | 171 | out_arr[inner_idx] = inner_poly(r[inner_idx]) 172 | out_arr[rim_idx] = rim_hoverd 173 | out_arr[outer_idx] = outer_poly(r[outer_idx]) 174 | 175 | floor = rim_hoverd - (self.depth / self.diameter) 176 | out_arr[out_arr < floor] = floor 177 | 178 | return out_arr * self.diameter 179 | 180 | 181 | class MPD_Crater(Crater): 182 | """A crater whose profile is defined by functions described in 183 | Martin, Parkes, and Dunstan (2014, 184 | https:doi.org/10.1109/TAES.2014.120282). The published equations 185 | for beta and h3 result in non-realistic profiles. For this class, 186 | the definition of beta has been adjusted so that it is a positive value 187 | (which we think was intended). We have also replaced the published 188 | function for h3, with a cubic that actually matches up with h2 and h4, 189 | although the matching with h4 is imperfect, so there is likely a better 190 | representation for h3. 191 | """ 192 | 193 | def __init__( 194 | self, 195 | diameter, 196 | depth, 197 | rim_height=None, 198 | emin=0, # height of the ejecta at x = D/2 199 | pre_rim_elevation=0, 200 | plane_elevation=0, 201 | ): 202 | self.h0 = self.height_naught(diameter) 203 | self.hr0 = self.height_r_naught(diameter) 204 | self.h = depth 205 | if rim_height is None: 206 | self.hr = self.hr0 207 | else: 208 | self.hr = rim_height 209 | 210 | self.emin = emin 211 | 212 | self.tr = pre_rim_elevation 213 | self.pr = plane_elevation 214 | 215 | # print(self.hr) 216 | # print(self.h) 217 | # print(self.hr0) 218 | # print(self.tr) 219 | # print(self.pr) 220 | 221 | super().__init__(diameter) 222 | 223 | def profile(self, r: float): 224 | return self.profile_x(r - 1) 225 | 226 | def profile_x(self, x: float): 227 | err_msg = ( 228 | "The value of x must be greater than -1, as defined in " 229 | "Martin, Parkes, and Dunstan (2012), eqn 3." 230 | ) 231 | if not -1 <= x: 232 | raise ValueError(err_msg) 233 | 234 | alpha = self.alpha(self.hr, self.h, self.hr0, self.tr, self.pr) 235 | beta = self.beta(self.hr, self.h, self.hr0, self.tr, self.pr) 236 | 237 | if -1 <= x <= alpha: 238 | return self.h1(x, self.hr, self.h, self.hr0) 239 | 240 | if alpha <= x <= 0: 241 | return self.h2(x, self.hr, self.h, self.hr0, alpha, self.tr, self.pr) 242 | 243 | if 0 <= x <= beta: 244 | # return self.h3( 245 | return self.h3_alt( 246 | self.diameter, 247 | self.emin, 248 | x, 249 | self.hr, 250 | self.h, 251 | self.hr0, 252 | alpha, 253 | beta, 254 | self.tr, 255 | self.pr, 256 | ) 257 | 258 | if beta <= x: 259 | return self.h4(x, self.diameter, self.fc(x, self.emin, self.tr, self.pr)) 260 | 261 | # Really should not be able to get here. 262 | raise ValueError(err_msg) 263 | 264 | @staticmethod 265 | def height_naught(diameter: float): 266 | """H_0 as defined by Melosh, 1989. Eqn 1 in Martin, Parkes, and Dunstan.""" 267 | return 0.196 * math.pow(diameter, 1.01) 268 | 269 | @staticmethod 270 | def height_r_naught(diameter: float): 271 | """H_r0 as defined by Melosh, 1989. Eqn 2 in Martin, Parkes, and 272 | Dunstan.""" 273 | return 0.036 * math.pow(diameter, 1.01) 274 | 275 | @staticmethod 276 | def h1(x: float, hr: float, h: float, hr_naught: float): 277 | """Eqn 4 in Martin, Parkes, and Dunstan.""" 278 | h_ = hr_naught - hr + h 279 | return (h_ * math.pow(x, 2)) + (2 * h_ * x) + hr_naught 280 | 281 | @staticmethod 282 | def h2(x: float, hr: float, h: float, hr_naught: float, alpha: float, tr=0, pr=0): 283 | """Eqn 5 in Martin, Parkes, and Dunstan.""" 284 | h_ = hr_naught - hr + h 285 | return ((h_ * (alpha + 1)) / alpha) * math.pow(x, 2) + hr + tr - pr 286 | 287 | @staticmethod 288 | def alpha(hr: float, h: float, hr_naught: float, tr=0, pr=0): 289 | """Eqn 6 in Martin, Parkes, and Dunstan.""" 290 | # print(f"{hr}, {h}, {hr_naught}, {tr}, {pr}") 291 | return (hr + tr - pr - hr_naught) / (hr_naught - hr + h) 292 | 293 | @staticmethod 294 | def h3( 295 | x: float, 296 | hr: float, 297 | h: float, 298 | hr_naught: float, 299 | alpha: float, 300 | beta: float, 301 | tr=0, 302 | pr=0, 303 | ): 304 | """Eqn 7 in Martin, Parkes, and Dunstan.""" 305 | h_ = hr_naught - hr + h 306 | t1 = -1 * ((2 * h_) / (3 * math.pow(beta, 2))) * math.pow(x, 3) 307 | t2 = (h_ + ((2 * h_) / beta)) * math.pow(x, 2) 308 | return t1 + t2 + hr + tr - pr 309 | 310 | @staticmethod 311 | def h3_alt( 312 | diameter, 313 | emin, 314 | x: float, 315 | hr: float, 316 | h: float, 317 | hr_naught: float, 318 | alpha: float, 319 | beta: float, 320 | tr=0, 321 | pr=0, 322 | ): 323 | """Improved cubic form.""" 324 | # ax^3 + bx ^ 2 + cx + d = elevation 325 | # At x = 0, the cubic should be H_r, so d = H_r 326 | # The the positive critical point, should be at x=0, which 327 | # implies that c = 0. 328 | # The inflection point should be where this function meets up 329 | # with h4, so that means that the inflection point is at x = beta 330 | h4_at_beta = MPD_Crater.h4(beta, diameter, MPD_Crater.fc(beta, emin, tr, pr)) 331 | a = (hr - h4_at_beta) / (2 * math.pow(beta, 3)) 332 | b = -3 * a * beta 333 | cubic = Polynomial([hr, 0, b, a]) 334 | return cubic(x) 335 | 336 | @staticmethod 337 | def beta(hr: float, h: float, hr_naught: float, tr=0, pr=0): 338 | """Eqn 8 in Martin, Parkes, and Dunstan.""" 339 | h_ = hr_naught - hr + h 340 | # This changes the order of hr_naught and hr from the 341 | # paper, as this ensures that this term will be positive. 342 | return (3 * (hr_naught - hr + tr - pr)) / (2 * h_) 343 | 344 | @staticmethod 345 | def h4(x: float, diameter: float, fc: float): 346 | """Eqn 9 in Martin, Parkes, and Dunstan.""" 347 | return 0.14 * pow(diameter / 2, 0.74) * pow(x + 1, -3) + fc 348 | 349 | @staticmethod 350 | def fc(x: float, emin: float, tr=0, pr=0): 351 | return ((emin + tr - pr) * x) + (2 * (pr - tr)) - emin 352 | 353 | 354 | def stopar_fresh_dd(diameter): 355 | """ 356 | Returns a depth/Diameter ratio based on the set of graduated d/D 357 | categories in Stopar et al. (2017), defined down to 40 m. This 358 | function also adds two extrapolated categories. 359 | """ 360 | # The last two elements are extrapolated 361 | d_lower_bounds = (400, 200, 100, 40, 10, 0) 362 | dds = (0.21, 0.17, 0.15, 0.13, 0.11, 0.10) 363 | for d, dd in zip(d_lower_bounds, dds): 364 | if diameter >= d: 365 | return dd 366 | else: 367 | raise ValueError(f"Diameter was less than zero: {diameter}.") 368 | -------------------------------------------------------------------------------- /src/python/synthterrain/rock/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Generates synthetic rock populations. 3 | """ 4 | 5 | # Copyright © 2024, United States Government, as represented by the 6 | # Administrator of the National Aeronautics and Space Administration. 7 | # All rights reserved. 8 | # 9 | # The “synthterrain” software is licensed under the Apache License, 10 | # Version 2.0 (the "License"); you may not use this file except in 11 | # compliance with the License. You may obtain a copy of the License 12 | # at http://www.apache.org/licenses/LICENSE-2.0. 13 | 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 17 | # implied. See the License for the specific language governing 18 | # permissions and limitations under the License. 19 | 20 | import logging 21 | import math 22 | import time 23 | from pathlib import Path 24 | 25 | import matplotlib.pyplot as plt 26 | import numpy as np 27 | 28 | import opensimplex 29 | import pandas as pd 30 | from matplotlib.collections import PatchCollection 31 | from matplotlib.patches import Circle 32 | from matplotlib.ticker import ScalarFormatter 33 | from rasterio.features import geometry_mask 34 | from rasterio.transform import from_origin, rowcol, xy 35 | from rasterio.windows import from_bounds, intersect, shape, Window 36 | from shapely.geometry import Polygon 37 | 38 | from synthterrain.crater import generate_diameters 39 | from synthterrain.crater.functions import Crater_rv_continuous 40 | 41 | logger = logging.getLogger(__name__) 42 | 43 | 44 | def crater_probability_map(diameter_px, domain_size=None, decay_multiplier=(-1 / 0.7)): 45 | """ 46 | Returns two numpy arrays of size (*domain_size*, *domain_size*) representing the 47 | probability of rocks exterior to a crater of *diameter_px* and the second is the 48 | probability of rocks interior to the crater of that size. 49 | 50 | If *domain_size* is not given, it will be twice *diameter_px*. 51 | 52 | The *decay_multiplier* is multiplied by the distance from the crater center in the 53 | exponential that defines the radial probability field outside of the rim. Larger 54 | negative numbers result in steeper fall-offs. Positive values will result in a 55 | ValueError. 56 | """ 57 | if decay_multiplier > 0: 58 | raise ValueError( 59 | "The decay_multiplier must be negative, otherwise the probability increases" 60 | "as you move outwards." 61 | ) 62 | 63 | if domain_size is None: 64 | # Span a space 2x the diameter. 65 | domain_size = math.ceil(diameter_px * 2) 66 | domain_edge = 2 67 | else: 68 | domain_edge = domain_size / diameter_px 69 | x = np.linspace(-1 * domain_edge, domain_edge, domain_size) 70 | 71 | # The array rr contains radius fraction values from the center of the 72 | # domain. 73 | xx, yy = np.meshgrid(x, x, sparse=True) # square domain 74 | rr = np.sqrt(xx**2 + yy**2) 75 | 76 | outer_pmap = np.zeros_like(rr) 77 | 78 | inner_idx = rr <= 1 79 | outer_idx = np.logical_and(1 < rr, rr <= domain_edge) 80 | 81 | outer_pmap[outer_idx] = np.exp(decay_multiplier * rr[outer_idx]) 82 | 83 | inner_pmap = np.zeros_like(rr) 84 | inner_pmap[inner_idx] = 0.05 * np.amax(outer_pmap) 85 | 86 | return outer_pmap, inner_pmap 87 | 88 | 89 | def craters_probability_map( 90 | crater_frame: pd.DataFrame, 91 | transform, 92 | window: Window, 93 | rock_age_decay=3, 94 | ): 95 | """ 96 | Returns a 2D numpy array whose elements sum to 1 which describes the probability 97 | of a rock existing in the ejecta pattern of the craters in *crater_frame*. The 98 | affine *transform* describes the relation of the *window* to the x,y coordinates 99 | of the craters in *crater_frame*. The *rock_age_decay* parameter is the exponential 100 | in the equation that determines how the probability field changes with crater 101 | age. 102 | """ 103 | if abs(transform.a) != abs(transform.e): 104 | raise ValueError("The transform does not have even spacing in X and Y.") 105 | pmap = np.zeros(shape(window)) 106 | df = crater_frame.sort_values(by="age", ascending=False) 107 | for row in df.itertuples(index=False): 108 | outer, inner = crater_probability_map(row.diameter / abs(transform.a)) 109 | 110 | # print(pmap.shape) 111 | # print(outer.shape) 112 | row_center, col_center = rowcol(transform, row.x, row.y) 113 | # print(row_center) 114 | # print(col_center) 115 | crater_window = Window( 116 | col_center - int(outer.shape[0] / 2), 117 | row_center - int(outer.shape[1] / 2), 118 | *outer.shape, 119 | ) 120 | # print(f"crater_window: {crater_window}") 121 | 122 | if not intersect((window, crater_window)): 123 | # print("does not intersect") 124 | continue 125 | 126 | window_inter = window.intersection(crater_window) 127 | # print(f"window_inter {window_inter}") 128 | # print(window_inter.toslices()) 129 | crater_inter = intersection_relative_to(crater_window, window) 130 | # print(f"crater_inter {crater_inter}") 131 | 132 | # Need to determine scheme for reducing the outer and inner maps relative 133 | # to their age, but original code was actually based on d/D and otherwise 134 | # backwards (more degraded craters had larger probability multipliers. 135 | age_multiplier = np.power(1 - (row.age / 4e9), rock_age_decay) 136 | 137 | if age_multiplier < 0: 138 | age_multiplier = 0 139 | 140 | outer *= age_multiplier 141 | inner *= age_multiplier 142 | 143 | # Ejecta field adds to probability: 144 | pmap[window_inter.toslices()] = ( 145 | pmap[window_inter.toslices()] + outer[crater_inter.toslices()] 146 | ) 147 | 148 | # Interior of crater replaces: 149 | pmap[window_inter.toslices()] = np.where( 150 | inner[crater_inter.toslices()] > 0, 151 | inner[crater_inter.toslices()], 152 | pmap[window_inter.toslices()], 153 | ) 154 | 155 | return pmap / np.sum(pmap) 156 | 157 | 158 | def intersection_relative_to(w1, w2): 159 | """ 160 | Returns the intersection of the two windows relative to the row_off and col_off of 161 | *w1*. 162 | """ 163 | 164 | row_shift = w1.row_off 165 | col_shift = w1.col_off 166 | 167 | w1_shift = Window(0, 0, w1.width, w1.height) 168 | w2_shift = Window( 169 | w2.col_off - col_shift, w2.row_off - row_shift, w2.width, w2.height 170 | ) 171 | 172 | return w1_shift.intersection(w2_shift) 173 | 174 | 175 | def place_rocks( 176 | diameters, 177 | polygon: Polygon, 178 | pmap: np.array, 179 | transform, 180 | seed=None, 181 | epsilon=0.0001, 182 | ): 183 | """ 184 | Return a pandas DataFrame containing the diameter and x,y location of the rocks 185 | provided in *diameters*. These locations will be interior to *polygon* and 186 | will conform to the affine *transform* which describes the relation of the *polygon* 187 | to the *pmap* array which should be a probability map. 188 | 189 | A ValueError will be raised if the elements of *pmap* sum to less than *epsilon*. 190 | """ 191 | # Mask the probability map 192 | mask = geometry_mask((polygon,), pmap.shape, transform) 193 | pmap[mask] = 0 194 | 195 | prob_map_sum = np.sum(pmap) 196 | 197 | if prob_map_sum < epsilon: 198 | raise ValueError(f"The sum of the pmap is less than {epsilon}!") 199 | 200 | logger.debug(f"The probability map sums to {prob_map_sum} before normalization.") 201 | 202 | # Flatten and normalize the probability map for choosing. 203 | # flat_prob_map = pmap.ravel() / prob_map_sum 204 | flat_prob_map = pmap.ravel() 205 | 206 | rng = np.random.default_rng(seed) 207 | position_idxs = rng.choice( 208 | len(flat_prob_map), 209 | size=len(diameters), 210 | replace=True, 211 | p=flat_prob_map, 212 | ) 213 | 214 | # Convert indexes back to row/column coordinates and then x/y coordinates. 215 | rows, cols = np.unravel_index(position_idxs, pmap.shape) 216 | xs, ys = xy(transform, rows, cols) 217 | 218 | # Select sub-pixel location: 219 | delta_pos = rng.random((2, len(diameters))) 220 | 221 | xs += delta_pos[0] * abs(transform.a) 222 | ys += delta_pos[1] * abs(transform.a) 223 | 224 | return pd.DataFrame(data={"diameter": diameters, "x": xs, "y": ys}) 225 | 226 | 227 | def random_probability_map(rows, cols, seed=None): 228 | """ 229 | Returns a 2D numpy array with shape (*rows*, *cols*) which is a random probability 230 | map. 231 | 232 | This function uses opensimplex to produce 2D noise as a basis for 233 | the map, and then normalizes the returned array, such that all of 234 | its probabilities sum to 1. 235 | 236 | If *rows* or *cols* is less than 10, then a uniform probability map is returned. 237 | """ 238 | 239 | if rows < 10 or cols < 10: 240 | p_map = np.ones((rows, cols)) 241 | else: 242 | if seed is None: 243 | opensimplex.seed(time.time_ns()) 244 | else: 245 | opensimplex.seed(seed) 246 | noise = opensimplex.noise2array(np.arange(cols), np.arange(rows)) 247 | p_map = (noise + 1) / 2 # noise runs from -1 to +1 248 | 249 | # Don't place rocks anywhere the probability is 250 | # less than 0.5 to make things more "clumpy" I guess? 251 | p_map = np.where(p_map > 0.5, p_map, 0) 252 | 253 | return p_map / np.sum(p_map) 254 | 255 | 256 | def synthesize( 257 | rock_dist: Crater_rv_continuous, 258 | polygon: Polygon, 259 | pmap_gsd: int, 260 | crater_frame=None, 261 | min_d=None, 262 | max_d=None, 263 | seed=None, 264 | ): 265 | """ 266 | Return a two-tuple with a pandas DataFrame and a 2D numpy array. 267 | 268 | The DataFrame contains information about rocks and their properties synthesized 269 | from the input parameters. The 2D numpy array is the probability map used to 270 | generate the x and y values in the DataFrame. 271 | """ 272 | 273 | logger.info(f"Rock distribution function is {rock_dist.__class__}") 274 | # Get Rocks 275 | if min_d is None and max_d is None: 276 | diameters = rock_dist.rvs(area=polygon.area) 277 | elif min_d is not None and max_d is not None: 278 | diameters = generate_diameters(rock_dist, polygon.area, min_d, max_d) 279 | else: 280 | raise ValueError( 281 | f"One of min_d, max_d ({min_d}, {max_d}) was None, they must " 282 | "either both be None or both have a value." 283 | ) 284 | logger.info(f"In {polygon.area} m^2, generated {len(diameters)} rocks.") 285 | 286 | # Build probability map 287 | (minx, miny, maxx, maxy) = polygon.bounds 288 | transform = from_origin(minx, maxy, pmap_gsd, pmap_gsd) 289 | window = ( 290 | from_bounds(minx, miny, maxx, maxy, transform).round_lengths().round_offsets() 291 | ) 292 | random_map = random_probability_map(*shape(window), seed) 293 | logger.info(f"Random probability map of size {random_map.shape} generated.") 294 | logger.debug(f"Random probability map sums to {np.sum(random_map)}.") 295 | 296 | if crater_frame is not None: 297 | crater_map = craters_probability_map(crater_frame, transform, window) 298 | pmap = (random_map + crater_map) / 2 299 | else: 300 | pmap = random_map 301 | 302 | return place_rocks(diameters, polygon, pmap, transform, seed), pmap 303 | 304 | 305 | def plot(df, pmap=None, extent=None): 306 | """ 307 | Generates a plot display with a variety of subplots for the provided 308 | pandas DataFrame, consistent with the columns in the DataFrame output 309 | by synthesize(). 310 | """ 311 | # Plots are: 312 | # CSFD 313 | # probability map, location map 314 | 315 | plt.ioff() 316 | # fig, ((ax_csfd, ax_), (ax_pmap, ax_location)) = plt.subplots(2, 2) 317 | fig, (ax_csfd, ax_pmap, ax_location) = plt.subplots(1, 3) 318 | 319 | ax_csfd.hist( 320 | df["diameter"], 321 | cumulative=-1, 322 | log=True, 323 | bins=50, 324 | histtype="stepfilled", 325 | label="Rocks", 326 | ) 327 | ax_csfd.set_ylabel("Count") 328 | ax_csfd.yaxis.set_major_formatter(ScalarFormatter()) 329 | ax_csfd.set_xlabel("Diameter (m)") 330 | ax_csfd.legend(loc="best", frameon=False) 331 | 332 | # ax_age.scatter(df["diameter"], df["age"], alpha=0.2, edgecolors="none", s=10) 333 | # ax_age.set_xscale("log") 334 | # ax_age.xaxis.set_major_formatter(ScalarFormatter()) 335 | # ax_age.set_yscale("log") 336 | # ax_age.set_ylabel("Age (yr)") 337 | # ax_age.set_xlabel("Diameter (m)") 338 | 339 | ax_pmap.imshow(pmap) 340 | 341 | ax_location.imshow(pmap, extent=extent) 342 | patches = [ 343 | Circle((x_, y_), s_) 344 | for x_, y_, s_ in np.broadcast(df["x"], df["y"], df["diameter"] / 2) 345 | ] 346 | collection = PatchCollection(patches) 347 | collection.set_color("white") 348 | ax_location.add_collection(collection) 349 | ax_location.autoscale_view() 350 | ax_location.set_aspect("equal") 351 | 352 | plt.show() 353 | return 354 | 355 | 356 | def to_file(df: pd.DataFrame, outfile: Path, xml=False): 357 | if xml: 358 | # Write out the dataframe in the XML style of the old MATLAB 359 | # program. 360 | df.to_xml( 361 | outfile, 362 | index=False, 363 | root_name="RockList", 364 | row_name="RockData", 365 | parser="etree", 366 | attr_cols=["diameter", "x", "y"], 367 | ) 368 | else: 369 | df.to_csv( 370 | outfile, 371 | index=False, 372 | columns=["diameter", "x", "y"], 373 | ) 374 | 375 | return 376 | -------------------------------------------------------------------------------- /src/python/synthterrain/rock/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Generates synthetic rock populations. 3 | """ 4 | 5 | # Copyright © 2024, United States Government, as represented by the 6 | # Administrator of the National Aeronautics and Space Administration. 7 | # All rights reserved. 8 | # 9 | # The “synthterrain” software is licensed under the Apache License, 10 | # Version 2.0 (the "License"); you may not use this file except in 11 | # compliance with the License. You may obtain a copy of the License 12 | # at http://www.apache.org/licenses/LICENSE-2.0. 13 | 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 17 | # implied. See the License for the specific language governing 18 | # permissions and limitations under the License. 19 | 20 | import logging 21 | import sys 22 | from pathlib import Path 23 | 24 | from shapely.geometry import box 25 | 26 | from synthterrain import crater, rock, util 27 | from synthterrain.rock import functions 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | 32 | def arg_parser(): 33 | parser = util.FileArgumentParser( 34 | description=__doc__, 35 | parents=[util.parent_parser()], 36 | ) 37 | parser.add_argument( 38 | "--bbox", 39 | nargs=4, 40 | type=float, 41 | default=[0, 1000, 1000, 0], 42 | metavar=("MINX", "MAXY", "MAXX", "MINY"), 43 | help="The coordinates of the bounding box, expressed in meters, to " 44 | "evaluate in min-x, max-y, max-x, min-y order (which is ulx, " 45 | "uly, lrx, lry, the GDAL pattern). " 46 | "Default: %(default)s", 47 | ) 48 | parser.add_argument( 49 | "-c", "--craters", type=Path, help="Crater csv file from synthcraters." 50 | ) 51 | parser.add_argument( 52 | "--maxd", 53 | default=2, 54 | type=float, 55 | help="Maximum rock diameter in meters. Default: %(default)s", 56 | ) 57 | parser.add_argument( 58 | "--mind", 59 | default=0.1, 60 | type=float, 61 | help="Minimum rock diameter in meters. Default: %(default)s", 62 | ) 63 | parser.add_argument( 64 | "-p", 65 | "--plot", 66 | action="store_true", 67 | help="This will cause a matplotlib window to open with some summary " 68 | "plots after the program has generated the data.", 69 | ) 70 | parser.add_argument( 71 | "--probability_map_gsd", 72 | type=float, 73 | default=1, 74 | help="This program builds a probability map to generate locations, and this " 75 | "sets the ground sample distance in the units of --bbox for that map.", 76 | ) 77 | parser.add_argument( 78 | "-x", 79 | "--xml", 80 | action="store_true", 81 | help="Default output is in CSV format, but if given this will result " 82 | "in XML output that conforms to the old MATLAB code.", 83 | ) 84 | parser.add_argument( 85 | "-o", 86 | "--outfile", 87 | required=True, 88 | default=None, 89 | type=Path, 90 | help="Path to output file.", 91 | ) 92 | 93 | return parser 94 | 95 | 96 | def main(): 97 | args = arg_parser().parse_args() 98 | 99 | util.set_logger(args.verbose) 100 | 101 | # This could more generically take an arbitrary polygon 102 | # bbox argument takes: 'MINX', 'MAXY', 'MAXX', 'MINY' 103 | # the box() function takes: (minx, miny, maxx, maxy) 104 | poly = box(args.bbox[0], args.bbox[3], args.bbox[2], args.bbox[1]) 105 | 106 | rock_dist = functions.VIPER_Env_Spec(a=args.mind, b=args.maxd) 107 | 108 | if args.craters is None: 109 | cf = None 110 | else: 111 | cf = crater.from_file(args.craters) 112 | 113 | df, pmap = rock.synthesize( 114 | rock_dist, 115 | polygon=poly, 116 | pmap_gsd=args.probability_map_gsd, 117 | crater_frame=cf, 118 | min_d=args.mind, 119 | max_d=args.maxd, 120 | ) 121 | 122 | if args.plot: 123 | rock.plot( 124 | df, pmap, [poly.bounds[0], poly.bounds[2], poly.bounds[1], poly.bounds[3]] 125 | ) 126 | 127 | # Write out results. 128 | rock.to_file(df, args.outfile, args.xml) 129 | 130 | 131 | if __name__ == "__main__": 132 | sys.exit(main()) 133 | -------------------------------------------------------------------------------- /src/python/synthterrain/rock/functions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """This module contains abstract and concrete classes for representing 3 | rock size-frequency distributions as probability distributions. 4 | """ 5 | 6 | # Copyright © 2024, United States Government, as represented by the 7 | # Administrator of the National Aeronautics and Space Administration. 8 | # All rights reserved. 9 | # 10 | # The “synthterrain” software is licensed under the Apache License, 11 | # Version 2.0 (the "License"); you may not use this file except in 12 | # compliance with the License. You may obtain a copy of the License 13 | # at http://www.apache.org/licenses/LICENSE-2.0. 14 | 15 | # Unless required by applicable law or agreed to in writing, software 16 | # distributed under the License is distributed on an "AS IS" BASIS, 17 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 18 | # implied. See the License for the specific language governing 19 | # permissions and limitations under the License. 20 | 21 | import logging 22 | 23 | import numpy as np 24 | 25 | from synthterrain.crater.functions import Crater_rv_continuous 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | class InterCrater(Crater_rv_continuous): 31 | """ 32 | This distribution is from the pre-existing synthetic_moon MATLAB code, no 33 | citation is given. 34 | """ 35 | 36 | def csfd(self, d): 37 | # Low blockiness? 38 | return 0.0001 * np.float_power(d, -1.75457) 39 | 40 | 41 | class VIPER_Env_Spec(Crater_rv_continuous): 42 | """ 43 | This distribution is from the VIPER Environmental Specification, 44 | VIPER-MSE-SPEC-001 (2021-09-16). Sadly, no citation is provided 45 | for it in that document. 46 | """ 47 | 48 | def csfd(self, d): 49 | """ 50 | CSFD( d ) = N_cum = 0.0003 / d^(2.482) 51 | """ 52 | return 0.0003 * np.float_power(d, -2.482) 53 | 54 | 55 | class Haworth(Crater_rv_continuous): 56 | """ 57 | This distribution is from the pre-existing synthetic_moon MATLAB code, no 58 | citation is given. 59 | """ 60 | 61 | def csfd(self, d): 62 | # High blockiness? 63 | return 0.002 * np.float_power(d, -2.6607) 64 | -------------------------------------------------------------------------------- /src/python/synthterrain/util.py: -------------------------------------------------------------------------------- 1 | """This module contains viss utility functions.""" 2 | 3 | # Copyright © 2024, United States Government, as represented by the 4 | # Administrator of the National Aeronautics and Space Administration. 5 | # All rights reserved. 6 | # 7 | # The “synthterrain” software is licensed under the Apache License, 8 | # Version 2.0 (the "License"); you may not use this file except in 9 | # compliance with the License. You may obtain a copy of the License 10 | # at http://www.apache.org/licenses/LICENSE-2.0. 11 | 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 15 | # implied. See the License for the specific language governing 16 | # permissions and limitations under the License. 17 | 18 | import argparse 19 | import logging 20 | import sys 21 | import textwrap 22 | 23 | import synthterrain 24 | 25 | 26 | class FileArgumentParser(argparse.ArgumentParser): 27 | """This argument parser sets the fromfile_prefix_chars to the 28 | at-symbol (@), treats lines that begin with the octothorpe (#) 29 | as comments, and allows multiple argument elements per line. 30 | """ 31 | 32 | def __init__(self, *args, **kwargs): 33 | kwargs["fromfile_prefix_chars"] = "@" 34 | 35 | fileinfo = textwrap.dedent( 36 | """\ 37 | In addition to regular command-line arguments, this program also 38 | accepts filenames prepended with the at-symbol (@) which can 39 | contain command line arguments (lines that begin with # are 40 | ignored, multiple lines are allowed) as you'd type them so you 41 | can keep often-used arguments in a handy file. 42 | """ 43 | ) 44 | 45 | if "description" in kwargs: 46 | kwargs["description"] += " " + fileinfo 47 | else: 48 | kwargs["description"] = fileinfo 49 | 50 | super().__init__(*args, **kwargs) 51 | 52 | def convert_arg_line_to_args(self, arg_line): 53 | if arg_line.startswith("#"): 54 | return [] 55 | 56 | return arg_line.split() 57 | 58 | 59 | class PrintDictAction(argparse.Action): 60 | """A custom action that interrupts argument processing, prints 61 | the contents of the *dict* argument, and then exits the 62 | program. 63 | 64 | It may need to be placed in a mutually exclusive argument 65 | group (see argparse documentation) with any required arguments 66 | that your program should have. 67 | """ 68 | 69 | def __init__(self, *args, dict=None, **kwargs): 70 | kwargs["nargs"] = 0 71 | super().__init__(*args, **kwargs) 72 | self.dict = dict 73 | 74 | def __call__(self, parser, namespace, values, option_string=None): 75 | for k, v in self.dict.items(): 76 | print(k) 77 | if v.startswith("\n"): 78 | docstring = v[1:] 79 | else: 80 | docstring = v 81 | print(textwrap.indent(textwrap.dedent(docstring), " ")) 82 | sys.exit() 83 | 84 | 85 | def parent_parser() -> argparse.ArgumentParser: 86 | """Returns a parent parser with common arguments.""" 87 | parent = argparse.ArgumentParser(add_help=False) 88 | parent.add_argument( 89 | "-v", 90 | "--verbose", 91 | action="count", 92 | default=0, 93 | help="Displays additional information.", 94 | ) 95 | parent.add_argument( 96 | "--version", 97 | action="version", 98 | version=f"synthterrain Software version {synthterrain.__version__}", 99 | help="Show library version number.", 100 | ) 101 | return parent 102 | 103 | 104 | def set_logger(verblvl=None) -> None: 105 | """Sets the log level and configuration for applications.""" 106 | logger = logging.getLogger(__name__.split(".", maxsplit=1)[0]) 107 | lvl_dict = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG} 108 | if verblvl in lvl_dict: 109 | lvl = lvl_dict[verblvl] 110 | else: 111 | lvl = lvl_dict[max(lvl_dict.keys())] 112 | 113 | logger.setLevel(lvl) 114 | 115 | ch = logging.StreamHandler() 116 | ch.setLevel(lvl) 117 | 118 | if lvl < 20: # less than INFO 119 | formatter = logging.Formatter("%(name)s - %(levelname)s: %(message)s") 120 | else: 121 | formatter = logging.Formatter("%(levelname)s: %(message)s") 122 | 123 | ch.setFormatter(formatter) 124 | logger.addHandler(ch) 125 | -------------------------------------------------------------------------------- /tests/python/crater/test_age.py: -------------------------------------------------------------------------------- 1 | """This module has tests for the synthterrain crater age functions.""" 2 | 3 | # Copyright © 2024, United States Government, as represented by the 4 | # Administrator of the National Aeronautics and Space Administration. 5 | # All rights reserved. 6 | # 7 | # The “synthterrain” software is licensed under the Apache License, 8 | # Version 2.0 (the "License"); you may not use this file except in 9 | # compliance with the License. You may obtain a copy of the License 10 | # at http://www.apache.org/licenses/LICENSE-2.0. 11 | 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 15 | # implied. See the License for the specific language governing 16 | # permissions and limitations under the License. 17 | 18 | import unittest 19 | 20 | import numpy as np 21 | import pandas as pd 22 | 23 | import synthterrain.crater.age as age 24 | from synthterrain.crater import functions as fns 25 | 26 | 27 | class Test_Ages(unittest.TestCase): 28 | def test_equilibrium_age(self): 29 | diameters = np.array([1, 2, 3, 4, 5, 10, 20, 50, 100]) 30 | a = 1 31 | b = 1000 32 | pd_func = fns.GNPF(a=a, b=b) 33 | eq_func = fns.VIPER_Env_Spec(a=a, b=b) 34 | eq_ages = age.equilibrium_age(diameters, pd_func.csfd, eq_func.csfd) 35 | 36 | np.testing.assert_allclose( 37 | eq_ages, 38 | np.array( 39 | [ 40 | 1.359312e06, 41 | 6.918704e06, 42 | 2.213654e07, 43 | 2.899164e07, 44 | 3.573974e07, 45 | 6.550186e07, 46 | 1.381746e08, 47 | 4.223119e08, 48 | 7.262570e08, 49 | ] 50 | ), 51 | rtol=1e-6, 52 | ) 53 | 54 | def test_estimate_age(self): 55 | a = age.estimate_age(10, 0.09, 5e7) 56 | self.assertAlmostEqual(a, 25000000, places=0) 57 | 58 | def test_estimate_age_by_bin(self): 59 | pd_func = fns.GNPF(a=1, b=1000) 60 | eq_func = fns.VIPER_Env_Spec(a=1, b=1000) 61 | 62 | df = pd.DataFrame( 63 | data={ 64 | "diameter": [ 65 | 1.0, 66 | 1.0, 67 | 2.0, 68 | 2.0, 69 | 3.0, 70 | 3.0, 71 | 4.0, 72 | 4.0, 73 | 5.0, 74 | 5.0, 75 | 10.0, 76 | 10.0, 77 | 20.0, 78 | 20.0, 79 | 50.0, 80 | 50.0, 81 | 100.0, 82 | 100.0, 83 | ], 84 | "d/D": [ 85 | 0.05, 86 | 0.1, 87 | 0.05, 88 | 0.1, 89 | 0.05, 90 | 0.1, 91 | 0.05, 92 | 0.1, 93 | 0.05, 94 | 0.1, 95 | 0.05, 96 | 0.1, 97 | 0.05, 98 | 0.1, 99 | 0.05, 100 | 0.12, 101 | 0.05, 102 | 0.13, 103 | ], 104 | } 105 | ) 106 | df_out = age.estimate_age_by_bin( 107 | df, 108 | 50, # the bin size can have a real impact here. 109 | pd_func.csfd, 110 | eq_func.csfd, 111 | ) 112 | 113 | age_series = pd.Series( 114 | [ 115 | 2000000, 116 | 0, 117 | 6000000, 118 | 0, 119 | 12000000, 120 | 0, 121 | 25000000, 122 | 0, 123 | 36000000, 124 | 0, 125 | 63000000, 126 | 0, 127 | 138000000, 128 | 12000000, 129 | 424000000, 130 | 68000000, 131 | 702000000, 132 | 8000000, 133 | ], 134 | name="age", 135 | ) 136 | 137 | pd.testing.assert_series_equal(age_series, df_out["age"]) 138 | 139 | df2 = pd.DataFrame( 140 | data={ 141 | "diameter": [100.0, 100.0, 100.0, 100.0], 142 | "d/D": [0.01, 0.06, 0.10, 0.17], 143 | } 144 | ) 145 | 146 | df2_out = age.estimate_age_by_bin( 147 | df2, 148 | 50, # With only one diameter, num is irrelevant 149 | ) 150 | 151 | age2_series = pd.Series([4500000000, 4500000000, 1388000000, 0], name="age") 152 | 153 | pd.testing.assert_series_equal(age2_series, df2_out["age"]) 154 | -------------------------------------------------------------------------------- /tests/python/crater/test_functions.py: -------------------------------------------------------------------------------- 1 | """This module has tests for the synthterrain crater distribution functions.""" 2 | 3 | # Copyright © 2024, United States Government, as represented by the 4 | # Administrator of the National Aeronautics and Space Administration. 5 | # All rights reserved. 6 | # 7 | # The “synthterrain” software is licensed under the Apache License, 8 | # Version 2.0 (the "License"); you may not use this file except in 9 | # compliance with the License. You may obtain a copy of the License 10 | # at http://www.apache.org/licenses/LICENSE-2.0. 11 | 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 15 | # implied. See the License for the specific language governing 16 | # permissions and limitations under the License. 17 | 18 | import unittest 19 | 20 | import numpy as np 21 | from numpy.polynomial import Polynomial 22 | 23 | import synthterrain.crater.functions as fns # usort: skip 24 | 25 | 26 | class Test_Crater_rv_continuous(unittest.TestCase): 27 | def test_abstract(self): 28 | self.assertRaises(TypeError, fns.Crater_rv_continuous) 29 | 30 | class Test_Dist(fns.Crater_rv_continuous): 31 | def csfd(self, d): 32 | return np.power(d, -2.0) 33 | 34 | self.assertRaises(TypeError, Test_Dist) 35 | self.assertRaises(ValueError, Test_Dist, a=0) 36 | 37 | d = Test_Dist(a=1) 38 | self.assertEqual(d._cdf(10.0), 0.99) 39 | self.assertEqual(len(d.rvs(area=1)), 1) 40 | 41 | def test_VIPER_Env_Spec(self): 42 | rv = fns.VIPER_Env_Spec(a=1, b=100) 43 | self.assertAlmostEqual(rv.csfd(10), 0.0003507) 44 | np.testing.assert_allclose( 45 | rv._cdf( 46 | np.array( 47 | [ 48 | 10, 49 | ] 50 | ) 51 | ), 52 | np.array([0.98797736]), 53 | ) 54 | 55 | np.testing.assert_allclose( 56 | rv._ppf( 57 | np.array( 58 | [ 59 | 0.5, 60 | 0.99, 61 | ] 62 | ) 63 | ), 64 | np.array([1.43478377, 11.00694171]), 65 | ) 66 | 67 | def test_Trask(self): 68 | rv = fns.Trask(a=10, b=100) 69 | self.assertEqual(rv.csfd(20), 0.00019858205868107036) 70 | 71 | def test_Coef_Distribution(self): 72 | self.assertRaises(ValueError, fns.Coef_Distribution, a=10, b=300000) 73 | 74 | rv = fns.Coef_Distribution( 75 | a=10, 76 | b=300000, 77 | poly=Polynomial( 78 | [ 79 | -3.0768, 80 | -3.557528, 81 | 0.781027, 82 | 1.021521, 83 | -0.156012, 84 | -0.444058, 85 | 0.019977, 86 | 0.086850, 87 | -0.005874, 88 | -0.006809, 89 | 8.25e-04, 90 | 5.54e-05, 91 | ] 92 | ), 93 | ) 94 | self.assertEqual(rv.csfd(10), 0.003796582136635746) 95 | self.assertEqual( 96 | rv._cdf( 97 | np.array( 98 | [ 99 | 10, 100 | ] 101 | ) 102 | ), 103 | np.array( 104 | [ 105 | 0, 106 | ] 107 | ), 108 | ) 109 | 110 | def test_NPF(self): 111 | self.assertRaises(ValueError, fns.NPF, a=10, b=300001) 112 | self.assertRaises(ValueError, fns.NPF, a=9, b=300) 113 | rv = fns.NPF(a=10, b=300000) 114 | self.assertEqual(rv.csfd(10), 0.0037969668020723783) 115 | -------------------------------------------------------------------------------- /tests/python/crater/test_init.py: -------------------------------------------------------------------------------- 1 | """This module has tests for the synthterrain crater init functions.""" 2 | 3 | # Copyright © 2024, United States Government, as represented by the 4 | # Administrator of the National Aeronautics and Space Administration. 5 | # All rights reserved. 6 | # 7 | # The “synthterrain” software is licensed under the Apache License, 8 | # Version 2.0 (the "License"); you may not use this file except in 9 | # compliance with the License. You may obtain a copy of the License 10 | # at http://www.apache.org/licenses/LICENSE-2.0. 11 | 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 15 | # implied. See the License for the specific language governing 16 | # permissions and limitations under the License. 17 | 18 | import unittest 19 | 20 | from shapely.geometry import Polygon 21 | 22 | import synthterrain.crater as cr # usort: skip 23 | import synthterrain.crater.functions as fns 24 | 25 | 26 | class Test_Init(unittest.TestCase): 27 | def test_generate_diameters(self): 28 | min_d = 10 29 | max_d = 11 30 | area = 10000 31 | cd = fns.Trask(a=min_d, b=max_d) 32 | 33 | d = cr.generate_diameters(cd, area, min_d, max_d) 34 | 35 | size = cd.count(area, min_d) - cd.count(area, max_d) 36 | self.assertEqual(size, d.size) 37 | self.assertEqual(0, d[d < min_d].size) 38 | self.assertEqual(0, d[d > max_d].size) 39 | 40 | def test_random_points(self): 41 | poly = Polygon(((0, 0), (1, 0), (0, 1), (0, 0))) 42 | 43 | xs, ys = cr.random_points(poly, 5) 44 | -------------------------------------------------------------------------------- /tests/python/test_util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """This module has tests for the util module.""" 3 | 4 | # Copyright © 2024, United States Government, as represented by the 5 | # Administrator of the National Aeronautics and Space Administration. 6 | # All rights reserved. 7 | # 8 | # The “synthterrain” software is licensed under the Apache License, 9 | # Version 2.0 (the "License"); you may not use this file except in 10 | # compliance with the License. You may obtain a copy of the License 11 | # at http://www.apache.org/licenses/LICENSE-2.0. 12 | 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 16 | # implied. See the License for the specific language governing 17 | # permissions and limitations under the License. 18 | 19 | import argparse 20 | import logging 21 | import unittest 22 | from unittest.mock import call, patch 23 | 24 | import synthterrain.util as util 25 | 26 | 27 | class TestUtil(unittest.TestCase): 28 | def test_FileArgumentParser(self): 29 | p = util.FileArgumentParser() 30 | 31 | self.assertEqual( 32 | p.convert_arg_line_to_args("# Comment, should be ignored"), 33 | list(), 34 | ) 35 | 36 | self.assertEqual( 37 | p.convert_arg_line_to_args("should be split"), 38 | ["should", "be", "split"], 39 | ) 40 | 41 | @patch("synthterrain.util.sys.exit") 42 | @patch("builtins.print") 43 | def test_PrintDictAction(self, m_print, m_exit): 44 | a = util.PrintDictAction( 45 | "--dummy", "dummy", dict={"a": "a value", "b": "b value"} 46 | ) 47 | 48 | a("dummy", "dummy", "dummy") 49 | self.assertEqual( 50 | m_print.call_args_list, 51 | [call("a"), call(" a value"), call("b"), call(" b value")], 52 | ) 53 | m_exit.assert_called_once() 54 | 55 | def test_parent_parser(self): 56 | self.assertIsInstance(util.parent_parser(), argparse.ArgumentParser) 57 | 58 | def test_logging(self): 59 | util.set_logger(verblvl=2) 60 | logger = logging.getLogger() 61 | self.assertEqual(30, logger.getEffectiveLevel()) 62 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, py37, py38, flake8 3 | 4 | [travis] 5 | python = 6 | 3.8: py38 7 | 3.7: py37 8 | 3.6: py36 9 | 10 | [testenv:flake8] 11 | basepython = python 12 | deps = flake8 13 | commands = flake8 src/vipersci 14 | 15 | [testenv] 16 | setenv = 17 | PYTHONPATH = {toxinidir} 18 | deps = 19 | -r{toxinidir}/requirements_dev.txt 20 | ; If you want to make tox run the tests with the same versions, create a 21 | ; requirements.txt with the pinned versions and uncomment the following line: 22 | ; -r{toxinidir}/requirements.txt 23 | commands = 24 | pip install -U pip 25 | pytest --basetemp={envtmpdir} 26 | 27 | --------------------------------------------------------------------------------