Skip to content

Commit

Permalink
Merge pull request #69 from DHI/feature/export-to-geopandas
Browse files Browse the repository at this point in the history
Feature/export to geopandas
  • Loading branch information
ryan-kipawa authored Dec 21, 2023
2 parents 252eb22 + 4bcaa00 commit ec5208b
Show file tree
Hide file tree
Showing 22 changed files with 1,232 additions and 7 deletions.
10 changes: 8 additions & 2 deletions .github/workflows/full_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,15 @@ jobs:
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Install mikeio1d
- name: Install mikeio1d (basic)
run: |
pip install .[test]
- name: Test with pytest
- name: Test with pytest (basic)
run: |
pytest
- name: Install mikeio1d (optional dependencies)
run: |
pip install .[all]
- name: Test with pytest (optional dependencies)
run: |
pytest
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

### Added

- Linux support.
- Support for Python 3.12
- Initial support for GeoPandas (ability to export static network)
- Geometry package for converting IRes1DLocation objects to corresponding Shapely objects
- Linux support (experimental).

### Fixed

Expand Down
11 changes: 11 additions & 0 deletions mikeio1d/geometry/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from .node_point import NodePoint
from .reach_point import ReachPoint
from .reach_geometry import ReachGeometry
from .catchment_geometry import CatchmentGeometry

__all__ = [
"NodePoint",
"ReachPoint",
"ReachGeometry",
"CatchmentGeometry",
]
38 changes: 38 additions & 0 deletions mikeio1d/geometry/catchment_geometry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import List
from typing import Tuple

from shapely.geometry.base import BaseGeometry
from shapely.geometry import Polygon


@dataclass(frozen=True)
class CatchmentGeometry:
"""
A utility class for working with catchment geometries.
Parameters
----------
points : List[Tuple[float, float]]
List of points (x, y) defining the catchment boundary. The first and last points should be the same.
"""

points: List[Tuple[float, float]]

@staticmethod
def from_res1d_catchment(res1d_catchment) -> CatchmentGeometry:
"""
Create a CatchmentGeometry from an IRes1DCatchment object.
"""
shape = res1d_catchment.Shape[0] # there will always be one element
points = []
for i in range(shape.VertexCount()):
vertex = shape.GetVertex(i)
points.append((vertex.X, vertex.Y))
return CatchmentGeometry(points)

def to_shapely(self) -> BaseGeometry:
"""Convert to a shapely geometry."""
return Polygon(self.points)
35 changes: 35 additions & 0 deletions mikeio1d/geometry/node_point.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import annotations

from dataclasses import dataclass

from shapely.geometry.base import BaseGeometry
from shapely.geometry import Point


@dataclass(frozen=True)
class NodePoint:
"""
A utility class for working with node geometries.
Parameters
----------
x : float
X coordinate
y : float
Y coordinate
"""

x: float
y: float

@staticmethod
def from_res1d_node(res1d_node) -> NodePoint:
"""
Create a NodePoint from an IRes1DNode object.
"""
xcoord = res1d_node.XCoordinate
ycoord = res1d_node.YCoordinate
return NodePoint(xcoord, ycoord)

def to_shapely(self) -> BaseGeometry:
return Point(self.x, self.y)
9 changes: 9 additions & 0 deletions mikeio1d/geometry/protocols.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from __future__ import annotations

from typing import Protocol
from shapely.geometry.base import BaseGeometry


class ConvertableToShapely(Protocol):
def to_shapely(self) -> BaseGeometry:
...
107 changes: 107 additions & 0 deletions mikeio1d/geometry/reach_geometry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
from __future__ import annotations

from functools import lru_cache
from typing import Iterable
from typing import List

import numpy as np
from shapely.geometry.base import BaseGeometry
from shapely.geometry import LineString

from .reach_point import ReachPoint


class ReachGeometry:
"""
A utility class for working with reach geometries.
"""

def __init__(self, points: List[ReachPoint]):
self._points = sorted(points)

@staticmethod
def from_res1d_reaches(res1d_reaches):
"""
Create a ReachGeometry from a list of IRes1DReach objects.
Parameters
----------
res1d_reaches : List[IRes1DReach]
"""

if not isinstance(res1d_reaches, Iterable):
res1d_reaches = [res1d_reaches]

points = []
for reach in res1d_reaches:
points.extend([ReachPoint.from_digipoint(dp) for dp in reach.DigiPoints])
points.extend([ReachPoint.from_gridpoint(gp) for gp in reach.GridPoints])

return ReachGeometry(points)

@property
def chainages(self) -> List[float]:
return [p.chainage for p in self._get_unique_points()]

@property
def points(self) -> List[ReachPoint]:
return self._points

@property
def digipoints(self) -> List[ReachPoint]:
return [p for p in self.points if p.is_digipoint()]

@property
def gridpoints(self) -> List[ReachPoint]:
return [p for p in self.points if p.is_gridpoint()]

@property
def length(self) -> float:
return self.chainages[-1] - self.chainages[0]

def to_shapely(self) -> BaseGeometry:
"""Convert to a shapely geometry."""
points = self._get_unique_points()
xy = [(p.x, p.y) for p in points]
return LineString(xy)

def chainage_to_geometric_distance(self, chainage: float) -> float:
"""Convert chainage to geometric distance."""
chainages = self.chainages
distances = self._get_distances()
if chainage < chainages[0] or chainage > chainages[-1]:
raise ValueError(
f"Chainage of {chainage} outside reach range of {chainages[0]} to {chainages[-1]}"
)
distance = float(np.interp(chainage, chainages, distances))
return distance

def chainage_from_geometric_distance(self, geometric_distance: float) -> float:
"""Convert geometric distance to chainage."""
chainages = self.chainages
distances = self._get_distances()
if geometric_distance < distances[0] or geometric_distance > distances[-1]:
raise ValueError(
f"Geometric distance of {geometric_distance} outside reach range of {distances[0]} to {distances[-1]}"
)
chainage = float(np.interp(geometric_distance, distances, chainages))
return chainage

@lru_cache(maxsize=None)
def _get_unique_points(self) -> List[ReachPoint]:
"""Removes points sharing the same chainage and coordinates."""
return sorted(list(set(self._points)))

@lru_cache(maxsize=None)
def _get_distances(self) -> List[float]:
"""Returns a list of geometric distances between all unique points."""
points = self._get_unique_points()
distances = []
total_distance = 0.0
prev_point = points[0]
for point in points:
distance = point.to_shapely().distance(prev_point.to_shapely())
total_distance += distance
distances.append(total_distance)
prev_point = point
return distances
74 changes: 74 additions & 0 deletions mikeio1d/geometry/reach_point.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from __future__ import annotations

from dataclasses import dataclass
from dataclasses import field
from enum import Enum

from shapely.geometry.base import BaseGeometry
from shapely.geometry import Point


class ReachPointType(Enum):
DIGIPOINT = 0
GRIDPOINT = 1


@dataclass(frozen=True, order=True)
class ReachPoint:
"""
A utility class for working with points along a reach.
Parameters
----------
point_type : ReachPointType
Either DIGIPOINT or GRIDPOINT
chainage : float
Chainage along the reach. Need not match geometric distance.
x : float
X coordinate
y : float
Y coordinate
z : float
Z coordinate
"""

point_type: ReachPointType = field(compare=False)
chainage: float
x: float
y: float
z: float

def is_digipoint(self):
return self.point_type == ReachPointType.DIGIPOINT

def is_gridpoint(self):
return self.point_type == ReachPointType.GRIDPOINT

def to_shapely(self) -> BaseGeometry:
return Point(self.x, self.y)

@staticmethod
def from_digipoint(res1d_digipoint):
"""
Create a ReachPoint from an IRes1DDigiPoint object.
"""
return ReachPoint(
ReachPointType.DIGIPOINT,
res1d_digipoint.M,
res1d_digipoint.X,
res1d_digipoint.Y,
res1d_digipoint.Z,
)

@staticmethod
def from_gridpoint(res1d_gridpoint):
"""
Create a ReachPoint from an IRes1DGridPoint object.
"""
return ReachPoint(
ReachPointType.GRIDPOINT,
res1d_gridpoint.Chainage,
res1d_gridpoint.X,
res1d_gridpoint.Y,
res1d_gridpoint.Z,
)
5 changes: 5 additions & 0 deletions mikeio1d/res1d.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,11 @@ def data(self):
"""
return self.result_reader.data

@property
def projection_string(self):
"""Projection string of the result file."""
return self.data.ProjectionString

# region Query wrappers

def get_catchment_values(self, catchment_id, quantity):
Expand Down
16 changes: 16 additions & 0 deletions mikeio1d/result_network/result_catchment.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from __future__ import annotations
from warnings import warn
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from ..geometry import CatchmentGeometry

from ..query import QueryDataCatchment
from .result_location import ResultLocation
from ..various import try_import_shapely


class ResultCatchment(ResultLocation):
Expand Down Expand Up @@ -63,3 +69,13 @@ def get_query(self, data_item):
catchment_id = self._catchment.Id
query = QueryDataCatchment(quantity_id, catchment_id)
return query

@property
def geometry(self) -> CatchmentGeometry:
"""
A geometric representation of the catchment. Requires shapely.
"""
try_import_shapely()
from ..geometry import CatchmentGeometry

return CatchmentGeometry.from_res1d_catchment(self._catchment)
Loading

0 comments on commit ec5208b

Please sign in to comment.