exxamalte/python-aio-georss-client

View on GitHub
aio_georss_client/xml_parser/geometry.py

Summary

Maintainability
A
2 hrs
Test Coverage
"""Geometry models."""
from __future__ import annotations


class Geometry:
    """Represents a geometry."""


class Point(Geometry):
    """Represents a point."""

    def __init__(self, latitude: float, longitude: float):
        """Initialise point."""
        self._latitude: float = latitude
        self._longitude: float = longitude

    def __repr__(self):
        """Return string representation of this point."""
        return "<{}(latitude={}, longitude={})>".format(
            self.__class__.__name__, self.latitude, self.longitude
        )

    def __hash__(self) -> int:
        """Return unique hash of this geometry."""
        return hash((self.latitude, self.longitude))

    def __eq__(self, other: object) -> bool:
        """Return if this object is equal to other object."""
        return (
            self.__class__ == other.__class__
            and self.latitude == other.latitude
            and self.longitude == other.longitude
        )

    @property
    def latitude(self) -> float | None:
        """Return the latitude of this point."""
        return self._latitude

    @property
    def longitude(self) -> float | None:
        """Return the longitude of this point."""
        return self._longitude


class Polygon(Geometry):
    """Represents a polygon."""

    def __init__(self, points: list[Point]):
        """Initialise polygon."""
        self._points: list[Point] = points

    def __repr__(self):
        """Return string representation of this polygon."""
        return f"<{self.__class__.__name__}(centroid={self.centroid})>"

    def __hash__(self) -> int:
        """Return unique hash of this geometry."""
        return hash(self.points)

    def __eq__(self, other: object) -> bool:
        """Return if this object is equal to other object."""
        return self.__class__ == other.__class__ and self.points == other.points

    @property
    def points(self) -> list[Point] | None:
        """Return the points of this polygon."""
        return self._points

    @property
    def edges(self) -> list[tuple[Point, Point]]:
        """Return all edges of this polygon."""
        edges: list[tuple[Point, Point]] = []
        for i in range(1, len(self.points)):
            previous = self.points[i - 1]
            current = self.points[i]
            edges.append((previous, current))
        return edges

    @property
    def centroid(self) -> Point:
        """Find the polygon's centroid as a best approximation."""
        longitudes_list: list[float] = [point.longitude for point in self.points]
        latitudes_list: list[float] = [point.latitude for point in self.points]
        number_of_points: int = len(self.points)
        longitude: float = sum(longitudes_list) / number_of_points
        latitude: float = sum(latitudes_list) / number_of_points
        return Point(latitude, longitude)

    def is_inside(self, point: Point | None) -> bool:
        """Check if the provided point is inside this polygon."""
        if point:
            crossings: int = 0
            for edge in self.edges:
                if Polygon._ray_crosses_segment(point, edge):
                    crossings += 1
            return crossings % 2 == 1
        return False

    @staticmethod
    def _ray_crosses_segment(point: Point, edge: tuple[Point, Point]):
        """Use ray-casting algorithm to check provided point and edge."""
        a, b = edge
        px = point.longitude
        py = point.latitude
        ax = a.longitude
        ay = a.latitude
        bx = b.longitude
        by = b.latitude
        if ay > by:
            ax = b.longitude
            ay = b.latitude
            bx = a.longitude
            by = a.latitude
        # Alter longitude to cater for 180 degree crossings.
        if px < 0:
            px += 360.0
        if ax < 0:
            ax += 360.0
        if bx < 0:
            bx += 360.0

        if py == ay or py == by:
            py += 0.00000001
        if (py > by or py < ay) or (px > max(ax, bx)):
            return False
        if px < min(ax, bx):
            return True

        red = ((by - ay) / (bx - ax)) if (ax != bx) else float("inf")
        blue = ((py - ay) / (px - ax)) if (ax != px) else float("inf")
        return blue >= red


class BoundingBox(Geometry):
    """Represents a bounding box (bbox)."""

    # <!--gdacs: bbox format = lonmin lonmax latmin latmax -->
    # <gdacs:bbox> 164.5652 172.5652 -24.9041 -16.9041 </gdacs:bbox>

    def __init__(self, bottom_left: Point, top_right: Point):
        """Initialise bounding box."""
        self._bottom_left: Point = bottom_left
        self._top_right: Point = top_right

    def __repr__(self):
        """Return string representation of this bounding box."""
        return "<{}(bottom_left={}, top_right={})>".format(
            self.__class__.__name__, self._bottom_left, self._top_right
        )

    def __hash__(self) -> int:
        """Return unique hash of this geometry."""
        return hash((self.bottom_left, self.top_right))

    def __eq__(self, other: object) -> bool:
        """Return if this object is equal to other object."""
        return (
            self.__class__ == other.__class__
            and self.bottom_left == other.bottom_left
            and self.top_right == other.top_right
        )

    @property
    def bottom_left(self) -> Point:
        """Return bottom left point."""
        return self._bottom_left

    @property
    def top_right(self) -> Point:
        """Return top right point."""
        return self._top_right

    @property
    def centroid(self) -> Point:
        """Find the bounding box's centroid as a best approximation."""
        transposed_top_right_longitude: float = self._top_right.longitude
        if self._bottom_left.longitude > self._top_right.longitude:
            # bounding box spans across 180 degree longitude
            transposed_top_right_longitude = self._top_right.longitude + 360
        longitude: float = (
            self._bottom_left.longitude + transposed_top_right_longitude
        ) / 2
        latitude: float = (self._bottom_left.latitude + self._top_right.latitude) / 2
        return Point(latitude, longitude)

    def is_inside(self, point: Point) -> bool:
        """Check if the provided point is inside this bounding box."""
        if point:
            transposed_point_longitude: float = point.longitude
            transposed_top_right_longitude = self._top_right.longitude
            if self._bottom_left.longitude > self._top_right.longitude:
                # bounding box spans across 180 degree longitude
                transposed_top_right_longitude = self._top_right.longitude + 360
                if point.longitude < 0:
                    transposed_point_longitude += 360
            return (
                self._bottom_left.latitude <= point.latitude <= self._top_right.latitude
            ) and (
                self._bottom_left.longitude
                <= transposed_point_longitude
                <= transposed_top_right_longitude
            )