Creating a pydantic model for GIS polygons

In this article, I will explain how to create a pydantic model to validate and create polygons for GIS (geographic information systems).

We start by creating constraints for Latitude and Longitude.

from pydantic import BaseModel, ConstrainedFloat

class Latitude(ConstrainedFloat):
    ge = -90
    le = 90


class Longitude(ConstrainedFloat):
    ge = -180
    le = 180

These types will come in handy. You can, for example, use them to create a Coordinates model.

class Coordinates(BaseModel):

    lat: Latitude
    lng: Longitude

Now, the polygon. For this demonstration we will do like and use Shapely, which takes one set of coordinates to define a surface, and then none or several other sets to define holes. Each set must have at least 3 coordinates. So we get:

from typing import List, Tuple

from pydantic import conlist

class MyModel(BaseModel):

    polygon: conlist(conlist(Tuple[Longitude, Latitude], min_items=3), min_items=1)

MyModel.parse_obj({
    "polygon": [
        # shell
        [(45, -100), (45, -101), (46, -101)],
        # hole
        [(45.3, -100.3), (45.3, -100.6), (44.6, -101.6)]
    ]
})

Now, to make the type reusable, we need to declare a new template and use the __root__ property as follows:

from typing import Any

class Polygon(BaseModel):
    __root__: conlist(conlist(Tuple[Longitude, Latitude], min_items=3), min_items=1)

    def __init__(self, **data: Any):
        super().__init__(**data)

class MyModel(BaseModel):

    polygon: Polygon

To take it a step further, why not initialize the Shapely.geometry.Polygon object as soon as the model is created? Note that I renamed Polygon to RawPolygon to avoid any conflict with shapely.

from pydantic import Field, PrivateAttr
from shapely.geometry import Polygon

class RawPolygon(BaseModel):
    __root__: conlist(conlist(Tuple[Longitude, Latitude], min_items=3), min_items=1)
    _polygon: Polygon = PrivateAttr()

    def __init__(self, **data: Any):
        super().__init__(**data)
        self._polygon = Polygon(data["__root__"][0], data["__root__"][1:])

    @property
    def polygon(self) -> Polygon:
        return self._polygon

class MyModel(BaseModel):

    raw_polygon: RawPolygon = Field(..., alias="polygon")

    @property
    def polygon(self) -> Polygon:
        return self.raw_polygon.polygon

And our initial example still works in addition to the directly available polygon.

m = MyModel.parse_obj({
    "polygon": [
        # polygon
        [(45, -100), (45, -101), (46, -101)],
        # hole
        [(45.3, -100.3), (45.3, -100.6), (44.6, -101.6)]
    ]
})

print(m.polygon)
#> POLYGON ((45 -100, 45 -101, 46 -101, 45 -100), (45.3 -100.3, 45.3 -100.6, 44.6 -101.6, 45.3 -100.3))

Full Code

from typing import Any, Tuple

from pydantic import BaseModel, ConstrainedFloat, Field, PrivateAttr, conlist
from shapely.geometry import Polygon


class Latitude(ConstrainedFloat):
    ge = -90
    le = 90


class Longitude(ConstrainedFloat):
    ge = -180
    le = 180

class RawPolygon(BaseModel):
    __root__: conlist(conlist(Tuple[Longitude, Latitude], min_items=3), min_items=1)
    _polygon: Polygon = PrivateAttr()

    def __init__(self, **data: Any):
        super().__init__(**data)
        self._polygon = Polygon(data["__root__"][0], data["__root__"][1:])

    @property
    def polygon(self) -> Polygon:
        return self._polygon

class MyModel(BaseModel):

    raw_polygon: RawPolygon = Field(..., alias="polygon")

    @property
    def polygon(self) -> Polygon:
        return self.raw_polygon.polygon

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.