diff --git a/README.md b/README.md index b19dd66..775ecb8 100644 --- a/README.md +++ b/README.md @@ -23,20 +23,6 @@ Python-based API for Electronic Navigational Charts (ENC) This module follows the [PEP8](https://www.python.org/dev/peps/pep-0008/) convention for Python code. -## Roadmap - -- 1: Add better compatibility for all operating systems (Windows, Linux++). Right - now, GDAL and Cartopy are problematic to install on most platforms. - Consider finding other packages for map loading and charts projections. -- 2: Add support for multiple map data formats (.gis, .gdb, .json) from any region in - the world, in all UTM zones or lat/lon coordinates. -- 3: Use another plotting framework that has higher refresh rate or is feasible for - real-time (Qt?, React?). -- 4: Add options for plotting trajectories, ships, traffic information/AIS data etc. - on the frontend display. -- 5: Add support for reading and loading in weather data (wind and current - maps++) from a separate module. - ## Prerequisites diff --git a/seacharts/__init__.py b/seacharts/__init__.py index 7a997c1..d64e1da 100644 --- a/seacharts/__init__.py +++ b/seacharts/__init__.py @@ -1,2 +1,5 @@ +""" +Contains and exposes the ENC class and its Config class for the maritime spatial API. +""" +from .core import Config from .enc import ENC -from .utils.config import Config diff --git a/seacharts/core/__init__.py b/seacharts/core/__init__.py new file mode 100644 index 0000000..d8b5816 --- /dev/null +++ b/seacharts/core/__init__.py @@ -0,0 +1,8 @@ +""" +Contains core classes and functions used throughout the SeaCharts package. +""" +from . import files +from . import paths +from .config import Config +from .parser import DataParser +from .scope import Scope diff --git a/seacharts/utils/config.py b/seacharts/core/config.py similarity index 96% rename from seacharts/utils/config.py rename to seacharts/core/config.py index 731085b..e5387a7 100644 --- a/seacharts/utils/config.py +++ b/seacharts/core/config.py @@ -1,3 +1,6 @@ +""" +Contains the Config class for ENC configuration settings. +""" from pathlib import Path import yaml @@ -9,7 +12,7 @@ class Config: """ - Class for maintaining Electronic Navigational Charts configuration settings + Class for maintaining Electronic Navigational Charts configuration settings. """ def __init__(self, config_path: Path | str = None): diff --git a/seacharts/environment/extent.py b/seacharts/core/extent.py similarity index 93% rename from seacharts/environment/extent.py rename to seacharts/core/extent.py index 3c49c04..de94b46 100644 --- a/seacharts/environment/extent.py +++ b/seacharts/core/extent.py @@ -1,7 +1,8 @@ -from dataclasses import dataclass +""" +Contains the Extent class for defining the span of spatial data. +""" -@dataclass class Extent: def __init__(self, settings: dict): self.size = tuple(settings["enc"].get("size", (0, 0))) diff --git a/seacharts/utils/files.py b/seacharts/core/files.py similarity index 93% rename from seacharts/utils/files.py rename to seacharts/core/files.py index f3e4546..d96b9ac 100644 --- a/seacharts/utils/files.py +++ b/seacharts/core/files.py @@ -1,6 +1,5 @@ """ -Contains file/directory-related utility functions, such as functions for -writing to csv files. +Contains utility functions related to system files and directories. """ import csv from collections.abc import Generator diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py new file mode 100644 index 0000000..a13ba93 --- /dev/null +++ b/seacharts/core/parser.py @@ -0,0 +1,164 @@ +""" +Contains the DataParser class for spatial data parsing. +""" +import time +import warnings +from pathlib import Path +from typing import Generator + +import fiona + +from seacharts.core import paths +from seacharts.layers import labels, Layer + + +class DataParser: + def __init__( + self, + bounding_box: tuple[int, int, int, int], + path_strings: list[str], + ): + self.bounding_box = bounding_box + self.paths = set([p.resolve() for p in (map(Path, path_strings))]) + self.paths.update(paths.default_resources) + + def load_shapefiles(self, layers: list[Layer]) -> None: + for layer in layers: + records = list(self._read_shapefile(layer.label)) + layer.records_as_geometry(records) + + def parse_resources( + self, + regions_list: list[Layer], + resources: list[str], + area: float + ) -> None: + if not list(self._gdb_paths): + resources = sorted(list(set(resources))) + if not resources: + print("WARNING: No spatial data source location given in config.") + else: + message = "WARNING: No spatial data sources were located in\n" + message += " " + resources = [f"'{r}'" for r in resources] + message += ", ".join(resources[:-1]) + if len(resources) > 1: + message += f" and {resources[-1]}" + print(message + ".") + return + print("INFO: Updating ENC with data from available resources...\n") + print(f"Processing {area // 10 ** 6} km^2 of ENC features:") + for regions in regions_list: + start_time = time.time() + records = self._load_from_fgdb(regions) + info = f"{len(records)} {regions.name} geometries" + + if not records: + print(f"\rFound {info}.") + return + else: + print(f"\rMerging {info}...", end="") + regions.unify(records) + + print(f"\rSimplifying {info}...", end="") + regions.simplify(0) + + print(f"\rBuffering {info}...", end="") + regions.buffer(0) + + print(f"\rClipping {info}...", end="") + regions.clip(self.bounding_box) + + self._write_to_shapefile(regions) + end_time = round(time.time() - start_time, 1) + print(f"\rSaved {info} to shapefile in {end_time} s.") + + @property + def _gdb_paths(self) -> Generator[Path, None, None]: + for path in self.paths: + if not path.is_absolute(): + path = paths.cwd / path + if self._is_gdb(path): + yield path + elif path.is_dir(): + for p in path.iterdir(): + if self._is_gdb(p): + yield p + + def _load_from_fgdb(self, layer: Layer) -> list[dict]: + depth = layer.depth if hasattr(layer, "depth") else 0 + external_labels = labels.NORWEGIAN_LABELS[layer.__class__.__name__] + return list(self._read_fgdb(layer.label, external_labels, depth)) + + def _parse_layers( + self, path: Path, external_labels: list[str], depth: int + ) -> Generator: + for label in external_labels: + if isinstance(label, dict): + layer, depth_label = label["layer"], label["depth"] + records = self._read_spatial_file(path, layer=layer) + for record in records: + if record["properties"][depth_label] >= depth: + yield record + else: + yield from self._read_spatial_file(path, layer=label) + + def _read_fgdb( + self, name: str, external_labels: list[str], depth: int + ) -> Generator: + for gdb_path in self._gdb_paths: + records = self._parse_layers(gdb_path, external_labels, depth) + yield from self._parse_records(records, name) + + def _read_shapefile(self, label: str) -> Generator: + file_path = self._shapefile_path(label) + if file_path.exists(): + yield from self._read_spatial_file(file_path) + + def _read_spatial_file(self, path: Path, **kwargs) -> Generator: + try: + with fiona.open(path, "r", **kwargs) as source: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=RuntimeWarning) + for record in source.filter(bbox=self.bounding_box): + yield record + except ValueError as e: + message = str(e) + if "Null layer: " in message: + message = f"Warning: {message[12:]} not found in data set." + print(message) + return + + def _shapefile_writer(self, file_path, geometry_type): + return fiona.open( + file_path, + "w", + schema=self._as_record("int", geometry_type), + driver="ESRI Shapefile", + crs={"init": "epsg:25833"}, + ) + + def _write_to_shapefile(self, regions: Layer): + geometry = regions.mapping + file_path = self._shapefile_path(regions.label) + with self._shapefile_writer(file_path, geometry["type"]) as sink: + sink.write(self._as_record(regions.depth, geometry)) + + @staticmethod + def _as_record(depth, geometry): + return {"properties": {"depth": depth}, "geometry": geometry} + + @staticmethod + def _is_gdb(path: Path) -> bool: + return path.is_dir() and path.suffix == ".gdb" + + @staticmethod + def _parse_records(records, name): + for i, record in enumerate(records): + print(f"\rNumber of {name} records read: {i + 1}", end="") + yield record + return + + @staticmethod + def _shapefile_path(label): + return paths.shapefiles / label / (label + ".shp") diff --git a/seacharts/utils/paths.py b/seacharts/core/paths.py similarity index 85% rename from seacharts/utils/paths.py rename to seacharts/core/paths.py index f72c1e5..c1d6ad1 100644 --- a/seacharts/utils/paths.py +++ b/seacharts/core/paths.py @@ -1,5 +1,5 @@ """ -Contains hard-coded paths to relevant files and directories +Contains hard-coded paths to relevant files and directories. """ from pathlib import Path diff --git a/seacharts/environment/scope.py b/seacharts/core/scope.py similarity index 72% rename from seacharts/environment/scope.py rename to seacharts/core/scope.py index 47f68aa..988e236 100644 --- a/seacharts/environment/scope.py +++ b/seacharts/core/scope.py @@ -1,6 +1,9 @@ +""" +Contains the Extent class for defining details related to files of spatial data. +""" from dataclasses import dataclass -import seacharts.utils as utils +from seacharts.core import files from .extent import Extent @@ -12,8 +15,7 @@ def __init__(self, settings: dict): self.extent = Extent(settings) self.depths = settings["enc"].get("depths", self.default_depths) self.resources = settings["enc"].get("resources", []) - self.parser = utils.ShapefileParser(self.extent.bbox, self.resources) self.features = ["land", "shore"] for depth in self.depths: self.features.append(f"seabed{depth}m") - utils.files.build_directory_structure(self.features, self.resources) + files.build_directory_structure(self.features, self.resources) diff --git a/seacharts/display/__init__.py b/seacharts/display/__init__.py index 6332319..fefd874 100644 --- a/seacharts/display/__init__.py +++ b/seacharts/display/__init__.py @@ -1 +1,4 @@ +""" +Contains the Display class for displaying and plotting maritime spatial data. +""" from .display import Display diff --git a/seacharts/display/colors.py b/seacharts/display/colors.py index e509ea1..84b38f0 100644 --- a/seacharts/display/colors.py +++ b/seacharts/display/colors.py @@ -1,3 +1,6 @@ +""" +Contains functions and structures for color management. +""" import matplotlib.colors as clr import matplotlib.pyplot as plt import numpy as np @@ -43,9 +46,9 @@ def _greens(bins: int = 9) -> np.ndarray: ) _layer_colors = dict( - seabed=_blues()[0], - land=_greens()[4], - shore=_greens()[3], + Seabed=_blues()[0], + Land=_greens()[4], + Shore=_greens()[3], highlight=("#ffffff44", "#ffffff44"), blank=("#ffffffff", "#ffffffff"), ) @@ -69,10 +72,10 @@ def color_picker(name: str, bins: int = None) -> tuple: def colorbar(axes: Axes, depths: list[int]) -> Colorbar: depths = list(depths) ocean = list(_blues(len(depths))) - colors = [_layer_colors["shore"]] + ocean[:-1] + colors = [_layer_colors["Shore"]] + ocean[:-1] c_map = clr.LinearSegmentedColormap.from_list("Custom terrain", colors, len(colors)) c_map.set_over(ocean[-1]) - c_map.set_under(_layer_colors["land"]) + c_map.set_under(_layer_colors["Land"]) norm = clr.BoundaryNorm([0] + depths[1:], c_map.N) kwargs = dict( extend="both", diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 3912e19..6ce0a83 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -1,5 +1,6 @@ -from __future__ import annotations - +""" +Contains the Display class for displaying and plotting maritime spatial data. +""" import tkinter as tk from pathlib import Path from typing import Any diff --git a/seacharts/display/events.py b/seacharts/display/events.py index 1824275..eff0603 100644 --- a/seacharts/display/events.py +++ b/seacharts/display/events.py @@ -1,3 +1,6 @@ +""" +Contains the EventsManager class for managing display interaction events. +""" from typing import Any import matplotlib.pyplot as plt diff --git a/seacharts/display/features.py b/seacharts/display/features.py index 0bb1fd8..be81877 100644 --- a/seacharts/display/features.py +++ b/seacharts/display/features.py @@ -1,8 +1,10 @@ +""" +Contains the FeaturesManager class for plotting spatial features on a display. +""" import shapely.geometry as geo from cartopy.feature import ShapelyFeature -import seacharts.spatial as spl -import seacharts.utils as utils +from seacharts import shapes, core from .colors import color_picker @@ -18,22 +20,23 @@ def __init__(self, display): self._init_layers() def _init_layers(self): - layers = self._display._environment.hydrography.loaded_layers - for i, layer in enumerate(layers): - rank = layer.z_order + i - bins = len(self._display._environment.scope.depths) - color = color_picker(i, bins) - artist = self.new_artist(layer.geometry, color, rank) - self._seabeds[rank] = artist - shore = self._display._environment.topography.shore - color = color_picker(shore.color) - self._shore = self.new_artist(shore.geometry, color, shore.z_order) - land = self._display._environment.topography.land - color = color_picker(land.color) - self._land = self.new_artist(land.geometry, color, land.z_order) + seabeds = list(self._display._environment.map.bathymetry.values()) + for i, seabed in enumerate(seabeds): + if not seabed.geometry.is_empty: + rank = -300 + i + bins = len(self._display._environment.scope.depths) + color = color_picker(i, bins) + artist = self.new_artist(seabed.geometry, color, rank) + self._seabeds[rank] = artist + shore = self._display._environment.map.shore + color = color_picker(shore.__class__.__name__) + self._shore = self.new_artist(shore.geometry, color, -200) + land = self._display._environment.map.land + color = color_picker(land.__class__.__name__) + self._land = self.new_artist(land.geometry, color, -100) center = self._display._environment.scope.extent.center size = self._display._environment.scope.extent.size - geometry = spl.Rectangle( + geometry = shapes.Rectangle( *center, width=size[0] / 2, heading=0, height=size[1] / 2 ).geometry color = (color_picker("black")[0], "none") @@ -64,11 +67,11 @@ def add_arrow( buffer = 5 if head_size is None: head_size = 50 - body = spl.Arrow(start=start, end=end, width=buffer).body(head_size) + body = shapes.Arrow(start=start, end=end, width=buffer).body(head_size) self.add_overlay(body, color_name, fill, linewidth, linestyle) def add_circle(self, center, radius, color_name, fill, linewidth, linestyle, alpha): - geometry = spl.Circle(*center, radius).geometry + geometry = shapes.Circle(*center, radius).geometry self.add_overlay(geometry, color_name, fill, linewidth, linestyle, alpha) def add_line(self, points, color_name, buffer, linewidth, linestyle, marker): @@ -86,7 +89,7 @@ def add_line(self, points, color_name, buffer, linewidth, linestyle, marker): transform=self._display.crs, ) else: - geometry = spl.Line(points=points).geometry.buffer(buffer) + geometry = shapes.Line(points=points).geometry.buffer(buffer) self.add_overlay(geometry, color_name, True, linewidth, linestyle) def add_polygon( @@ -104,13 +107,13 @@ def add_polygon( if isinstance(shape[0], tuple) or isinstance(shape[0], list): shape = [shape] for geometry in shape: - geometry = spl.Area.new_polygon(geometry, interiors) + geometry = shapes.Area.new_polygon(geometry, interiors) self.add_overlay(geometry, color, fill, linewidth, linestyle, alpha) def add_rectangle( self, center, size, color_name, rotation, fill, linewidth, linestyle, alpha ): - geometry = spl.Rectangle( + geometry = shapes.Rectangle( *center, heading=rotation, width=size[0], height=size[1] ).geometry self.add_overlay(geometry, color_name, fill, linewidth, linestyle, alpha) @@ -129,7 +132,7 @@ def add_overlay(self, geometry, color_name, fill, linewidth, linestyle, alpha=1. def update_vessels(self): if self.show_vessels: - entries = list(utils.files.read_ship_poses()) + entries = list(core.files.read_ship_poses()) if entries is not None: new_vessels = {} for ship_details in entries: @@ -145,7 +148,7 @@ def update_vessels(self): lon_scale=float(other[2]) if len(other) > 2 else 2.0, lat_scale=float(other[3]) if len(other) > 3 else 1.0, ) - ship = spl.Ship(*pose, **kwargs) + ship = shapes.Ship(*pose, **kwargs) artist = self.new_artist(ship.geometry, color) if self._vessels.get(ship_id, None): self._vessels.pop(ship_id)["artist"].remove() @@ -210,7 +213,7 @@ def _next_visibility_layer(artists, visibility): @staticmethod def vessels_to_file(vessel_poses: list[tuple]) -> None: - utils.files.write_rows_to_csv( + core.files.write_rows_to_csv( [("id", "x", "y", "heading", "color")] + vessel_poses, - utils.paths.vessels, + core.paths.vessels, ) diff --git a/seacharts/enc.py b/seacharts/enc.py index 6ff69e8..5443972 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -1,9 +1,12 @@ +""" +Contains the ENC class for reading, storing and plotting maritime spatial data. +""" from pathlib import Path +from seacharts.core import Config from seacharts.display import Display from seacharts.environment import Environment -from seacharts.spatial.base import Layer -from seacharts.utils.config import Config +from seacharts.layers import Layer class ENC: @@ -28,7 +31,7 @@ def update(self) -> None: Update ENC with spatial data parsed from user-specified resources :return: None """ - self._environment.parse_data_into_shapefiles() + self._environment.map.parse_resources_into_shapefiles() @property def display(self) -> Display: @@ -44,21 +47,21 @@ def land(self) -> Layer: """ :return: land layer container of Shapely geometries """ - return self._environment.topography.land + return self._environment.map.land @property def shore(self) -> Layer: """ :return: shore layer container of Shapely geometries """ - return self._environment.topography.shore + return self._environment.map.shore @property def seabed(self) -> dict[int, Layer]: """ :return: seabed dict of Shapely geometries for each depth bin """ - return self._environment.hydrography.bathymetry + return self._environment.map.bathymetry @property def size(self) -> tuple[int, int]: diff --git a/seacharts/environment/__init__.py b/seacharts/environment/__init__.py index 34d3b88..776e919 100644 --- a/seacharts/environment/__init__.py +++ b/seacharts/environment/__init__.py @@ -1 +1,4 @@ +""" +Contains the Environment class for collecting and manipulating loaded spatial data. +""" from .environment import Environment diff --git a/seacharts/environment/collection.py b/seacharts/environment/collection.py new file mode 100644 index 0000000..4ca86b0 --- /dev/null +++ b/seacharts/environment/collection.py @@ -0,0 +1,19 @@ +""" +Contains the DataCollection abstract class for containing parsed spatial data. +""" +from abc import abstractmethod, ABC +from dataclasses import dataclass, field + +from seacharts.core import Scope, DataParser +from seacharts.layers import Layer + + +@dataclass +class DataCollection(ABC): + scope: Scope + parser: DataParser = field(init=False) + + @property + @abstractmethod + def layers(self) -> list[Layer]: + raise NotImplementedError diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index 2fa5075..40ba665 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -1,41 +1,19 @@ -import seacharts.spatial as spl -from .scope import Scope +""" +Contains the Environment class for collecting and manipulating loaded spatial data. +""" +from seacharts.core import Scope +from .map import MapData +from .user import UserData +from .weather import WeatherData class Environment: def __init__(self, settings: dict): self.scope = Scope(settings) - self.hydrography = spl.Hydrography(self.scope) - self.topography = spl.Topography(self.scope) - self.load_existing_shapefiles() + self.map = MapData(self.scope) + self.user = UserData(self.scope) + self.weather = WeatherData(self.scope) - def load_existing_shapefiles(self) -> None: - self.hydrography.load(self.scope) - self.topography.load(self.scope) - if self.hydrography.loaded or self.topography.loaded: - print("INFO: ENC created using data from existing shapefiles.\n") - else: - print("INFO: No existing spatial data was found.") - self.parse_data_into_shapefiles() - - def parse_data_into_shapefiles(self) -> None: - if not list(self.scope.parser.gdb_paths): - resources = sorted(list(set(self.scope.resources))) - if not resources: - print("WARNING: No spatial data source location given in config.") - else: - message = "WARNING: No spatial data sources were located in\n" - message += " " - resources = [f"'{r}'" for r in resources] - message += ", ".join(resources[:-1]) - if len(resources) > 1: - message += f" and {resources[-1]}" - print(message + ".") - return - print("INFO: Updating ENC with data from available resources...") - self.hydrography.parse(self.scope) - self.topography.parse(self.scope) - if self.hydrography.loaded or self.topography.loaded: - print("\nENC update complete.\n") - else: - print("WARNING: Given spatial data source(s) seem empty.\n") + self.map.load_existing_shapefiles() + if not self.map.loaded: + self.map.parse_resources_into_shapefiles() diff --git a/seacharts/environment/map.py b/seacharts/environment/map.py new file mode 100644 index 0000000..dbfb289 --- /dev/null +++ b/seacharts/environment/map.py @@ -0,0 +1,49 @@ +""" +Contains the MapData class for containing parsed map (charts) data. +""" +from dataclasses import dataclass + +from seacharts.core import DataParser +from seacharts.layers import Layer, Land, Shore, Seabed +from .collection import DataCollection + + +@dataclass +class MapData(DataCollection): + def __post_init__(self): + self.bathymetry = {d: Seabed(d) for d in self.scope.depths} + self.land = Land() + self.shore = Shore() + self.parser = DataParser(self.scope.extent.bbox, self.scope.resources) + + def load_existing_shapefiles(self) -> None: + self.parser.load_shapefiles(self.featured_regions) + if self.loaded: + print("INFO: ENC created using data from existing shapefiles.\n") + else: + print("INFO: No existing spatial data was found.\n") + + def parse_resources_into_shapefiles(self) -> None: + self.parser.parse_resources( + self.featured_regions, self.scope.resources, self.scope.extent.area + ) + if self.loaded: + print("\nENC update complete.\n") + else: + print("WARNING: Given spatial data source(s) seem empty.\n") + + @property + def layers(self) -> list[Layer]: + return [self.land, self.shore, *self.bathymetry.values()] + + @property + def loaded(self) -> bool: + return any(self.loaded_regions) + + @property + def loaded_regions(self) -> list[Layer]: + return [layer for layer in self.layers if not layer.geometry.is_empty] + + @property + def featured_regions(self) -> list[Layer]: + return [x for x in self.layers if x.label in self.scope.features] diff --git a/seacharts/environment/user.py b/seacharts/environment/user.py new file mode 100644 index 0000000..9d35034 --- /dev/null +++ b/seacharts/environment/user.py @@ -0,0 +1,14 @@ +""" +Contains the UserData class for containing user-specified spatial data. +""" +from seacharts.layers import Layer +from .collection import DataCollection + + +class UserData(DataCollection): + def __post_init__(self): + self.shapes = {} + + @property + def layers(self) -> list[Layer]: + return list(self.shapes.values()) diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py new file mode 100644 index 0000000..ecb3c00 --- /dev/null +++ b/seacharts/environment/weather.py @@ -0,0 +1,18 @@ +""" +Contains the WeatherData class for containing parsed weather data. +""" +from seacharts.layers import Layer +from .collection import DataCollection + + +class WeatherData(DataCollection): + def __post_init__(self): + ... + + @property + def layers(self) -> list[Layer]: + return [] + + @property + def loaded(self) -> bool: + return any(self.layers) diff --git a/seacharts/layers/__init__.py b/seacharts/layers/__init__.py new file mode 100644 index 0000000..c815d57 --- /dev/null +++ b/seacharts/layers/__init__.py @@ -0,0 +1,5 @@ +""" +Contains data classes for containing layered spatial data. +""" +from .layer import Layer +from .layers import Seabed, Land, Shore diff --git a/seacharts/layers/labels.py b/seacharts/layers/labels.py new file mode 100644 index 0000000..d1eba5f --- /dev/null +++ b/seacharts/layers/labels.py @@ -0,0 +1,24 @@ +""" +Contains dictionaries of supported regional database labels. +""" +NORWEGIAN_LABELS = dict( + Seabed=[ + dict( + layer="dybdeareal", # depth area + depth="minimumsdybde", # minimum depth + ), + dict( + layer="grunne", # shallows + depth="dybde", # depth + ) + ], + Land=[ + "landareal", # land area + ], + Shore=[ + "ikkekartlagtsjomaltomr", # unmapped ocean areas + "landareal", # land area (shore area includes all land) + "skjer", # rocks + "torrfall", # dry fall + ], +) diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py new file mode 100644 index 0000000..37c1e74 --- /dev/null +++ b/seacharts/layers/layer.py @@ -0,0 +1,47 @@ +""" +Contains the Layer class and depth-specific types for layered spatial data. +""" +from abc import ABC +from dataclasses import dataclass, field + +from shapely import geometry as geo + +from seacharts.layers.types import ZeroDepth, SingleDepth, MultiDepth +from seacharts.shapes import Shape + + +@dataclass +class Layer(Shape, ABC): + geometry: geo.MultiPolygon = field(default_factory=geo.MultiPolygon) + depth: int = None + + @property + def label(self) -> str: + return self.name.lower() + + def records_as_geometry(self, records: list[dict]) -> None: + if len(records) > 0: + self.geometry = self._record_to_geometry(records[0]) + if isinstance(self.geometry, geo.Polygon): + self.geometry = self.as_multi(self.geometry) + + def unify(self, records: list[dict]) -> None: + geometries = [self._record_to_geometry(r) for r in records] + self.geometry = self.collect(geometries) + + +@dataclass +class ZeroDepthLayer(Layer, ZeroDepth, ABC): + ... + + +@dataclass +class SingleDepthLayer(Layer, SingleDepth, ABC): + @property + def name(self) -> str: + return self.__class__.__name__ + f"{self.depth}m" + + +@dataclass +class MultiDepthLayer(Layer, MultiDepth, ABC): + ... diff --git a/seacharts/layers/layers.py b/seacharts/layers/layers.py new file mode 100644 index 0000000..c6c9bf9 --- /dev/null +++ b/seacharts/layers/layers.py @@ -0,0 +1,21 @@ +""" +Contains depth-specific layer definitions used by the MapData container class. +""" +from dataclasses import dataclass + +from seacharts.layers.layer import SingleDepthLayer, ZeroDepthLayer + + +@dataclass +class Seabed(SingleDepthLayer): + ... + + +@dataclass +class Land(ZeroDepthLayer): + ... + + +@dataclass +class Shore(ZeroDepthLayer): + ... diff --git a/seacharts/layers/types.py b/seacharts/layers/types.py new file mode 100644 index 0000000..86a4f18 --- /dev/null +++ b/seacharts/layers/types.py @@ -0,0 +1,21 @@ +""" +Contains attribute-specific subtypes for inheritance usage with the Layer class. +""" +from dataclasses import dataclass + + +@dataclass +class ZeroDepth: + depth = 0 + + +@dataclass +class SingleDepth: + depth: int + + +@dataclass +class MultiDepth: + @property + def depth(self) -> None: + raise AttributeError("Multi-depth shapes have no single depth.") diff --git a/seacharts/shapes/__init__.py b/seacharts/shapes/__init__.py new file mode 100644 index 0000000..c20b1a4 --- /dev/null +++ b/seacharts/shapes/__init__.py @@ -0,0 +1,7 @@ +""" +Contains multiple convenience classes for creating and manipulating shapes. +""" +from .areas import Area, Circle +from .bodies import Rectangle, Ship +from .lines import Arrow, Line +from .shape import Shape diff --git a/seacharts/shapes/areas.py b/seacharts/shapes/areas.py new file mode 100644 index 0000000..3d6ad66 --- /dev/null +++ b/seacharts/shapes/areas.py @@ -0,0 +1,28 @@ +""" +Contains convenience classes for creating and manipulating area-based shapes. +""" +from dataclasses import dataclass, field + +from shapely import geometry as geo + +from . import shape, types + + +@dataclass +class Area(shape.Shape): + geometry: geo.Polygon = field(default_factory=geo.Polygon) + + @staticmethod + def new_polygon(exterior: list, interiors=None) -> geo.Polygon: + return geo.Polygon(exterior, interiors) + + +@dataclass +class Circle(Area, types.Radial, types.Coordinates): + def __post_init__(self): + if self.radius <= 0: + raise ValueError( + f"{self.__class__.__name__} " f"should have a positive area" + ) + self.center = geo.Point(self.x, self.y) + self.geometry = geo.Polygon(self.center.buffer(self.radius)) diff --git a/seacharts/shapes/bodies.py b/seacharts/shapes/bodies.py new file mode 100644 index 0000000..3e342b5 --- /dev/null +++ b/seacharts/shapes/bodies.py @@ -0,0 +1,65 @@ +""" +Contains convenience classes for creating and manipulating spatial bodies. +""" +from dataclasses import dataclass + +from shapely import geometry as geo, affinity + +from . import areas, types + + +@dataclass +class Body(areas.Area, types.Oriented, types.Coordinates): + scale: float = 1.0 + + def __post_init__(self): + self.center = geo.Point(self.x, self.y) + self.geometry = self.rotate(self._body_polygon()) + + def _body_polygon(self) -> geo.Polygon: + raise NotImplementedError + + def rotate(self, polygon: geo.Polygon) -> geo.Polygon: + return affinity.rotate( + polygon, + -self.heading, + use_radians=not self.in_degrees, + origin=(self.center.x, self.center.y), + ) + + +@dataclass +class Rectangle(Body): + width: float = 0.0 + height: float = 0.0 + + def _body_polygon(self) -> geo.Polygon: + if not self.width > 0 or not self.height > 0: + raise ValueError( + f"{self.__class__.__name__} " f"should have a positive area" + ) + return geo.Polygon( + ( + (self.x - self.width, self.y - self.height), + (self.x + self.width, self.y - self.height), + (self.x + self.width, self.y + self.height), + (self.x - self.width, self.y + self.height), + ) + ) + + +@dataclass +class Ship(Body): + dimensions = 16, 80 + lon_scale: float = 10.0 + lat_scale: float = 10.0 + + def _body_polygon(self) -> geo.Polygon: + x, y = self.x, self.y + w, h = (d * self.scale for d in self.dimensions) + x_min, x_max = x - w / 2, x + w / 2 + y_min, y_max = y - h / 2, y + h / 2 - w + left_aft, right_aft = (x_min, y_min), (x_max, y_min) + left_bow, right_bow = (x_min, y_max), (x_max, y_max) + coords = [left_aft, left_bow, (x, y + h / 2), right_bow, right_aft] + return geo.Polygon(coords) diff --git a/seacharts/shapes/lines.py b/seacharts/shapes/lines.py new file mode 100644 index 0000000..2bf4079 --- /dev/null +++ b/seacharts/shapes/lines.py @@ -0,0 +1,66 @@ +""" +Contains convenience classes for creating and manipulating line-based shapes. +""" +from dataclasses import dataclass + +from shapely import geometry as geo + +from seacharts.shapes import shape + + +@dataclass +class Line(shape.Shape): + points: list[tuple[float, float]] = None + + def __post_init__(self): + if self.points is None or len(self.points) < 2: + raise ValueError( + f"{self.__class__.__name__} must contain at least " + f"2 pairs of coordinates" + ) + self.geometry = geo.LineString(self.points) + + +@dataclass +class Arrow(shape.Shape): + start: tuple[float, float] = None + end: tuple[float, float] = None + width: float = None + + def __post_init__(self): + if self.start is None or self.end is None: + raise ValueError( + f"{self.__class__.__name__} must have a start and an end point" + ) + self.geometry = geo.LineString((self.start, self.end)) + + @property + def vector(self) -> tuple[float, float]: + return self.end[0] - self.start[0], self.end[1] - self.start[1] + + def body(self, head_size: int) -> geo.Polygon: + if not head_size >= 0: + raise ValueError( + f"{self.__class__.__name__} should have non-negative head size" + ) + length = self.geometry.length + arrow_head_length = max(length - head_size, 0) + x1, y1 = self.start + x2, y2 = self.geometry.interpolate(arrow_head_length).coords[0] + unit = self.vector[0] / length, self.vector[1] / length + dx1, dy1 = -unit[1] * self.width, unit[0] * self.width + dx2, dy2 = dx1 * 3, dy1 * 3 + tip_left, tip_right = (x2 + dx2, y2 + dy2), (x2 - dx2, y2 - dy2) + base_left, base_right = (x2 + dx1, y2 + dy1), (x2 - dx1, y2 - dy1) + start_left, start_right = (x1 + dx1, y1 + dy1), (x1 - dx1, y1 - dy1) + return geo.Polygon( + ( + self.end, + tip_left, + base_left, + start_left, + start_right, + base_right, + tip_right, + ) + ) diff --git a/seacharts/shapes/shape.py b/seacharts/shapes/shape.py new file mode 100644 index 0000000..ea90524 --- /dev/null +++ b/seacharts/shapes/shape.py @@ -0,0 +1,64 @@ +""" +Contains the Shape base class for creating and manipulating shapes. +""" +from abc import ABC +from dataclasses import dataclass +from typing import Any + +from shapely import geometry as geo, ops + + +@dataclass +class Shape(ABC): + geometry: geo.base.BaseGeometry = None + color: str = None + z_order: int = None + artist: Any = None + + def simplify(self, tolerance: int, preserve_topology: bool = True) -> None: + self.geometry = self.geometry.simplify(tolerance, preserve_topology) + + def clip(self, bbox: tuple[int, int, int, int]) -> None: + bounding_box = geo.box(*bbox) + self.geometry = bounding_box.intersection(self.geometry) + + def buffer(self, distance: int) -> None: + self.geometry = self.geometry.buffer(distance) + + def merge(self, other: "Shape") -> None: + self.geometry = self.geometry.union(other.geometry) + + def closest_points(self, geometry: Any) -> geo.Point: + return ops.nearest_points(self.geometry, geometry)[1] + + @property + def mapping(self) -> dict: + return geo.mapping(self.geometry) + + @property + def name(self) -> str: + return self.__class__.__name__ + + @staticmethod + def _record_to_geometry(record: dict) -> Any: + return geo.shape(record["geometry"]) + + @staticmethod + def as_multi(geometry: Any) -> Any: + if isinstance(geometry, geo.Point): + return geo.MultiPoint([geometry]) + elif isinstance(geometry, geo.Polygon): + return geo.MultiPolygon([geometry]) + elif isinstance(geometry, geo.LineString): + return geo.MultiLineString([geometry]) + else: + raise NotImplementedError(type(geometry)) + + @staticmethod + def collect(geometries: list[Any]) -> Any: + if any(not g.is_valid for g in geometries): + geometries = [g.buffer(0) if not g.is_valid else g for g in geometries] + geometry = ops.unary_union(geometries) + if not geometry.is_valid: + geometry = geometry.buffer(0) + return geometry diff --git a/seacharts/shapes/types.py b/seacharts/shapes/types.py new file mode 100644 index 0000000..f24845d --- /dev/null +++ b/seacharts/shapes/types.py @@ -0,0 +1,26 @@ +""" +Contains attribute-specific subtypes for inheritance usage with the Shape class. +""" +from dataclasses import dataclass + + +@dataclass +class Coordinates: + x: float + y: float + + +@dataclass +class Vector(Coordinates): + pass + + +@dataclass +class Radial: + radius: float + + +@dataclass +class Oriented: + heading: float + in_degrees: bool = True diff --git a/seacharts/spatial/__init__.py b/seacharts/spatial/__init__.py deleted file mode 100644 index 798f1bb..0000000 --- a/seacharts/spatial/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .base import Shape -from .hypsometry import Hydrography, Topography -from .shapes import Area, Arrow, Circle, Line, Rectangle, Ship diff --git a/seacharts/spatial/base.py b/seacharts/spatial/base.py deleted file mode 100644 index e6477a4..0000000 --- a/seacharts/spatial/base.py +++ /dev/null @@ -1,185 +0,0 @@ -from __future__ import annotations - -from abc import ABC -from dataclasses import dataclass, field -from typing import Any - -from shapely import geometry as geo -from shapely import ops - -from seacharts.utils import ShapefileParser - - -@dataclass -class Drawable: - scale: float = field(init=False, repr=False) - color: str = field(init=False, repr=False) - z_order: int = field(init=False, repr=False) - artist: Any = field(init=False, repr=False) - - -@dataclass -class Coordinates: - x: float - y: float - - -@dataclass -class Vector(Coordinates): - pass - - -@dataclass -class Radial: - radius: float - - -@dataclass -class Oriented: - heading: float - in_degrees: bool = True - - -@dataclass -class ZeroDepth: - depth = 0 - - -@dataclass -class SingleDepth: - depth: int - - -@dataclass -class MultiDepth: - @property - def depth(self) -> None: - raise AttributeError("Multi-depth shapes have no single depth.") - - -@dataclass -class Shape(Drawable, ABC): - geometry: geo.base.BaseGeometry = None - - def simplify(self, tolerance: int, preserve_topology: bool = True) -> None: - self.geometry = self.geometry.simplify(tolerance, preserve_topology) - - def clip(self, bbox: tuple[int, int, int, int]) -> None: - bounding_box = geo.box(*bbox) - self.geometry = bounding_box.intersection(self.geometry) - - def buffer(self, distance: int) -> None: - self.geometry = self.geometry.buffer(distance) - - def merge(self, other: Shape) -> None: - self.geometry = self.geometry.union(other.geometry) - - def closest_points(self, geometry: Any) -> geo.Point: - return ops.nearest_points(self.geometry, geometry)[1] - - @property - def mapping(self) -> dict: - return geo.mapping(self.geometry) - - @property - def name(self) -> str: - return self.__class__.__name__ - - @staticmethod - def _record_to_geometry(record: dict) -> Any: - return geo.shape(record["geometry"]) - - @staticmethod - def as_multi(geometry: Any) -> Any: - if isinstance(geometry, geo.Point): - return geo.MultiPoint([geometry]) - elif isinstance(geometry, geo.Polygon): - return geo.MultiPolygon([geometry]) - elif isinstance(geometry, geo.LineString): - return geo.MultiLineString([geometry]) - else: - raise NotImplementedError(type(geometry)) - - @staticmethod - def collect(geometries: list[Any]) -> Any: - if any(not g.is_valid for g in geometries): - geometries = [g.buffer(0) if not g.is_valid else g for g in geometries] - geometry = ops.unary_union(geometries) - if not geometry.is_valid: - geometry = geometry.buffer(0) - return geometry - - -@dataclass -class Layer(Shape, ABC): - @property - def _external_labels(self) -> list[str]: - raise NotImplementedError - - @property - def name(self) -> str: - return self.__class__.__name__ - - @property - def label(self) -> str: - return self.name.lower() - - def save(self, parser: ShapefileParser) -> None: - parser.write(self) - - def load_shapefile(self, parser: ShapefileParser) -> None: - records = list(parser.read_shapefile(self.label)) - if len(records) > 0: - self.geometry = self._record_to_geometry(records[0]) - if isinstance(self.geometry, geo.Polygon): - self.geometry = self.as_multi(self.geometry) - - def load_fgdb(self, parser: ShapefileParser) -> list[dict]: - depth = self.depth if hasattr(self, "depth") else 0 - return list(parser.read_fgdb(self.label, self._external_labels, depth)) - - def unify(self, records: list[dict]) -> None: - geometries = [self._record_to_geometry(r) for r in records] - self.geometry = self.collect(geometries) - - -@dataclass -class Locations(Layer, ABC): - geometry: geo.MultiPoint = field(default_factory=geo.MultiPoint) - - -@dataclass -class ZeroDepthLocations(Locations, ZeroDepth, ABC): - pass - - -@dataclass -class SingleDepthLocations(Locations, SingleDepth, ABC): - pass - - -@dataclass -class MultiDepthLocations(Locations, MultiDepth, ABC): - pass - - -@dataclass -class Regions(Layer, ABC): - geometry: geo.MultiPolygon = field(default_factory=geo.MultiPolygon) - - -@dataclass -class ZeroDepthRegions(Regions, ZeroDepth, ABC): - pass - - -@dataclass -class SingleDepthRegions(Regions, SingleDepth, ABC): - @property - def name(self) -> str: - return self.__class__.__name__ + f"{self.depth}m" - - -@dataclass -class MultiDepthRegions(Regions, MultiDepth, ABC): - pass diff --git a/seacharts/spatial/hypsometry.py b/seacharts/spatial/hypsometry.py deleted file mode 100644 index 5f2d2a5..0000000 --- a/seacharts/spatial/hypsometry.py +++ /dev/null @@ -1,91 +0,0 @@ -import time -from abc import ABC -from dataclasses import InitVar, dataclass, field - -from .base import Layer -from .layers import Land, Seabed, Shore -from ..environment.scope import Scope - - -@dataclass -class _Hypsometry(ABC): - scope: InitVar[Scope] - - def __post_init__(self, scope: Scope): - raise NotImplementedError - - @property - def layers(self) -> list[Layer]: - raise NotImplementedError - - @property - def loaded_layers(self) -> list[Layer]: - return [layer for layer in self.layers if not layer.geometry.is_empty] - - @property - def loaded(self) -> bool: - return any(self.loaded_layers) - - def parse(self, scope: Scope) -> None: - layers = [x for x in self.layers if x.label in scope.features] - if not list(scope.parser.gdb_paths): - return - print( - f"\nProcessing {scope.extent.area // 10 ** 6} km^2 of " - f"{self.__class__.__name__} features:" - ) - for layer in layers: - start_time = time.time() - records = layer.load_fgdb(scope.parser) - info = f"{len(records)} {layer.name} geometries" - - if not records: - print(f"\rFound {info}.") - return - else: - print(f"\rMerging {info}...", end="") - layer.unify(records) - - print(f"\rSimplifying {info}...", end="") - layer.simplify(0) - - print(f"\rBuffering {info}...", end="") - layer.buffer(0) - - print(f"\rClipping {info}...", end="") - layer.clip(scope.extent.bbox) - - layer.save(scope.parser) - end_time = round(time.time() - start_time, 1) - print(f"\rSaved {info} to shapefile in {end_time} s.") - - def load(self, scope: Scope) -> None: - layers = [x for x in self.layers if x.label in scope.features] - for layer in layers: - layer.load_shapefile(scope.parser) - - -@dataclass -class Hydrography(_Hypsometry): - bathymetry: dict[int, Layer] = field(init=False) - - @property - def layers(self) -> list[Layer]: - return [*self.bathymetry.values()] - - def __post_init__(self, scope: Scope): - self.bathymetry = {d: Seabed(d) for d in scope.depths} - - -@dataclass -class Topography(_Hypsometry): - land: Land = field(init=False) - shore: Shore = field(init=False) - - @property - def layers(self) -> list[Layer]: - return [self.land, self.shore] - - def __post_init__(self, scope: Scope): - self.land = Land() - self.shore = Shore() diff --git a/seacharts/spatial/layers.py b/seacharts/spatial/layers.py deleted file mode 100644 index b190d58..0000000 --- a/seacharts/spatial/layers.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Module containing the supported feature layers, which currently only include -labels found in the FGDB distributed by the Norwegian Mapping Authority. -""" -from dataclasses import dataclass - -from . import base - - -@dataclass -class Seabed(base.SingleDepthRegions): - color = "seabed" - z_order = -300 - _external_labels = [ - dict(layer="dybdeareal", depth="minimumsdybde"), - dict(layer="grunne", depth="dybde"), - ] - - -@dataclass -class Land(base.ZeroDepthRegions): - color = "land" - z_order = -100 - _external_labels = ["landareal"] - - -@dataclass -class Shore(base.ZeroDepthRegions): - color = "shore" - z_order = -200 - _external_labels = ["skjer", "torrfall", "landareal", "ikkekartlagtsjomaltomr"] diff --git a/seacharts/spatial/shapes.py b/seacharts/spatial/shapes.py deleted file mode 100644 index d13ff66..0000000 --- a/seacharts/spatial/shapes.py +++ /dev/null @@ -1,140 +0,0 @@ -from dataclasses import dataclass, field - -from shapely import affinity -from shapely import geometry as geo - -from . import base - - -@dataclass -class Area(base.Shape): - geometry: geo.Polygon = field(default_factory=geo.Polygon) - - @staticmethod - def new_polygon(exterior: list, interiors=None) -> geo.Polygon: - return geo.Polygon(exterior, interiors) - - -@dataclass -class Line(base.Shape): - points: list[tuple[float, float]] = None - - def __post_init__(self): - if self.points is None or len(self.points) < 2: - raise ValueError( - f"{self.__class__.__name__} must contain at least " - f"2 pairs of coordinates" - ) - self.geometry = geo.LineString(self.points) - - -@dataclass -class Arrow(base.Shape): - start: tuple[float, float] = None - end: tuple[float, float] = None - width: float = None - - def __post_init__(self): - if self.start is None or self.end is None: - raise ValueError( - f"{self.__class__.__name__} must have a start and an end point" - ) - self.geometry = geo.LineString((self.start, self.end)) - - @property - def vector(self) -> tuple[float, float]: - return self.end[0] - self.start[0], self.end[1] - self.start[1] - - def body(self, head_size: int) -> geo.Polygon: - if not head_size >= 0: - raise ValueError( - f"{self.__class__.__name__} should have non-negative head size" - ) - length = self.geometry.length - arrow_head_length = max(length - head_size, 0) - x1, y1 = self.start - x2, y2 = self.geometry.interpolate(arrow_head_length).coords[0] - unit = self.vector[0] / length, self.vector[1] / length - dx1, dy1 = -unit[1] * self.width, unit[0] * self.width - dx2, dy2 = dx1 * 3, dy1 * 3 - tip_left, tip_right = (x2 + dx2, y2 + dy2), (x2 - dx2, y2 - dy2) - base_left, base_right = (x2 + dx1, y2 + dy1), (x2 - dx1, y2 - dy1) - start_left, start_right = (x1 + dx1, y1 + dy1), (x1 - dx1, y1 - dy1) - return geo.Polygon( - ( - self.end, - tip_left, - base_left, - start_left, - start_right, - base_right, - tip_right, - ) - ) - - -@dataclass -class Circle(Area, base.Radial, base.Coordinates): - def __post_init__(self): - if self.radius <= 0: - raise ValueError( - f"{self.__class__.__name__} " f"should have a positive area" - ) - self.center = geo.Point(self.x, self.y) - self.geometry = geo.Polygon(self.center.buffer(self.radius)) - - -@dataclass -class Body(Area, base.Oriented, base.Coordinates): - def __post_init__(self): - self.center = geo.Point(self.x, self.y) - self.geometry = self.rotate(self._body_polygon()) - - def _body_polygon(self) -> geo.Polygon: - raise NotImplementedError - - def rotate(self, polygon: geo.Polygon) -> geo.Polygon: - return affinity.rotate( - polygon, - -self.heading, - use_radians=not self.in_degrees, - origin=(self.center.x, self.center.y), - ) - - -@dataclass -class Rectangle(Body): - width: float = 0.0 - height: float = 0.0 - - def _body_polygon(self) -> geo.Polygon: - if not self.width > 0 or not self.height > 0: - raise ValueError( - f"{self.__class__.__name__} " f"should have a positive area" - ) - return geo.Polygon( - ( - (self.x - self.width, self.y - self.height), - (self.x + self.width, self.y - self.height), - (self.x + self.width, self.y + self.height), - (self.x - self.width, self.y + self.height), - ) - ) - - -@dataclass -class Ship(Body): - dimensions = 16, 80 - scale: float = 1.0 - lon_scale: float = 10.0 - lat_scale: float = 10.0 - - def _body_polygon(self) -> geo.Polygon: - x, y = self.x, self.y - w, h = (d * self.scale for d in self.dimensions) - x_min, x_max = x - w / 2, x + w / 2 - y_min, y_max = y - h / 2, y + h / 2 - w - left_aft, right_aft = (x_min, y_min), (x_max, y_min) - left_bow, right_bow = (x_min, y_max), (x_max, y_max) - coords = [left_aft, left_bow, (x, y + h / 2), right_bow, right_aft] - return geo.Polygon(coords) diff --git a/seacharts/utils/__init__.py b/seacharts/utils/__init__.py deleted file mode 100644 index 33e7519..0000000 --- a/seacharts/utils/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from . import config -from . import files -from . import paths -from .parser import ShapefileParser diff --git a/seacharts/utils/parser.py b/seacharts/utils/parser.py deleted file mode 100644 index 319b519..0000000 --- a/seacharts/utils/parser.py +++ /dev/null @@ -1,93 +0,0 @@ -import warnings -from collections.abc import Generator -from pathlib import Path - -import fiona - -from . import paths - - -class ShapefileParser: - def __init__(self, bounding_box: tuple, path_strings: list[str]): - self.bounding_box = bounding_box - self.paths = set([p.resolve() for p in (map(Path, path_strings))]) - self.paths.update(paths.default_resources) - - def read_fgdb( - self, label: str, external_labels: list[str], depth: int - ) -> Generator[dict]: - for gdb_path in self.gdb_paths: - records = self._parse_layers(gdb_path, external_labels, depth) - yield from self._parse_records(records, label) - - def read_shapefile(self, label: str) -> Generator[dict]: - file_path = self._shapefile_path(label) - if file_path.exists(): - yield from self._read_spatial_file(file_path) - - def _parse_layers( - self, path: Path, external_labels: list[str], depth: int - ) -> Generator[dict]: - for label in external_labels: - if isinstance(label, dict): - layer, depth_label = label["layer"], label["depth"] - records = self._read_spatial_file(path, layer=layer) - for record in records: - if record["properties"][depth_label] >= depth: - yield record - else: - yield from self._read_spatial_file(path, layer=label) - - def _read_spatial_file(self, path: Path, **kwargs) -> Generator[dict]: - with fiona.open(path, "r", **kwargs) as source: - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=RuntimeWarning) - for record in source.filter(bbox=self.bounding_box): - yield record - return - - @property - def gdb_paths(self) -> Generator[Path]: - for path in self.paths: - if not path.is_absolute(): - path = paths.cwd / path - if self._is_gdb(path): - yield path - elif path.is_dir(): - for p in path.iterdir(): - if self._is_gdb(p): - yield p - - @staticmethod - def _is_gdb(path: Path) -> bool: - return path.is_dir() and path.suffix == ".gdb" - - @staticmethod - def _parse_records(records, label): - for i, record in enumerate(records): - print(f"\rNumber of {label} records read: {i + 1}", end="") - yield record - return - - def write(self, shape): - geometry = shape.mapping - file_path = self._shapefile_path(shape.label) - with self.writer(file_path, geometry["type"]) as sink: - sink.write(self._as_record(shape.depth, geometry)) - - def writer(self, file_path, geometry_type): - return fiona.open( - file_path, - "w", - schema=self._as_record("int", geometry_type), - driver="ESRI Shapefile", - crs={"init": "epsg:25833"}, - ) - - @staticmethod - def _as_record(depth, geometry): - return {"properties": {"depth": depth}, "geometry": geometry} - - @staticmethod - def _shapefile_path(label): - return paths.shapefiles / label / (label + ".shp")