├── .gitignore ├── docs ├── topics │ ├── index.md │ └── simplification.md ├── images │ ├── Planet_primarymark_RGB_White.png │ ├── logo.svg │ └── SatelliteOrbits-main100Dove4RapidEye6Skysat-100mm01-pulsing.svg ├── requirements.txt ├── custom_theme │ └── main.html ├── commands.md ├── stylesheets │ └── extra.css ├── index.md └── expressions.md ├── src └── fio_planet │ ├── __init__.py │ ├── errors.py │ ├── cli.py │ ├── snuggs.py │ └── features.py ├── conftest.py ├── .readthedocs.yml ├── .github └── workflows │ ├── check.yml │ └── test.yml ├── tox.ini ├── tests ├── test_snuggs.py ├── data │ ├── trio.seq │ ├── trio.geojson │ └── rmnp.geojson ├── test_cli.py └── test_mod.py ├── UNLICENSE.txt ├── pyproject.toml ├── CHANGES ├── CODE_OF_CONDUCT ├── mkdocs.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | __pycache__ 3 | .coverage 4 | .vscode 5 | .tox 6 | dist 7 | site/ 8 | -------------------------------------------------------------------------------- /docs/topics/index.md: -------------------------------------------------------------------------------- 1 | More topics 2 | =========== 3 | 4 | * [Simplification of shapes](simplification.md) -------------------------------------------------------------------------------- /docs/images/Planet_primarymark_RGB_White.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/planetlabs/fio-planet/HEAD/docs/images/Planet_primarymark_RGB_White.png -------------------------------------------------------------------------------- /src/fio_planet/__init__.py: -------------------------------------------------------------------------------- 1 | # fio_planet: a package of Fiona CLI plugins from Planet Labs. 2 | 3 | """fio_planet: Fiona CLI plugins from Planet Labs.""" 4 | 5 | __version__ = "1.1.0" 6 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fio_planet import snuggs 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def add_snuggs(doctest_namespace): 8 | doctest_namespace["snuggs"] = snuggs 9 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.4.2 2 | mkdocs-autorefs==0.4.1 3 | mkdocs-click==0.8.0 4 | mkdocs-material==9.1.2 5 | mkdocs-material-extensions==1.1.1 6 | mkdocstrings==0.20.0 -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | mkdocs: 9 | configuration: mkdocs.yml 10 | 11 | python: 12 | install: 13 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /src/fio_planet/errors.py: -------------------------------------------------------------------------------- 1 | """Fio-planet exceptions.""" 2 | 3 | 4 | class PlanetError(Exception): 5 | """Base class for fio-planet exceptions.""" 6 | 7 | 8 | class ReduceError(PlanetError): 9 | """Raised when an expression does not reduce to a single object.""" 10 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Static checks 2 | on: [push, pull_request] 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-python@v4 9 | with: 10 | python-version: "3.10" 11 | - name: Run black and flake8 12 | run: | 13 | pip install --upgrade black flake8 mypy 14 | black src/fio_planet/ tests/ 15 | flake8 16 | mypy 17 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = 4 | py37,py38,py39,py310,py311 5 | 6 | [testenv] 7 | deps = 8 | pytest-cov 9 | commands = 10 | python -m pytest -v --cov fio_planet --cov-report term-missing --doctest-glob="*.md" 11 | 12 | [gh-actions] 13 | python = 14 | 3.7: py37 15 | 3.8: py38 16 | 3.9: py39 17 | 3.10: py310 18 | 3.11: py311 19 | 20 | [flake8] 21 | exclude = 22 | .coverage, 23 | .git, 24 | .github, 25 | .mypy_cache, 26 | .pytest_cache, 27 | .tox, 28 | .vscode, 29 | venv, 30 | 31 | ignore = E501,W503 32 | -------------------------------------------------------------------------------- /docs/images/logo.svg: -------------------------------------------------------------------------------- 1 | Untitled-5 -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | jobs: 4 | tests: 5 | name: tox on ${{ matrix.python-version }} 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-python@v4 13 | with: 14 | python-version: ${{ matrix.python-version }} 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | python -m pip install --upgrade tox tox-gh-actions 19 | - name: Run tox targets for ${{ matrix.python-version }} 20 | run: python -m tox 21 | -------------------------------------------------------------------------------- /tests/test_snuggs.py: -------------------------------------------------------------------------------- 1 | # Python module tests 2 | 3 | """Tests of the snuggs module.""" 4 | 5 | import pytest # type: ignore 6 | 7 | from fio_planet import snuggs 8 | 9 | 10 | @pytest.mark.parametrize("arg", ["''", "null", "false", 0]) 11 | def test_truth_false(arg): 12 | """Expression is not true.""" 13 | assert not snuggs.eval(f"(truth {arg})") 14 | 15 | 16 | @pytest.mark.parametrize("arg", ["'hi'", "true", 1]) 17 | def test_truth(arg): 18 | """Expression is true.""" 19 | assert snuggs.eval(f"(truth {arg})") 20 | 21 | 22 | @pytest.mark.parametrize("arg", ["''", "null", "false", 0]) 23 | def test_not(arg): 24 | """Expression is true.""" 25 | assert snuggs.eval(f"(not {arg})") 26 | -------------------------------------------------------------------------------- /tests/data/trio.seq: -------------------------------------------------------------------------------- 1 | {"geometry": {"coordinates": [3.869011402130127, 43.611401128587104], "type": "Point"}, "id": "0", "properties": {"name": "Le ch\u00e2teau d'eau"}, "type": "Feature"} 2 | {"geometry": {"coordinates": [[3.8645052909851074, 43.61172738574996], [3.868989944458008, 43.61140889663537]], "type": "LineString"}, "id": "1", "properties": {"aqueduct": "yes"}, "type": "Feature"} 3 | {"geometry": {"coordinates": [[[3.8684856891632085, 43.61205364114294], [3.8683247566223145, 43.6108340583545], [3.8685393333435054, 43.610748608951816], [3.871554136276245, 43.610577709782206], [3.871725797653198, 43.61063208684338], [3.8719189167022705, 43.61183613774427], [3.8684856891632085, 43.61205364114294]]], "type": "Polygon"}, "id": "2", "properties": {"architect": "Giral", "name": "promenade du Peyrou"}, "type": "Feature"} 4 | -------------------------------------------------------------------------------- /UNLICENSE.txt: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /docs/custom_theme/main.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "base.html" %} 3 | 4 | 5 | {% block libs %} 6 | 18 | {% endblock %} 19 | 20 | 21 | {% block footer %} 22 | 35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core>=3.2"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "fio_planet" 7 | dynamic = ["version", "description"] 8 | readme = "README.md" 9 | authors = [{name = "Sean Gillies"}] 10 | maintainers = [ 11 | {name = "Planet Developer Relations", email = "developers@planet.com"} 12 | ] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Programming Language :: Python :: 3", 16 | ] 17 | keywords = [ 18 | "GeoJSON", 19 | "GIS", 20 | "CLI", 21 | ] 22 | requires-python = ">=3.7" 23 | dependencies = [ 24 | "click", 25 | "fiona", 26 | "pyparsing>=3.0", 27 | "shapely>=2.0", 28 | ] 29 | 30 | [tool.flit.sdist] 31 | exclude = [".github/", ".gitignore"] 32 | 33 | [project.optional-dependencies] 34 | test = ["pytest-cov"] 35 | docs = ["mkdocs", "mkdocs-material", "mkdocs-click", "mkdocstrings"] 36 | 37 | [project.entry-points."fiona.fio_plugins"] 38 | map = "fio_planet.cli:map_cmd" 39 | filter = "fio_planet.cli:filter_cmd" 40 | reduce = "fio_planet.cli:reduce_cmd" 41 | 42 | [tool.mypy] 43 | mypy_path = "src" 44 | namespace_packages = true 45 | explicit_package_bases = true 46 | files = "src,tests" 47 | 48 | [tool.pytest.ini_options] 49 | filterwarnings = [ 50 | "error", 51 | "ignore:.*pkg_resources is deprecated as an API", 52 | "ignore:.*module \\'sre_constants\\' is deprecated", 53 | ] 54 | doctest_optionflags = "NORMALIZE_WHITESPACE" -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | 1.1.0 (2024-03-15) 5 | ------------------ 6 | 7 | - Release to the public domain. 8 | 9 | 1.0.1 (2023-04-21) 10 | ------------------ 11 | 12 | - Documentation has been updated to remove the --pre flag from pip usage, to 13 | note that fio-filter shadows fiona's original filter command, and note the 14 | existence of truth, not, and is functions. 15 | - The project now runs tests with warnings turned into errors, but ignores 16 | pkg_resources related deprecation warnings. Those warnings are caused by 17 | munch, a transitive dependency via fiona, and will be eliminated by fiona 18 | 1.9.4, coming in June 2023. 19 | 20 | 1.0.0 (2023-04-20) 21 | ------------------ 22 | 23 | This is the 1.0.0 release. There have been no changes since 1.0rc1. 24 | 25 | 1.0rc1 (2023-04-18) 26 | ------------------ 27 | 28 | - The truth, is, and not functions from operators have been added to 29 | the mapping of functions available in expressions. 30 | 31 | 1.0b1 (2023-04-13) 32 | ------------------ 33 | 34 | - Require pyparsing >= 3.0 (#29). 35 | - Added a documentation site hosted on Read the Docs (#26, #28, #32). 36 | 37 | 1.0a3 (2023-03-24) 38 | ------------------ 39 | 40 | - Add shapely.ops functions (#23). 41 | - Add builtin buffer, distance, length, simplify, and set_precision functions 42 | that allow projected computation of these values (#27). 43 | 44 | 1.0a2 (2023-03-01) 45 | ------------------ 46 | 47 | - Added a new builtin area function that allows projected area computation 48 | (#21). 49 | - Add short options -r for raw mode and -n for no input (#18). 50 | - The expression parser has been fixed so that functions with underscores and 51 | numbers in their names such as shapely.force_2d can be used (#20). 52 | 53 | 1.0a1 (2023-02-22) 54 | ------------------ 55 | 56 | This is the first pre-release and is almost feature complete. 57 | -------------------------------------------------------------------------------- /docs/commands.md: -------------------------------------------------------------------------------- 1 | Commands 2 | ======== 3 | 4 | The fio-planet packages adds three commands to the `fio` CLI from the Fiona 5 | package: `filter`, `map`, and `reduce`. 6 | 7 | !!! note 8 | 9 | fio-planet's `filter` command shadows, or overrides, Fiona's own `fio 10 | filter`. 11 | 12 | fio-filter 13 | ---------- 14 | 15 | For each feature read from stdin, fio-filter evaluates a pipeline of one or 16 | more steps described using methods from the Shapely library in Lisp-like 17 | expressions. If the pipeline expression evaluates to True, the feature passes 18 | through the filter. Otherwise the feature does not pass. 19 | 20 | For example, this pipeline expression 21 | 22 | ``` 23 | $ fio cat zip+https://s3.amazonaws.com/fiona-testing/coutwildrnp.zip \ 24 | | fio filter '< (distance g (Point -109.0 38.5)) 100' 25 | ``` 26 | 27 | lets through all features that are less than 100 meters from the given point 28 | and filters out all other features. 29 | 30 | fio-map 31 | ------- 32 | 33 | For each feature read from stdin, fio-map applies a transformation pipeline and 34 | writes a copy of the feature, containing the modified geometry, to stdout. For 35 | example, polygonal features can be roughly "cleaned" by using a `buffer g 0` 36 | pipeline. 37 | 38 | ``` 39 | $ fio cat zip+https://s3.amazonaws.com/fiona-testing/coutwildrnp.zip \ 40 | | fio map 'buffer g 0' 41 | ``` 42 | 43 | fio-reduce 44 | ---------- 45 | 46 | Given a sequence of GeoJSON features (RS-delimited or not) on stdin this prints 47 | a single value using a provided transformation pipeline. The set of geometries 48 | of the input features in the context of these expressions is named "c". 49 | 50 | For example, the pipeline expression 51 | 52 | ``` 53 | $ fio cat zip+https://s3.amazonaws.com/fiona-testing/coutwildrnp.zip \ 54 | | fio reduce 'unary_union c' 55 | ``` 56 | 57 | dissolves the geometries of input features. 58 | -------------------------------------------------------------------------------- /tests/data/trio.geojson: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [ 4 | { 5 | "type": "Feature", 6 | "properties": { 7 | "name": "Le château d'eau" 8 | }, 9 | "geometry": { 10 | "type": "Point", 11 | "coordinates": [ 12 | 3.869011402130127, 13 | 43.611401128587104 14 | ] 15 | } 16 | }, 17 | { 18 | "type": "Feature", 19 | "properties": { 20 | "aqueduct": "yes" 21 | }, 22 | "geometry": { 23 | "type": "LineString", 24 | "coordinates": [ 25 | [ 26 | 3.8645052909851074, 27 | 43.61172738574996 28 | ], 29 | [ 30 | 3.868989944458008, 31 | 43.61140889663537 32 | ] 33 | ] 34 | } 35 | }, 36 | { 37 | "type": "Feature", 38 | "properties": {"name": "promenade du Peyrou", "architect": "Giral"}, 39 | "geometry": { 40 | "type": "Polygon", 41 | "coordinates": [ 42 | [ 43 | [ 44 | 3.8684856891632085, 45 | 43.61205364114294 46 | ], 47 | [ 48 | 3.8683247566223145, 49 | 43.6108340583545 50 | ], 51 | [ 52 | 3.8685393333435054, 53 | 43.610748608951816 54 | ], 55 | [ 56 | 3.871554136276245, 57 | 43.610577709782206 58 | ], 59 | [ 60 | 3.871725797653198, 61 | 43.61063208684338 62 | ], 63 | [ 64 | 3.8719189167022705, 65 | 43.61183613774427 66 | ], 67 | [ 68 | 3.8684856891632085, 69 | 43.61205364114294 70 | ] 71 | ] 72 | ] 73 | } 74 | } 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT: -------------------------------------------------------------------------------- 1 | Contributor Code of Conduct 2 | --------------------------- 3 | 4 | As contributors and maintainers of this project, and in the interest of 5 | fostering an open and welcoming community, we pledge to respect all people who 6 | contribute through reporting issues, posting feature requests, updating 7 | documentation, submitting pull requests or patches, and other activities. 8 | 9 | We are committed to making participation in this project a harassment-free 10 | experience for everyone, regardless of level of experience, gender, gender 11 | identity and expression, sexual orientation, disability, personal appearance, 12 | body size, race, ethnicity, age, religion, or nationality. 13 | 14 | Examples of unacceptable behavior by participants include: 15 | 16 | * The use of sexualized language or imagery 17 | * Personal attacks 18 | * Trolling or insulting/derogatory comments 19 | * Public or private harassment 20 | * Publishing other's private information, such as physical or electronic 21 | addresses, without explicit permission 22 | * Other unethical or unprofessional conduct. 23 | 24 | Project maintainers have the right and responsibility to remove, edit, or 25 | reject comments, commits, code, wiki edits, issues, and other contributions 26 | that are not aligned to this Code of Conduct. By adopting this Code of Conduct, 27 | project maintainers commit themselves to fairly and consistently applying these 28 | principles to every aspect of managing this project. Project maintainers who do 29 | not follow or enforce the Code of Conduct may be permanently removed from the 30 | project team. 31 | 32 | This code of conduct applies both within project spaces and in public spaces 33 | when an individual is representing the project or its community. 34 | 35 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 36 | reported by opening an issue or contacting one or more of the project 37 | maintainers. 38 | 39 | This Code of Conduct is adapted from the `Contributor Covenant`_, version 40 | 1.2.0, available at http://contributor-covenant.org/version/1/2/0/ 41 | 42 | .. _Contributor Covenant: http://contributor-covenant.org 43 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: fio-planet 2 | site_url: https://github.com/planetlabs/fio-planet 3 | site_author: https://developers.planet.com 4 | site_description: >- 5 | A package of Fiona CLI plugin commands from Planet Labs PBC. 6 | repo_name: planetlabs/fio-planet 7 | repo_url: https://github.com/planetlabs/fio-planet 8 | edit_uri: "" 9 | 10 | theme: 11 | name: 'material' 12 | logo: 'images/Planet_primarymark_RGB_White.png' 13 | favicon: 'images/logo.svg' 14 | custom_dir: 'docs/custom_theme/' 15 | features: 16 | - navigation.tabs 17 | # - navigation.instant # Not compatible with i18n plugin 18 | - navigation.expand 19 | - navigation.indexes 20 | - navigation.top 21 | - navigation.sections 22 | - navigation.tracking 23 | - search.suggest 24 | - search.highlight 25 | - toc.follow 26 | - toc.integrate 27 | palette: 28 | - scheme: default 29 | primary: #007f99 30 | toggle: 31 | icon: octicons/moon-24 32 | name: Switch to dark mode 33 | - scheme: slate 34 | primary: #004352 35 | toggle: 36 | icon: octicons/sun-24 37 | name: Switch to light mode 38 | 39 | extra: 40 | company: 41 | name: "Planet Labs PBC" 42 | product: 43 | name: "Fio CLI plugins from Planet" 44 | 45 | extra_css: 46 | - stylesheets/extra.css 47 | 48 | plugins: 49 | - search 50 | - mkdocstrings: 51 | handlers: 52 | python: 53 | rendering: 54 | show_root_heading: true 55 | selection: 56 | inherited_members: true 57 | filters: 58 | - "!^_" # exlude all members starting with _ 59 | - "^__init__$" # but always include __init__ modules and methods 60 | watch: 61 | - src/fio_planet 62 | - docs/custom_theme/ 63 | 64 | nav: 65 | - "Home": 'index.md' 66 | - "Commands": 'commands.md' 67 | - "Expressions": 'expressions.md' 68 | - "Topics": 'topics' 69 | 70 | markdown_extensions: 71 | - pymdownx.inlinehilite 72 | - pymdownx.snippets: 73 | dedent_subsections: True 74 | - pymdownx.highlight 75 | - pymdownx.superfences 76 | - mkdocs-click 77 | - admonition 78 | - toc: 79 | permalink: True 80 | -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | :root > * { 2 | --md-primary-fg-color: #007f99; 3 | --md-primary-fg-color--light: #99d8e0; 4 | --md-primary-fg-color--dark: #004352; 5 | --md-accent-fg-color : #db3a25; 6 | --md-primary-bg-color: #f0f0f0; 7 | --md-primary-bg-color--light: #ffffff; 8 | --md-primary-bg-color--dark: #242e33; 9 | --md-footer-bg-color: var(--md-primary-bg-color--dark); 10 | } 11 | 12 | [data-md-color-scheme="slate"]{ 13 | --md-default-bg-color:var(--md-primary-bg-color--dark); 14 | --md-code-bg-color: #21282B; 15 | } 16 | 17 | .toc ul { 18 | list-style: none; 19 | } 20 | 21 | section.md-header{position:initial} 22 | section.md-main__inner{margin:0} 23 | section.md-content{display:none} 24 | @media screen and (min-width:60em){ 25 | section.md-sidebar--secondary{display:none} 26 | } 27 | @media screen and (min-width:76.25em){ 28 | section.md-sidebar--primary{display:none} 29 | } 30 | 31 | section.mdx-container { 32 | background-image: linear-gradient(180deg, #004352, #007f99, transparent); 33 | position: relative; 34 | } 35 | 36 | section.mdx-container::before{ 37 | 38 | content: ' '; 39 | display: block; 40 | position: absolute; 41 | left: 0; 42 | top: 0; 43 | width: 100%; 44 | height: 100%; 45 | opacity: 0.5; 46 | background-image: url(../images/SatelliteOrbits-main100Dove4RapidEye6Skysat-100mm01-pulsing.svg); 47 | background-repeat: no-repeat; 48 | background-position: right 10% bottom 45%; 49 | background-size: auto; 50 | 51 | } 52 | 53 | 54 | .mdx-hero { 55 | position: relative; 56 | height: 90vh; 57 | width: 45vw; 58 | padding: 5vw 2vw; 59 | color: var(--md-primary-fg-color--light); 60 | } 61 | 62 | .mdx-hero__content > *{ 63 | color: var(--md-primary-fg-color--light) !important; 64 | } 65 | .mdx-hero__content > .md-button--secondary{ 66 | background-color:var(--md-primary-fg-color--dark); 67 | border-color:var(--md-primary-fg-color--dark); 68 | color:var(--md-primary-bg-color); 69 | } 70 | .mdx-hero__content > .md-button--primary{ 71 | border-color:var(--md-primary-fg-color--light); 72 | } 73 | 74 | .mdx-hero__content > p { 75 | margin-right: 2.5vw; 76 | } 77 | 78 | @media screen and (min-width:76.25em){ 79 | section.md-sidebar--primary{display:none} 80 | } 81 | @media screen and (max-width:1040px){ 82 | .mdx-hero { 83 | width: 90vw; 84 | margin: 0 auto; 85 | } 86 | } 87 | 88 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # CLI tests 2 | 3 | from click.testing import CliRunner 4 | 5 | from fiona.fio.main import main_group # type: ignore 6 | import pytest # type: ignore 7 | 8 | 9 | def test_map_count(): 10 | """fio-map prints correct number of results.""" 11 | with open("tests/data/trio.seq") as seq: 12 | data = seq.read() 13 | 14 | # Define our map arg using a mkdocs snippet. 15 | arg = """ 16 | --8<-- [start:map] 17 | centroid (buffer g 1.0) 18 | --8<-- [end:map] 19 | """.splitlines()[ 20 | 2 21 | ].strip() 22 | 23 | runner = CliRunner() 24 | result = runner.invoke( 25 | main_group, 26 | ["map", arg], # "centroid (buffer g 1.0)"], 27 | input=data, 28 | ) 29 | 30 | assert result.exit_code == 0 31 | assert result.output.count('"type": "Point"') == 3 32 | 33 | 34 | @pytest.mark.parametrize("raw_opt", ["--raw", "-r"]) 35 | def test_reduce_area(raw_opt): 36 | """Reduce features to their (raw) area.""" 37 | with open("tests/data/trio.seq") as seq: 38 | data = seq.read() 39 | 40 | runner = CliRunner() 41 | result = runner.invoke( 42 | main_group, 43 | ["reduce", raw_opt, "area (unary_union c) :projected false"], 44 | input=data, 45 | ) 46 | assert result.exit_code == 0 47 | assert 0 < float(result.output) < 1e-5 48 | 49 | 50 | def test_reduce_union(): 51 | """Reduce features to one single feature.""" 52 | with open("tests/data/trio.seq") as seq: 53 | data = seq.read() 54 | 55 | # Define our reduce command using a mkdocs snippet. 56 | arg = """ 57 | --8<-- [start:reduce] 58 | unary_union c 59 | --8<-- [end:reduce] 60 | """.splitlines()[ 61 | 2 62 | ].strip() 63 | 64 | runner = CliRunner() 65 | result = runner.invoke(main_group, ["reduce", arg], input=data) 66 | assert result.exit_code == 0 67 | assert result.output.count('"type": "Polygon"') == 1 68 | assert result.output.count('"type": "LineString"') == 1 69 | assert result.output.count('"type": "GeometryCollection"') == 1 70 | 71 | 72 | def test_reduce_union_zip_properties(): 73 | """Reduce features to one single feature, zipping properties.""" 74 | with open("tests/data/trio.seq") as seq: 75 | data = seq.read() 76 | 77 | runner = CliRunner() 78 | result = runner.invoke( 79 | main_group, ["reduce", "--zip-properties", "unary_union c"], input=data 80 | ) 81 | assert result.exit_code == 0 82 | assert result.output.count('"type": "Polygon"') == 1 83 | assert result.output.count('"type": "LineString"') == 1 84 | assert result.output.count('"type": "GeometryCollection"') == 1 85 | assert ( 86 | """"name": ["Le ch\\u00e2teau d\'eau", "promenade du Peyrou"]""" 87 | in result.output 88 | ) 89 | 90 | 91 | def test_filter(): 92 | """Filter features by distance.""" 93 | with open("tests/data/trio.seq") as seq: 94 | data = seq.read() 95 | 96 | # Define our reduce command using a mkdocs snippet. 97 | arg = """ 98 | --8<-- [start:filter] 99 | < (distance g (Point 4 43)) 62.5E3 100 | --8<-- [end:filter] 101 | """.splitlines()[ 102 | 2 103 | ].strip() 104 | 105 | runner = CliRunner() 106 | result = runner.invoke( 107 | main_group, 108 | ["filter", arg], 109 | input=data, 110 | catch_exceptions=False, 111 | ) 112 | assert result.exit_code == 0 113 | assert result.output.count('"type": "Polygon"') == 1 114 | 115 | 116 | @pytest.mark.parametrize("opts", [["--no-input", "--raw"], ["-rn"]]) 117 | def test_map_no_input(opts): 118 | runner = CliRunner() 119 | result = runner.invoke(main_group, ["map"] + opts + ["(Point 4 43)"]) 120 | assert result.exit_code == 0 121 | assert result.output.count('"type": "Point"') == 1 122 | -------------------------------------------------------------------------------- /docs/topics/simplification.md: -------------------------------------------------------------------------------- 1 | Sizing up and simplifying shapes with fio-planet 2 | ================================================ 3 | 4 | Here are a few examples related to the Planet Developers Blog deep dive into 5 | [simplifying areas of interest](https://developers.planet.com/blog/2022/Dec/15/simplifying-your-complex-area-of-interest-a-planet-developers-deep-dive/). 6 | The examples use a 25-feature shapefile. You can get it from [rmnp.zip](https://github.com/planetlabs/fio-planet/files/10045442/rmnp.zip) or access 7 | it in a streaming fashion as shown in the examples below. 8 | 9 | Counting vertices in a feature collection 10 | ----------------------------------------- 11 | 12 | The builtin `vertex_count` function, in conjunction with fio-map's `--raw` 13 | option, prints out the number of vertices in each feature. The default for 14 | fio-map is to wrap the result of every evaluated expression in a GeoJSON 15 | feature; `--raw` disables this. The program jq provides a nice way of summing 16 | the sequence of numbers. 17 | 18 | ``` 19 | fio cat zip+https://github.com/planetlabs/fio-planet/files/10045442/rmnp.zip \ 20 | | fio map 'vertex_count g' --raw \ 21 | | jq -s 'add' 22 | 28915 23 | ``` 24 | 25 | Here's what the RMNP wilderness patrol zones features look like in QGIS. 26 | 27 | ![](https://user-images.githubusercontent.com/33697/202820493-2cae58f4-a968-4078-8a60-ba7e2cbe0434.png) 28 | 29 | Counting vertices after making a simplified buffer 30 | -------------------------------------------------- 31 | 32 | One traditional way of simplifying an area of interest is to buffer and 33 | simplify. There's no need to use jq here because fio-reduce prints out a 34 | sequence of exactly one feature. The effectiveness of this method depends a bit 35 | on the nature of the data, especially the distance between vertices. The total 36 | length of the perimeters of all zones is 889 kilometers. 37 | 38 | ``` 39 | fio cat zip+https://github.com/planetlabs/fio-planet/files/10045442/rmnp.zip \ 40 | | fio map 'length g' --raw \ 41 | | jq -s 'add' 42 | 889332.0900809917 43 | ``` 44 | 45 | The mean distance between vertices on the edges of zones is 889332 / 28915, or 46 | 30.7 meters. You need to buffer and simplify by this value or more to get a 47 | significant reduction in the number of vertices. Choosing 40 as a buffer 48 | distance and simplification tolerance results in a shape with 469 vertices. 49 | It's a suitable area of interest for Planet APIs that require this number to be 50 | less than 500. 51 | 52 | ``` 53 | fio cat zip+https://github.com/planetlabs/fio-planet/files/10045442/rmnp.zip \ 54 | | fio reduce 'unary_union c' \ 55 | | fio map 'simplify (buffer g 40) 40' \ 56 | | fio map 'vertex_count g' --raw 57 | 469 58 | ``` 59 | 60 | ![](https://user-images.githubusercontent.com/33697/202821086-5bfd4437-3c42-420e-84cf-d3e1287d2d8c.png) 61 | 62 | Counting vertices after dissolving convex hulls of features 63 | ----------------------------------------------------------- 64 | 65 | Convex hulls are an easy means of simplification. There are no distance 66 | parameters to tweak. The `--dump-parts` option of fio-map turns the parts of 67 | multi-part features into separate single-part features. This is one of the ways 68 | in which fio-map can multiply its inputs, printing out more features than it 69 | receives. 70 | 71 | ``` 72 | fio cat zip+https://github.com/planetlabs/fio-planet/files/10045442/rmnp.zip \ 73 | | fio map 'convex_hull g' --dump-parts \ 74 | | fio reduce 'unary_union c' \ 75 | | fio map 'vertex_count g' --raw 76 | 157 77 | ``` 78 | 79 | ![](https://user-images.githubusercontent.com/33697/202820595-491c590c-0f5a-4cdb-89de-7cd2067cbf90.png) 80 | 81 | Counting vertices after dissolving concave hulls of features 82 | ------------------------------------------------------------ 83 | 84 | Convex hulls simplify, but also dilate concave areas of interest. They fill the 85 | "bays", so to speak, and this can be undesirable. Concave hulls do a better job 86 | at preserving the concave nature of a shape and result in a smaller increase of 87 | area. 88 | 89 | ``` 90 | fio cat zip+https://github.com/planetlabs/fio-planet/files/10045442/rmnp.zip \ 91 | | fio map 'concave_hull g :ratio 0.4' --dump-parts \ 92 | | fio reduce 'unary_union c' \ 93 | | fio map 'vertex_count g' --raw 94 | 301 95 | ``` 96 | 97 | ![](https://user-images.githubusercontent.com/33697/218189621-446b743e-daba-4e3c-bc24-7ce74771fb8a.png) 98 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | fio-planet 2 | ========== 3 | 4 | A package of [Fiona CLI](https://fiona.readthedocs.io/en/stable/cli.html) 5 | plugins from Planet Labs. 6 | 7 | These CLI commands are for creating Unix pipelines that manipulate streams of 8 | GeoJSON features. Such pipelines provide a subset of the functionality of more 9 | complicated tools such as GeoPandas, PostGIS, or QGIS, and are intended for use 10 | with streams of hundreds of features, where the overhead of JSON serialization 11 | between pieces of a pipeline is tolerable. 12 | 13 | Installation 14 | ------------ 15 | 16 | ``` 17 | python -m pip install --user fio-planet 18 | ``` 19 | 20 | Usage 21 | ----- 22 | 23 | fio-planet adds `filter`, `map`, and `reduce` commands to Fiona's `fio` 24 | program. These commands afford some of the capabilities of spatial SQL, but act 25 | on features of a GeoJSON feature sequence instead of rows of a spatial RDBMS 26 | table. fio-filter decimates a seqence of features, fio-map multiplies and 27 | transforms features, and fio-reduce turns a sequence of many features into a 28 | sequence of exactly one. In combination, many transformations are possible. 29 | 30 | Expressions take the form of parenthesized lists that may contain other 31 | expressions. The first item in a list is the name of a function or method, or 32 | an expression that evaluates to a function. The second item is the function's 33 | first argument or the object to which the method is bound. The remaining list 34 | items are the positional and keyword arguments for the named function or 35 | method. The list of functions and callables available in an expression 36 | includes: 37 | 38 | * Python operators such as `+`, `/`, and `<=` plus `truth`, `not`, and `is` 39 | * Python builtins such as `dict`, `list`, and `map` 40 | * From functools: `reduce`. 41 | * All public functions from itertools, e.g. `islice`, and `repeat` 42 | * All functions importable from Shapely 2.0, e.g. `Point`, and 43 | `unary_union` 44 | * All methods of Shapely geometry classes. 45 | 46 | Here's an expression that evaluates to a Shapely Point instance. 47 | 48 | ```lisp 49 | (Point 0 0) 50 | ``` 51 | 52 | `Point` is a callable instance constructor and the pair of `0` values are 53 | positional arguments. Note that the outermost parentheses of an expression are 54 | optional. 55 | 56 | Here's an expression that evaluates to a Polygon, using `buffer`. 57 | 58 | ```lisp 59 | buffer (Point 0 0) :distance 1.0 60 | ``` 61 | 62 | The inner expression `(Point 0 0)` evaluates to a Shapely Point instance, 63 | `buffer` evaluates to `shapely.buffer()`, and `:distance 1.0` assigns a value 64 | of 1.0 to that method's `distance` keyword argument. 65 | 66 | In a fio-planet expression, all coordinates and geometry objects are in the 67 | `OGC:CRS84` reference system, like GeoJSON. However, function arguments related 68 | to length or area, such as buffer's distance argument, are understood to have 69 | units of meters. 70 | 71 | fio-filter and fio-map evaluate expressions in the context of a GeoJSON feature 72 | and its geometry attribute. These are named `f` and `g`. For example, here is 73 | an expression that tests whether the input feature is within 50 meters of the 74 | given point. 75 | 76 | ```lisp 77 | <= (distance g (Point -105.0 39.753056)) 50.0 78 | ``` 79 | 80 | fio-reduce evaluates expressions in the context of the sequence of all input 81 | geometries, named `c`. For example, this expression dissolves input 82 | geometries using Shapely's `unary_union`. 83 | 84 | ```lisp 85 | unary_union c 86 | ``` 87 | 88 | Support 89 | ------- 90 | 91 | For usage help, please use the project discussion forum or email 92 | developers@planet.com. 93 | 94 | If you think you've found a bug, please use the project issue tracker. 95 | 96 | Roadmap 97 | ------- 98 | 99 | Version 1.0 adds `filter`, `map`, and `reduce` to Fiona's `fio` CLI. 100 | 101 | Note that there are no conditional forms in 1.0's expressions. The project will 102 | likely add a `cond` after 1.0. 103 | 104 | Contributing 105 | ------------ 106 | 107 | Before 1.0 the project is looking for feedback on the existing commands more 108 | than it is looking for new commands. 109 | 110 | The project uses black, flake8, mypy, and tox for static checks 111 | and testing. 112 | 113 | ``` 114 | black src tests && flake8 && mypy && tox 115 | ``` 116 | 117 | Authors and acknowledgment 118 | -------------------------- 119 | 120 | Contributors to this project are 121 | 122 | * Sean Gillies 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | fio-planet 2 | ========== 3 | 4 | A package of Fiona CLI plugins from Planet Labs. 5 | 6 | [![](https://github.com/planetlabs/fio-planet/actions/workflows/check.yml/badge.svg)](https://github.com/planetlabs/fio-planet/actions/workflows/check.yml) 7 | [![](https://github.com/planetlabs/fio-planet/actions/workflows/test.yml/badge.svg)](https://github.com/planetlabs/fio-planet/actions/workflows/test.yml) 8 | [![Documentation Status](https://readthedocs.org/projects/fio-planet/badge/?version=latest)](https://fio-planet.readthedocs.io/en/latest/?badge=latest) 9 | 10 | These CLI commands are for creating Unix pipelines that manipulate streams of 11 | GeoJSON features. Such pipelines provide a subset of the functionality of more 12 | complicated tools such as GeoPandas, PostGIS, or QGIS, and are intended for use 13 | with streams of hundreds of features, where the overhead of JSON serialization 14 | between pieces of a pipeline is tolerable. 15 | 16 | 17 | Installation 18 | ------------ 19 | 20 | ``` 21 | python -m pip install --user fio-planet 22 | ``` 23 | 24 | Example 25 | ------- 26 | 27 | If you've been in the GIS business for more than a day, you've seen 28 | topologically invalid GeoJSON. Polygons with tiny bowties and rings that touch, 29 | that kind of thing. A stream of GeoJSON features can be made topologically 30 | valid with the following fio-map command. 31 | 32 | ``` 33 | fio cat zip+https://s3.amazonaws.com/fiona-testing/coutwildrnp.zip \ 34 | | fio map 'make_valid g' 35 | ``` 36 | 37 | Usage 38 | ----- 39 | 40 | fio-planet adds `filter`, `map`, and `reduce` commands to Fiona's `fio` 41 | program. These commands afford some of the capabilities of spatial SQL, but act 42 | on features of a GeoJSON feature sequence instead of rows of a spatial RDBMS 43 | table. fio-filter decimates a seqence of features, fio-map multiplies and 44 | transforms features, and fio-reduce turns a sequence of many features into a 45 | sequence of exactly one. In combination, many transformations are possible. 46 | 47 | Expressions take the form of parenthesized lists that may contain other 48 | expressions. The first item in a list is the name of a function or method, or 49 | an expression that evaluates to a function. The second item is the function's 50 | first argument or the object to which the method is bound. The remaining list 51 | items are the positional and keyword arguments for the named function or 52 | method. The list of functions and callables available in an expression 53 | includes: 54 | 55 | * Python operators such as `+`, `/`, and `<=` plus `truth`, `not`, and `is` 56 | * Python builtins such as `dict`, `list`, and `map` 57 | * All public functions from itertools, e.g. `islice`, and `repeat` 58 | * All functions importable from Shapely 2.0, e.g. `Point`, and `unary_union` 59 | * All methods of Shapely geometry classes 60 | * Functions specific to fio-planet 61 | 62 | Here's an expression that evaluates to a Shapely Point instance. 63 | 64 | ```lisp 65 | (Point 0 0) 66 | ``` 67 | 68 | `Point` is a callable instance constructor and the pair of `0` values are 69 | positional arguments. Note that the outermost parentheses of an expression are 70 | optional. 71 | 72 | Here's an expression that evaluates to a Polygon, using `buffer`. 73 | 74 | ```lisp 75 | buffer (Point 0 0) :distance 1.0 76 | ``` 77 | 78 | The inner expression `(Point 0 0)` evaluates to a Shapely Point instance, 79 | `buffer` evaluates to `shapely.buffer()`, and `:distance 1.0` assigns a value 80 | of 1.0 to that method's `distance` keyword argument. 81 | 82 | In a fio-planet expression, all coordinates and geometry objects are in the 83 | `OGC:CRS84` reference system, like GeoJSON. However, function arguments related 84 | to length or area, such as buffer's distance argument, are understood to have 85 | units of meters. 86 | 87 | fio-filter and fio-map evaluate expressions in the context of a GeoJSON feature 88 | and its geometry attribute. These are named `f` and `g`. For example, here is 89 | an expression that tests whether the input feature is within 50 meters of the 90 | given point. 91 | 92 | ```lisp 93 | <= (distance g (Point -105.0 39.753056)) 50.0 94 | ``` 95 | 96 | fio-reduce evaluates expressions in the context of the sequence of all input 97 | geometries, named `c`. For example, this expression dissolves input 98 | geometries using Shapely's `unary_union`. 99 | 100 | ```lisp 101 | unary_union c 102 | ``` 103 | 104 | Support 105 | ------- 106 | 107 | Documentation is hosted at Read the Docs: https://fio-planet.readthedocs.io/en/latest/. 108 | 109 | For usage help, please use the project discussion forum or email 110 | developers@planet.com. 111 | 112 | If you think you've found a bug, please use the project issue tracker. 113 | 114 | Roadmap 115 | ------- 116 | 117 | Version 1.0 adds `filter`, `map`, and `reduce` to Fiona's `fio` CLI. 118 | 119 | Note that there are no conditional forms in 1.0's expressions. The project will 120 | likely add a `cond` after 1.0. 121 | 122 | Contributing 123 | ------------ 124 | 125 | Before 1.0 the project is looking for feedback on the existing commands more 126 | than it is looking for new commands. 127 | 128 | The project uses black, flake8, mypy, and tox for static checks 129 | and testing. 130 | 131 | ``` 132 | black src tests && flake8 && mypy && tox 133 | ``` 134 | 135 | Authors and acknowledgment 136 | -------------------------- 137 | 138 | Contributors to this project are 139 | 140 | * Sean Gillies 141 | -------------------------------------------------------------------------------- /docs/expressions.md: -------------------------------------------------------------------------------- 1 | Expressions and functions 2 | ========================= 3 | 4 | Expressions take the form of parenthesized lists that may contain other 5 | expressions. The first item in a list is the name of a function or method, or 6 | an expression that evaluates to a function. The second item is the function's 7 | first argument or the object to which the method is bound. The remaining list 8 | items are the positional and keyword arguments for the named function or 9 | method. The list of functions and callables available in an expression 10 | includes: 11 | 12 | * Python operators such as `+`, `/`, and `<=` 13 | * Python builtins such as `dict`, `list`, and `map` 14 | * All public functions from itertools, e.g. `islice`, and `repeat` 15 | * All functions importable from Shapely 2.0, e.g. `Point`, and `unary_union` 16 | * All methods of Shapely geometry classes 17 | * Functions specific to fio-planet 18 | 19 | Expressions are evaluated by `fio_planet.features.snuggs.eval()`. Let's look at 20 | some examples using that function. 21 | 22 | !!! note 23 | 24 | The outer parentheses are not optional within `snuggs.eval()`. 25 | 26 | !!! note 27 | 28 | `snuggs.eval()` does not use Python's builtin `eval()` but isn't intended 29 | to be a secure computing environment. Expressions which access the 30 | computer's filesystem and create new processes are possible. 31 | 32 | ## Builtin Python functions 33 | 34 | `bool`: 35 | 36 | ```python 37 | >>> snuggs.eval('(bool 0)') 38 | False 39 | 40 | ``` 41 | 42 | `range`: 43 | 44 | ```python 45 | >>> snuggs.eval('(range 1 4)') 46 | range(1, 4) 47 | 48 | ``` 49 | 50 | `list`: 51 | 52 | ```python 53 | >>> snuggs.eval('(list (range 1 4))') 54 | [1, 2, 3] 55 | 56 | ``` 57 | 58 | Values can be bound to names for use in expressions. 59 | 60 | ```python 61 | >>> snuggs.eval('(list (range start stop))', start=0, stop=5) 62 | [0, 1, 2, 3, 4] 63 | 64 | ``` 65 | 66 | ## Itertools functions 67 | 68 | Here's an example of using `itertools.repeat()`. 69 | 70 | ```python 71 | >>> snuggs.eval('(list (repeat "*" times))', times=6) 72 | ['*', '*', '*', '*', '*', '*'] 73 | 74 | ``` 75 | 76 | ## Shapely functions 77 | 78 | Here's an expression that evaluates to a Shapely Point instance. 79 | 80 | ```python 81 | >>> snuggs.eval('(Point 0 0)') 82 | 83 | 84 | ``` 85 | 86 | The expression below evaluates to a MultiPoint instance. 87 | 88 | ```python 89 | >>> snuggs.eval('(union (Point 0 0) (Point 1 1))') 90 | 91 | 92 | ``` 93 | 94 | ## Functions specific to fio-planet 95 | 96 | The fio-planet package introduces four new functions not available in Python's 97 | standard library, Fiona, or Shapely: `collect`, `dump`, `identity`, and 98 | `vertex_count`. 99 | 100 | The `collect` function turns a list of geometries into a geometry collection 101 | and `dump` does the inverse, turning a geometry collection into a sequence of 102 | geometries. 103 | 104 | ```python 105 | >>> snuggs.eval('(collect (Point 0 0) (Point 1 1))') 106 | 107 | >>> snuggs.eval('(list (dump (collect (Point 0 0) (Point 1 1))))') 108 | [, ] 109 | 110 | ``` 111 | 112 | The `identity` function returns its single argument. 113 | 114 | ```python 115 | >>> snuggs.eval('(identity 42)') 116 | 42 117 | 118 | ``` 119 | 120 | To count the number of vertices in a geometry, use `vertex_count`. 121 | 122 | ```python 123 | >>> snuggs.eval('(vertex_count (Point 0 0))') 124 | 1 125 | 126 | ``` 127 | 128 | The `area`, `buffer`, `distance`, `length`, `simplify`, and `set_precision` 129 | functions shadow, or override, functions from the shapely module. They 130 | automatically reproject geometry objects from their natural coordinate 131 | reference system (CRS) of `OGC:CRS84` to `EPSG:6933` so that the shapes can be 132 | measured or modified using meters as units. 133 | 134 | `buffer` dilates (or erodes) a given geometry, with coordinates in decimal 135 | longitude and latitude degrees, by a given distance in meters. 136 | 137 | ```python 138 | >>> snuggs.eval('(buffer (Point 0 0) :distance 100)') 139 | 140 | 141 | ``` 142 | 143 | The `area` and `length` of this polygon have units of square meter and meter. 144 | 145 | ```python 146 | >>> snuggs.eval('(area (buffer (Point 0 0) :distance 100))') 147 | 31214.451487413342 148 | >>> snuggs.eval('(length (buffer (Point 0 0) :distance 100))') 149 | 627.3096977558143 150 | 151 | ``` 152 | 153 | The `distance` between two geometries is in meters. 154 | 155 | ```python 156 | >>> snuggs.eval('(distance (Point 0 0) (Point 0.1 0.1))') 157 | 15995.164946207413 158 | 159 | ``` 160 | 161 | A geometry can be simplified to a tolerance value in meters using `simplify`. 162 | There are more examples of this function under 163 | [topics:simplification](topics/simplification/). 164 | 165 | ```python 166 | >>> snuggs.eval('(simplify (buffer (Point 0 0) :distance 100) :tolerance 100)') 167 | 168 | 169 | ``` 170 | 171 | The `set_precision` function snaps a geometry to a fixed precision grid with a 172 | size in meters. 173 | 174 | ```python 175 | >>> snuggs.eval('(set_precision (Point 0.001 0.001) :grid_size 500)') 176 | 177 | 178 | ``` 179 | 180 | ## Feature and geometry context for expressions 181 | 182 | `fio-filter` and `fio-map` evaluate expressions in the context of a GeoJSON 183 | feature and its geometry attribute. These are named `f` and `g`. For example, 184 | here is an expression that tests whether the input feature is within 62.5 185 | kilometers of the given point. 186 | 187 | ```lisp 188 | --8<-- "tests/test_cli.py:filter" 189 | ``` 190 | 191 | `fio-reduce` evaluates expressions in the context of the sequence of all input 192 | geometries, named `c`. For example, this expression dissolves input 193 | geometries using Shapely's `unary_union`. 194 | 195 | ```lisp 196 | --8<-- "tests/test_cli.py:reduce" 197 | ``` 198 | -------------------------------------------------------------------------------- /src/fio_planet/cli.py: -------------------------------------------------------------------------------- 1 | # cli.py: command line interface. 2 | 3 | """Fiona CLI command plugins.""" 4 | 5 | from collections import defaultdict 6 | from copy import copy 7 | import itertools 8 | import json 9 | 10 | import click 11 | from cligj import use_rs_opt # type: ignore 12 | from fiona.fio.helpers import obj_gen # type: ignore 13 | 14 | from .features import map_feature, reduce_features 15 | 16 | 17 | @click.command( 18 | "map", 19 | short_help="Map a pipeline expression over GeoJSON features.", 20 | ) 21 | @click.argument("pipeline") 22 | @click.option( 23 | "--raw", 24 | "-r", 25 | is_flag=True, 26 | default=False, 27 | help="Print raw result, do not wrap in a GeoJSON Feature.", 28 | ) 29 | @click.option( 30 | "--no-input", 31 | "-n", 32 | is_flag=True, 33 | default=False, 34 | help="Do not read input from stream.", 35 | ) 36 | @click.option( 37 | "--dump-parts", 38 | is_flag=True, 39 | default=False, 40 | help="Dump parts of geometries to create new inputs before evaluating pipeline.", 41 | ) 42 | @use_rs_opt 43 | def map_cmd(pipeline, raw, no_input, dump_parts, use_rs): 44 | """Map a pipeline expression over GeoJSON features. 45 | 46 | Given a sequence of GeoJSON features (RS-delimited or not) on stdin 47 | this prints copies with geometries that are transformed using a 48 | provided transformation pipeline. In "raw" output mode, this 49 | command prints pipeline results without wrapping them in a feature 50 | object. 51 | 52 | The pipeline is a string that, when evaluated by fio-map, produces 53 | a new geometry object. The pipeline consists of expressions in the 54 | form of parenthesized lists that may contain other expressions. 55 | The first item in a list is the name of a function or method, or an 56 | expression that evaluates to a function. The second item is the 57 | function's first argument or the object to which the method is 58 | bound. The remaining list items are the positional and keyword 59 | arguments for the named function or method. The names of the input 60 | feature and its geometry in the context of these expressions are 61 | "f" and "g". 62 | 63 | For example, this pipeline expression 64 | 65 | '(simplify (buffer g 100.0) 5.0)' 66 | 67 | buffers input geometries and then simplifies them so that no 68 | vertices are closer than 5 units. Keyword arguments for the shapely 69 | methods are supported. A keyword argument is preceded by ':' and 70 | followed immediately by its value. For example: 71 | 72 | '(simplify g 5.0 :preserve_topology true)' 73 | 74 | and 75 | 76 | '(buffer g 100.0 :resolution 8 :join_style 1)' 77 | 78 | Numerical and string arguments may be replaced by expressions. The 79 | buffer distance could be a function of a geometry's area. 80 | 81 | '(buffer g (/ (area g) 100.0))' 82 | 83 | """ 84 | if no_input: 85 | features = [None] 86 | else: 87 | stdin = click.get_text_stream("stdin") 88 | features = obj_gen(stdin) 89 | 90 | for feat in features: 91 | for i, value in enumerate(map_feature(pipeline, feat, dump_parts=dump_parts)): 92 | if use_rs: 93 | click.echo("\x1e", nl=False) 94 | if raw: 95 | click.echo(json.dumps(value)) 96 | else: 97 | new_feat = copy(feat) 98 | new_feat["id"] = f"{feat.get('id', '0')}:{i}" 99 | new_feat["geometry"] = value 100 | click.echo(json.dumps(new_feat)) 101 | 102 | 103 | @click.command( 104 | "filter", 105 | short_help="Evaluate pipeline expressions to filter GeoJSON features.", 106 | ) 107 | @click.argument("pipeline") 108 | @use_rs_opt 109 | def filter_cmd(pipeline, use_rs): 110 | """Evaluate pipeline expressions to filter GeoJSON features. 111 | 112 | The pipeline is a string that, when evaluated, gives a new value 113 | for each input feature. If the value evaluates to True, the feature 114 | passes through the filter. Otherwise the feature does not pass. 115 | 116 | The pipeline consists of expressions in the 117 | form of parenthesized lists that may contain other expressions. 118 | The first item in a list is the name of a function or method, or an 119 | expression that evaluates to a function. The second item is the 120 | function's first argument or the object to which the method is 121 | bound. The remaining list items are the positional and keyword 122 | arguments for the named function or method. The names of the input 123 | feature and its geometry in the context of these expressions are 124 | "f" and "g". 125 | 126 | For example, this pipeline expression 127 | 128 | '(< (distance g (Point 4 43)) 1)' 129 | 130 | lets through all features that are less than one unit from the 131 | given point and filters out all other features. 132 | 133 | """ 134 | stdin = click.get_text_stream("stdin") 135 | features = obj_gen(stdin) 136 | 137 | for feat in features: 138 | for value in map_feature(pipeline, feat): 139 | if value: 140 | if use_rs: 141 | click.echo("\x1e", nl=False) 142 | click.echo(json.dumps(feat)) 143 | 144 | 145 | @click.command("reduce", short_help="Reduce a stream of GeoJSON features to one value.") 146 | @click.argument("pipeline") 147 | @click.option( 148 | "--raw", 149 | "-r", 150 | is_flag=True, 151 | default=False, 152 | help="Print raw result, do not wrap in a GeoJSON Feature.", 153 | ) 154 | @use_rs_opt 155 | @click.option( 156 | "--zip-properties", 157 | is_flag=True, 158 | default=False, 159 | help="Zip the items of input feature properties together for output.", 160 | ) 161 | def reduce_cmd(pipeline, raw, use_rs, zip_properties): 162 | """Reduce a stream of GeoJSON features to one value. 163 | 164 | Given a sequence of GeoJSON features (RS-delimited or not) on stdin 165 | this prints a single value using a provided transformation pipeline. 166 | 167 | The pipeline is a string that, when evaluated, produces 168 | a new geometry object. The pipeline consists of expressions in the 169 | form of parenthesized lists that may contain other expressions. 170 | The first item in a list is the name of a function or method, or an 171 | expression that evaluates to a function. The second item is the 172 | function's first argument or the object to which the method is 173 | bound. The remaining list items are the positional and keyword 174 | arguments for the named function or method. The set of geometries 175 | of the input features in the context of these expressions is named 176 | "c". 177 | 178 | For example, the pipeline expression 179 | 180 | '(unary_union c)' 181 | 182 | dissolves the geometries of input features. 183 | 184 | To keep the properties of input features while reducing them to a 185 | single feature, use the --zip-properties flag. The properties of the 186 | input features will surface in the output feature as lists 187 | containing the input values. 188 | 189 | """ 190 | stdin = click.get_text_stream("stdin") 191 | features = (feat for feat in obj_gen(stdin)) 192 | 193 | if zip_properties: 194 | prop_features, geom_features = itertools.tee(features) 195 | properties = defaultdict(list) 196 | for feat in prop_features: 197 | for key, val in feat["properties"].items(): 198 | properties[key].append(val) 199 | else: 200 | geom_features = features 201 | properties = {} 202 | 203 | for result in reduce_features(pipeline, geom_features): 204 | if use_rs: 205 | click.echo("\x1e", nl=False) 206 | if raw: 207 | click.echo(json.dumps(result)) 208 | else: 209 | click.echo( 210 | json.dumps( 211 | { 212 | "type": "Feature", 213 | "properties": properties, 214 | "geometry": result, 215 | "id": "0", 216 | } 217 | ) 218 | ) 219 | -------------------------------------------------------------------------------- /src/fio_planet/snuggs.py: -------------------------------------------------------------------------------- 1 | """Snuggs are s-expressions for Numpy.""" 2 | 3 | # This file is a modified version of snuggs 1.4.7. The numpy 4 | # requirement has been removed and support for keyword arguments in 5 | # expressions has been added. 6 | # 7 | # The original license follows. 8 | # 9 | # Copyright (c) 2014 Mapbox 10 | # 11 | # Permission is hereby granted, free of charge, to any person obtaining a copy 12 | # of this software and associated documentation files (the "Software"), to deal 13 | # in the Software without restriction, including without limitation the rights 14 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | # copies of the Software, and to permit persons to whom the Software is 16 | # furnished to do so, subject to the following conditions: 17 | # 18 | # The above copyright notice and this permission notice shall be included in all 19 | # copies or substantial portions of the Software. 20 | # 21 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | # SOFTWARE. 28 | 29 | from collections import OrderedDict 30 | import functools 31 | import operator 32 | import re 33 | from typing import Mapping 34 | 35 | from pyparsing import ( # type: ignore 36 | Keyword, 37 | oneOf, 38 | Literal, 39 | QuotedString, 40 | ParseException, 41 | Forward, 42 | Group, 43 | OneOrMore, 44 | ParseResults, 45 | Regex, 46 | ZeroOrMore, 47 | alphanums, 48 | pyparsing_common, 49 | replace_with, 50 | ) 51 | 52 | __all__ = ["eval"] 53 | __version__ = "1.4.7" 54 | 55 | 56 | class Context(object): 57 | def __init__(self): 58 | self._data = OrderedDict() 59 | 60 | def add(self, name, val): 61 | self._data[name] = val 62 | 63 | def get(self, name): 64 | return self._data[name] 65 | 66 | def lookup(self, index, subindex=None): 67 | s = list(self._data.values())[int(index) - 1] 68 | if subindex: 69 | return s[int(subindex) - 1] 70 | else: 71 | return s 72 | 73 | def clear(self): 74 | self._data = OrderedDict() 75 | 76 | 77 | _ctx = Context() 78 | 79 | 80 | class ctx(object): 81 | def __init__(self, kwd_dict=None, **kwds): 82 | self.kwds = kwd_dict or kwds 83 | 84 | def __enter__(self): 85 | _ctx.clear() 86 | for k, v in self.kwds.items(): 87 | _ctx.add(k, v) 88 | return self 89 | 90 | def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): 91 | self.kwds = None 92 | _ctx.clear() 93 | 94 | 95 | class ExpressionError(SyntaxError): 96 | """A Snuggs-specific syntax error.""" 97 | 98 | filename = "" 99 | lineno = 1 100 | 101 | 102 | op_map = { 103 | "*": lambda *args: functools.reduce(lambda x, y: operator.mul(x, y), args), 104 | "+": lambda *args: functools.reduce(lambda x, y: operator.add(x, y), args), 105 | "/": lambda *args: functools.reduce(lambda x, y: operator.truediv(x, y), args), 106 | "-": lambda *args: functools.reduce(lambda x, y: operator.sub(x, y), args), 107 | "&": lambda *args: functools.reduce(lambda x, y: operator.and_(x, y), args), 108 | "|": lambda *args: functools.reduce(lambda x, y: operator.or_(x, y), args), 109 | "<": operator.lt, 110 | "<=": operator.le, 111 | "==": operator.eq, 112 | "!=": operator.ne, 113 | ">=": operator.ge, 114 | ">": operator.gt, 115 | "truth": operator.truth, 116 | "is": operator.is_, 117 | "not": operator.not_, 118 | } 119 | 120 | 121 | def compose(f, g): 122 | """Compose two functions. 123 | 124 | compose(f, g)(x) = f(g(x)). 125 | 126 | """ 127 | return lambda x, *args, **kwds: f(g(x)) 128 | 129 | 130 | func_map: Mapping = {} 131 | 132 | higher_func_map: Mapping = { 133 | "compose": compose, 134 | "map": map, 135 | "partial": functools.partial, 136 | "reduce": functools.reduce, 137 | "attrgetter": operator.attrgetter, 138 | "methodcaller": operator.methodcaller, 139 | "itemgetter": operator.itemgetter, 140 | } 141 | 142 | nil = Keyword("null").set_parse_action(replace_with(None)) 143 | true = Keyword("true").set_parse_action(replace_with(True)) 144 | false = Keyword("false").set_parse_action(replace_with(False)) 145 | 146 | 147 | def resolve_var(source, loc, toks): 148 | try: 149 | return _ctx.get(toks[0]) 150 | except KeyError: 151 | err = ExpressionError("name '{}' is not defined".format(toks[0])) 152 | err.text = source 153 | err.offset = loc + 1 154 | raise err 155 | 156 | 157 | var = pyparsing_common.identifier.set_parse_action(resolve_var) 158 | string = QuotedString("'") | QuotedString('"') 159 | lparen = Literal("(").suppress() 160 | rparen = Literal(")").suppress() 161 | op = oneOf(" ".join(op_map.keys())).set_parse_action( 162 | lambda source, loc, toks: op_map[toks[0]] 163 | ) 164 | 165 | 166 | def resolve_func(source, loc, toks): 167 | try: 168 | return func_map[toks[0]] 169 | except (AttributeError, KeyError): 170 | err = ExpressionError("'{}' is not a function or operator".format(toks[0])) 171 | err.text = source 172 | err.offset = loc + 1 173 | raise err 174 | 175 | 176 | # The look behind assertion is to disambiguate between functions and 177 | # variables. 178 | func = Regex(r"(?<=\()[{}]+".format(alphanums + "_")).set_parse_action(resolve_func) 179 | 180 | higher_func = oneOf(" ".join(higher_func_map.keys())).set_parse_action( 181 | lambda source, loc, toks: higher_func_map[toks[0]] 182 | ) 183 | 184 | func_expr = Forward() 185 | higher_func_expr = Forward() 186 | expr = higher_func_expr | func_expr 187 | 188 | 189 | class KeywordArg: 190 | def __init__(self, name): 191 | self.name = name 192 | 193 | 194 | kwarg = Regex(r":[{}]+".format(alphanums + "_")).set_parse_action( 195 | lambda source, loc, toks: KeywordArg(toks[0][1:]) 196 | ) 197 | 198 | operand = ( 199 | higher_func_expr 200 | | func_expr 201 | | true 202 | | false 203 | | nil 204 | | var 205 | | kwarg 206 | | pyparsing_common.sci_real 207 | | pyparsing_common.real 208 | | pyparsing_common.signed_integer 209 | | string 210 | ) 211 | 212 | func_expr << Group( 213 | lparen + (higher_func_expr | op | func) + OneOrMore(operand) + rparen 214 | ) 215 | 216 | higher_func_expr << Group( 217 | lparen 218 | + higher_func 219 | + (nil | higher_func_expr | op | func | OneOrMore(operand)) 220 | + ZeroOrMore(operand) 221 | + rparen 222 | ) 223 | 224 | 225 | def processArg(arg): 226 | if isinstance(arg, ParseResults): 227 | return processList(arg) 228 | else: 229 | return arg 230 | 231 | 232 | def processList(lst): 233 | items = [processArg(x) for x in lst[1:]] 234 | args = [] 235 | kwds = {} 236 | 237 | # An iterator is used instead of implicit iteration to allow 238 | # skipping ahead in the keyword argument case. 239 | itemitr = iter(items) 240 | 241 | for item in itemitr: 242 | if isinstance(item, KeywordArg): 243 | # The next item after the keyword arg marker is its value. 244 | # This advances the iterator in a way that is compatible 245 | # with the for loop. 246 | val = next(itemitr) 247 | key = item.name 248 | kwds[key] = val 249 | else: 250 | args.append(item) 251 | 252 | func = processArg(lst[0]) 253 | 254 | # list and tuple are two builtins that take a single argument, 255 | # whereas args is a list. On a KeyError, the call is retried 256 | # without arg unpacking. 257 | try: 258 | return func(*args, **kwds) 259 | except TypeError: 260 | return func(args, **kwds) 261 | 262 | 263 | def handleLine(line): 264 | try: 265 | result = expr.parseString(line) 266 | return processList(result[0]) 267 | except ParseException as exc: 268 | text = str(exc) 269 | m = re.search(r"(Expected .+) \(at char (\d+)\), \(line:(\d+)", text) 270 | msg = m.group(1) 271 | if "map|partial" in msg: 272 | msg = "expected a function or operator" 273 | err = ExpressionError(msg) 274 | err.text = line 275 | err.offset = int(m.group(2)) + 1 276 | raise err 277 | 278 | 279 | def eval(source, kwd_dict=None, **kwds): 280 | """Evaluate a snuggs expression. 281 | 282 | Parameters 283 | ---------- 284 | source : str 285 | Expression source. 286 | kwd_dict : dict 287 | A dict of items that form the evaluation context. Deprecated. 288 | kwds : dict 289 | A dict of items that form the valuation context. 290 | 291 | Returns 292 | ------- 293 | object 294 | 295 | """ 296 | kwd_dict = kwd_dict or kwds 297 | with ctx(kwd_dict): 298 | return handleLine(source) 299 | -------------------------------------------------------------------------------- /src/fio_planet/features.py: -------------------------------------------------------------------------------- 1 | # geomcalc.py: module supporting "fio coords". 2 | 3 | """Operations on GeoJSON feature and geometry objects.""" 4 | 5 | from collections import UserDict 6 | from functools import wraps 7 | import itertools 8 | from typing import Generator, Iterable, Mapping, Union 9 | 10 | from fiona.transform import transform_geom # type: ignore 11 | import shapely # type: ignore 12 | import shapely.ops # type: ignore 13 | from shapely.geometry import mapping, shape # type: ignore 14 | from shapely.geometry.base import BaseGeometry, BaseMultipartGeometry # type: ignore 15 | 16 | from .errors import ReduceError 17 | from . import snuggs 18 | 19 | # Patch snuggs's func_map, extending it with Python builtins, geometry 20 | # methods and attributes, and functions exported in the shapely module 21 | # (such as set_precision). 22 | 23 | 24 | class FuncMapper(UserDict, Mapping): 25 | """Resolves functions from names in pipeline expressions.""" 26 | 27 | def __getitem__(self, key): 28 | """Get a function by its name.""" 29 | if key in self.data: 30 | return self.data[key] 31 | elif key in __builtins__ and not key.startswith("__"): 32 | return __builtins__[key] 33 | elif key in dir(shapely): 34 | return lambda g, *args, **kwargs: getattr(shapely, key)(g, *args, **kwargs) 35 | elif key in dir(shapely.ops): 36 | return lambda g, *args, **kwargs: getattr(shapely.ops, key)( 37 | g, *args, **kwargs 38 | ) 39 | else: 40 | return ( 41 | lambda g, *args, **kwargs: getattr(g, key)(*args, **kwargs) 42 | if callable(getattr(g, key)) 43 | else getattr(g, key) 44 | ) 45 | 46 | 47 | def collect(geoms: Iterable) -> object: 48 | """Turn a sequence of geometries into a single GeometryCollection. 49 | 50 | Parameters 51 | ---------- 52 | geoms : Iterable 53 | A sequence of geometry objects. 54 | 55 | Returns 56 | ------- 57 | Geometry 58 | 59 | """ 60 | return shapely.GeometryCollection(list(geoms)) 61 | 62 | 63 | def dump(geom: Union[BaseGeometry, BaseMultipartGeometry]) -> Generator: 64 | """Get the individual parts of a geometry object. 65 | 66 | If the given geometry object has a single part, e.g., is an 67 | instance of LineString, Point, or Polygon, this function yields a 68 | single result, the geometry itself. 69 | 70 | Parameters 71 | ---------- 72 | geom : a shapely geometry object. 73 | 74 | Yields 75 | ------ 76 | A shapely geometry object. 77 | 78 | """ 79 | if hasattr(geom, "geoms"): 80 | parts = geom.geoms 81 | else: 82 | parts = [geom] 83 | for part in parts: 84 | yield part 85 | 86 | 87 | def identity(obj: object) -> object: 88 | """Get back the given argument. 89 | 90 | To help in making expression lists, where the first item must be a 91 | callable object. 92 | 93 | Parameters 94 | ---------- 95 | obj : objeect 96 | 97 | Returns 98 | ------- 99 | obj 100 | 101 | """ 102 | return obj 103 | 104 | 105 | def vertex_count(obj: object) -> int: 106 | """Count the vertices of a GeoJSON-like geometry object. 107 | 108 | Parameters 109 | ---------- 110 | obj: object 111 | A GeoJSON-like mapping or an object that provides 112 | __geo_interface__. 113 | 114 | Returns 115 | ------- 116 | int 117 | 118 | """ 119 | shp = shape(obj) 120 | if hasattr(shp, "geoms"): 121 | return sum(vertex_count(part) for part in shp.geoms) 122 | elif hasattr(shp, "exterior"): 123 | return vertex_count(shp.exterior) + sum( 124 | vertex_count(ring) for ring in shp.interiors 125 | ) 126 | else: 127 | return len(shp.coords) 128 | 129 | 130 | def binary_projectable_property_wrapper(func): 131 | """Project func's geometry args before computing a property. 132 | 133 | Parameters 134 | ---------- 135 | func : callable 136 | Signature is func(geom1, geom2, *args, **kwargs) 137 | 138 | Returns 139 | ------- 140 | callable 141 | Signature is func(geom1, geom2, projected=True, *args, **kwargs) 142 | 143 | """ 144 | 145 | @wraps(func) 146 | def wrapper(geom1, geom2, *args, projected=True, **kwargs): 147 | if projected: 148 | geom1 = shape(transform_geom("OGC:CRS84", "EPSG:6933", mapping(geom1))) 149 | geom2 = shape(transform_geom("OGC:CRS84", "EPSG:6933", mapping(geom2))) 150 | 151 | return func(geom1, geom2, *args, **kwargs) 152 | 153 | return wrapper 154 | 155 | 156 | def unary_projectable_property_wrapper(func): 157 | """Project func's geometry arg before computing a property. 158 | 159 | Parameters 160 | ---------- 161 | func : callable 162 | Signature is func(geom1, *args, **kwargs) 163 | 164 | Returns 165 | ------- 166 | callable 167 | Signature is func(geom1, projected=True, *args, **kwargs) 168 | 169 | """ 170 | 171 | @wraps(func) 172 | def wrapper(geom, *args, projected=True, **kwargs): 173 | if projected: 174 | geom = shape(transform_geom("OGC:CRS84", "EPSG:6933", mapping(geom))) 175 | 176 | return func(geom, *args, **kwargs) 177 | 178 | return wrapper 179 | 180 | 181 | def unary_projectable_constructive_wrapper(func): 182 | """Project func's geometry arg before constructing a new geometry. 183 | 184 | Parameters 185 | ---------- 186 | func : callable 187 | Signature is func(geom1, *args, **kwargs) 188 | 189 | Returns 190 | ------- 191 | callable 192 | Signature is func(geom1, projected=True, *args, **kwargs) 193 | 194 | """ 195 | 196 | @wraps(func) 197 | def wrapper(geom, *args, projected=True, **kwargs): 198 | if projected: 199 | geom = shape(transform_geom("OGC:CRS84", "EPSG:6933", mapping(geom))) 200 | product = func(geom, *args, **kwargs) 201 | return shape(transform_geom("EPSG:6933", "OGC:CRS84", mapping(product))) 202 | else: 203 | return func(geom, *args, **kwargs) 204 | 205 | return wrapper 206 | 207 | 208 | area = unary_projectable_property_wrapper(shapely.area) 209 | buffer = unary_projectable_constructive_wrapper(shapely.buffer) 210 | distance = binary_projectable_property_wrapper(shapely.distance) 211 | set_precision = unary_projectable_constructive_wrapper(shapely.set_precision) 212 | simplify = unary_projectable_constructive_wrapper(shapely.simplify) 213 | length = unary_projectable_property_wrapper(shapely.length) 214 | 215 | snuggs.func_map = FuncMapper( 216 | area=area, 217 | buffer=buffer, 218 | collect=collect, 219 | distance=distance, 220 | dump=dump, 221 | identity=identity, 222 | length=length, 223 | simplify=simplify, 224 | set_precision=set_precision, 225 | vertex_count=vertex_count, 226 | **{ 227 | k: getattr(itertools, k) 228 | for k in dir(itertools) 229 | if not k.startswith("_") and callable(getattr(itertools, k)) 230 | }, 231 | ) 232 | 233 | 234 | def map_feature( 235 | expression: str, feature: Mapping, dump_parts: bool = False 236 | ) -> Generator: 237 | """Map a pipeline expression to a feature. 238 | 239 | Yields one or more values. 240 | 241 | Parameters 242 | ---------- 243 | expression : str 244 | A snuggs expression. The outermost parentheses are optional. 245 | feature : dict 246 | A Fiona feature object. 247 | dump_parts : bool, optional (default: False) 248 | If True, the parts of the feature's geometry are turned into 249 | new features. 250 | 251 | Yields 252 | ------ 253 | object 254 | 255 | """ 256 | if not (expression.startswith("(") and expression.endswith(")")): 257 | expression = f"({expression})" 258 | 259 | try: 260 | geom = shape(feature.get("geometry", None)) 261 | if dump_parts and hasattr(geom, "geoms"): 262 | parts = geom.geoms 263 | else: 264 | parts = [geom] 265 | except (AttributeError, KeyError): 266 | parts = [None] 267 | 268 | for part in parts: 269 | result = snuggs.eval(expression, g=part, f=feature) 270 | if isinstance(result, (str, float, int, Mapping)): 271 | yield result 272 | elif isinstance(result, (BaseGeometry, BaseMultipartGeometry)): 273 | yield mapping(result) 274 | else: 275 | try: 276 | for item in result: 277 | if isinstance(item, (BaseGeometry, BaseMultipartGeometry)): 278 | item = mapping(item) 279 | yield item 280 | except TypeError: 281 | yield result 282 | 283 | 284 | def reduce_features(expression: str, features: Iterable[Mapping]) -> Generator: 285 | """Reduce a collection of features to a single value. 286 | 287 | The pipeline is a string that, when evaluated by snuggs, produces 288 | a new value. The name of the input feature collection in the 289 | context of the pipeline is "c". 290 | 291 | Parameters 292 | ---------- 293 | pipeline : str 294 | Geometry operation pipeline such as "(unary_union c)". 295 | features : iterable 296 | A sequence of Fiona feature objects. 297 | 298 | Yields 299 | ------ 300 | object 301 | 302 | Raises 303 | ------ 304 | ReduceError 305 | 306 | """ 307 | if not (expression.startswith("(") and expression.endswith(")")): 308 | expression = f"({expression})" 309 | 310 | collection = (shape(feat["geometry"]) for feat in features) 311 | result = snuggs.eval(expression, c=collection) 312 | 313 | if isinstance(result, (str, float, int, Mapping)): 314 | yield result 315 | elif isinstance(result, (BaseGeometry, BaseMultipartGeometry)): 316 | yield mapping(result) 317 | else: 318 | raise ReduceError("Expression failed to reduce to a single value.") 319 | -------------------------------------------------------------------------------- /tests/test_mod.py: -------------------------------------------------------------------------------- 1 | # Python module tests 2 | 3 | import json 4 | 5 | import pytest # type: ignore 6 | import shapely # type: ignore 7 | from shapely.geometry import LineString, MultiPoint, Point, mapping, shape # type: ignore 8 | 9 | from fio_planet.errors import ReduceError 10 | from fio_planet.features import ( # type: ignore 11 | map_feature, 12 | reduce_features, 13 | vertex_count, 14 | area, 15 | buffer, 16 | collect, 17 | distance, 18 | dump, 19 | identity, 20 | length, 21 | unary_projectable_property_wrapper, 22 | unary_projectable_constructive_wrapper, 23 | binary_projectable_property_wrapper, 24 | ) 25 | 26 | 27 | def test_modulate_simple(): 28 | """Set a feature's geometry.""" 29 | # map_feature() is a generator. list() materializes the values. 30 | feat = list(map_feature("Point 0 0", {"type": "Feature"})) 31 | assert len(feat) == 1 32 | 33 | feat = feat[0] 34 | assert "Point" == feat["type"] 35 | assert (0.0, 0.0) == feat["coordinates"] 36 | 37 | 38 | def test_modulate_complex(): 39 | """Exercise a fairly complicated pipeline.""" 40 | bufkwd = "resolution" if shapely.__version__.startswith("1") else "quad_segs" 41 | 42 | with open("tests/data/trio.geojson") as src: 43 | collection = json.loads(src.read()) 44 | 45 | feat = collection["features"][0] 46 | results = list( 47 | map_feature( 48 | f"simplify (buffer g (* 0.1 2) :projected false :{bufkwd} (- 4 3)) 0.001 :projected false :preserve_topology false", 49 | feat, 50 | ) 51 | ) 52 | assert 1 == len(results) 53 | 54 | geom = results[0] 55 | assert geom["type"] == "Polygon" 56 | assert len(geom["coordinates"][0]) == 5 57 | 58 | 59 | @pytest.mark.parametrize( 60 | "obj, count", 61 | [ 62 | (Point(0, 0), 1), 63 | (MultiPoint([(0, 0), (1, 1)]), 2), 64 | (Point(0, 0).buffer(10.0).difference(Point(0, 0).buffer(1.0)), 130), 65 | ], 66 | ) 67 | def test_vertex_count(obj, count): 68 | """Check vertex counting correctness.""" 69 | assert count == vertex_count(obj) 70 | 71 | 72 | @pytest.mark.parametrize( 73 | "obj, count", 74 | [ 75 | (Point(0, 0), 1), 76 | (MultiPoint([(0, 0), (1, 1)]), 2), 77 | (Point(0, 0).buffer(10.0).difference(Point(0, 0).buffer(1.0)), 130), 78 | ], 79 | ) 80 | def test_calculate_vertex_count(obj, count): 81 | """Confirm vertex counting is in func_map.""" 82 | feat = {"type": "Feature", "properties": {}, "geometry": mapping(obj)} 83 | assert count == list(map_feature("vertex_count g", feat))[0] 84 | 85 | 86 | def test_calculate_builtin(): 87 | """Confirm builtin function evaluation.""" 88 | assert 42 == list(map_feature("int '42'", None))[0] 89 | 90 | 91 | def test_calculate_feature_attr(): 92 | """Confirm feature attr evaluation.""" 93 | assert "LOLWUT" == list(map_feature("upper f", "lolwut"))[0] 94 | 95 | 96 | def test_calculate_point(): 97 | """Confirm feature attr evaluation.""" 98 | result = list(map_feature("Point 0 0", None))[0] 99 | assert "Point" == result["type"] 100 | 101 | 102 | def test_calculate_points(): 103 | """Confirm feature attr evaluation.""" 104 | result = list(map_feature("list (Point 0 0) (buffer (Point 1 1) 1)", None)) 105 | assert 2 == len(result) 106 | assert "Point" == result[0]["type"] 107 | assert "Polygon" == result[1]["type"] 108 | 109 | 110 | def test_reduce_len(): 111 | """Reduce can count the number of input features.""" 112 | with open("tests/data/trio.seq") as seq: 113 | data = [json.loads(line) for line in seq.readlines()] 114 | 115 | # reduce() is a generator. list() materializes the values. 116 | assert 3 == list(reduce_features("len c", data))[0] 117 | 118 | 119 | def test_reduce_union(): 120 | """Reduce yields one feature by default.""" 121 | with open("tests/data/trio.seq") as seq: 122 | data = [json.loads(line) for line in seq.readlines()] 123 | 124 | # reduce() is a generator. list() materializes the values. 125 | result = list(reduce_features("unary_union c", data)) 126 | assert len(result) == 1 127 | 128 | val = result[0] 129 | assert "GeometryCollection" == val["type"] 130 | assert 2 == len(val["geometries"]) 131 | 132 | 133 | def test_reduce_union_area(): 134 | """Reduce can yield total area using raw output.""" 135 | with open("tests/data/trio.seq") as seq: 136 | data = [json.loads(line) for line in seq.readlines()] 137 | 138 | # reduce() is a generator. 139 | result = list(reduce_features("area (unary_union c)", data)) 140 | assert len(result) == 1 141 | 142 | val = result[0] 143 | assert isinstance(val, float) 144 | assert 3e4 < val < 4e4 145 | 146 | 147 | def test_reduce_union_geom_type(): 148 | """Reduce and print geom_type using raw output.""" 149 | with open("tests/data/trio.seq") as seq: 150 | data = [json.loads(line) for line in seq.readlines()] 151 | 152 | # reduce() is a generator. 153 | result = list(reduce_features("geom_type (unary_union c)", data)) 154 | assert len(result) == 1 155 | assert "GeometryCollection" == result[0] 156 | 157 | 158 | def test_reduce_error(): 159 | """Raise ReduceError when expression doesn't reduce.""" 160 | with open("tests/data/trio.seq") as seq: 161 | data = [json.loads(line) for line in seq.readlines()] 162 | 163 | with pytest.raises(ReduceError): 164 | list(reduce_features("(identity c)", data)) 165 | 166 | 167 | @pytest.mark.parametrize( 168 | "obj, count", 169 | [ 170 | (MultiPoint([(0, 0), (1, 1)]), 2), 171 | ], 172 | ) 173 | def test_dump_eval(obj, count): 174 | feature = {"type": "Feature", "properties": {}, "geometry": mapping(obj)} 175 | result = map_feature("identity g", feature, dump_parts=True) 176 | assert len(list(result)) == count 177 | 178 | 179 | def test_collect(): 180 | """Collect two points.""" 181 | geom = collect((Point(0, 0), Point(1, 1))) 182 | assert geom.geom_type == "GeometryCollection" 183 | 184 | 185 | def test_dump(): 186 | """Dump a point.""" 187 | geoms = list(dump(Point(0, 0))) 188 | assert len(geoms) == 1 189 | assert geoms[0].geom_type == "Point" 190 | 191 | 192 | def test_dump_multi(): 193 | """Dump two points.""" 194 | geoms = list(dump(MultiPoint([(0, 0), (1, 1)]))) 195 | assert len(geoms) == 2 196 | assert all(g.geom_type == "Point" for g in geoms) 197 | 198 | 199 | def test_identity(): 200 | """Check identity.""" 201 | geom = Point(1.1, 2.2) 202 | assert geom == identity(geom) 203 | 204 | 205 | def test_area(): 206 | """Check projected area of RMNP against QGIS.""" 207 | with open("tests/data/rmnp.geojson", "rb") as f: 208 | collection = json.load(f) 209 | 210 | geom = shape(collection["features"][0]["geometry"]) 211 | 212 | # QGIS uses a geodesic area computation and WGS84 ellipsoid. 213 | qgis_ellipsoidal_area = 1117.433937055 # kilometer squared 214 | 215 | # We expect no more than a 0.0001 km^2 difference. That's .00001%. 216 | assert round(qgis_ellipsoidal_area, 4) == round(area(geom) / 1e6, 4) 217 | 218 | 219 | @pytest.mark.parametrize( 220 | ["kwargs", "exp_distance"], 221 | [({}, 9648.6280), ({"projected": True}, 9648.6280), ({"projected": False}, 0.1)], 222 | ) 223 | def test_distance(kwargs, exp_distance): 224 | """Distance measured properly.""" 225 | assert round(exp_distance, 4) == round( 226 | distance(Point(0, 0), Point(0.1, 0), **kwargs), 4 227 | ) 228 | 229 | 230 | @pytest.mark.parametrize( 231 | ["kwargs", "distance", "exp_area"], 232 | [ 233 | ({}, 1.0e4, 312e6), 234 | ({"projected": True}, 10000.0, 312e6), 235 | ({"projected": False}, 0.1, 0.0312), 236 | ], 237 | ) 238 | def test_buffer(kwargs, distance, exp_area): 239 | """Check area of a point buffered by 10km using 8 quadrant segments, should be ~312 km2.""" 240 | # float(f"{x:.3g}") is used to round x to 3 significant figures. 241 | assert exp_area == float( 242 | f"{area(buffer(Point(0, 0), distance, **kwargs), **kwargs):.3g}" 243 | ) 244 | 245 | 246 | @pytest.mark.parametrize( 247 | ["kwargs", "exp_length"], 248 | [({}, 9648.6280), ({"projected": True}, 9648.6280), ({"projected": False}, 0.1)], 249 | ) 250 | def test_length(kwargs, exp_length): 251 | """Length measured properly.""" 252 | assert round(exp_length, 4) == round( 253 | length(LineString([(0, 0), (0.1, 0)]), **kwargs), 4 254 | ) 255 | 256 | 257 | @pytest.mark.parametrize( 258 | ["in_xy", "exp_xy", "kwargs"], 259 | [ 260 | ((0.1, 0.0), (9648.628, 0.0), {}), 261 | ((0.1, 0.0), (9648.628, 0.0), {"projected": True}), 262 | ((0.1, 0.0), (0.1, 0.0), {"projected": False}), 263 | ], 264 | ) 265 | def test_unary_property_wrapper(in_xy, exp_xy, kwargs): 266 | """Correctly wraps a function like shapely.area.""" 267 | 268 | def func(geom, *args, **kwargs): 269 | """Echoes its input.""" 270 | return geom, args, kwargs 271 | 272 | wrapper = unary_projectable_property_wrapper(func) 273 | assert wrapper.__doc__ == "Echoes its input." 274 | assert wrapper.__name__ == "func" 275 | g, *rest = wrapper(Point(*in_xy), "hello", this=True, **kwargs) 276 | assert rest == [("hello",), {"this": True}] 277 | assert round(g.x, 4) == round(exp_xy[0], 4) 278 | assert round(g.y, 4) == round(exp_xy[1], 4) 279 | 280 | 281 | @pytest.mark.parametrize( 282 | ["in_xy", "exp_xy", "kwargs"], 283 | [ 284 | ((0.1, 0.0), (9648.628, 0.0), {}), 285 | ((0.1, 0.0), (9648.628, 0.0), {"projected": True}), 286 | ((0.1, 0.0), (0.1, 0.0), {"projected": False}), 287 | ], 288 | ) 289 | def test_unary_projectable_constructive_wrapper(in_xy, exp_xy, kwargs): 290 | """Correctly wraps a function like shapely.buffer.""" 291 | 292 | def func(geom, required, this=False): 293 | """Echoes its input geom.""" 294 | assert round(geom.x, 4) == round(exp_xy[0], 4) 295 | assert round(geom.y, 4) == round(exp_xy[1], 4) 296 | assert this is True 297 | return geom 298 | 299 | wrapper = unary_projectable_constructive_wrapper(func) 300 | assert wrapper.__doc__ == "Echoes its input geom." 301 | assert wrapper.__name__ == "func" 302 | g = wrapper(Point(*in_xy), "hello", this=True, **kwargs) 303 | assert round(g.x, 4) == round(in_xy[0], 4) 304 | assert round(g.y, 4) == round(in_xy[1], 4) 305 | 306 | 307 | @pytest.mark.parametrize( 308 | ["in_xy", "exp_xy", "kwargs"], 309 | [ 310 | ((0.1, 0.0), (9648.628, 0.0), {}), 311 | ((0.1, 0.0), (9648.628, 0.0), {"projected": True}), 312 | ((0.1, 0.0), (0.1, 0.0), {"projected": False}), 313 | ], 314 | ) 315 | def test_binary_projectable_property_wrapper(in_xy, exp_xy, kwargs): 316 | """Correctly wraps a function like shapely.distance.""" 317 | 318 | def func(geom1, geom2, *args, **kwargs): 319 | """Echoes its inputs.""" 320 | return geom1, geom2, args, kwargs 321 | 322 | wrapper = binary_projectable_property_wrapper(func) 323 | assert wrapper.__doc__ == "Echoes its inputs." 324 | assert wrapper.__name__ == "func" 325 | g1, g2, *rest = wrapper(Point(*in_xy), Point(*in_xy), "hello", this=True, **kwargs) 326 | assert rest == [("hello",), {"this": True}] 327 | assert round(g1.x, 4) == round(exp_xy[0], 4) 328 | assert round(g1.y, 4) == round(exp_xy[1], 4) 329 | assert round(g2.x, 4) == round(exp_xy[0], 4) 330 | assert round(g2.y, 4) == round(exp_xy[1], 4) 331 | -------------------------------------------------------------------------------- /tests/data/rmnp.geojson: -------------------------------------------------------------------------------- 1 | {"features": [{"geometry": {"coordinates": [[[[-105.82993236599998, 40.23429787900005], [-105.82532010999995, 40.234090120000076], [-105.80130186799994, 40.238022355000055], [-105.79971981686558, 40.23834806093726], [-105.80615125399999, 40.248891729000036], [-105.80604781499994, 40.25259529400006], [-105.80323751899999, 40.256223652000074], [-105.84344629799995, 40.256590533000065], [-105.85209887999997, 40.27187296100004], [-105.85215931999994, 40.27205192000008], [-105.86034979199997, 40.30162991100008], [-105.86041877199995, 40.30189618600008], [-105.86043595199999, 40.302194443000076], [-105.86044234999997, 40.30242113800006], [-105.85872471399995, 40.305752203000054], [-105.85865781799998, 40.30587968700007], [-105.85858570499994, 40.30600321500003], [-105.85779012199998, 40.306659301000025], [-105.87150570599994, 40.31460928800004], [-105.87096989399998, 40.34002330200008], [-105.87089412899996, 40.34472017200005], [-105.86851234235445, 40.36866639070081], [-105.87465775499999, 40.36700144500003], [-105.89323599499994, 40.37071941500005], [-105.91207991899995, 40.37242368300008], [-105.91272218299997, 40.37249360700008], [-105.91287933999996, 40.37253276000007], [-105.91360267899995, 40.372845693000045], [-105.91372432699995, 40.37293256000004], [-105.90505138299994, 40.39897570900007], [-105.90346006599998, 40.431925275000026], [-105.90344864899998, 40.43216639100007], [-105.89927318399998, 40.465591937000056], [-105.89922084599999, 40.465786036000054], [-105.89880951399999, 40.466564007000045], [-105.89822195199997, 40.46764514500006], [-105.89732067099999, 40.46917015400004], [-105.89188595999997, 40.476450914000054], [-105.89177666799998, 40.47657788200007], [-105.89159512399999, 40.47670540800004], [-105.88663187599997, 40.47736520700005], [-105.85499564999998, 40.486197454000035], [-105.85496571099998, 40.48620149300007], [-105.85489999899994, 40.486210355000026], [-105.85474552299996, 40.48623118900008], [-105.83766572599995, 40.48284897600007], [-105.83753977299995, 40.48282070100004], [-105.82538607593936, 40.477886708345466], [-105.81588885599996, 40.484865049000064], [-105.80526615399998, 40.49096921100005], [-105.80478208599999, 40.49119330100007], [-105.79614713399997, 40.49419001000007], [-105.76340551899995, 40.51327554900007], [-105.75138246899996, 40.52241727900008], [-105.75068548999997, 40.52287225300006], [-105.75041200099997, 40.52302718000004], [-105.74898221500828, 40.52333502440806], [-105.75294155799997, 40.53132139600007], [-105.75297840399998, 40.531415749000075], [-105.75298352099998, 40.531510308000065], [-105.75304362899999, 40.53500084500007], [-105.75046013199994, 40.538454529000035], [-105.75026447499994, 40.53870009700006], [-105.75022003999999, 40.538730858000065], [-105.73601753799994, 40.54698421300003], [-105.73543265699999, 40.54729423800006], [-105.73490789599998, 40.54750027600005], [-105.73417386699998, 40.54760404000007], [-105.72224790899998, 40.54923323300005], [-105.70060787699998, 40.553433977000054], [-105.69951366899994, 40.553625289000024], [-105.69846461499998, 40.55379379500005], [-105.69740136799999, 40.55376868700006], [-105.69590444199997, 40.553529971000046], [-105.68403573899997, 40.55086216500007], [-105.68354186699997, 40.55067590200008], [-105.68264478099996, 40.55025328600004], [-105.68224110499995, 40.550043963000064], [-105.68188181999994, 40.54981710000004], [-105.68171810199999, 40.549713723000025], [-105.68137426799996, 40.54947251200008], [-105.67910352499996, 40.54780573200003], [-105.67111994099997, 40.539478649000046], [-105.67089639699998, 40.53922769900004], [-105.67076231199997, 40.53906631800004], [-105.66685877099997, 40.533507919000044], [-105.66666479199995, 40.53322075400007], [-105.66460226864893, 40.52988730500347], [-105.64525353599998, 40.532981953000046], [-105.62241943799995, 40.53981883100005], [-105.62153453099995, 40.540057825000076], [-105.62042534799997, 40.54030702600005], [-105.62003581299996, 40.54036317100008], [-105.61955640099995, 40.54039727300005], [-105.61904733599994, 40.54033694000003], [-105.60211979999997, 40.53829128900003], [-105.60162589399994, 40.538230800000065], [-105.60016054299996, 40.53781500300005], [-105.59972731399995, 40.537565004000044], [-105.59929443299995, 40.53722941700005], [-105.59910215099995, 40.537014759000044], [-105.59906149099999, 40.53696936700004], [-105.59893700599997, 40.53683037400003], [-105.57756808599999, 40.51851144700004], [-105.57734419699995, 40.518386441000075], [-105.57649357899999, 40.51787717900004], [-105.57619528499998, 40.51763542600003], [-105.57028856699998, 40.513556933000075], [-105.57010850299997, 40.51345394200007], [-105.56840699299994, 40.51241728800005], [-105.53543969099997, 40.51327744200006], [-105.53504912299996, 40.513517988000046], [-105.53257093699995, 40.51499789300004], [-105.53209022499999, 40.51527037500006], [-105.53189886999996, 40.515325492000045], [-105.53156575899999, 40.515421436000054], [-105.52984182799997, 40.515825747000065], [-105.51756163999994, 40.51918768100006], [-105.51739464799999, 40.51920669100008], [-105.51700292299995, 40.51227282200006], [-105.51681120999996, 40.508877280000036], [-105.51661963499998, 40.50548173400006], [-105.51642772099996, 40.50208528600007], [-105.51623569999998, 40.498688833000074], [-105.51616764099998, 40.49747991800007], [-105.51615748699999, 40.497299560000044], [-105.51614708899996, 40.497114870000075], [-105.51604451099996, 40.49529272700005], [-105.51585310799999, 40.49189670900006], [-105.51566160099998, 40.488499699000045], [-105.51546987699999, 40.485102598000026], [-105.51527841899997, 40.48170648400003], [-105.51508686399995, 40.47831036700006], [-105.51489496899995, 40.47491325900006], [-105.51470297699996, 40.471516149000024], [-105.51451112999996, 40.46811993700004], [-105.51273101209253, 40.43773005864719], [-105.51268141899999, 40.43771853100003], [-105.49359377399998, 40.433632946000046], [-105.49384450399998, 40.42265419300003], [-105.49445713499995, 40.40834343900008], [-105.49462260299998, 40.404728686000055], [-105.51175716599994, 40.392979152000066], [-105.52357935999999, 40.38692618600004], [-105.53802058899998, 40.38253243100007], [-105.55202337099996, 40.38337468200007], [-105.56086616230968, 40.38911621698159], [-105.55199686599997, 40.37976664800004], [-105.54432240599999, 40.36429782000005], [-105.54390615499995, 40.36299049200005], [-105.54997263999996, 40.36027357100005], [-105.57119972699996, 40.348654846000045], [-105.58199381599997, 40.32891757900006], [-105.58199446199995, 40.32889092400006], [-105.58205034399998, 40.326759225000046], [-105.57230443299994, 40.32527691300004], [-105.56764467899995, 40.32528722600006], [-105.56268212499998, 40.325360086000046], [-105.55805262399997, 40.325314278000064], [-105.55330571199994, 40.32532461400007], [-105.54849582199995, 40.325334963000046], [-105.54383945699999, 40.318265127000075], [-105.53509543499996, 40.31407892900006], [-105.53403439699997, 40.30697408900005], [-105.53362917399994, 40.29996820400004], [-105.53743801499996, 40.30014405900005], [-105.53829550999995, 40.300192554000034], [-105.53857702999994, 40.300208473000055], [-105.54211996799995, 40.30046056600003], [-105.55233481799996, 40.29336605000003], [-105.55659629599995, 40.284294583000076], [-105.55658333899999, 40.28274042900006], [-105.55638339299998, 40.27564530300003], [-105.54206666299996, 40.26143052400005], [-105.54175131899996, 40.24686076900008], [-105.54177471199995, 40.24486010000004], [-105.53265796099998, 40.225078703000065], [-105.53268332499994, 40.21936352200004], [-105.53370137907686, 40.21890372610939], [-105.53264866599994, 40.216782204000026], [-105.53282174599997, 40.21053215400008], [-105.54226766999994, 40.19058178800003], [-105.57130617599995, 40.165553240000065], [-105.57154539499999, 40.16537187000006], [-105.57194869999995, 40.16510860400007], [-105.57850588199994, 40.16121546900007], [-105.57889369499998, 40.16107838800008], [-105.57917676799997, 40.16100940200005], [-105.57944527699999, 40.16095400200004], [-105.59509419199998, 40.15830693100003], [-105.59555592999999, 40.15823698500003], [-105.59665870999999, 40.158082652000076], [-105.59761194299995, 40.15807323100006], [-105.60285317299997, 40.15809561300006], [-105.60294910099998, 40.15811294400004], [-105.60370143199998, 40.158248859000025], [-105.62170529799994, 40.16156817700005], [-105.65465245599995, 40.16767968000005], [-105.66688537484622, 40.17000001598165], [-105.66686555399997, 40.16991096000004], [-105.67000857399995, 40.16730004600004], [-105.67016297799995, 40.167195606000064], [-105.67141738899994, 40.16646097700004], [-105.67154528999998, 40.16646576100004], [-105.69696639199998, 40.162049558000035], [-105.69735396799996, 40.16194812100008], [-105.69783084399995, 40.161823620000064], [-105.69809911899995, 40.16179047000003], [-105.69856090999997, 40.161769666000055], [-105.69885836099996, 40.161790394000036], [-105.70324941299998, 40.16243050500003], [-105.70347260999995, 40.16247419700005], [-105.70375553299999, 40.16254455300003], [-105.70390424299995, 40.16258869600006], [-105.70415694699994, 40.16271779600004], [-105.70531632599995, 40.16340446600003], [-105.71618835799995, 40.16834696700005], [-105.71659006199997, 40.16850665100003], [-105.71685758599995, 40.16864464100007], [-105.71702112799994, 40.16880579800005], [-105.71722881499994, 40.169011728000044], [-105.71743665699995, 40.169267209000054], [-105.71771866899996, 40.16963034200006], [-105.74348628399997, 40.168688426000074], [-105.74383073499996, 40.16800149800008], [-105.74411460099998, 40.167648307000036], [-105.74439820099997, 40.16737169700008], [-105.74468148799997, 40.16723473400003], [-105.75252233799995, 40.16418840700004], [-105.75953475599994, 40.16484084700005], [-105.75999619699996, 40.16488737000003], [-105.76080028999996, 40.16499020200007], [-105.76197617699995, 40.16516265800004], [-105.76516258499998, 40.16561463800008], [-105.76550512899996, 40.16569345800008], [-105.77107118899994, 40.167417862000036], [-105.79663688699998, 40.17037553900008], [-105.79802255699997, 40.16995606900008], [-105.79875292299994, 40.16973931200005], [-105.79921441199997, 40.169691080000064], [-105.81725341699996, 40.16740740300003], [-105.81999581999997, 40.16951501300008], [-105.82021443499997, 40.17101724500003], [-105.82022265399996, 40.171120795000036], [-105.82022362999999, 40.17120187300003], [-105.82600834399994, 40.19147235300005], [-105.83044446399998, 40.19612547200006], [-105.83056168099995, 40.19622373400006], [-105.83415290399995, 40.19890521700006], [-105.83590610799996, 40.20007279200007], [-105.83622886799998, 40.20031371300007], [-105.83724425399998, 40.201819940000064], [-105.83997704999996, 40.20730591200004], [-105.84082691599997, 40.20925216400008], [-105.84235351099994, 40.214368886000045], [-105.84235770799995, 40.21444093000008], [-105.84207465799994, 40.21575384300007], [-105.83545929499996, 40.234072627000046], [-105.83526359799998, 40.23451919900003], [-105.82993236599998, 40.23429787900005]], [[-105.58371488559169, 40.40142222697275], [-105.57356491679676, 40.39736138986373], [-105.57949503399999, 40.40121175500008], [-105.58371488559169, 40.40142222697275]]], [[[-105.52387338099999, 40.29587849500007], [-105.51833431299997, 40.289190561000055], [-105.51805444999997, 40.289083703000074], [-105.51783916499994, 40.28900808800006], [-105.51771470599999, 40.28891414100008], [-105.51770271699996, 40.28890509200005], [-105.51753073399999, 40.28877072200004], [-105.51610842599996, 40.28685359100007], [-105.51610838199997, 40.28685353000003], [-105.51606038099999, 40.28678602700006], [-105.51593599699999, 40.286611109000035], [-105.51585722899995, 40.286453797000036], [-105.51584008899994, 40.28641720100006], [-105.51577090499995, 40.28626949100004], [-105.51573753999998, 40.28616337100004], [-105.51568463099994, 40.285995092000064], [-105.51342582599995, 40.27889229300007], [-105.51204804099996, 40.27581721400003], [-105.51178041799994, 40.27498503600003], [-105.51168516799999, 40.27464401300006], [-105.51191604799999, 40.274650560000055], [-105.51389726599996, 40.27470672100003], [-105.51864548699996, 40.27484292500003], [-105.52323037599996, 40.26789955000004], [-105.52782832999998, 40.26816456300003], [-105.52793138299995, 40.27156944300003], [-105.52804855399995, 40.27511655600006], [-105.52817409, 40.27859085100005], [-105.52823393999995, 40.28210154200008], [-105.52831281699997, 40.28556538600003], [-105.53304071299999, 40.289422278000075], [-105.53352711399998, 40.297435663000044], [-105.53362917399994, 40.29996820400004], [-105.52898800099996, 40.29965667500005], [-105.52419156499997, 40.29937584700008], [-105.52417450299998, 40.298356116000036], [-105.52387338099999, 40.29587849500007]]]], "type": "MultiPolygon"}, "id": "0:0", "properties": {}, "type": "Feature"}], "type": "FeatureCollection"} 2 | -------------------------------------------------------------------------------- /docs/images/SatelliteOrbits-main100Dove4RapidEye6Skysat-100mm01-pulsing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | --------------------------------------------------------------------------------