├── .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 |
--------------------------------------------------------------------------------
/.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 | 
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 | 
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 | 
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 | 
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)
7 | [](https://github.com/planetlabs/fio-planet/actions/workflows/test.yml)
8 | [](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 |
59 |
--------------------------------------------------------------------------------