From 5fbd7c422547b080449f0493498b34ec44e5a3f2 Mon Sep 17 00:00:00 2001 From: miqn Date: Fri, 23 Feb 2024 18:34:51 +0100 Subject: [PATCH 01/84] Initial changes, started work on format separation --- seacharts/config.yaml | 9 ++-- seacharts/config_schema.yaml | 21 ++++++++- seacharts/core/__init__.py | 4 +- seacharts/core/parser.py | 65 +++++++++++++--------------- seacharts/core/parserFGDB.py | 38 ++++++++++++++++ seacharts/core/parserS57.py | 9 ++++ seacharts/core/scope.py | 28 ++++++++++-- seacharts/environment/environment.py | 12 +++-- seacharts/environment/map.py | 2 +- 9 files changed, 141 insertions(+), 47 deletions(-) create mode 100644 seacharts/core/parserFGDB.py create mode 100644 seacharts/core/parserS57.py diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 197a9e6..9d9a454 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,13 +1,16 @@ enc: + autosize: True size: [ 9000, 5062 ] center: [ 44300, 6956450 ] - depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] - resources: [ "/", "data/", "data/db/" ] + #Only one of these attributes allowed TODO: check if yaml offers single choice structure + #FGDB_depths: [ 0, 1 , 5, 20] + S57_layers: ["DEPARE", "LNDARE", "TSSLS"] + resources: [ "data/", "data/db/" ] display: colorbar: False dark_mode: True fullscreen: False - resolution: 720 + resolution: 640 anchor: "center" dpi: 96 diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index 3f57845..4736a5c 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -2,6 +2,9 @@ enc: required: True type: dict schema: + autosize: + required: False + type: boolean size: required: True type: list @@ -9,6 +12,7 @@ enc: schema: type: float min: 1.0 + origin: required: True excludes: center @@ -16,6 +20,7 @@ enc: maxlength: 2 schema: type: float + center: required: True excludes: origin @@ -23,13 +28,22 @@ enc: maxlength: 2 schema: type: float - depths: + + FGDB_depths: required: False type: list minlength: 1 schema: type: integer min: 0 + + S57_layers: + required: False + type: list + minlength: 1 + schema: + type: string + resources: required: False type: list @@ -44,18 +58,23 @@ display: colorbar: required: False type: boolean + dark_mode: required: False type: boolean + fullscreen: required: False type: boolean + resolution: required: False type: integer + anchor: required: False type: string + dpi: required: False type: integer diff --git a/seacharts/core/__init__.py b/seacharts/core/__init__.py index d8b5816..c379a85 100644 --- a/seacharts/core/__init__.py +++ b/seacharts/core/__init__.py @@ -5,4 +5,6 @@ from . import paths from .config import Config from .parser import DataParser -from .scope import Scope +from .parserFGDB import FGDBParser +from .parserS57 import S57Parser +from .scope import Scope, MapFormat diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index a13ba93..3e171fb 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -1,6 +1,7 @@ """ Contains the DataParser class for spatial data parsing. """ +from abc import abstractmethod import time import warnings from pathlib import Path @@ -9,7 +10,7 @@ import fiona from seacharts.core import paths -from seacharts.layers import labels, Layer +from seacharts.layers import Layer class DataParser: @@ -22,18 +23,19 @@ def __init__( self.paths = set([p.resolve() for p in (map(Path, path_strings))]) self.paths.update(paths.default_resources) + #STAYS 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 + self, + regions_list: list[Layer], + resources: list[str], + area: float ) -> None: - if not list(self._gdb_paths): + if not list(self.paths): resources = sorted(list(set(resources))) if not resources: print("WARNING: No spatial data source location given in config.") @@ -50,7 +52,7 @@ def parse_resources( 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) + records = self._load_from_file(regions) info = f"{len(records)} {regions.name} geometries" if not records: @@ -74,53 +76,50 @@ def parse_resources( print(f"\rSaved {info} to shapefile in {end_time} s.") @property - def _gdb_paths(self) -> Generator[Path, None, None]: + def _file_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): + if self._is_map_type(path): yield path elif path.is_dir(): for p in path.iterdir(): - if self._is_gdb(p): + if self._is_map_type(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)) + @abstractmethod + def _is_map_type(self, path) -> bool: + pass + + @abstractmethod + def _load_from_file(self, layer: Layer) -> list[dict]: + pass + @abstractmethod 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) + pass - def _read_fgdb( + @abstractmethod + def _read_file( 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) + pass + #STAYS 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) + #STAYS 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): + for record in source.filter(bbox=self.bounding_box): #TODO: auto coordinates yield record except ValueError as e: message = str(e) @@ -129,7 +128,7 @@ def _read_spatial_file(self, path: Path, **kwargs) -> Generator: print(message) return - def _shapefile_writer(self, file_path, geometry_type): + def _shapefile_writer(self, file_path, geometry_type): #TODO rozbic return fiona.open( file_path, "w", @@ -138,7 +137,7 @@ def _shapefile_writer(self, file_path, geometry_type): crs={"init": "epsg:25833"}, ) - def _write_to_shapefile(self, regions: Layer): + def _write_to_shapefile(self, regions: Layer): #TODO rozbic geometry = regions.mapping file_path = self._shapefile_path(regions.label) with self._shapefile_writer(file_path, geometry["type"]) as sink: @@ -148,10 +147,6 @@ def _write_to_shapefile(self, regions: Layer): 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): @@ -159,6 +154,8 @@ def _parse_records(records, name): yield record return + #STAYS @staticmethod def _shapefile_path(label): return paths.shapefiles / label / (label + ".shp") + diff --git a/seacharts/core/parserFGDB.py b/seacharts/core/parserFGDB.py new file mode 100644 index 0000000..ef1e7fe --- /dev/null +++ b/seacharts/core/parserFGDB.py @@ -0,0 +1,38 @@ +import time +from pathlib import Path +from typing import Generator + +from seacharts.core import DataParser, paths +from seacharts.layers import Layer, labels + + +class FGDBParser(DataParser): + + def _load_from_file(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_file(layer.label, external_labels, depth)) + + def _read_file( + self, name: str, external_labels: list[str], depth: int + ) -> Generator: + for gdb_path in self._file_paths: + records = self._parse_layers(gdb_path, external_labels, depth) + yield from self._parse_records(records, name) + + def _is_map_type(self, path): + return path.is_dir() and path.suffix is ".gdb" + + 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) + diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py new file mode 100644 index 0000000..6149a93 --- /dev/null +++ b/seacharts/core/parserS57.py @@ -0,0 +1,9 @@ +from seacharts.core import DataParser + + +class S57Parser(DataParser): + def _is_map_type(self, path): + if path.is_dir(): + for p in path.iterdir(): + if p.suffix is ".000": + return True diff --git a/seacharts/core/scope.py b/seacharts/core/scope.py index 988e236..2e597a7 100644 --- a/seacharts/core/scope.py +++ b/seacharts/core/scope.py @@ -2,20 +2,40 @@ Contains the Extent class for defining details related to files of spatial data. """ from dataclasses import dataclass +from enum import Enum, auto from seacharts.core import files from .extent import Extent +class MapFormat(Enum): + FGDB = auto() + S57 = auto() + + @dataclass class Scope: - default_depths = [0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500] - def __init__(self, settings: dict): self.extent = Extent(settings) - self.depths = settings["enc"].get("depths", self.default_depths) self.resources = settings["enc"].get("resources", []) + + if settings["enc"].get("FGDB_depths",[]): + self.__fgdb_init(settings) + elif settings["enc"].get("S57_layers", []): + self.__s57_init(settings) + + files.build_directory_structure(self.features, self.resources) + + def __fgdb_init(self, settings: dict): + default_depths = [0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500] + self.depths = settings["enc"].get("FGDB_depths", default_depths) self.features = ["land", "shore"] for depth in self.depths: self.features.append(f"seabed{depth}m") - files.build_directory_structure(self.features, self.resources) + self.type = MapFormat.FGDB + + def __s57_init(self, settings: dict): + default_layers = ["LNDARE", "DEPARE"] #TODO: double-check desired default layers + self.features = settings["enc"].get("S57_layers", default_layers) + self.type = MapFormat.S57 + diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index 40ba665..6641e8a 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -1,19 +1,25 @@ """ Contains the Environment class for collecting and manipulating loaded spatial data. """ -from seacharts.core import Scope +from seacharts.core import Scope, MapFormat, DataParser, S57Parser, FGDBParser from .map import MapData from .user import UserData from .weather import WeatherData - class Environment: def __init__(self, settings: dict): self.scope = Scope(settings) + self.parser = None + self.set_parser() self.map = MapData(self.scope) self.user = UserData(self.scope) self.weather = WeatherData(self.scope) - self.map.load_existing_shapefiles() if not self.map.loaded: self.map.parse_resources_into_shapefiles() + + def set_parser(self): + if self.scope.type is MapFormat.S57: + self.parser = S57Parser(self.scope.extent.bbox, self.scope.resources) + elif self.scope.type is MapFormat.FGDB: + self.parser = FGDBParser(self.scope.extent.bbox, self.scope.resources) diff --git a/seacharts/environment/map.py b/seacharts/environment/map.py index dbfb289..a77d775 100644 --- a/seacharts/environment/map.py +++ b/seacharts/environment/map.py @@ -14,7 +14,7 @@ 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) From d316396907c0982a4e3a7cd29e1f72326274fcc3 Mon Sep 17 00:00:00 2001 From: miqn Date: Thu, 29 Feb 2024 15:20:34 +0100 Subject: [PATCH 02/84] Further changes regarding early parsing --- seacharts/config.yaml | 4 +- seacharts/core/parser.py | 139 ++++++--------------------- seacharts/core/parserFGDB.py | 75 ++++++++++++++- seacharts/core/parserS57.py | 54 ++++++++++- seacharts/environment/collection.py | 2 +- seacharts/environment/environment.py | 18 ++-- seacharts/environment/map.py | 2 - 7 files changed, 170 insertions(+), 124 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 9d9a454..e3be231 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -3,8 +3,8 @@ enc: size: [ 9000, 5062 ] center: [ 44300, 6956450 ] #Only one of these attributes allowed TODO: check if yaml offers single choice structure - #FGDB_depths: [ 0, 1 , 5, 20] - S57_layers: ["DEPARE", "LNDARE", "TSSLS"] + FGDB_depths: [ 0, 1 , 5, 20] +# S57_layers: ["DEPARE", "LNDARE", "TSSLS"] resources: [ "data/", "data/db/" ] display: diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index 3e171fb..c3c3cb5 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -23,57 +23,48 @@ def __init__( self.paths = set([p.resolve() for p in (map(Path, path_strings))]) self.paths.update(paths.default_resources) - #STAYS + @staticmethod + def _shapefile_path(label): + return paths.shapefiles / label / (label + ".shp") + + ######LOADING SHAPEFILES##### + 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): #TODO: auto coordinates + 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 _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 load_shapefiles(self, layers: list[Layer]) -> None: for layer in layers: records = list(self._read_shapefile(layer.label)) layer.records_as_geometry(records) + ######LOADING SHAPEFILES##### + @abstractmethod def parse_resources( self, regions_list: list[Layer], resources: list[str], area: float ) -> None: - if not list(self.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_file(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.") + pass #main method for parsing corresponding map format + + @abstractmethod + def _is_map_type(self, path) -> bool: + pass #method for detecting files/directories containing corresponding map format @property def _file_paths(self) -> Generator[Path, None, None]: @@ -87,75 +78,7 @@ def _file_paths(self) -> Generator[Path, None, None]: if self._is_map_type(p): yield p - @abstractmethod - def _is_map_type(self, path) -> bool: - pass - @abstractmethod - def _load_from_file(self, layer: Layer) -> list[dict]: - pass - @abstractmethod - def _parse_layers( - self, path: Path, external_labels: list[str], depth: int - ) -> Generator: - pass - @abstractmethod - def _read_file( - self, name: str, external_labels: list[str], depth: int - ) -> Generator: - pass - - #STAYS - 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) - - #STAYS - 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): #TODO: auto coordinates - 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): #TODO rozbic - 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): #TODO rozbic - 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 _parse_records(records, name): - for i, record in enumerate(records): - print(f"\rNumber of {name} records read: {i + 1}", end="") - yield record - return - - #STAYS - @staticmethod - def _shapefile_path(label): - return paths.shapefiles / label / (label + ".shp") diff --git a/seacharts/core/parserFGDB.py b/seacharts/core/parserFGDB.py index ef1e7fe..bd28e81 100644 --- a/seacharts/core/parserFGDB.py +++ b/seacharts/core/parserFGDB.py @@ -2,6 +2,8 @@ from pathlib import Path from typing import Generator +import fiona + from seacharts.core import DataParser, paths from seacharts.layers import Layer, labels @@ -13,6 +15,59 @@ def _load_from_file(self, layer: Layer) -> list[dict]: external_labels = labels.NORWEGIAN_LABELS[layer.__class__.__name__] return list(self._read_file(layer.label, external_labels, depth)) + def parse_resources( + self, + regions_list: list[Layer], + resources: list[str], + area: float + ) -> None: + if not list(self.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:") #TODO: return when fixing coords + for regions in regions_list: + start_time = time.time() + records = self._load_from_file(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.") + + @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 + def _read_file( self, name: str, external_labels: list[str], depth: int ) -> Generator: @@ -21,7 +76,7 @@ def _read_file( yield from self._parse_records(records, name) def _is_map_type(self, path): - return path.is_dir() and path.suffix is ".gdb" + return path.is_dir() and path.suffix == ".gdb" def _parse_layers( self, path: Path, external_labels: list[str], depth: int @@ -36,3 +91,21 @@ def _parse_layers( else: yield from self._read_spatial_file(path, layer=label) + @staticmethod + def _as_record(depth, geometry): + return {"properties": {"depth": depth}, "geometry": geometry} + + 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)) diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index 6149a93..cec5795 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -1,9 +1,61 @@ +import subprocess +import time from seacharts.core import DataParser +from seacharts.layers import Layer class S57Parser(DataParser): + @staticmethod + def convert_s57_to_shapefile(s57_file_path, shapefile_output_path, parsedLayers): + commandLayers = "" + for layer in parsedLayers: + commandLayers += layer + " " + commandLayers = commandLayers.split(' ') + for layer in commandLayers: + layer = layer.replace(" ", "") + if len(layer) > 0: + ogr2ogr_cmd = [ + 'ogr2ogr', + '-f', 'ESRI Shapefile', # Output format + # '-update', + shapefile_output_path, # Output shapefile + s57_file_path, # Input S57 file + layer, + '-skipfailures' + ] + try: + subprocess.run(ogr2ogr_cmd, check=True) + print(f"Conversion successful: {s57_file_path} -> {shapefile_output_path}") + except subprocess.CalledProcessError as e: + print(f"Error during conversion: {e}") + + def parse_resources(self, regions_list: list[Layer], resources: list[str], area: float) -> None: + if not list(self.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:") # TODO: return when fixing coords + for regions in regions_list: + #TODO: for each region, + # generate appropriate destination path with shapefile_path() func + # get source S-57 path from file_paths() func + + # start_time = time.time() + # records = self._load_from_file(regions) + # info = f"{len(records)} {regions.name} geometries" + def _is_map_type(self, path): if path.is_dir(): for p in path.iterdir(): - if p.suffix is ".000": + if p.suffix == ".000": return True diff --git a/seacharts/environment/collection.py b/seacharts/environment/collection.py index 4ca86b0..752b290 100644 --- a/seacharts/environment/collection.py +++ b/seacharts/environment/collection.py @@ -11,7 +11,7 @@ @dataclass class DataCollection(ABC): scope: Scope - parser: DataParser = field(init=False) + parser: DataParser #= field(init=False) @property @abstractmethod diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index 6641e8a..c4c5478 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -1,25 +1,25 @@ """ Contains the Environment class for collecting and manipulating loaded spatial data. """ -from seacharts.core import Scope, MapFormat, DataParser, S57Parser, FGDBParser +from seacharts.core import Scope, MapFormat, S57Parser, FGDBParser, DataParser from .map import MapData from .user import UserData from .weather import WeatherData + class Environment: def __init__(self, settings: dict): self.scope = Scope(settings) - self.parser = None - self.set_parser() - self.map = MapData(self.scope) - self.user = UserData(self.scope) - self.weather = WeatherData(self.scope) + self.parser = self.set_parser() + self.map = MapData(self.scope, self.parser) + self.user = UserData(self.scope, self.parser) + self.weather = WeatherData(self.scope, self.parser) self.map.load_existing_shapefiles() if not self.map.loaded: self.map.parse_resources_into_shapefiles() - def set_parser(self): + def set_parser(self) -> DataParser: if self.scope.type is MapFormat.S57: - self.parser = S57Parser(self.scope.extent.bbox, self.scope.resources) + return S57Parser(self.scope.extent.bbox, self.scope.resources) elif self.scope.type is MapFormat.FGDB: - self.parser = FGDBParser(self.scope.extent.bbox, self.scope.resources) + return FGDBParser(self.scope.extent.bbox, self.scope.resources) diff --git a/seacharts/environment/map.py b/seacharts/environment/map.py index a77d775..4f880cf 100644 --- a/seacharts/environment/map.py +++ b/seacharts/environment/map.py @@ -3,7 +3,6 @@ """ from dataclasses import dataclass -from seacharts.core import DataParser from seacharts.layers import Layer, Land, Shore, Seabed from .collection import DataCollection @@ -15,7 +14,6 @@ def __post_init__(self): self.land = Land() self.shore = Shore() - def load_existing_shapefiles(self) -> None: self.parser.load_shapefiles(self.featured_regions) if self.loaded: From e8b5c22ca38de6b2373467531631894d5cdbb17f Mon Sep 17 00:00:00 2001 From: Natalia Czapla Date: Sun, 3 Mar 2024 20:31:02 +0100 Subject: [PATCH 03/84] 3.03. changes - coordinates --- seacharts/config.yaml | 4 ++-- seacharts/core/extent.py | 13 +++++++++++++ seacharts/core/parserS57.py | 1 + seacharts/environment/environment.py | 1 + seacharts/environment/map.py | 7 ++++--- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index e3be231..9d9a454 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -3,8 +3,8 @@ enc: size: [ 9000, 5062 ] center: [ 44300, 6956450 ] #Only one of these attributes allowed TODO: check if yaml offers single choice structure - FGDB_depths: [ 0, 1 , 5, 20] -# S57_layers: ["DEPARE", "LNDARE", "TSSLS"] + #FGDB_depths: [ 0, 1 , 5, 20] + S57_layers: ["DEPARE", "LNDARE", "TSSLS"] resources: [ "data/", "data/db/" ] display: diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index de94b46..0435847 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -1,7 +1,9 @@ """ Contains the Extent class for defining the span of spatial data. """ +import math +from pyproj import Proj, transform class Extent: def __init__(self, settings: dict): @@ -15,9 +17,20 @@ def __init__(self, settings: dict): self.center = tuple(settings["enc"].get("center", (0, 0))) self.origin = self._origin_from_center() + utm_east, utm_north = self.convert_lat_lon_to_utm(62.457464, 6.146678) + utm_east=math.ceil(utm_east) + utm_north=math.ceil(utm_north) self.bbox = self._bounding_box_from_origin_size() self.area: int = self.size[0] * self.size[1] + def convert_lat_lon_to_utm(self, latitude, longitude): + in_proj = Proj(init='epsg:4326') # WGS84 + out_proj = Proj(init='epsg:32633') # UTM zone 33N (change if needed) + + utm_east, utm_north = transform(in_proj, out_proj, longitude, latitude) + return utm_east, utm_north + + def _origin_from_center(self) -> tuple[int, int]: return ( self.center[0] - self.size[0] / 2, diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index cec5795..d811f49 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -46,6 +46,7 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: print("INFO: Updating ENC with data from available resources...\n") print(f"Processing {area // 10 ** 6} km^2 of ENC features:") # TODO: return when fixing coords for regions in regions_list: + ... #TODO: for each region, # generate appropriate destination path with shapefile_path() func # get source S-57 path from file_paths() func diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index c4c5478..6510f54 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -12,6 +12,7 @@ def __init__(self, settings: dict): self.scope = Scope(settings) self.parser = self.set_parser() self.map = MapData(self.scope, self.parser) + self.map.parse_resources_into_shapefiles() self.user = UserData(self.scope, self.parser) self.weather = WeatherData(self.scope, self.parser) self.map.load_existing_shapefiles() diff --git a/seacharts/environment/map.py b/seacharts/environment/map.py index 4f880cf..b12af27 100644 --- a/seacharts/environment/map.py +++ b/seacharts/environment/map.py @@ -10,9 +10,10 @@ @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.bathymetry = {d: Seabed(d) for d in self.scope.depths} + #self.land = Land() + #self.shore = Shore() def load_existing_shapefiles(self) -> None: self.parser.load_shapefiles(self.featured_regions) From 3582a85c6fada3093e7304998039681ba7da3642 Mon Sep 17 00:00:00 2001 From: miqn Date: Sun, 3 Mar 2024 20:31:05 +0100 Subject: [PATCH 04/84] Changes for autosize --- seacharts/config.yaml | 4 ++-- seacharts/core/parser.py | 10 ++++++++-- seacharts/core/parserS57.py | 1 + seacharts/core/scope.py | 4 ++-- seacharts/environment/environment.py | 4 ++-- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index e3be231..ac2f88a 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -3,8 +3,8 @@ enc: size: [ 9000, 5062 ] center: [ 44300, 6956450 ] #Only one of these attributes allowed TODO: check if yaml offers single choice structure - FGDB_depths: [ 0, 1 , 5, 20] -# S57_layers: ["DEPARE", "LNDARE", "TSSLS"] +# FGDB_depths: [ 0, 1 , 5, 20] + S57_layers: ["DEPARE", "LNDARE", "TSSLS"] resources: [ "data/", "data/db/" ] display: diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index c3c3cb5..c64e7af 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -18,10 +18,12 @@ def __init__( self, bounding_box: tuple[int, int, int, int], path_strings: list[str], + autosize: bool ): self.bounding_box = bounding_box self.paths = set([p.resolve() for p in (map(Path, path_strings))]) self.paths.update(paths.default_resources) + self.autosize = autosize @staticmethod def _shapefile_path(label): @@ -33,8 +35,12 @@ def _read_spatial_file(self, path: Path, **kwargs) -> Generator: 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): #TODO: auto coordinates - yield record + if self.autosize is True: #TODO: Extend in Scope needs to be updated according to record sizes when using autosize + for record in source: + yield record + else: + for record in source.filter(bbox=self.bounding_box): + yield record except ValueError as e: message = str(e) if "Null layer: " in message: diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index cec5795..d811f49 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -46,6 +46,7 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: print("INFO: Updating ENC with data from available resources...\n") print(f"Processing {area // 10 ** 6} km^2 of ENC features:") # TODO: return when fixing coords for regions in regions_list: + ... #TODO: for each region, # generate appropriate destination path with shapefile_path() func # get source S-57 path from file_paths() func diff --git a/seacharts/core/scope.py b/seacharts/core/scope.py index 2e597a7..8b98162 100644 --- a/seacharts/core/scope.py +++ b/seacharts/core/scope.py @@ -18,8 +18,8 @@ class Scope: def __init__(self, settings: dict): self.extent = Extent(settings) self.resources = settings["enc"].get("resources", []) - - if settings["enc"].get("FGDB_depths",[]): + self.autosize = settings["enc"].get("autosize") + if settings["enc"].get("FGDB_depths", []): self.__fgdb_init(settings) elif settings["enc"].get("S57_layers", []): self.__s57_init(settings) diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index c4c5478..2adaab2 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -20,6 +20,6 @@ def __init__(self, settings: dict): def set_parser(self) -> DataParser: if self.scope.type is MapFormat.S57: - return S57Parser(self.scope.extent.bbox, self.scope.resources) + return S57Parser(self.scope.extent.bbox, self.scope.resources, self.scope.autosize) elif self.scope.type is MapFormat.FGDB: - return FGDBParser(self.scope.extent.bbox, self.scope.resources) + return FGDBParser(self.scope.extent.bbox, self.scope.resources, self.scope.autosize) From 8948ff8caf978474f0bd2a9265c4bf14630e38c1 Mon Sep 17 00:00:00 2001 From: Natalia Czapla Date: Sun, 3 Mar 2024 21:37:26 +0100 Subject: [PATCH 05/84] 3.03. changes - coordinates --- seacharts/config.yaml | 2 +- seacharts/core/parser.py | 4 +++ seacharts/core/parserS57.py | 43 ++++++++++++---------------- seacharts/environment/environment.py | 2 +- seacharts/environment/map.py | 7 +++-- seacharts/layers/layer.py | 2 +- seacharts/shapes/shape.py | 5 ++-- 7 files changed, 32 insertions(+), 33 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index ac2f88a..9d9a454 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -3,7 +3,7 @@ enc: size: [ 9000, 5062 ] center: [ 44300, 6956450 ] #Only one of these attributes allowed TODO: check if yaml offers single choice structure -# FGDB_depths: [ 0, 1 , 5, 20] + #FGDB_depths: [ 0, 1 , 5, 20] S57_layers: ["DEPARE", "LNDARE", "TSSLS"] resources: [ "data/", "data/db/" ] diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index c64e7af..f629477 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -29,6 +29,10 @@ def __init__( def _shapefile_path(label): return paths.shapefiles / label / (label + ".shp") + @staticmethod + def _shapefile_dir_path(label): + return paths.shapefiles / label + ######LOADING SHAPEFILES##### def _read_spatial_file(self, path: Path, **kwargs) -> Generator: try: diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index d811f49..5839bdd 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -6,29 +6,21 @@ class S57Parser(DataParser): @staticmethod - def convert_s57_to_shapefile(s57_file_path, shapefile_output_path, parsedLayers): - commandLayers = "" - for layer in parsedLayers: - commandLayers += layer + " " - commandLayers = commandLayers.split(' ') - for layer in commandLayers: - layer = layer.replace(" ", "") - if len(layer) > 0: - ogr2ogr_cmd = [ - 'ogr2ogr', - '-f', 'ESRI Shapefile', # Output format - # '-update', - shapefile_output_path, # Output shapefile - s57_file_path, # Input S57 file - layer, - '-skipfailures' - ] - try: - subprocess.run(ogr2ogr_cmd, check=True) - print(f"Conversion successful: {s57_file_path} -> {shapefile_output_path}") - except subprocess.CalledProcessError as e: - print(f"Error during conversion: {e}") - + def convert_s57_to_shapefile(s57_file_path, shapefile_output_path, layer): + ogr2ogr_cmd = [ + 'ogr2ogr', + '-f', 'ESRI Shapefile', # Output format + # '-update', + shapefile_output_path, # Output shapefile + s57_file_path, # Input S57 file + layer, + '-skipfailures' + ] + try: + subprocess.run(ogr2ogr_cmd, check=True) + print(f"Conversion successful: {s57_file_path} -> {shapefile_output_path}") + except subprocess.CalledProcessError as e: + print(f"Error during conversion: {e}") def parse_resources(self, regions_list: list[Layer], resources: list[str], area: float) -> None: if not list(self.paths): resources = sorted(list(set(resources))) @@ -46,7 +38,10 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: print("INFO: Updating ENC with data from available resources...\n") print(f"Processing {area // 10 ** 6} km^2 of ENC features:") # TODO: return when fixing coords for regions in regions_list: - ... + for s57_path in self._file_paths: + self.convert_s57_to_shapefile(s57_path.__str__(), self._shapefile_dir_path(regions.name.lower()).__str__(), regions.name) + + #TODO: for each region, # generate appropriate destination path with shapefile_path() func # get source S-57 path from file_paths() func diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index 368c8d2..e7dcb9d 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -12,7 +12,7 @@ def __init__(self, settings: dict): self.scope = Scope(settings) self.parser = self.set_parser() self.map = MapData(self.scope, self.parser) - self.map.parse_resources_into_shapefiles() + #self.map.parse_resources_into_shapefiles() self.user = UserData(self.scope, self.parser) self.weather = WeatherData(self.scope, self.parser) self.map.load_existing_shapefiles() diff --git a/seacharts/environment/map.py b/seacharts/environment/map.py index b12af27..9b58d42 100644 --- a/seacharts/environment/map.py +++ b/seacharts/environment/map.py @@ -10,9 +10,10 @@ @dataclass class MapData(DataCollection): def __post_init__(self): - ... + self.map_layers={d: Layer(name=d) for d in self.scope.features} + #self.bathymetry = {d: Seabed(d) for d in self.scope.depths} - #self.land = Land() + #self.land = Land(name="land") #self.shore = Shore() def load_existing_shapefiles(self) -> None: @@ -33,7 +34,7 @@ def parse_resources_into_shapefiles(self) -> None: @property def layers(self) -> list[Layer]: - return [self.land, self.shore, *self.bathymetry.values()] + return [*self.map_layers.values()] @property def loaded(self) -> bool: diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py index 37c1e74..4946834 100644 --- a/seacharts/layers/layer.py +++ b/seacharts/layers/layer.py @@ -17,7 +17,7 @@ class Layer(Shape, ABC): @property def label(self) -> str: - return self.name.lower() + return self.name def records_as_geometry(self, records: list[dict]) -> None: if len(records) > 0: diff --git a/seacharts/shapes/shape.py b/seacharts/shapes/shape.py index ea90524..cb0d218 100644 --- a/seacharts/shapes/shape.py +++ b/seacharts/shapes/shape.py @@ -14,6 +14,7 @@ class Shape(ABC): color: str = None z_order: int = None artist: Any = None + name: str= None def simplify(self, tolerance: int, preserve_topology: bool = True) -> None: self.geometry = self.geometry.simplify(tolerance, preserve_topology) @@ -35,9 +36,7 @@ def closest_points(self, geometry: Any) -> geo.Point: 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: From 46d199537639c719961e4b9aab8021c2c16eb9f9 Mon Sep 17 00:00:00 2001 From: Natalia Czapla Date: Sun, 3 Mar 2024 21:40:45 +0100 Subject: [PATCH 06/84] 3.03. changes - coordinates --- seacharts/core/parserS57.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index 5839bdd..a321fa9 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -39,9 +39,9 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: print(f"Processing {area // 10 ** 6} km^2 of ENC features:") # TODO: return when fixing coords for regions in regions_list: for s57_path in self._file_paths: - self.convert_s57_to_shapefile(s57_path.__str__(), self._shapefile_dir_path(regions.name.lower()).__str__(), regions.name) - + self.convert_s57_to_shapefile(s57_path.__str__() + "\\US1GC09M.000", self._shapefile_dir_path(regions.name.lower()).__str__(), regions.name) + print("test") #TODO: for each region, # generate appropriate destination path with shapefile_path() func # get source S-57 path from file_paths() func From fecb285530ecb3fb47ded4c4593b43c62e438457 Mon Sep 17 00:00:00 2001 From: Natalia Czapla Date: Sun, 3 Mar 2024 21:41:15 +0100 Subject: [PATCH 07/84] 3.03. changes - parser S-57 --- .idea/.gitignore | 3 +++ .idea/inspectionProfiles/profiles_settings.xml | 6 ++++++ .idea/misc.xml | 4 ++++ .idea/modules.xml | 8 ++++++++ .idea/seacharts_nana.iml | 12 ++++++++++++ .idea/vcs.xml | 6 ++++++ 6 files changed, 39 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/seacharts_nana.iml create mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..bef197f --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ec50621 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/seacharts_nana.iml b/.idea/seacharts_nana.iml new file mode 100644 index 0000000..c282c8a --- /dev/null +++ b/.idea/seacharts_nana.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file From a46a4dcc1b8fe9038e1fbe338d93b66e00642090 Mon Sep 17 00:00:00 2001 From: Natalia Czapla Date: Wed, 13 Mar 2024 15:12:08 +0100 Subject: [PATCH 08/84] 13.03. changes - zone --- seacharts/config.yaml | 16 ---------------- seacharts/core/extent.py | 3 ++- seacharts/core/parser.py | 2 +- seacharts/core/parserS57.py | 12 ++++++++++-- 4 files changed, 13 insertions(+), 20 deletions(-) delete mode 100644 seacharts/config.yaml diff --git a/seacharts/config.yaml b/seacharts/config.yaml deleted file mode 100644 index 9d9a454..0000000 --- a/seacharts/config.yaml +++ /dev/null @@ -1,16 +0,0 @@ -enc: - autosize: True - size: [ 9000, 5062 ] - center: [ 44300, 6956450 ] - #Only one of these attributes allowed TODO: check if yaml offers single choice structure - #FGDB_depths: [ 0, 1 , 5, 20] - S57_layers: ["DEPARE", "LNDARE", "TSSLS"] - resources: [ "data/", "data/db/" ] - -display: - colorbar: False - dark_mode: True - fullscreen: False - resolution: 640 - anchor: "center" - dpi: 96 diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index 0435847..b678d1b 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -25,7 +25,8 @@ def __init__(self, settings: dict): def convert_lat_lon_to_utm(self, latitude, longitude): in_proj = Proj(init='epsg:4326') # WGS84 - out_proj = Proj(init='epsg:32633') # UTM zone 33N (change if needed) + zone=str(math.ceil(longitude/6+31)) + out_proj = Proj(init='epsg:326'+zone) utm_east, utm_north = transform(in_proj, out_proj, longitude, latitude) return utm_east, utm_north diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index f629477..5f217be 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -74,7 +74,7 @@ def parse_resources( @abstractmethod def _is_map_type(self, path) -> bool: - pass #method for detecting files/directories containing corresponding map format + pass @property def _file_paths(self) -> Generator[Path, None, None]: diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index a321fa9..5179df2 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -1,5 +1,7 @@ import subprocess import time +from pathlib import Path + from seacharts.core import DataParser from seacharts.layers import Layer @@ -21,6 +23,7 @@ def convert_s57_to_shapefile(s57_file_path, shapefile_output_path, layer): print(f"Conversion successful: {s57_file_path} -> {shapefile_output_path}") except subprocess.CalledProcessError as e: print(f"Error during conversion: {e}") + def parse_resources(self, regions_list: list[Layer], resources: list[str], area: float) -> None: if not list(self.paths): resources = sorted(list(set(resources))) @@ -36,10 +39,10 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: print(message + ".") return print("INFO: Updating ENC with data from available resources...\n") - print(f"Processing {area // 10 ** 6} km^2 of ENC features:") # TODO: return when fixing coords + print(f"Processing {area // 10 ** 6} km^2 of ENC features:") for regions in regions_list: for s57_path in self._file_paths: - self.convert_s57_to_shapefile(s57_path.__str__() + "\\US1GC09M.000", self._shapefile_dir_path(regions.name.lower()).__str__(), regions.name) + self.convert_s57_to_shapefile(self.get_s57_file(s57_path).__str__(), self._shapefile_dir_path(regions.name.lower()).__str__(), regions.name) print("test") #TODO: for each region, @@ -49,6 +52,11 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: # start_time = time.time() # records = self._load_from_file(regions) # info = f"{len(records)} {regions.name} geometries" + @staticmethod + def get_s57_file(path) -> Path: + for p in path.iterdir(): + if p.suffix == ".000": + return p def _is_map_type(self, path): if path.is_dir(): From cac154d3f39a575b62112ff9591244de5d6f1e6d Mon Sep 17 00:00:00 2001 From: Natalia Czapla <126421760+nanatalia1@users.noreply.github.com> Date: Sat, 16 Mar 2024 10:55:25 +0100 Subject: [PATCH 09/84] Delete .idea directory --- .idea/.gitignore | 3 --- .idea/inspectionProfiles/profiles_settings.xml | 6 ------ .idea/misc.xml | 4 ---- .idea/modules.xml | 8 -------- .idea/seacharts_nana.iml | 12 ------------ .idea/vcs.xml | 6 ------ 6 files changed, 39 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/seacharts_nana.iml delete mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index bef197f..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index ec50621..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/seacharts_nana.iml b/.idea/seacharts_nana.iml deleted file mode 100644 index c282c8a..0000000 --- a/.idea/seacharts_nana.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From 65e7ad67916467845b328de9c7db9b76b81d02c0 Mon Sep 17 00:00:00 2001 From: Natalia Czapla Date: Sat, 16 Mar 2024 11:15:01 +0100 Subject: [PATCH 10/84] config new --- seacharts/config.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 seacharts/config.yaml diff --git a/seacharts/config.yaml b/seacharts/config.yaml new file mode 100644 index 0000000..84f3663 --- /dev/null +++ b/seacharts/config.yaml @@ -0,0 +1,16 @@ +enc: + autosize: True + size: [ 9000, 5062 ] + center: [ 44300, 6956450 ] + #Only one of these attributes allowed TODO: check if yaml offers single choice structure + #FGDB_depths: [ 0, 1 , 5, 20] + S57_layers: ["DEPARE", "LNDARE", "TSSLPT"] + resources: [ "data/", "data/db/" ] + +display: + colorbar: False + dark_mode: True + fullscreen: False + resolution: 640 + anchor: "center" + dpi: 96 From 6a311616bd47c7ce4a4376db6f59c676230839dd Mon Sep 17 00:00:00 2001 From: Natalia Czapla Date: Sat, 16 Mar 2024 14:44:32 +0100 Subject: [PATCH 11/84] lat long to UTM --- seacharts/config.yaml | 4 ++-- seacharts/core/extent.py | 22 ++++++++++++++++------ seacharts/core/parserS57.py | 4 ++++ 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 84f3663..158aee6 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,7 +1,7 @@ enc: autosize: True - size: [ 9000, 5062 ] - center: [ 44300, 6956450 ] + size: [ 16.0, 12.0, ] + center: [ -87.01, 24.88 ] #Only one of these attributes allowed TODO: check if yaml offers single choice structure #FGDB_depths: [ 0, 1 , 5, 20] S57_layers: ["DEPARE", "LNDARE", "TSSLPT"] diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index b678d1b..8ad6ccc 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -2,7 +2,7 @@ Contains the Extent class for defining the span of spatial data. """ import math - +from pyproj import Transformer, CRS from pyproj import Proj, transform class Extent: @@ -17,18 +17,20 @@ def __init__(self, settings: dict): self.center = tuple(settings["enc"].get("center", (0, 0))) self.origin = self._origin_from_center() - utm_east, utm_north = self.convert_lat_lon_to_utm(62.457464, 6.146678) - utm_east=math.ceil(utm_east) - utm_north=math.ceil(utm_north) - self.bbox = self._bounding_box_from_origin_size() + if settings["enc"].get("FGDB_depths", []): + self.bbox = self._bounding_box_from_origin_size() + elif settings["enc"].get("S57_layers", []): + self.bbox=self._bounding_box_from_origin_size_lat_long() self.area: int = self.size[0] * self.size[1] def convert_lat_lon_to_utm(self, latitude, longitude): in_proj = Proj(init='epsg:4326') # WGS84 - zone=str(math.ceil(longitude/6+31)) + zone = str(math.floor(longitude/6+31)) out_proj = Proj(init='epsg:326'+zone) utm_east, utm_north = transform(in_proj, out_proj, longitude, latitude) + utm_east=math.ceil(utm_east) + utm_north=math.ceil(utm_north) return utm_east, utm_north @@ -48,3 +50,11 @@ def _bounding_box_from_origin_size(self) -> tuple[int, int, int, int]: x_min, y_min = self.origin x_max, y_max = x_min + self.size[0], y_min + self.size[1] return x_min, y_min, x_max, y_max + + def _bounding_box_from_origin_size_lat_long(self) -> tuple[int, int, int, int]: + x_min, y_min = self.origin + x_max, y_max = x_min + self.size[0], y_min + self.size[1] + + converted_x_min, converted_y_min= self.convert_lat_lon_to_utm(y_min, x_min) + converted_x_max, converted_y_max= self.convert_lat_lon_to_utm(y_max, x_max) + return converted_x_min, converted_y_min, converted_x_max, converted_y_max \ No newline at end of file diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index 5179df2..f492415 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -7,6 +7,8 @@ class S57Parser(DataParser): + + @staticmethod def convert_s57_to_shapefile(s57_file_path, shapefile_output_path, layer): ogr2ogr_cmd = [ @@ -63,3 +65,5 @@ def _is_map_type(self, path): for p in path.iterdir(): if p.suffix == ".000": return True + + From 9ca1875a99fc3d1a64685e45ccd3c9c28d7855ed Mon Sep 17 00:00:00 2001 From: Natalia Czapla Date: Mon, 18 Mar 2024 11:55:22 +0100 Subject: [PATCH 12/84] changed zone (one per map) --- seacharts/core/extent.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index 8ad6ccc..71e0a05 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -23,9 +23,8 @@ def __init__(self, settings: dict): self.bbox=self._bounding_box_from_origin_size_lat_long() self.area: int = self.size[0] * self.size[1] - def convert_lat_lon_to_utm(self, latitude, longitude): + def convert_lat_lon_to_utm(self, latitude, longitude, zone): in_proj = Proj(init='epsg:4326') # WGS84 - zone = str(math.floor(longitude/6+31)) out_proj = Proj(init='epsg:326'+zone) utm_east, utm_north = transform(in_proj, out_proj, longitude, latitude) @@ -54,7 +53,7 @@ def _bounding_box_from_origin_size(self) -> tuple[int, int, int, int]: def _bounding_box_from_origin_size_lat_long(self) -> tuple[int, int, int, int]: x_min, y_min = self.origin x_max, y_max = x_min + self.size[0], y_min + self.size[1] - - converted_x_min, converted_y_min= self.convert_lat_lon_to_utm(y_min, x_min) - converted_x_max, converted_y_max= self.convert_lat_lon_to_utm(y_max, x_max) + zone = str(math.floor(self.center[0] / 6 + 31)) + converted_x_min, converted_y_min= self.convert_lat_lon_to_utm(y_min, x_min, zone) + converted_x_max, converted_y_max= self.convert_lat_lon_to_utm(y_max, x_max, zone) return converted_x_min, converted_y_min, converted_x_max, converted_y_max \ No newline at end of file From 6181aaf4a9300e2b10485924e484afa8ab849f92 Mon Sep 17 00:00:00 2001 From: miqn Date: Sat, 23 Mar 2024 20:24:35 +0100 Subject: [PATCH 13/84] pre pull --- seacharts/config.yaml | 4 ++-- seacharts/core/extent.py | 21 +++++++++++++-------- seacharts/core/mapFormat.py | 6 ++++++ seacharts/core/parser.py | 2 +- seacharts/core/parserS57.py | 10 +++------- seacharts/core/scope.py | 12 ++++-------- seacharts/environment/environment.py | 1 - seacharts/environment/map.py | 11 ++++++----- seacharts/layers/layer.py | 3 ++- seacharts/shapes/shape.py | 7 ++++--- 10 files changed, 41 insertions(+), 36 deletions(-) create mode 100644 seacharts/core/mapFormat.py diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 84f3663..06aa46f 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -3,8 +3,8 @@ enc: size: [ 9000, 5062 ] center: [ 44300, 6956450 ] #Only one of these attributes allowed TODO: check if yaml offers single choice structure - #FGDB_depths: [ 0, 1 , 5, 20] - S57_layers: ["DEPARE", "LNDARE", "TSSLPT"] + FGDB_depths: [ 0, 1 , 5, 20] +# S57_layers: ["DEPARE", "LNDARE", "TSSLPT"] resources: [ "data/", "data/db/" ] display: diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index b678d1b..c6bff4d 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -5,6 +5,7 @@ from pyproj import Proj, transform + class Extent: def __init__(self, settings: dict): self.size = tuple(settings["enc"].get("size", (0, 0))) @@ -18,19 +19,23 @@ def __init__(self, settings: dict): self.origin = self._origin_from_center() utm_east, utm_north = self.convert_lat_lon_to_utm(62.457464, 6.146678) - utm_east=math.ceil(utm_east) - utm_north=math.ceil(utm_north) self.bbox = self._bounding_box_from_origin_size() self.area: int = self.size[0] * self.size[1] - def convert_lat_lon_to_utm(self, latitude, longitude): - in_proj = Proj(init='epsg:4326') # WGS84 - zone=str(math.ceil(longitude/6+31)) - out_proj = Proj(init='epsg:326'+zone) + # @overload + # def __init__(self, x_min, y_min, x_max, y_max): + # self.size = x_max - x_min, y_max - y_min + # self.bbox = x_min, y_min, x_max, y_max + # self.center = x_min + self.size[0]/2, y_min + self.size[1]/2 + # self.origin = x_min, y_min + @staticmethod + def convert_lat_lon_to_utm(latitude, longitude): + in_proj = Proj(init='epsg:4326') # WGS84 + zone = str(math.ceil(longitude / 6 + 31)) + out_proj = Proj(init='epsg:326' + zone) utm_east, utm_north = transform(in_proj, out_proj, longitude, latitude) - return utm_east, utm_north - + return math.ceil(utm_east), math.ceil(utm_north) def _origin_from_center(self) -> tuple[int, int]: return ( diff --git a/seacharts/core/mapFormat.py b/seacharts/core/mapFormat.py new file mode 100644 index 0000000..0ff8eba --- /dev/null +++ b/seacharts/core/mapFormat.py @@ -0,0 +1,6 @@ +from enum import Enum, auto + + +class MapFormat(Enum): + FGDB = auto() + S57 = auto() diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index 5f217be..0c2346d 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -39,7 +39,7 @@ def _read_spatial_file(self, path: Path, **kwargs) -> Generator: with fiona.open(path, "r", **kwargs) as source: with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=RuntimeWarning) - if self.autosize is True: #TODO: Extend in Scope needs to be updated according to record sizes when using autosize + if self.autosize is True: for record in source: yield record else: diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index 5179df2..bd31b0a 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -42,13 +42,9 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: print(f"Processing {area // 10 ** 6} km^2 of ENC features:") for regions in regions_list: for s57_path in self._file_paths: - self.convert_s57_to_shapefile(self.get_s57_file(s57_path).__str__(), self._shapefile_dir_path(regions.name.lower()).__str__(), regions.name) - - print("test") - #TODO: for each region, - # generate appropriate destination path with shapefile_path() func - # get source S-57 path from file_paths() func - + self.convert_s57_to_shapefile(self.get_s57_file(s57_path).__str__(), + self._shapefile_dir_path(regions.name.lower()).__str__(), + regions.name) # start_time = time.time() # records = self._load_from_file(regions) # info = f"{len(records)} {regions.name} geometries" diff --git a/seacharts/core/scope.py b/seacharts/core/scope.py index 8b98162..53e2dd2 100644 --- a/seacharts/core/scope.py +++ b/seacharts/core/scope.py @@ -2,23 +2,19 @@ Contains the Extent class for defining details related to files of spatial data. """ from dataclasses import dataclass -from enum import Enum, auto - from seacharts.core import files from .extent import Extent - - -class MapFormat(Enum): - FGDB = auto() - S57 = auto() +from .mapFormat import MapFormat @dataclass class Scope: def __init__(self, settings: dict): self.extent = Extent(settings) + self.settings = settings self.resources = settings["enc"].get("resources", []) self.autosize = settings["enc"].get("autosize") + if settings["enc"].get("FGDB_depths", []): self.__fgdb_init(settings) elif settings["enc"].get("S57_layers", []): @@ -35,7 +31,7 @@ def __fgdb_init(self, settings: dict): self.type = MapFormat.FGDB def __s57_init(self, settings: dict): - default_layers = ["LNDARE", "DEPARE"] #TODO: double-check desired default layers + default_layers = ["LNDARE", "DEPARE"] #TODO: decide on default layers self.features = settings["enc"].get("S57_layers", default_layers) self.type = MapFormat.S57 diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index e7dcb9d..2adaab2 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -12,7 +12,6 @@ def __init__(self, settings: dict): self.scope = Scope(settings) self.parser = self.set_parser() self.map = MapData(self.scope, self.parser) - #self.map.parse_resources_into_shapefiles() self.user = UserData(self.scope, self.parser) self.weather = WeatherData(self.scope, self.parser) self.map.load_existing_shapefiles() diff --git a/seacharts/environment/map.py b/seacharts/environment/map.py index 9b58d42..17e53fe 100644 --- a/seacharts/environment/map.py +++ b/seacharts/environment/map.py @@ -10,11 +10,11 @@ @dataclass class MapData(DataCollection): def __post_init__(self): - self.map_layers={d: Layer(name=d) for d in self.scope.features} + # self.map_layers = {d: Layer(name=d) for d in self.scope.features} - #self.bathymetry = {d: Seabed(d) for d in self.scope.depths} - #self.land = Land(name="land") - #self.shore = Shore() + self.bathymetry = {d: Seabed(depth=d) for d in self.scope.depths} + self.land = Land() + self.shore = Shore() def load_existing_shapefiles(self) -> None: self.parser.load_shapefiles(self.featured_regions) @@ -34,7 +34,8 @@ def parse_resources_into_shapefiles(self) -> None: @property def layers(self) -> list[Layer]: - return [*self.map_layers.values()] + return [self.land, self.shore, *self.bathymetry.values()] + # return [*self.map_layers.values()] @property def loaded(self) -> bool: diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py index 4946834..404b453 100644 --- a/seacharts/layers/layer.py +++ b/seacharts/layers/layer.py @@ -17,7 +17,8 @@ class Layer(Shape, ABC): @property def label(self) -> str: - return self.name + return self.name.lower() + # return self.name def records_as_geometry(self, records: list[dict]) -> None: if len(records) > 0: diff --git a/seacharts/shapes/shape.py b/seacharts/shapes/shape.py index cb0d218..b00d8de 100644 --- a/seacharts/shapes/shape.py +++ b/seacharts/shapes/shape.py @@ -14,7 +14,7 @@ class Shape(ABC): color: str = None z_order: int = None artist: Any = None - name: str= None + # name: str = None def simplify(self, tolerance: int, preserve_topology: bool = True) -> None: self.geometry = self.geometry.simplify(tolerance, preserve_topology) @@ -33,11 +33,12 @@ def closest_points(self, geometry: Any) -> geo.Point: return ops.nearest_points(self.geometry, geometry)[1] @property + def name(self) -> str: + return self.__class__.__name__ + @property def mapping(self) -> dict: return geo.mapping(self.geometry) - - @staticmethod def _record_to_geometry(record: dict) -> Any: return geo.shape(record["geometry"]) From 1802f74f10aadbd9fee6513d7f7c50f0ca31c640 Mon Sep 17 00:00:00 2001 From: miqn Date: Sat, 23 Mar 2024 20:26:24 +0100 Subject: [PATCH 14/84] pre pull --- seacharts/config.yaml | 4 ++-- seacharts/core/extent.py | 21 ++++++++------------- seacharts/core/parserS57.py | 4 ++++ 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 06aa46f..ed39150 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,7 +1,7 @@ enc: autosize: True - size: [ 9000, 5062 ] - center: [ 44300, 6956450 ] + size: [ 16.0, 12.0, ] + center: [ -87.01, 24.88 ] #Only one of these attributes allowed TODO: check if yaml offers single choice structure FGDB_depths: [ 0, 1 , 5, 20] # S57_layers: ["DEPARE", "LNDARE", "TSSLPT"] diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index c6bff4d..b678d1b 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -5,7 +5,6 @@ from pyproj import Proj, transform - class Extent: def __init__(self, settings: dict): self.size = tuple(settings["enc"].get("size", (0, 0))) @@ -19,23 +18,19 @@ def __init__(self, settings: dict): self.origin = self._origin_from_center() utm_east, utm_north = self.convert_lat_lon_to_utm(62.457464, 6.146678) + utm_east=math.ceil(utm_east) + utm_north=math.ceil(utm_north) self.bbox = self._bounding_box_from_origin_size() self.area: int = self.size[0] * self.size[1] - # @overload - # def __init__(self, x_min, y_min, x_max, y_max): - # self.size = x_max - x_min, y_max - y_min - # self.bbox = x_min, y_min, x_max, y_max - # self.center = x_min + self.size[0]/2, y_min + self.size[1]/2 - # self.origin = x_min, y_min - - @staticmethod - def convert_lat_lon_to_utm(latitude, longitude): + def convert_lat_lon_to_utm(self, latitude, longitude): in_proj = Proj(init='epsg:4326') # WGS84 - zone = str(math.ceil(longitude / 6 + 31)) - out_proj = Proj(init='epsg:326' + zone) + zone=str(math.ceil(longitude/6+31)) + out_proj = Proj(init='epsg:326'+zone) + utm_east, utm_north = transform(in_proj, out_proj, longitude, latitude) - return math.ceil(utm_east), math.ceil(utm_north) + return utm_east, utm_north + def _origin_from_center(self) -> tuple[int, int]: return ( diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index bd31b0a..ee76a1d 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -7,6 +7,8 @@ class S57Parser(DataParser): + + @staticmethod def convert_s57_to_shapefile(s57_file_path, shapefile_output_path, layer): ogr2ogr_cmd = [ @@ -59,3 +61,5 @@ def _is_map_type(self, path): for p in path.iterdir(): if p.suffix == ".000": return True + + From 18d2acc8617643b83e14b266d2ad6d156299b65d Mon Sep 17 00:00:00 2001 From: miqn Date: Wed, 27 Mar 2024 11:45:12 +0100 Subject: [PATCH 15/84] working early S-57 maps conversion + display for requested depths, similarly to fgdb --- seacharts/config.yaml | 14 +++++---- seacharts/config_schema.yaml | 17 +++++++++-- seacharts/core/extent.py | 22 ++++++++------- seacharts/core/parser.py | 10 ++----- seacharts/core/parserFGDB.py | 2 +- seacharts/core/parserS57.py | 55 ++++++++++++++++++++++++++++++------ seacharts/core/scope.py | 30 +++++++++++--------- seacharts/display/display.py | 4 +-- seacharts/environment/map.py | 3 -- seacharts/layers/layer.py | 10 +++++-- seacharts/shapes/shape.py | 14 ++++----- 11 files changed, 117 insertions(+), 64 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index ed39150..b82278f 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,10 +1,12 @@ enc: - autosize: True - size: [ 16.0, 12.0, ] - center: [ -87.01, 24.88 ] - #Only one of these attributes allowed TODO: check if yaml offers single choice structure - FGDB_depths: [ 0, 1 , 5, 20] -# S57_layers: ["DEPARE", "LNDARE", "TSSLPT"] + #autosize: True #TODO: implement autosize +# size: [ 16.0, 12.0, ] + size: [ 22.0, 15.5 ] + origin: [ -98.0, 18.0 ] +# depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] + depths: [ 0, 50, 100, 200, 2000 ] + #UTM_zone: 33 + S57_layers: ["DEPARE", "LNDARE", "TSSLPT"] resources: [ "data/", "data/db/" ] display: diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index 4736a5c..ad0f81b 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -2,9 +2,12 @@ enc: required: True type: dict schema: + #set True if you want to display whole uploaded map autosize: required: False type: boolean + + #size of displayed map size: required: True type: list @@ -13,6 +16,7 @@ enc: type: float min: 1.0 + #that's where you want top-left corner to be origin: required: True excludes: center @@ -21,6 +25,7 @@ enc: schema: type: float + #that's where you want the center of a map to be center: required: True excludes: origin @@ -29,7 +34,13 @@ enc: schema: type: float - FGDB_depths: + #UTM zone required for coordinates to work correctly + UTM_zone: + required: False + type: integer + + #depths are required for both formats, if not set they will be assigned default values + depths: required: False type: list minlength: 1 @@ -37,6 +48,7 @@ enc: type: integer min: 0 + #you can pick specific S-57 layers you want to extract, default required is LNDARE, DEPARE and COALNE S57_layers: required: False type: list @@ -44,8 +56,9 @@ enc: schema: type: string + #you must put paths to some resources resources: - required: False + required: True type: list minlength: 1 schema: diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index 71e0a05..3cb74fb 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -5,10 +5,11 @@ from pyproj import Transformer, CRS from pyproj import Proj, transform +#TODO size in extent for latlong needs fixing class Extent: def __init__(self, settings: dict): self.size = tuple(settings["enc"].get("size", (0, 0))) - + self.UTM_zone = settings["enc"].get("UTM_zone") if "origin" in settings["enc"]: self.origin = tuple(settings["enc"].get("origin", (0, 0))) self.center = self._center_from_origin() @@ -20,19 +21,19 @@ def __init__(self, settings: dict): if settings["enc"].get("FGDB_depths", []): self.bbox = self._bounding_box_from_origin_size() elif settings["enc"].get("S57_layers", []): - self.bbox=self._bounding_box_from_origin_size_lat_long() + self.bbox = self._bounding_box_from_origin_size_lat_long() self.area: int = self.size[0] * self.size[1] - def convert_lat_lon_to_utm(self, latitude, longitude, zone): + @staticmethod + def convert_lat_lon_to_utm(latitude, longitude, zone): in_proj = Proj(init='epsg:4326') # WGS84 - out_proj = Proj(init='epsg:326'+zone) + out_proj = Proj(init='epsg:326' + zone) utm_east, utm_north = transform(in_proj, out_proj, longitude, latitude) - utm_east=math.ceil(utm_east) - utm_north=math.ceil(utm_north) + utm_east = math.ceil(utm_east) + utm_north = math.ceil(utm_north) return utm_east, utm_north - def _origin_from_center(self) -> tuple[int, int]: return ( self.center[0] - self.size[0] / 2, @@ -54,6 +55,7 @@ def _bounding_box_from_origin_size_lat_long(self) -> tuple[int, int, int, int]: x_min, y_min = self.origin x_max, y_max = x_min + self.size[0], y_min + self.size[1] zone = str(math.floor(self.center[0] / 6 + 31)) - converted_x_min, converted_y_min= self.convert_lat_lon_to_utm(y_min, x_min, zone) - converted_x_max, converted_y_max= self.convert_lat_lon_to_utm(y_max, x_max, zone) - return converted_x_min, converted_y_min, converted_x_max, converted_y_max \ No newline at end of file + self.UTM_zone = zone + converted_x_min, converted_y_min = self.convert_lat_lon_to_utm(y_min, x_min, zone) + converted_x_max, converted_y_max = self.convert_lat_lon_to_utm(y_max, x_max, zone) + return converted_x_min, converted_y_min, converted_x_max, converted_y_max diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index 0c2346d..0ee1e1b 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -33,7 +33,6 @@ def _shapefile_path(label): def _shapefile_dir_path(label): return paths.shapefiles / label - ######LOADING SHAPEFILES##### def _read_spatial_file(self, path: Path, **kwargs) -> Generator: try: with fiona.open(path, "r", **kwargs) as source: @@ -62,7 +61,7 @@ def load_shapefiles(self, layers: list[Layer]) -> None: records = list(self._read_shapefile(layer.label)) layer.records_as_geometry(records) - ######LOADING SHAPEFILES##### + # main method for parsing corresponding map format @abstractmethod def parse_resources( self, @@ -70,7 +69,7 @@ def parse_resources( resources: list[str], area: float ) -> None: - pass #main method for parsing corresponding map format + pass @abstractmethod def _is_map_type(self, path) -> bool: @@ -87,8 +86,3 @@ def _file_paths(self) -> Generator[Path, None, None]: for p in path.iterdir(): if self._is_map_type(p): yield p - - - - - diff --git a/seacharts/core/parserFGDB.py b/seacharts/core/parserFGDB.py index bd28e81..90a8ab6 100644 --- a/seacharts/core/parserFGDB.py +++ b/seacharts/core/parserFGDB.py @@ -35,7 +35,7 @@ def parse_resources( print(message + ".") return print("INFO: Updating ENC with data from available resources...\n") - print(f"Processing {area // 10 ** 6} km^2 of ENC features:") #TODO: return when fixing coords + print(f"Processing {area // 10 ** 6} km^2 of ENC features:") for regions in regions_list: start_time = time.time() records = self._load_from_file(regions) diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index ee76a1d..35968d9 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -3,7 +3,8 @@ from pathlib import Path from seacharts.core import DataParser -from seacharts.layers import Layer +from seacharts.layers import Layer, Land, Shore, Seabed +from seacharts.layers.layer import SingleDepthLayer class S57Parser(DataParser): @@ -15,6 +16,7 @@ def convert_s57_to_shapefile(s57_file_path, shapefile_output_path, layer): 'ogr2ogr', '-f', 'ESRI Shapefile', # Output format # '-update', + '-t_srs', 'EPSG:32616', # TODO fixit to take calculated utm zone shapefile_output_path, # Output shapefile s57_file_path, # Input S57 file layer, @@ -26,6 +28,24 @@ def convert_s57_to_shapefile(s57_file_path, shapefile_output_path, layer): except subprocess.CalledProcessError as e: print(f"Error during conversion: {e}") + @staticmethod + def convert_s57_depth_to_shapefile(s57_file_path, shapefile_output_path, depth): + ogr2ogr_cmd = [ + 'ogr2ogr', + '-f', 'ESRI Shapefile', # Output format + # '-update', + '-t_srs', 'EPSG:32616', # TODO fixit to take calculated utm zone + shapefile_output_path, # Output shapefile + s57_file_path, # Input S57 file + '-sql', 'SELECT * FROM DEPARE WHERE DRVAL1 >= ' + depth.__str__(), + '-skipfailures' + ] + try: + subprocess.run(ogr2ogr_cmd, check=True) + print(f"Conversion successful: {s57_file_path} -> {shapefile_output_path}") + except subprocess.CalledProcessError as e: + print(f"Error during conversion: {e}") + def parse_resources(self, regions_list: list[Layer], resources: list[str], area: float) -> None: if not list(self.paths): resources = sorted(list(set(resources))) @@ -42,14 +62,31 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: 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: - for s57_path in self._file_paths: - self.convert_s57_to_shapefile(self.get_s57_file(s57_path).__str__(), - self._shapefile_dir_path(regions.name.lower()).__str__(), - regions.name) - # start_time = time.time() - # records = self._load_from_file(regions) - # info = f"{len(records)} {regions.name} geometries" + + # ogr2ogr was crashing for backslashes, temporary fix + s57_path = None + for path in self._file_paths: + s57_path = self.get_s57_file(path) + s57_path = s57_path.__str__().replace("\\", "/") + # end + + for region in regions_list: + if isinstance(region, Seabed): + self.convert_s57_depth_to_shapefile(s57_path, + self._shapefile_dir_path(region.label).__str__() + + "\\" + region.label + ".shp", + region.depth) + elif isinstance(region, Land): + self.convert_s57_to_shapefile(s57_path, + self._shapefile_dir_path(region.label).__str__() + "\\" + + region.label + ".shp", + "LNDARE") + elif isinstance(region, Shore): + self.convert_s57_to_shapefile(s57_path, + self._shapefile_dir_path(region.label).__str__() + "\\" + + region.label + ".shp", + "LNDARE") + @staticmethod def get_s57_file(path) -> Path: for p in path.iterdir(): diff --git a/seacharts/core/scope.py b/seacharts/core/scope.py index 53e2dd2..e1629fe 100644 --- a/seacharts/core/scope.py +++ b/seacharts/core/scope.py @@ -6,32 +6,36 @@ from .extent import Extent from .mapFormat import MapFormat +default_depths = [0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500] #TODO move to separate file + @dataclass class Scope: + def __init__(self, settings: dict): self.extent = Extent(settings) self.settings = settings self.resources = settings["enc"].get("resources", []) self.autosize = settings["enc"].get("autosize") - if settings["enc"].get("FGDB_depths", []): - self.__fgdb_init(settings) - elif settings["enc"].get("S57_layers", []): - self.__s57_init(settings) - - files.build_directory_structure(self.features, self.resources) - - def __fgdb_init(self, settings: dict): - default_depths = [0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500] - self.depths = settings["enc"].get("FGDB_depths", default_depths) + self.depths = settings["enc"].get("depths", default_depths) self.features = ["land", "shore"] for depth in self.depths: self.features.append(f"seabed{depth}m") - self.type = MapFormat.FGDB + if settings["enc"].get("S57_layers", []): + self.__s57_init(settings) + else: + self.type = MapFormat.FGDB + + files.build_directory_structure(self.features, self.resources) + + #DEPARE --> depthsX - must be put into buffer dir first, then distributed between appropriate depths + #LNDARE --> land + #COALNE --> shore + #remaining layers --> ?? separate dir for all or shared dir like "info"? def __s57_init(self, settings: dict): - default_layers = ["LNDARE", "DEPARE"] #TODO: decide on default layers - self.features = settings["enc"].get("S57_layers", default_layers) + default_layers = ["LNDARE", "DEPARE", "COALNE"] #TODO move to separate file + self.layers = settings["enc"].get("S57_layers", default_layers) self.type = MapFormat.S57 diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 6ce0a83..a8ef02d 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -22,7 +22,6 @@ class Display: - crs = UTM(33) window_anchors = ( ("top_left", "top", "top_right"), ("left", "center", "right"), @@ -31,9 +30,10 @@ class Display: def __init__(self, settings: dict, environment: env.Environment): self._settings = settings + self.crs = UTM(environment.scope.extent.UTM_zone) self._environment = environment self._background = None - self._dark_mode = True + self._dark_mode = False self._colorbar_mode = False self._fullscreen_mode = False self._resolution = 720 diff --git a/seacharts/environment/map.py b/seacharts/environment/map.py index 17e53fe..27b283b 100644 --- a/seacharts/environment/map.py +++ b/seacharts/environment/map.py @@ -10,8 +10,6 @@ @dataclass class MapData(DataCollection): def __post_init__(self): - # self.map_layers = {d: Layer(name=d) for d in self.scope.features} - self.bathymetry = {d: Seabed(depth=d) for d in self.scope.depths} self.land = Land() self.shore = Shore() @@ -35,7 +33,6 @@ def parse_resources_into_shapefiles(self) -> None: @property def layers(self) -> list[Layer]: return [self.land, self.shore, *self.bathymetry.values()] - # return [*self.map_layers.values()] @property def loaded(self) -> bool: diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py index 404b453..2cd6af4 100644 --- a/seacharts/layers/layer.py +++ b/seacharts/layers/layer.py @@ -21,10 +21,14 @@ def label(self) -> str: # return self.name def records_as_geometry(self, records: list[dict]) -> None: + geometries = [] + 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) + for record in records: + geom_tmp = self._record_to_geometry(record) + if isinstance(geom_tmp, geo.Polygon): + geometries.append(geom_tmp) + self.geometry = self.as_multi(geometries) def unify(self, records: list[dict]) -> None: geometries = [self._record_to_geometry(r) for r in records] diff --git a/seacharts/shapes/shape.py b/seacharts/shapes/shape.py index b00d8de..3ae5957 100644 --- a/seacharts/shapes/shape.py +++ b/seacharts/shapes/shape.py @@ -44,13 +44,13 @@ 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]) + def as_multi(geometry: [Any]) -> Any: + if isinstance(geometry[0], geo.Point): + return geo.MultiPoint(geometry) + elif isinstance(geometry[0], geo.Polygon): + return geo.MultiPolygon(geometry) + elif isinstance(geometry[0], geo.LineString): + return geo.MultiLineString(geometry) else: raise NotImplementedError(type(geometry)) From f4a3896d505b9a3a29a4e78df881b12022f220dd Mon Sep 17 00:00:00 2001 From: miqn Date: Wed, 24 Apr 2024 00:24:28 +0200 Subject: [PATCH 16/84] updated configuration for different utm zones, display should now handle northern and southern projection --- seacharts/config.yaml | 19 +++++++----- seacharts/config_schema.yaml | 6 ++-- seacharts/core/extent.py | 56 +++++++++++++++++++++++++----------- seacharts/core/parserS57.py | 24 +++++++--------- seacharts/core/scope.py | 8 +++--- seacharts/display/display.py | 3 +- seacharts/enc.py | 8 ++++++ 7 files changed, 80 insertions(+), 44 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index b82278f..2582212 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,14 +1,19 @@ enc: - #autosize: True #TODO: implement autosize -# size: [ 16.0, 12.0, ] - size: [ 22.0, 15.5 ] - origin: [ -98.0, 18.0 ] -# depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] - depths: [ 0, 50, 100, 200, 2000 ] - #UTM_zone: 33 +# TODO: implement autosize +# test config for S-57 + size: [ 3.5, 3.5 ] + origin: [ -96.7, 27.0 ] + depths: [ 0, 1 ] + crs: "WGS84" S57_layers: ["DEPARE", "LNDARE", "TSSLPT"] resources: [ "data/", "data/db/" ] +# test config for FGDB +# size: [ 9000, 5062 ] +# center: [ 44300, 6956450 ] +# depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] +# crs: "UTM33N" +# resources: [ "/", "data/", "data/db/" ] display: colorbar: False dark_mode: True diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index ad0f81b..9503e39 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -35,9 +35,9 @@ enc: type: float #UTM zone required for coordinates to work correctly - UTM_zone: - required: False - type: integer + crs: + required: True + type: string #depths are required for both formats, if not set they will be assigned default values depths: diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index 3cb74fb..dce268f 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -2,14 +2,18 @@ Contains the Extent class for defining the span of spatial data. """ import math +import re + from pyproj import Transformer, CRS from pyproj import Proj, transform -#TODO size in extent for latlong needs fixing + +# TODO size in extent for latlong needs fixing class Extent: def __init__(self, settings: dict): self.size = tuple(settings["enc"].get("size", (0, 0))) - self.UTM_zone = settings["enc"].get("UTM_zone") + crs: str = settings["enc"].get("crs") + if "origin" in settings["enc"]: self.origin = tuple(settings["enc"].get("origin", (0, 0))) self.center = self._center_from_origin() @@ -18,16 +22,30 @@ def __init__(self, settings: dict): self.center = tuple(settings["enc"].get("center", (0, 0))) self.origin = self._origin_from_center() - if settings["enc"].get("FGDB_depths", []): - self.bbox = self._bounding_box_from_origin_size() - elif settings["enc"].get("S57_layers", []): - self.bbox = self._bounding_box_from_origin_size_lat_long() + if crs.__eq__("WGS84"): + self.utm_zone = self.wgs2utm(self.center[0]) + self.southern_hemisphere = False if self.center[1] >= 0 else True + self.size = self._size_from_lat_long() + self.center = self.convert_lat_lon_to_utm(self.center[1], self.center[0], self.utm_zone) + self.origin = self.convert_lat_lon_to_utm(self.origin[1], self.origin[0], self.utm_zone) + + elif re.match(r'^UTM\d{2}[NS]', crs): + crs = re.search(r'\d+[A-Z]', crs).group(0) + self.utm_zone = crs[0:2] + self.southern_hemisphere = False if crs[2] == 'N' else True + + self.bbox = self._bounding_box_from_origin_size() self.area: int = self.size[0] * self.size[1] @staticmethod - def convert_lat_lon_to_utm(latitude, longitude, zone): + def wgs2utm(longitude): + return str(math.floor(longitude / 6 + 31)) + + def convert_lat_lon_to_utm(self, latitude, longitude, zone): in_proj = Proj(init='epsg:4326') # WGS84 - out_proj = Proj(init='epsg:326' + zone) + # TODO find if hemisphere code variation is necessary + hemisphere_code = '7' if self.southern_hemisphere is True else '6' + out_proj = Proj(init='epsg:32' + hemisphere_code + zone) utm_east, utm_north = transform(in_proj, out_proj, longitude, latitude) utm_east = math.ceil(utm_east) @@ -36,14 +54,14 @@ def convert_lat_lon_to_utm(latitude, longitude, zone): def _origin_from_center(self) -> tuple[int, int]: return ( - self.center[0] - self.size[0] / 2, - self.center[1] - self.size[1] / 2, + int(self.center[0] - self.size[0] / 2), + int(self.center[1] - self.size[1] / 2), ) def _center_from_origin(self) -> tuple[int, int]: return ( - self.origin[0] + self.size[0] / 2, - self.origin[1] + self.size[1] / 2, + int(self.origin[0] + self.size[0] / 2), + int(self.origin[1] + self.size[1] / 2), ) def _bounding_box_from_origin_size(self) -> tuple[int, int, int, int]: @@ -51,11 +69,17 @@ def _bounding_box_from_origin_size(self) -> tuple[int, int, int, int]: x_max, y_max = x_min + self.size[0], y_min + self.size[1] return x_min, y_min, x_max, y_max + def _size_from_lat_long(self) -> tuple[int, int]: + x_min, y_min = self.origin + x_max, y_max = x_min + self.size[0], y_min + self.size[1] + converted_x_min, converted_y_min = self.convert_lat_lon_to_utm(y_min, x_min, self.utm_zone) + converted_x_max, converted_y_max = self.convert_lat_lon_to_utm(y_max, x_max, self.utm_zone) + return converted_x_max - converted_x_min, converted_y_max - converted_y_min + def _bounding_box_from_origin_size_lat_long(self) -> tuple[int, int, int, int]: x_min, y_min = self.origin x_max, y_max = x_min + self.size[0], y_min + self.size[1] - zone = str(math.floor(self.center[0] / 6 + 31)) - self.UTM_zone = zone - converted_x_min, converted_y_min = self.convert_lat_lon_to_utm(y_min, x_min, zone) - converted_x_max, converted_y_max = self.convert_lat_lon_to_utm(y_max, x_max, zone) + converted_x_min, converted_y_min = self.convert_lat_lon_to_utm(y_min, x_min, self.utm_zone) + converted_x_max, converted_y_max = self.convert_lat_lon_to_utm(y_max, x_max, self.utm_zone) + self.size = tuple([converted_x_max - converted_x_min, converted_y_max - converted_y_min]) return converted_x_min, converted_y_min, converted_x_max, converted_y_max diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index 35968d9..fdf0b84 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -67,25 +67,23 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: s57_path = None for path in self._file_paths: s57_path = self.get_s57_file(path) - s57_path = s57_path.__str__().replace("\\", "/") + s57_path = r'' + s57_path.__str__() # end for region in regions_list: + start_time = time.time() + dest_path = r'' + self._shapefile_dir_path(region.label).__str__() + "\\" + region.label + ".shp" + if isinstance(region, Seabed): - self.convert_s57_depth_to_shapefile(s57_path, - self._shapefile_dir_path(region.label).__str__() + - "\\" + region.label + ".shp", - region.depth) + self.convert_s57_depth_to_shapefile(s57_path, dest_path, region.depth) elif isinstance(region, Land): - self.convert_s57_to_shapefile(s57_path, - self._shapefile_dir_path(region.label).__str__() + "\\" + - region.label + ".shp", - "LNDARE") + self.convert_s57_to_shapefile(s57_path, dest_path, "LNDARE") elif isinstance(region, Shore): - self.convert_s57_to_shapefile(s57_path, - self._shapefile_dir_path(region.label).__str__() + "\\" + - region.label + ".shp", - "LNDARE") + self.convert_s57_to_shapefile(s57_path, dest_path, "LNDARE") # TODO fix coastline + records = list(self._read_shapefile(region.label)) + region.records_as_geometry(records) + end_time = round(time.time() - start_time, 1) + print(f"\rSaved {region.name} to shapefile in {end_time} s.") @staticmethod def get_s57_file(path) -> Path: diff --git a/seacharts/core/scope.py b/seacharts/core/scope.py index e1629fe..2a6bcbc 100644 --- a/seacharts/core/scope.py +++ b/seacharts/core/scope.py @@ -30,10 +30,10 @@ def __init__(self, settings: dict): files.build_directory_structure(self.features, self.resources) - #DEPARE --> depthsX - must be put into buffer dir first, then distributed between appropriate depths - #LNDARE --> land - #COALNE --> shore - #remaining layers --> ?? separate dir for all or shared dir like "info"? + # DEPARE --> depthsX - must be put into buffer dir first, then distributed between appropriate depths + # LNDARE --> land + # COALNE --> shore + # remaining layers --> ?? separate dir for all or shared dir like "info"? def __s57_init(self, settings: dict): default_layers = ["LNDARE", "DEPARE", "COALNE"] #TODO move to separate file self.layers = settings["enc"].get("S57_layers", default_layers) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index a8ef02d..d96a762 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -30,7 +30,8 @@ class Display: def __init__(self, settings: dict, environment: env.Environment): self._settings = settings - self.crs = UTM(environment.scope.extent.UTM_zone) + self.crs = UTM(environment.scope.extent.utm_zone, + southern_hemisphere=environment.scope.extent.southern_hemisphere) self._environment = environment self._background = None self._dark_mode = False diff --git a/seacharts/enc.py b/seacharts/enc.py index 5443972..cfa1dbc 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -3,6 +3,8 @@ """ from pathlib import Path +from shapely.geometry import Point + from seacharts.core import Config from seacharts.display import Display from seacharts.environment import Environment @@ -26,6 +28,12 @@ def __init__(self, config: Config | Path | str = None): self._environment = Environment(self._config.settings) self._display = None + def get_depth_at_coord(self, easting, northing): + point = Point(easting, northing) + for seabed in reversed(self.seabed.values()): + if any(polygon.contains(point) for polygon in seabed.geometry): + return seabed + def update(self) -> None: """ Update ENC with spatial data parsed from user-specified resources From 52e73e12510205fdef88f181ad9a3718164776d3 Mon Sep 17 00:00:00 2001 From: miqn Date: Mon, 6 May 2024 12:19:24 +0200 Subject: [PATCH 17/84] fixed coordinate offsets --- seacharts/core/extent.py | 3 +-- seacharts/core/parserS57.py | 32 +++++++++++++++++----------- seacharts/environment/environment.py | 5 ++++- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index dce268f..c192241 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -4,7 +4,6 @@ import math import re -from pyproj import Transformer, CRS from pyproj import Proj, transform @@ -26,8 +25,8 @@ def __init__(self, settings: dict): self.utm_zone = self.wgs2utm(self.center[0]) self.southern_hemisphere = False if self.center[1] >= 0 else True self.size = self._size_from_lat_long() - self.center = self.convert_lat_lon_to_utm(self.center[1], self.center[0], self.utm_zone) self.origin = self.convert_lat_lon_to_utm(self.origin[1], self.origin[0], self.utm_zone) + self.center = self.origin[0] + self.size[0] / 2, self.origin[1] + self.size[1] / 2 elif re.match(r'^UTM\d{2}[NS]', crs): crs = re.search(r'\d+[A-Z]', crs).group(0) diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index fdf0b84..1f83cbe 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -8,19 +8,27 @@ class S57Parser(DataParser): - + def __init__( + self, + bounding_box: tuple[int, int, int, int], + path_strings: list[str], + autosize: bool, + epsg: str + ): + super().__init__(bounding_box, path_strings, autosize) + self.epsg = epsg @staticmethod - def convert_s57_to_shapefile(s57_file_path, shapefile_output_path, layer): + def convert_s57_to_utm_shapefile(s57_file_path, shapefile_output_path, layer, epsg, bounding_box): + x_min, y_min, x_max, y_max = map(str, bounding_box) ogr2ogr_cmd = [ 'ogr2ogr', '-f', 'ESRI Shapefile', # Output format - # '-update', - '-t_srs', 'EPSG:32616', # TODO fixit to take calculated utm zone + '-t_srs', 'EPSG:' + epsg, shapefile_output_path, # Output shapefile s57_file_path, # Input S57 file layer, - '-skipfailures' + '-skipfailures', ] try: subprocess.run(ogr2ogr_cmd, check=True) @@ -29,12 +37,12 @@ def convert_s57_to_shapefile(s57_file_path, shapefile_output_path, layer): print(f"Error during conversion: {e}") @staticmethod - def convert_s57_depth_to_shapefile(s57_file_path, shapefile_output_path, depth): + def convert_s57_depth_to_utm_shapefile(s57_file_path, shapefile_output_path, depth, epsg, bounding_box): + x_min, y_min, x_max, y_max = map(str, bounding_box) ogr2ogr_cmd = [ 'ogr2ogr', '-f', 'ESRI Shapefile', # Output format - # '-update', - '-t_srs', 'EPSG:32616', # TODO fixit to take calculated utm zone + '-t_srs', "EPSG:" + epsg, shapefile_output_path, # Output shapefile s57_file_path, # Input S57 file '-sql', 'SELECT * FROM DEPARE WHERE DRVAL1 >= ' + depth.__str__(), @@ -63,23 +71,21 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: print("INFO: Updating ENC with data from available resources...\n") print(f"Processing {area // 10 ** 6} km^2 of ENC features:") - # ogr2ogr was crashing for backslashes, temporary fix s57_path = None for path in self._file_paths: s57_path = self.get_s57_file(path) s57_path = r'' + s57_path.__str__() - # end for region in regions_list: start_time = time.time() dest_path = r'' + self._shapefile_dir_path(region.label).__str__() + "\\" + region.label + ".shp" if isinstance(region, Seabed): - self.convert_s57_depth_to_shapefile(s57_path, dest_path, region.depth) + self.convert_s57_depth_to_utm_shapefile(s57_path, dest_path, region.depth, self.epsg, self.bounding_box) elif isinstance(region, Land): - self.convert_s57_to_shapefile(s57_path, dest_path, "LNDARE") + self.convert_s57_to_utm_shapefile(s57_path, dest_path, "LNDARE", self.epsg, self.bounding_box) elif isinstance(region, Shore): - self.convert_s57_to_shapefile(s57_path, dest_path, "LNDARE") # TODO fix coastline + self.convert_s57_to_utm_shapefile(s57_path, dest_path, "LNDARE", self.epsg, self.bounding_box) # TODO fix coastline records = list(self._read_shapefile(region.label)) region.records_as_geometry(records) end_time = round(time.time() - start_time, 1) diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index 2adaab2..edb7e21 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -20,6 +20,9 @@ def __init__(self, settings: dict): def set_parser(self) -> DataParser: if self.scope.type is MapFormat.S57: - return S57Parser(self.scope.extent.bbox, self.scope.resources, self.scope.autosize) + epsg = "32" + epsg += '7' if self.scope.extent.southern_hemisphere is True else '6' + epsg += self.scope.extent.utm_zone + return S57Parser(self.scope.extent.bbox, self.scope.resources, self.scope.autosize, epsg) elif self.scope.type is MapFormat.FGDB: return FGDBParser(self.scope.extent.bbox, self.scope.resources, self.scope.autosize) From eb5eea31ee4102f1ffc25cc2e94b1a2319eecd00 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Mon, 3 Jun 2024 15:21:36 +0200 Subject: [PATCH 18/84] weather download implementation --- .gitignore | 5 ++++- seacharts/config.yaml | 10 ++++++++++ seacharts/config_schema.yaml | 31 +++++++++++++++++++++++++++++++ seacharts/core/__init__.py | 1 + seacharts/core/weatherManager.py | 18 ++++++++++++++++++ seacharts/enc.py | 3 ++- seacharts/environment/weather.py | 2 +- 7 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 seacharts/core/weatherManager.py diff --git a/.gitignore b/.gitignore index a72de23..f364736 100644 --- a/.gitignore +++ b/.gitignore @@ -138,4 +138,7 @@ data/db/ data/shapefiles/ data/vessels.csv output/ -.vscode/ \ No newline at end of file +.vscode/ + +#pycharm +.idea \ No newline at end of file diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 2582212..42aad7d 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -21,3 +21,13 @@ display: resolution: 640 anchor: "center" dpi: 96 + +weather: + Pythor_adress: "http://127.0.0.1:5000" + time_start: 1715415818 + time_end: 1715415818 + latitude_start: 36 + latitude_end: 37 + longitude_start: 15 + longitude_end: 16 + diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index 9503e39..3916090 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -64,6 +64,37 @@ enc: schema: type: string +weather: + required: False + type: dict + schema: + Pythor_adress: + required: False + type: string + #in epoch time + time_start: + required: False + type: integer + time_end: + required: False + type: integer + latitude_start: + required: False + type: float + latitude_end: + required: False + type: float + longitude_start: + required: False + type: float + longitude_end: + required: False + type: float + variables: + type: list + required: False + schema: + type: string display: required: False type: dict diff --git a/seacharts/core/__init__.py b/seacharts/core/__init__.py index c379a85..d86b4c1 100644 --- a/seacharts/core/__init__.py +++ b/seacharts/core/__init__.py @@ -8,3 +8,4 @@ from .parserFGDB import FGDBParser from .parserS57 import S57Parser from .scope import Scope, MapFormat +from .weatherManager import WeatherManager diff --git a/seacharts/core/weatherManager.py b/seacharts/core/weatherManager.py new file mode 100644 index 0000000..fe8cb24 --- /dev/null +++ b/seacharts/core/weatherManager.py @@ -0,0 +1,18 @@ +import requests + +class WeatherManager: + def __init__(self, weatherSettings: dict): + query_dict = weatherSettings.copy() + api_query = query_dict["Pythor_adress"] + "/api/weather?" + query_dict.pop("Pythor_adress") + for k,v in query_dict.items(): + api_query += k + "=" + if v is not list: + api_query += str(v)+"&" + else: + for weater_var in v: + api_query+= str(weater_var) + "," + api_query = api_query[:-1]+"&" + api_query = api_query[:-1] + print(api_query) + self.unformated_data = requests.get (api_query) diff --git a/seacharts/enc.py b/seacharts/enc.py index cfa1dbc..e03725a 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -5,7 +5,7 @@ from shapely.geometry import Point -from seacharts.core import Config +from seacharts.core import Config,WeatherManager from seacharts.display import Display from seacharts.environment import Environment from seacharts.layers import Layer @@ -26,6 +26,7 @@ class ENC: def __init__(self, config: Config | Path | str = None): self._config = config if isinstance(config, Config) else Config(config) self._environment = Environment(self._config.settings) + self.weather_manager = WeatherManager(self._config.settings["weather"]) self._display = None def get_depth_at_coord(self, easting, northing): diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index ecb3c00..8015157 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -3,7 +3,7 @@ """ from seacharts.layers import Layer from .collection import DataCollection - +import requests class WeatherData(DataCollection): def __post_init__(self): From df81fe1f2494a90f957a5858a09f51502e4c6bf7 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Mon, 3 Jun 2024 15:27:54 +0200 Subject: [PATCH 19/84] minor bug fix --- seacharts/config.yaml | 5 +++-- seacharts/core/weatherManager.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 42aad7d..26ae90f 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -24,10 +24,11 @@ display: weather: Pythor_adress: "http://127.0.0.1:5000" - time_start: 1715415818 - time_end: 1715415818 + time_start: 1717420980 + time_end: 1717420980 latitude_start: 36 latitude_end: 37 longitude_start: 15 longitude_end: 16 + variables: ["wave_direction","wave_height","wave_period","wind_direction","wind_speed"] diff --git a/seacharts/core/weatherManager.py b/seacharts/core/weatherManager.py index fe8cb24..2b1a37d 100644 --- a/seacharts/core/weatherManager.py +++ b/seacharts/core/weatherManager.py @@ -7,7 +7,7 @@ def __init__(self, weatherSettings: dict): query_dict.pop("Pythor_adress") for k,v in query_dict.items(): api_query += k + "=" - if v is not list: + if not isinstance(v,list): api_query += str(v)+"&" else: for weater_var in v: From 532c9717fdf3925b7f7ae57130aff499686a7194 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Mon, 3 Jun 2024 15:33:18 +0200 Subject: [PATCH 20/84] Update weatherManager.py --- seacharts/core/weatherManager.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/seacharts/core/weatherManager.py b/seacharts/core/weatherManager.py index 2b1a37d..5fc7524 100644 --- a/seacharts/core/weatherManager.py +++ b/seacharts/core/weatherManager.py @@ -1,18 +1,22 @@ import requests + class WeatherManager: def __init__(self, weatherSettings: dict): - query_dict = weatherSettings.copy() + self.unformatted_data = self.fetch_data(weatherSettings.copy()) + + @staticmethod + def fetch_data(query_dict): api_query = query_dict["Pythor_adress"] + "/api/weather?" query_dict.pop("Pythor_adress") - for k,v in query_dict.items(): + for k, v in query_dict.items(): api_query += k + "=" - if not isinstance(v,list): - api_query += str(v)+"&" + if not isinstance(v, list): + api_query += str(v) + "&" else: for weater_var in v: - api_query+= str(weater_var) + "," - api_query = api_query[:-1]+"&" + api_query += str(weater_var) + "," + api_query = api_query[:-1] + "&" api_query = api_query[:-1] print(api_query) - self.unformated_data = requests.get (api_query) + return requests.get(api_query) From 94898045dc753103ab96364034cd3829886b45d2 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Tue, 4 Jun 2024 21:16:06 +0200 Subject: [PATCH 21/84] weather data into layers --- seacharts/config.yaml | 19 ++++----- seacharts/config_schema.yaml | 60 ++++++++++++++-------------- seacharts/core/__init__.py | 1 - seacharts/core/scope.py | 2 + seacharts/core/weatherManager.py | 22 ---------- seacharts/enc.py | 3 +- seacharts/environment/environment.py | 1 - seacharts/environment/weather.py | 47 ++++++++++++++++++++-- seacharts/layers/__init__.py | 2 +- seacharts/layers/layer.py | 12 ++++++ 10 files changed, 101 insertions(+), 68 deletions(-) delete mode 100644 seacharts/core/weatherManager.py diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 26ae90f..8d41392 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -7,6 +7,15 @@ enc: crs: "WGS84" S57_layers: ["DEPARE", "LNDARE", "TSSLPT"] resources: [ "data/", "data/db/" ] + weather: + PyThor_adress: "http://127.0.0.1:5000" + time_start: 1717420980 + time_end: 1717420980 + latitude_start: 36 + latitude_end: 37 + longitude_start: 15 + longitude_end: 16 + variables: [ "wave_direction","wave_height","wave_period","wind_direction","wind_speed" ] # test config for FGDB # size: [ 9000, 5062 ] @@ -22,13 +31,5 @@ display: anchor: "center" dpi: 96 -weather: - Pythor_adress: "http://127.0.0.1:5000" - time_start: 1717420980 - time_end: 1717420980 - latitude_start: 36 - latitude_end: 37 - longitude_start: 15 - longitude_end: 16 - variables: ["wave_direction","wave_height","wave_period","wind_direction","wind_speed"] + diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index 3916090..637d7b3 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -64,37 +64,39 @@ enc: schema: type: string -weather: - required: False - type: dict - schema: - Pythor_adress: - required: False - type: string - #in epoch time - time_start: - required: False - type: integer - time_end: - required: False - type: integer - latitude_start: - required: False - type: float - latitude_end: - required: False - type: float - longitude_start: - required: False - type: float - longitude_end: - required: False - type: float - variables: - type: list + weather: required: False + type: dict schema: - type: string + PyThor_adress: + required: False + type: string + #in epoch time + time_start: + required: False + type: integer + time_end: + required: False + type: integer + latitude_start: + required: False + type: float + latitude_end: + required: False + type: float + longitude_start: + required: False + type: float + longitude_end: + required: False + type: float + variables: + type: list + required: False + schema: + type: string + + display: required: False type: dict diff --git a/seacharts/core/__init__.py b/seacharts/core/__init__.py index d86b4c1..c379a85 100644 --- a/seacharts/core/__init__.py +++ b/seacharts/core/__init__.py @@ -8,4 +8,3 @@ from .parserFGDB import FGDBParser from .parserS57 import S57Parser from .scope import Scope, MapFormat -from .weatherManager import WeatherManager diff --git a/seacharts/core/scope.py b/seacharts/core/scope.py index 2a6bcbc..c9ae4c1 100644 --- a/seacharts/core/scope.py +++ b/seacharts/core/scope.py @@ -28,6 +28,8 @@ def __init__(self, settings: dict): else: self.type = MapFormat.FGDB + self.weather = settings["enc"].get("weather", []) + files.build_directory_structure(self.features, self.resources) # DEPARE --> depthsX - must be put into buffer dir first, then distributed between appropriate depths diff --git a/seacharts/core/weatherManager.py b/seacharts/core/weatherManager.py deleted file mode 100644 index 5fc7524..0000000 --- a/seacharts/core/weatherManager.py +++ /dev/null @@ -1,22 +0,0 @@ -import requests - - -class WeatherManager: - def __init__(self, weatherSettings: dict): - self.unformatted_data = self.fetch_data(weatherSettings.copy()) - - @staticmethod - def fetch_data(query_dict): - api_query = query_dict["Pythor_adress"] + "/api/weather?" - query_dict.pop("Pythor_adress") - for k, v in query_dict.items(): - api_query += k + "=" - if not isinstance(v, list): - api_query += str(v) + "&" - else: - for weater_var in v: - api_query += str(weater_var) + "," - api_query = api_query[:-1] + "&" - api_query = api_query[:-1] - print(api_query) - return requests.get(api_query) diff --git a/seacharts/enc.py b/seacharts/enc.py index e03725a..cfa1dbc 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -5,7 +5,7 @@ from shapely.geometry import Point -from seacharts.core import Config,WeatherManager +from seacharts.core import Config from seacharts.display import Display from seacharts.environment import Environment from seacharts.layers import Layer @@ -26,7 +26,6 @@ class ENC: def __init__(self, config: Config | Path | str = None): self._config = config if isinstance(config, Config) else Config(config) self._environment = Environment(self._config.settings) - self.weather_manager = WeatherManager(self._config.settings["weather"]) self._display = None def get_depth_at_coord(self, easting, northing): diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index edb7e21..c902646 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -6,7 +6,6 @@ from .user import UserData from .weather import WeatherData - class Environment: def __init__(self, settings: dict): self.scope = Scope(settings) diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index 8015157..85a056d 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -1,17 +1,58 @@ """ Contains the WeatherData class for containing parsed weather data. """ -from seacharts.layers import Layer +from seacharts.layers import VirtualWeatherLayer, WeatherLayer from .collection import DataCollection import requests +from dataclasses import dataclass + +@dataclass class WeatherData(DataCollection): + longitude: float = None + latitude: float = None + time: int = None + def __post_init__(self): + print("k") + self.weather_layers = list() + if self.scope.weather != []: + self.verify_scope() + unformatted_data = self.fetch_data(self.scope.weather.copy()) + self.parse_data(unformatted_data) + + def verify_scope(self): ... + @staticmethod + def fetch_data(query_dict): + api_query = query_dict["PyThor_adress"] + "/api/weather?" + query_dict.pop("PyThor_adress") + for k, v in query_dict.items(): + api_query += k + "=" + if not isinstance(v, list): + api_query += str(v) + "&" + else: + for weater_var in v: + api_query += str(weater_var) + "," + api_query = api_query[:-1] + "&" + api_query = api_query[:-1] + print(api_query) + return requests.get(api_query).json() + + def parse_data(self, data: dict): + self.time = data.pop("time_inter") + self.latitude = data.pop("lat_inter") + self.longitude = data.pop("lon_inter") + for k, v in data.items(): + new_layer = VirtualWeatherLayer(name=k, weather=list()) + for time_index, weather_data in enumerate(v): + new_layer.weather.append(WeatherLayer(time=self.time[time_index], data=weather_data)) + self.weather_layers.append(new_layer) + @property - def layers(self) -> list[Layer]: - return [] + def layers(self) -> list[VirtualWeatherLayer]: + return self.weather_layers @property def loaded(self) -> bool: diff --git a/seacharts/layers/__init__.py b/seacharts/layers/__init__.py index c815d57..6beeb68 100644 --- a/seacharts/layers/__init__.py +++ b/seacharts/layers/__init__.py @@ -1,5 +1,5 @@ """ Contains data classes for containing layered spatial data. """ -from .layer import Layer +from .layer import Layer, VirtualWeatherLayer, WeatherLayer from .layers import Seabed, Land, Shore diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py index 2cd6af4..e258b8a 100644 --- a/seacharts/layers/layer.py +++ b/seacharts/layers/layer.py @@ -50,3 +50,15 @@ def name(self) -> str: @dataclass class MultiDepthLayer(Layer, MultiDepth, ABC): ... + + +@dataclass +class WeatherLayer: + time: int + data: list[list[float]] + + +@dataclass +class VirtualWeatherLayer: + name: str + weather: list[WeatherLayer] From 4679eee6c1ad827078620221b4275ee32801a566 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Tue, 4 Jun 2024 21:16:06 +0200 Subject: [PATCH 22/84] weather data into layers --- seacharts/config.yaml | 19 ++++----- seacharts/config_schema.yaml | 60 ++++++++++++++-------------- seacharts/core/__init__.py | 1 - seacharts/core/scope.py | 2 + seacharts/core/weatherManager.py | 22 ---------- seacharts/enc.py | 3 +- seacharts/environment/environment.py | 1 - seacharts/environment/weather.py | 51 +++++++++++++++++++++-- seacharts/layers/__init__.py | 2 +- seacharts/layers/layer.py | 12 ++++++ 10 files changed, 104 insertions(+), 69 deletions(-) delete mode 100644 seacharts/core/weatherManager.py diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 26ae90f..8d41392 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -7,6 +7,15 @@ enc: crs: "WGS84" S57_layers: ["DEPARE", "LNDARE", "TSSLPT"] resources: [ "data/", "data/db/" ] + weather: + PyThor_adress: "http://127.0.0.1:5000" + time_start: 1717420980 + time_end: 1717420980 + latitude_start: 36 + latitude_end: 37 + longitude_start: 15 + longitude_end: 16 + variables: [ "wave_direction","wave_height","wave_period","wind_direction","wind_speed" ] # test config for FGDB # size: [ 9000, 5062 ] @@ -22,13 +31,5 @@ display: anchor: "center" dpi: 96 -weather: - Pythor_adress: "http://127.0.0.1:5000" - time_start: 1717420980 - time_end: 1717420980 - latitude_start: 36 - latitude_end: 37 - longitude_start: 15 - longitude_end: 16 - variables: ["wave_direction","wave_height","wave_period","wind_direction","wind_speed"] + diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index 3916090..637d7b3 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -64,37 +64,39 @@ enc: schema: type: string -weather: - required: False - type: dict - schema: - Pythor_adress: - required: False - type: string - #in epoch time - time_start: - required: False - type: integer - time_end: - required: False - type: integer - latitude_start: - required: False - type: float - latitude_end: - required: False - type: float - longitude_start: - required: False - type: float - longitude_end: - required: False - type: float - variables: - type: list + weather: required: False + type: dict schema: - type: string + PyThor_adress: + required: False + type: string + #in epoch time + time_start: + required: False + type: integer + time_end: + required: False + type: integer + latitude_start: + required: False + type: float + latitude_end: + required: False + type: float + longitude_start: + required: False + type: float + longitude_end: + required: False + type: float + variables: + type: list + required: False + schema: + type: string + + display: required: False type: dict diff --git a/seacharts/core/__init__.py b/seacharts/core/__init__.py index d86b4c1..c379a85 100644 --- a/seacharts/core/__init__.py +++ b/seacharts/core/__init__.py @@ -8,4 +8,3 @@ from .parserFGDB import FGDBParser from .parserS57 import S57Parser from .scope import Scope, MapFormat -from .weatherManager import WeatherManager diff --git a/seacharts/core/scope.py b/seacharts/core/scope.py index 2a6bcbc..c9ae4c1 100644 --- a/seacharts/core/scope.py +++ b/seacharts/core/scope.py @@ -28,6 +28,8 @@ def __init__(self, settings: dict): else: self.type = MapFormat.FGDB + self.weather = settings["enc"].get("weather", []) + files.build_directory_structure(self.features, self.resources) # DEPARE --> depthsX - must be put into buffer dir first, then distributed between appropriate depths diff --git a/seacharts/core/weatherManager.py b/seacharts/core/weatherManager.py deleted file mode 100644 index 5fc7524..0000000 --- a/seacharts/core/weatherManager.py +++ /dev/null @@ -1,22 +0,0 @@ -import requests - - -class WeatherManager: - def __init__(self, weatherSettings: dict): - self.unformatted_data = self.fetch_data(weatherSettings.copy()) - - @staticmethod - def fetch_data(query_dict): - api_query = query_dict["Pythor_adress"] + "/api/weather?" - query_dict.pop("Pythor_adress") - for k, v in query_dict.items(): - api_query += k + "=" - if not isinstance(v, list): - api_query += str(v) + "&" - else: - for weater_var in v: - api_query += str(weater_var) + "," - api_query = api_query[:-1] + "&" - api_query = api_query[:-1] - print(api_query) - return requests.get(api_query) diff --git a/seacharts/enc.py b/seacharts/enc.py index e03725a..cfa1dbc 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -5,7 +5,7 @@ from shapely.geometry import Point -from seacharts.core import Config,WeatherManager +from seacharts.core import Config from seacharts.display import Display from seacharts.environment import Environment from seacharts.layers import Layer @@ -26,7 +26,6 @@ class ENC: def __init__(self, config: Config | Path | str = None): self._config = config if isinstance(config, Config) else Config(config) self._environment = Environment(self._config.settings) - self.weather_manager = WeatherManager(self._config.settings["weather"]) self._display = None def get_depth_at_coord(self, easting, northing): diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index edb7e21..c902646 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -6,7 +6,6 @@ from .user import UserData from .weather import WeatherData - class Environment: def __init__(self, settings: dict): self.scope = Scope(settings) diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index 8015157..24f2946 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -1,17 +1,60 @@ """ Contains the WeatherData class for containing parsed weather data. """ -from seacharts.layers import Layer -from .collection import DataCollection +from dataclasses import dataclass + import requests +from seacharts.layers import VirtualWeatherLayer, WeatherLayer +from .collection import DataCollection + + +@dataclass class WeatherData(DataCollection): + longitude: float = None + latitude: float = None + time: int = None + def __post_init__(self): + print("k") + self.weather_layers = list() + if self.scope.weather != []: + self.verify_scope() + unformatted_data = self.fetch_data(self.scope.weather.copy()) + self.parse_data(unformatted_data) + + def verify_scope(self): ... + @staticmethod + def fetch_data(query_dict) -> dict: + api_query = query_dict["PyThor_adress"] + "/api/weather?" + query_dict.pop("PyThor_adress") + for k, v in query_dict.items(): + api_query += k + "=" + if not isinstance(v, list): + api_query += str(v) + "&" + else: + for weater_var in v: + api_query += str(weater_var) + "," + api_query = api_query[:-1] + "&" + api_query = api_query[:-1] + print(api_query) + return requests.get(api_query).json() + + def parse_data(self, data: dict) -> None: + self.time = data.pop("time_inter") + self.latitude = data.pop("lat_inter") + self.longitude = data.pop("lon_inter") + for k, v in data.items(): + new_layer = VirtualWeatherLayer(name=k, weather=list()) + for time_index, weather_data in enumerate(v): + new_layer.weather.append(WeatherLayer(time=self.time[time_index], data=weather_data)) + self.weather_layers.append(new_layer) + @property - def layers(self) -> list[Layer]: - return [] + def layers(self) -> list[VirtualWeatherLayer]: + return self.weather_layers @property def loaded(self) -> bool: diff --git a/seacharts/layers/__init__.py b/seacharts/layers/__init__.py index c815d57..6beeb68 100644 --- a/seacharts/layers/__init__.py +++ b/seacharts/layers/__init__.py @@ -1,5 +1,5 @@ """ Contains data classes for containing layered spatial data. """ -from .layer import Layer +from .layer import Layer, VirtualWeatherLayer, WeatherLayer from .layers import Seabed, Land, Shore diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py index 2cd6af4..e258b8a 100644 --- a/seacharts/layers/layer.py +++ b/seacharts/layers/layer.py @@ -50,3 +50,15 @@ def name(self) -> str: @dataclass class MultiDepthLayer(Layer, MultiDepth, ABC): ... + + +@dataclass +class WeatherLayer: + time: int + data: list[list[float]] + + +@dataclass +class VirtualWeatherLayer: + name: str + weather: list[WeatherLayer] From 6ecf05a814dad61e1150b8ef03fb58724cdc299a Mon Sep 17 00:00:00 2001 From: miqn Date: Fri, 7 Jun 2024 18:34:53 +0200 Subject: [PATCH 23/84] added map clipping --- seacharts/config.yaml | 6 ++++-- seacharts/core/extent.py | 12 +++++++----- seacharts/core/parserS57.py | 22 ++++++++++++---------- seacharts/enc.py | 2 +- seacharts/environment/weather.py | 1 - 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 2582212..3236677 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,12 +1,14 @@ enc: # TODO: implement autosize # test config for S-57 +# size: [ 3.5, 3.5 ] size: [ 3.5, 3.5 ] - origin: [ -96.7, 27.0 ] +# origin: [ -96.7, 27.0 ] + origin: [ -59.99, -22.17 ] depths: [ 0, 1 ] crs: "WGS84" S57_layers: ["DEPARE", "LNDARE", "TSSLPT"] - resources: [ "data/", "data/db/" ] + resources: [ "data/", "data/db/BR7P0900" ] # test config for FGDB # size: [ 9000, 5062 ] diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index c192241..13303d5 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -4,7 +4,7 @@ import math import re -from pyproj import Proj, transform +from pyproj import Transformer # TODO size in extent for latlong needs fixing @@ -41,12 +41,14 @@ def wgs2utm(longitude): return str(math.floor(longitude / 6 + 31)) def convert_lat_lon_to_utm(self, latitude, longitude, zone): - in_proj = Proj(init='epsg:4326') # WGS84 - # TODO find if hemisphere code variation is necessary + in_proj = 'epsg:4326' # WGS84 hemisphere_code = '7' if self.southern_hemisphere is True else '6' - out_proj = Proj(init='epsg:32' + hemisphere_code + zone) + # TODO find if hemisphere code variation is necessary + out_proj = 'epsg:32' + hemisphere_code + zone + + transformer = Transformer.from_crs(in_proj, out_proj, always_xy=True) + utm_east, utm_north = transformer.transform(longitude, latitude) - utm_east, utm_north = transform(in_proj, out_proj, longitude, latitude) utm_east = math.ceil(utm_east) utm_north = math.ceil(utm_north) return utm_east, utm_north diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index 1f83cbe..fd74b4b 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -1,10 +1,10 @@ +import os.path import subprocess import time from pathlib import Path from seacharts.core import DataParser from seacharts.layers import Layer, Land, Shore, Seabed -from seacharts.layers.layer import SingleDepthLayer class S57Parser(DataParser): @@ -23,12 +23,13 @@ def convert_s57_to_utm_shapefile(s57_file_path, shapefile_output_path, layer, ep x_min, y_min, x_max, y_max = map(str, bounding_box) ogr2ogr_cmd = [ 'ogr2ogr', - '-f', 'ESRI Shapefile', # Output format - '-t_srs', 'EPSG:' + epsg, - shapefile_output_path, # Output shapefile - s57_file_path, # Input S57 file + '-f', 'ESRI Shapefile', # Output format + shapefile_output_path, # Output shapefile + s57_file_path, # Input S57 file layer, - '-skipfailures', + '-t_srs', 'EPSG:' + epsg, + '-clipdst', x_min, y_min, x_max, y_max, + '-skipfailures' ] try: subprocess.run(ogr2ogr_cmd, check=True) @@ -41,11 +42,12 @@ def convert_s57_depth_to_utm_shapefile(s57_file_path, shapefile_output_path, dep x_min, y_min, x_max, y_max = map(str, bounding_box) ogr2ogr_cmd = [ 'ogr2ogr', - '-f', 'ESRI Shapefile', # Output format - '-t_srs', "EPSG:" + epsg, - shapefile_output_path, # Output shapefile - s57_file_path, # Input S57 file + '-f', 'ESRI Shapefile', # Output format + shapefile_output_path, # Output shapefile + s57_file_path, # Input S57 file '-sql', 'SELECT * FROM DEPARE WHERE DRVAL1 >= ' + depth.__str__(), + '-t_srs', 'EPSG:' + epsg, + '-clipdst', x_min, y_min, x_max, y_max, '-skipfailures' ] try: diff --git a/seacharts/enc.py b/seacharts/enc.py index cfa1dbc..2da887a 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -31,7 +31,7 @@ def __init__(self, config: Config | Path | str = None): def get_depth_at_coord(self, easting, northing): point = Point(easting, northing) for seabed in reversed(self.seabed.values()): - if any(polygon.contains(point) for polygon in seabed.geometry): + if any(polygon.contains(point) for polygon in seabed.geometry.geoms): return seabed def update(self) -> None: diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index ecb3c00..d5613ba 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -8,7 +8,6 @@ class WeatherData(DataCollection): def __post_init__(self): ... - @property def layers(self) -> list[Layer]: return [] From 2868261a9b7e4225f376c185877501760a2bc8a2 Mon Sep 17 00:00:00 2001 From: miqn Date: Sat, 8 Jun 2024 11:38:59 +0200 Subject: [PATCH 24/84] more global coordinate converter --- seacharts/config.yaml | 2 +- seacharts/core/extent.py | 23 ++++++++++++----------- seacharts/environment/environment.py | 9 ++++----- seacharts/environment/weather.py | 3 ++- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 8d41392..cae39bc 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -5,7 +5,7 @@ enc: origin: [ -96.7, 27.0 ] depths: [ 0, 1 ] crs: "WGS84" - S57_layers: ["DEPARE", "LNDARE", "TSSLPT"] + S57_layers: ["DEPARE", "LNDARE"] resources: [ "data/", "data/db/" ] weather: PyThor_adress: "http://127.0.0.1:5000" diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index 13303d5..f30918c 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -7,7 +7,6 @@ from pyproj import Transformer -# TODO size in extent for latlong needs fixing class Extent: def __init__(self, settings: dict): self.size = tuple(settings["enc"].get("size", (0, 0))) @@ -24,14 +23,19 @@ def __init__(self, settings: dict): if crs.__eq__("WGS84"): self.utm_zone = self.wgs2utm(self.center[0]) self.southern_hemisphere = False if self.center[1] >= 0 else True + hemisphere_code = '7' if self.southern_hemisphere is True else '6' + self.out_proj = 'epsg:32' + hemisphere_code + self.utm_zone + self.size = self._size_from_lat_long() - self.origin = self.convert_lat_lon_to_utm(self.origin[1], self.origin[0], self.utm_zone) + self.origin = self.convert_lat_lon_to_utm(self.origin[1], self.origin[0]) self.center = self.origin[0] + self.size[0] / 2, self.origin[1] + self.size[1] / 2 elif re.match(r'^UTM\d{2}[NS]', crs): crs = re.search(r'\d+[A-Z]', crs).group(0) self.utm_zone = crs[0:2] self.southern_hemisphere = False if crs[2] == 'N' else True + hemisphere_code = '7' if self.southern_hemisphere is True else '6' + self.out_proj = 'epsg:32' + hemisphere_code + self.utm_zone self.bbox = self._bounding_box_from_origin_size() self.area: int = self.size[0] * self.size[1] @@ -40,13 +44,10 @@ def __init__(self, settings: dict): def wgs2utm(longitude): return str(math.floor(longitude / 6 + 31)) - def convert_lat_lon_to_utm(self, latitude, longitude, zone): + def convert_lat_lon_to_utm(self, latitude, longitude): in_proj = 'epsg:4326' # WGS84 - hemisphere_code = '7' if self.southern_hemisphere is True else '6' - # TODO find if hemisphere code variation is necessary - out_proj = 'epsg:32' + hemisphere_code + zone - transformer = Transformer.from_crs(in_proj, out_proj, always_xy=True) + transformer = Transformer.from_crs(in_proj, self.out_proj, always_xy=True) utm_east, utm_north = transformer.transform(longitude, latitude) utm_east = math.ceil(utm_east) @@ -73,14 +74,14 @@ def _bounding_box_from_origin_size(self) -> tuple[int, int, int, int]: def _size_from_lat_long(self) -> tuple[int, int]: x_min, y_min = self.origin x_max, y_max = x_min + self.size[0], y_min + self.size[1] - converted_x_min, converted_y_min = self.convert_lat_lon_to_utm(y_min, x_min, self.utm_zone) - converted_x_max, converted_y_max = self.convert_lat_lon_to_utm(y_max, x_max, self.utm_zone) + converted_x_min, converted_y_min = self.convert_lat_lon_to_utm(y_min, x_min) + converted_x_max, converted_y_max = self.convert_lat_lon_to_utm(y_max, x_max) return converted_x_max - converted_x_min, converted_y_max - converted_y_min def _bounding_box_from_origin_size_lat_long(self) -> tuple[int, int, int, int]: x_min, y_min = self.origin x_max, y_max = x_min + self.size[0], y_min + self.size[1] - converted_x_min, converted_y_min = self.convert_lat_lon_to_utm(y_min, x_min, self.utm_zone) - converted_x_max, converted_y_max = self.convert_lat_lon_to_utm(y_max, x_max, self.utm_zone) + converted_x_min, converted_y_min = self.convert_lat_lon_to_utm(y_min, x_min) + converted_x_max, converted_y_max = self.convert_lat_lon_to_utm(y_max, x_max) self.size = tuple([converted_x_max - converted_x_min, converted_y_max - converted_y_min]) return converted_x_min, converted_y_min, converted_x_max, converted_y_max diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index c902646..ab2c045 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -6,22 +6,21 @@ from .user import UserData from .weather import WeatherData + class Environment: def __init__(self, settings: dict): self.scope = Scope(settings) self.parser = self.set_parser() self.map = MapData(self.scope, self.parser) self.user = UserData(self.scope, self.parser) - self.weather = WeatherData(self.scope, self.parser) + # self.weather = WeatherData(self.scope, self.parser) self.map.load_existing_shapefiles() if not self.map.loaded: self.map.parse_resources_into_shapefiles() def set_parser(self) -> DataParser: if self.scope.type is MapFormat.S57: - epsg = "32" - epsg += '7' if self.scope.extent.southern_hemisphere is True else '6' - epsg += self.scope.extent.utm_zone - return S57Parser(self.scope.extent.bbox, self.scope.resources, self.scope.autosize, epsg) + return S57Parser(self.scope.extent.bbox, self.scope.resources, self.scope.autosize, + self.scope.extent.out_proj) elif self.scope.type is MapFormat.FGDB: return FGDBParser(self.scope.extent.bbox, self.scope.resources, self.scope.autosize) diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index e9d732b..de56ec9 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -19,7 +19,7 @@ class WeatherData(DataCollection): def __post_init__(self): print("k") self.weather_layers = list() - if self.scope.weather != []: + if self.scope.weather: self.verify_scope() unformatted_data = self.fetch_data(self.scope.weather.copy()) self.parse_data(unformatted_data) @@ -47,6 +47,7 @@ def parse_data(self, data: dict) -> None: self.time = data.pop("time_inter") self.latitude = data.pop("lat_inter") self.longitude = data.pop("lon_inter") + for k, v in data.items(): new_layer = VirtualWeatherLayer(name=k, weather=list()) for time_index, weather_data in enumerate(v): From f6effa6c990322c84964554f80c0d989f3bb3637 Mon Sep 17 00:00:00 2001 From: miqn Date: Sat, 8 Jun 2024 18:14:47 +0200 Subject: [PATCH 25/84] weather operation UI, bugfix --- seacharts/config.yaml | 2 +- seacharts/core/parserS57.py | 6 +++--- seacharts/display/display.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index cae39bc..304b9f4 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -3,7 +3,7 @@ enc: # test config for S-57 size: [ 3.5, 3.5 ] origin: [ -96.7, 27.0 ] - depths: [ 0, 1 ] + depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] crs: "WGS84" S57_layers: ["DEPARE", "LNDARE"] resources: [ "data/", "data/db/" ] diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index fd74b4b..9a7767e 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -27,7 +27,7 @@ def convert_s57_to_utm_shapefile(s57_file_path, shapefile_output_path, layer, ep shapefile_output_path, # Output shapefile s57_file_path, # Input S57 file layer, - '-t_srs', 'EPSG:' + epsg, + '-t_srs', epsg.upper(), '-clipdst', x_min, y_min, x_max, y_max, '-skipfailures' ] @@ -38,7 +38,7 @@ def convert_s57_to_utm_shapefile(s57_file_path, shapefile_output_path, layer, ep print(f"Error during conversion: {e}") @staticmethod - def convert_s57_depth_to_utm_shapefile(s57_file_path, shapefile_output_path, depth, epsg, bounding_box): + def convert_s57_depth_to_utm_shapefile(s57_file_path, shapefile_output_path, depth, epsg:str, bounding_box): x_min, y_min, x_max, y_max = map(str, bounding_box) ogr2ogr_cmd = [ 'ogr2ogr', @@ -46,7 +46,7 @@ def convert_s57_depth_to_utm_shapefile(s57_file_path, shapefile_output_path, dep shapefile_output_path, # Output shapefile s57_file_path, # Input S57 file '-sql', 'SELECT * FROM DEPARE WHERE DRVAL1 >= ' + depth.__str__(), - '-t_srs', 'EPSG:' + epsg, + '-t_srs', epsg.upper(), '-clipdst', x_min, y_min, x_max, y_max, '-skipfailures' ] diff --git a/seacharts/display/display.py b/seacharts/display/display.py index d96a762..1c5d812 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -7,6 +7,8 @@ import matplotlib import matplotlib.pyplot as plt +from matplotlib.widgets import Slider +from matplotlib.widgets import Button from cartopy.crs import UTM from matplotlib.gridspec import GridSpec from matplotlib_scalebar.scalebar import ScaleBar @@ -47,6 +49,7 @@ def __init__(self, settings: dict, environment: env.Environment): self._toggle_colorbar(self._colorbar_mode) self._toggle_dark_mode(self._dark_mode) self._add_scalebar() + self.add_slider() self.redraw_plot() if self._fullscreen_mode: self._toggle_fullscreen(self._fullscreen_mode) @@ -480,3 +483,29 @@ def _is_active(self): def _terminate(self): plt.close(self.figure) + + """ + def add_slider(self): + fig, (ax_slider, ax_button) = plt.subplots(1, 2, gridspec_kw={'width_ratios': [3, 1]}, figsize=(8, 1)) + self.slider = Slider(ax_slider, label='Slider', valmin=0, valmax=1, valinit=0.5) + button = Button(ax_button, 'Set', color='lightgray') + button.on_clicked(self.slider_changed_callback) + + fig.show() + """ + + def add_slider(self): + fig, ax_slider = plt.subplots(figsize=(8, 1)) + self.slider = Slider(ax_slider, label='Slider', valmin=0, valmax=1, valinit=0.5) + last_value = self.slider.val + + def onrelease(event): + nonlocal last_value + if event.button == 1 and event.inaxes == ax_slider: + val = self.slider.val + if val != last_value: + last_value = val + print(f"Slider value: {val}") + + fig.canvas.mpl_connect('button_release_event', onrelease) + fig.show() From 1e497a3157c23341a647387c5f1e1cc09a01e32f Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Sat, 8 Jun 2024 23:16:24 +0200 Subject: [PATCH 26/84] added weather display heatmap --- seacharts/config.yaml | 6 +----- seacharts/config_schema.yaml | 12 ------------ seacharts/core/extent.py | 8 ++++++++ seacharts/display/display.py | 16 ++++++++++++++++ seacharts/environment/environment.py | 2 +- seacharts/environment/weather.py | 11 +++++++++-- 6 files changed, 35 insertions(+), 20 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 304b9f4..b532f93 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -11,11 +11,7 @@ enc: PyThor_adress: "http://127.0.0.1:5000" time_start: 1717420980 time_end: 1717420980 - latitude_start: 36 - latitude_end: 37 - longitude_start: 15 - longitude_end: 16 - variables: [ "wave_direction","wave_height","wave_period","wind_direction","wind_speed" ] + variables: [ "wind_speed" ] # test config for FGDB # size: [ 9000, 5062 ] diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index 637d7b3..c269bd8 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -78,18 +78,6 @@ enc: time_end: required: False type: integer - latitude_start: - required: False - type: float - latitude_end: - required: False - type: float - longitude_start: - required: False - type: float - longitude_end: - required: False - type: float variables: type: list required: False diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index f30918c..005b8f3 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -54,6 +54,14 @@ def convert_lat_lon_to_utm(self, latitude, longitude): utm_north = math.ceil(utm_north) return utm_east, utm_north + def convert_utm_to_lat_lon(self, utm_east, utm_north): + out_proj = 'epsg:4326' # WGS84 + in_proj = self.out_proj + transformer = Transformer.from_crs(in_proj, out_proj, always_xy=True) + longitude, latitude = transformer.transform(utm_east, utm_north) + + return latitude, longitude + def _origin_from_center(self) -> tuple[int, int]: return ( int(self.center[0] - self.size[0] / 2), diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 1c5d812..2c516d2 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -7,6 +7,8 @@ import matplotlib import matplotlib.pyplot as plt +import numpy as np +from matplotlib import colors from matplotlib.widgets import Slider from matplotlib.widgets import Button from cartopy.crs import UTM @@ -112,6 +114,20 @@ def clear_vessels(self) -> None: :return: None """ self._refresh_vessels([]) + @staticmethod + def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100): + new_cmap = colors.LinearSegmentedColormap.from_list( + 'trunc({n},{a:.2f},{b:.2f})'.format(n=cmap.name, a=minval, b=maxval), + cmap(np.linspace(minval, maxval, n))) + return new_cmap + + def draw_heatmap(self): + heatmap_data = self._environment.weather.weather_layers[0].weather[0].data + print(heatmap_data) + x_min, y_min, x_max, y_max = self._environment.scope.extent.bbox + extent = (x_min, x_max, y_min, y_max) + cmap = self.truncate_colormap(plt.get_cmap('jet'), 0.30, 0.9) + heatmap = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5) def draw_arrow( self, diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index ab2c045..6fe3a20 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -13,7 +13,7 @@ def __init__(self, settings: dict): self.parser = self.set_parser() self.map = MapData(self.scope, self.parser) self.user = UserData(self.scope, self.parser) - # self.weather = WeatherData(self.scope, self.parser) + self.weather = WeatherData(self.scope, self.parser) self.map.load_existing_shapefiles() if not self.map.loaded: self.map.parse_resources_into_shapefiles() diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index de56ec9..aedb3d7 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -27,8 +27,7 @@ def __post_init__(self): def verify_scope(self): ... - @staticmethod - def fetch_data(query_dict) -> dict: + def fetch_data(self,query_dict) -> dict: api_query = query_dict["PyThor_adress"] + "/api/weather?" query_dict.pop("PyThor_adress") for k, v in query_dict.items(): @@ -40,6 +39,14 @@ def fetch_data(query_dict) -> dict: api_query += str(weater_var) + "," api_query = api_query[:-1] + "&" api_query = api_query[:-1] + x_min, y_min, x_max, y_max = self.scope.extent.bbox + latitude_start,longitude_start = self.scope.extent.convert_utm_to_lat_lon(x_min,y_min) + latitude_end, longitude_end = self.scope.extent.convert_utm_to_lat_lon(x_max, y_max) + print(latitude_start,longitude_start,latitude_end, longitude_end) + api_query += "&latitude_start="+str(latitude_start) + api_query += "&longitude_start=" + str(longitude_start) + api_query += "&latitude_end=" + str(latitude_end) + api_query += "&longitude_end=" + str(longitude_end) print(api_query) return requests.get(api_query).json() From c1fa69fc0ce25f6aed65c1b83984ff1fb929d9ae Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Sun, 9 Jun 2024 16:55:07 +0200 Subject: [PATCH 27/84] display update --- seacharts/config.yaml | 6 +++--- seacharts/display/display.py | 20 +++++++++++++++----- seacharts/environment/weather.py | 16 +++++++++++----- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index b532f93..d1c309a 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,8 +1,8 @@ enc: # TODO: implement autosize # test config for S-57 - size: [ 3.5, 3.5 ] - origin: [ -96.7, 27.0 ] + size: [ 22.0, 15.5 ] + origin: [ -98.0, 18.0 ] depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] crs: "WGS84" S57_layers: ["DEPARE", "LNDARE"] @@ -11,7 +11,7 @@ enc: PyThor_adress: "http://127.0.0.1:5000" time_start: 1717420980 time_end: 1717420980 - variables: [ "wind_speed" ] + variables: [ "wave_height" ] # test config for FGDB # size: [ 9000, 5062 ] diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 2c516d2..cb97f51 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -115,19 +115,29 @@ def clear_vessels(self) -> None: """ self._refresh_vessels([]) @staticmethod - def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100): + def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100) -> colors.LinearSegmentedColormap: new_cmap = colors.LinearSegmentedColormap.from_list( 'trunc({n},{a:.2f},{b:.2f})'.format(n=cmap.name, a=minval, b=maxval), cmap(np.linspace(minval, maxval, n))) return new_cmap - def draw_heatmap(self): - heatmap_data = self._environment.weather.weather_layers[0].weather[0].data - print(heatmap_data) + def draw_weather_heatmap(self,variable_name:str,cmap: colors.Colormap,label_colour: str)->None: + weather_layer = self._environment.weather.find_by_name(variable_name) + if weather_layer is None: + return + heatmap_data = weather_layer.weather[self._environment.weather.selected_time_index].data x_min, y_min, x_max, y_max = self._environment.scope.extent.bbox extent = (x_min, x_max, y_min, y_max) - cmap = self.truncate_colormap(plt.get_cmap('jet'), 0.30, 0.9) + #cmap = self.truncate_colormap(plt.get_cmap('summer'), 0.2, 1) + # cmap = self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9) + # fg_color = 'white' + heatmap = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5) + ticks = np.round(np.linspace(start=np.nanmin(np.array(heatmap_data)),stop=np.nanmax(np.array(heatmap_data)),num=8),2) + cbar = self.figure.colorbar(heatmap, ax=self.axes,shrink=0.7,ticks=ticks) + cbar.ax.yaxis.set_tick_params(color=label_colour) + cbar.outline.set_edgecolor(label_colour) + plt.setp(plt.getp(cbar.ax.axes, 'yticklabels'), color=label_colour) def draw_arrow( self, diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index aedb3d7..bf68709 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -12,12 +12,12 @@ @dataclass class WeatherData(DataCollection): - longitude: float = None - latitude: float = None - time: int = None + longitude: list[float] = None + latitude: list[float] = None + time: list[float] = None + selected_time_index: int = None def __post_init__(self): - print("k") self.weather_layers = list() if self.scope.weather: self.verify_scope() @@ -54,7 +54,7 @@ def parse_data(self, data: dict) -> None: self.time = data.pop("time_inter") self.latitude = data.pop("lat_inter") self.longitude = data.pop("lon_inter") - + self.selected_time_index = 0 for k, v in data.items(): new_layer = VirtualWeatherLayer(name=k, weather=list()) for time_index, weather_data in enumerate(v): @@ -68,3 +68,9 @@ def layers(self) -> list[VirtualWeatherLayer]: @property def loaded(self) -> bool: return any(self.layers) + + def find_by_name(self,name:str) -> VirtualWeatherLayer: + for layer in self.layers: + if layer.name == name: + return layer + return None From d2c238a366e0376f4150343342f38f7055d976f6 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Sun, 9 Jun 2024 22:55:57 +0200 Subject: [PATCH 28/84] update visualization --- seacharts/config.yaml | 4 ++-- seacharts/display/display.py | 27 +++++++++++++++++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index d1c309a..1778b1d 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -9,8 +9,8 @@ enc: resources: [ "data/", "data/db/" ] weather: PyThor_adress: "http://127.0.0.1:5000" - time_start: 1717420980 - time_end: 1717420980 + time_start: 1717931596 + time_end: 1717952909 variables: [ "wave_height" ] # test config for FGDB diff --git a/seacharts/display/display.py b/seacharts/display/display.py index cb97f51..8afe4a8 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -114,6 +114,7 @@ def clear_vessels(self) -> None: :return: None """ self._refresh_vessels([]) + @staticmethod def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100) -> colors.LinearSegmentedColormap: new_cmap = colors.LinearSegmentedColormap.from_list( @@ -128,16 +129,20 @@ def draw_weather_heatmap(self,variable_name:str,cmap: colors.Colormap,label_colo heatmap_data = weather_layer.weather[self._environment.weather.selected_time_index].data x_min, y_min, x_max, y_max = self._environment.scope.extent.bbox extent = (x_min, x_max, y_min, y_max) + # print(extent) #cmap = self.truncate_colormap(plt.get_cmap('summer'), 0.2, 1) - # cmap = self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9) + #cmap = self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9) # fg_color = 'white' - heatmap = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5) - ticks = np.round(np.linspace(start=np.nanmin(np.array(heatmap_data)),stop=np.nanmax(np.array(heatmap_data)),num=8),2) - cbar = self.figure.colorbar(heatmap, ax=self.axes,shrink=0.7,ticks=ticks) - cbar.ax.yaxis.set_tick_params(color=label_colour) - cbar.outline.set_edgecolor(label_colour) - plt.setp(plt.getp(cbar.ax.axes, 'yticklabels'), color=label_colour) + ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)),num=8) + self.weather_map = self.axes.imshow(heatmap_data,aspect='auto',extent=extent, origin='lower', cmap=cmap, alpha=0.5) + self._cbar = self.figure.colorbar( self.weather_map, ax=self.axes,shrink=0.7) + self._cbar.ax.yaxis.set_tick_params(color=label_colour) + self._cbar.outline.set_edgecolor(label_colour) + print(heatmap_data) + print(np.nanmin(np.array(heatmap_data))) + print(np.nanmax(np.array(heatmap_data))) + plt.setp(plt.getp(self._cbar.ax.axes, 'yticklabels'), color=label_colour) def draw_arrow( self, @@ -522,7 +527,8 @@ def add_slider(self): def add_slider(self): fig, ax_slider = plt.subplots(figsize=(8, 1)) - self.slider = Slider(ax_slider, label='Slider', valmin=0, valmax=1, valinit=0.5) + times = self._environment.weather.time + self.slider = Slider(ax_slider, label='Time:', valmin=0, valmax=len(times)-1, valinit=0,valstep=1) last_value = self.slider.val def onrelease(event): @@ -530,7 +536,12 @@ def onrelease(event): if event.button == 1 and event.inaxes == ax_slider: val = self.slider.val if val != last_value: + self._environment.weather.selected_time_index = val + self._cbar.remove() + self.weather_map.remove() last_value = val + self.draw_weather_heatmap(self._environment.weather.weather_layers[0].name,cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9),label_colour='white') + self.redraw_plot() print(f"Slider value: {val}") fig.canvas.mpl_connect('button_release_event', onrelease) From adfa9350f9018b366bb37c86f2a24680faad6477 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Sun, 9 Jun 2024 23:09:03 +0200 Subject: [PATCH 29/84] proper demo --- seacharts/config.yaml | 4 ++-- seacharts/display/display.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 1778b1d..d33f791 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,8 +1,8 @@ enc: # TODO: implement autosize # test config for S-57 - size: [ 22.0, 15.5 ] - origin: [ -98.0, 18.0 ] + size: [ 6, 6 ] + origin: [ -96.7, 24.0 ] depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] crs: "WGS84" S57_layers: ["DEPARE", "LNDARE"] diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 8afe4a8..8e760e2 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -135,8 +135,8 @@ def draw_weather_heatmap(self,variable_name:str,cmap: colors.Colormap,label_colo # fg_color = 'white' ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)),num=8) - self.weather_map = self.axes.imshow(heatmap_data,aspect='auto',extent=extent, origin='lower', cmap=cmap, alpha=0.5) - self._cbar = self.figure.colorbar( self.weather_map, ax=self.axes,shrink=0.7) + self.weather_map = self.axes.imshow(heatmap_data,extent=extent, origin='lower', cmap=cmap, alpha=0.5) + self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes,shrink=0.7) self._cbar.ax.yaxis.set_tick_params(color=label_colour) self._cbar.outline.set_edgecolor(label_colour) print(heatmap_data) From d842a2e8910782c16b5fb4fa790ccfd450f611d1 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Sun, 9 Jun 2024 23:32:59 +0200 Subject: [PATCH 30/84] Update shape.py --- seacharts/shapes/shape.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/seacharts/shapes/shape.py b/seacharts/shapes/shape.py index 3ae5957..13411b6 100644 --- a/seacharts/shapes/shape.py +++ b/seacharts/shapes/shape.py @@ -51,6 +51,8 @@ def as_multi(geometry: [Any]) -> Any: return geo.MultiPolygon(geometry) elif isinstance(geometry[0], geo.LineString): return geo.MultiLineString(geometry) + elif isinstance(geometry[0], geo.MultiPolygon): + return geometry[0] else: raise NotImplementedError(type(geometry)) From 4ec82ab40b8003b345f25b58ba18264d779e523d Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Tue, 25 Jun 2024 14:27:36 +0200 Subject: [PATCH 31/84] Added weather data access from code --- seacharts/display/display.py | 133 ++++++++++++++++--------------- seacharts/enc.py | 10 ++- seacharts/environment/weather.py | 2 + 3 files changed, 81 insertions(+), 64 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 8e760e2..6b43e1c 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -59,7 +59,7 @@ def __init__(self, settings: dict, environment: env.Environment): self._set_figure_position() def start(self) -> None: - """ + self.started__ = """ Starts the display, if it is not already started. """ if self._is_active: @@ -122,21 +122,26 @@ def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100) -> colors.LinearSegme cmap(np.linspace(minval, maxval, n))) return new_cmap - def draw_weather_heatmap(self,variable_name:str,cmap: colors.Colormap,label_colour: str)->None: + def draw_weather(self,variable_name): + None + + def _draw_arrow_map(self): + None + + def _draw_weather_heatmap(self, variable_name: str, cmap: colors.Colormap, label_colour: str) -> None: + """ + Draws a heatmap and colorbar for specified weather variable using provided color map and label colour for color bar + :return: None + """ weather_layer = self._environment.weather.find_by_name(variable_name) if weather_layer is None: return heatmap_data = weather_layer.weather[self._environment.weather.selected_time_index].data x_min, y_min, x_max, y_max = self._environment.scope.extent.bbox extent = (x_min, x_max, y_min, y_max) - # print(extent) - #cmap = self.truncate_colormap(plt.get_cmap('summer'), 0.2, 1) - #cmap = self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9) - # fg_color = 'white' - - ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)),num=8) - self.weather_map = self.axes.imshow(heatmap_data,extent=extent, origin='lower', cmap=cmap, alpha=0.5) - self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes,shrink=0.7) + ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)), num=8) + self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5) + self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes, shrink=0.7) self._cbar.ax.yaxis.set_tick_params(color=label_colour) self._cbar.outline.set_edgecolor(label_colour) print(heatmap_data) @@ -145,15 +150,15 @@ def draw_weather_heatmap(self,variable_name:str,cmap: colors.Colormap,label_colo plt.setp(plt.getp(self._cbar.ax.axes, 'yticklabels'), color=label_colour) def draw_arrow( - self, - start: tuple[float, float], - end: tuple[float, float], - color: str, - width: float = None, - fill: bool = False, - head_size: float = None, - thickness: float = None, - edge_style: str | tuple = None, + self, + start: tuple[float, float], + end: tuple[float, float], + color: str, + width: float = None, + fill: bool = False, + head_size: float = None, + thickness: float = None, + edge_style: str | tuple = None, ) -> None: """ Add a straight arrow overlay to the environment plot. @@ -172,14 +177,14 @@ def draw_arrow( ) def draw_circle( - self, - center: tuple[float, float], - radius: float, - color: str, - fill: bool = True, - thickness: float = None, - edge_style: str | tuple = None, - alpha: float = 1.0, + self, + center: tuple[float, float], + radius: float, + color: str, + fill: bool = True, + thickness: float = None, + edge_style: str | tuple = None, + alpha: float = 1.0, ) -> None: """ Add a circle or disk overlay to the environment plot. @@ -197,13 +202,13 @@ def draw_circle( ) def draw_line( - self, - points: list[tuple[float, float]], - color: str, - width: float = None, - thickness: float = None, - edge_style: str | tuple = None, - marker_type: str = None, + self, + points: list[tuple[float, float]], + color: str, + width: float = None, + thickness: float = None, + edge_style: str | tuple = None, + marker_type: str = None, ) -> None: """ Add a straight line overlay to the environment plot. @@ -218,14 +223,14 @@ def draw_line( self.features.add_line(points, color, width, thickness, edge_style, marker_type) def draw_polygon( - self, - geometry: Any | list[tuple[float, float]], - color: str, - interiors: list[list[tuple[float, float]]] = None, - fill: bool = True, - thickness: float = None, - edge_style: str | tuple = None, - alpha: float = 1.0, + self, + geometry: Any | list[tuple[float, float]], + color: str, + interiors: list[list[tuple[float, float]]] = None, + fill: bool = True, + thickness: float = None, + edge_style: str | tuple = None, + alpha: float = 1.0, ) -> None: """ Add an arbitrary polygon shape overlay to the environment plot. @@ -243,15 +248,15 @@ def draw_polygon( ) def draw_rectangle( - self, - center: tuple[float, float], - size: tuple[float, float], - color: str, - rotation: float = 0.0, - fill: bool = True, - thickness: float = None, - edge_style: str | tuple = None, - alpha: float = 1.0, + self, + center: tuple[float, float], + size: tuple[float, float], + color: str, + rotation: float = 0.0, + fill: bool = True, + thickness: float = None, + edge_style: str | tuple = None, + alpha: float = 1.0, ) -> None: """ Add a rectangle or box overlay to the environment plot. @@ -270,11 +275,11 @@ def draw_rectangle( ) def save_image( - self, - name: str = None, - path: Path | None = None, - scale: float = 1.0, - extension: str = "png", + self, + name: str = None, + path: Path | None = None, + scale: float = 1.0, + extension: str = "png", ) -> None: """ Save the environment plot as a .png image. @@ -485,11 +490,11 @@ def get_handles(self): return self.figure, self.axes def _save_figure( - self, - name: str | None = None, - path: Path | None = None, - scale: float = 1.0, - extension: str = "png", + self, + name: str | None = None, + path: Path | None = None, + scale: float = 1.0, + extension: str = "png", ): try: if name is None: @@ -528,7 +533,7 @@ def add_slider(self): def add_slider(self): fig, ax_slider = plt.subplots(figsize=(8, 1)) times = self._environment.weather.time - self.slider = Slider(ax_slider, label='Time:', valmin=0, valmax=len(times)-1, valinit=0,valstep=1) + self.slider = Slider(ax_slider, label='Time:', valmin=0, valmax=len(times) - 1, valinit=0, valstep=1) last_value = self.slider.val def onrelease(event): @@ -540,7 +545,9 @@ def onrelease(event): self._cbar.remove() self.weather_map.remove() last_value = val - self.draw_weather_heatmap(self._environment.weather.weather_layers[0].name,cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9),label_colour='white') + self.draw_weather_heatmap(self._environment.weather.weather_layers[0].name, + cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), + label_colour='white') self.redraw_plot() print(f"Slider value: {val}") diff --git a/seacharts/enc.py b/seacharts/enc.py index 2da887a..bb55a90 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -8,7 +8,7 @@ from seacharts.core import Config from seacharts.display import Display from seacharts.environment import Environment -from seacharts.layers import Layer +from seacharts.layers import Layer, VirtualWeatherLayer class ENC: @@ -105,3 +105,11 @@ def depth_bins(self) -> list[int]: :return: list of considered depth bins """ return self._environment.scope.depths + + @property + def weather_names(self) -> list(str): + return self._environment.weather.weather_names + + @property + def weather_data(self, name) -> VirtualWeatherLayer: + return self._environment.weather.find_by_name(name) diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index bf68709..cee3b5e 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -51,11 +51,13 @@ def fetch_data(self,query_dict) -> dict: return requests.get(api_query).json() def parse_data(self, data: dict) -> None: + self.weather_names = [] self.time = data.pop("time_inter") self.latitude = data.pop("lat_inter") self.longitude = data.pop("lon_inter") self.selected_time_index = 0 for k, v in data.items(): + self.append = self.weather_names.append(k) new_layer = VirtualWeatherLayer(name=k, weather=list()) for time_index, weather_data in enumerate(v): new_layer.weather.append(WeatherLayer(time=self.time[time_index], data=weather_data)) From 9844a4ffe21ef137bf7d1c121bd43c1317e80e1e Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Tue, 25 Jun 2024 19:11:10 +0200 Subject: [PATCH 32/84] Update weather.py --- seacharts/environment/weather.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index cee3b5e..4d22207 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -28,6 +28,11 @@ def verify_scope(self): ... def fetch_data(self,query_dict) -> dict: + """ + fetch data from PyThor service + :param query_dict: Dict with API query data + :return: Dictionary with weather data. + """ api_query = query_dict["PyThor_adress"] + "/api/weather?" query_dict.pop("PyThor_adress") for k, v in query_dict.items(): @@ -51,13 +56,17 @@ def fetch_data(self,query_dict) -> dict: return requests.get(api_query).json() def parse_data(self, data: dict) -> None: + """ + parse data from weather service + :param data: Dict with data from weather service + """ self.weather_names = [] self.time = data.pop("time_inter") self.latitude = data.pop("lat_inter") self.longitude = data.pop("lon_inter") self.selected_time_index = 0 for k, v in data.items(): - self.append = self.weather_names.append(k) + self.weather_names.append(k) new_layer = VirtualWeatherLayer(name=k, weather=list()) for time_index, weather_data in enumerate(v): new_layer.weather.append(WeatherLayer(time=self.time[time_index], data=weather_data)) From 482426dcb87df376dd167555480e82d00f63964d Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Tue, 2 Jul 2024 19:25:06 +0200 Subject: [PATCH 33/84] Update display.py --- seacharts/display/display.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 6b43e1c..50b5009 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -117,12 +117,15 @@ def clear_vessels(self) -> None: @staticmethod def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100) -> colors.LinearSegmentedColormap: + """ + helper function to truncate a colormap + """ new_cmap = colors.LinearSegmentedColormap.from_list( 'trunc({n},{a:.2f},{b:.2f})'.format(n=cmap.name, a=minval, b=maxval), cmap(np.linspace(minval, maxval, n))) return new_cmap - def draw_weather(self,variable_name): + def draw_weather(self, variable_name): None def _draw_arrow_map(self): @@ -529,10 +532,18 @@ def add_slider(self): fig.show() """ + def _weather_slider_handle(self,val): + self._environment.weather.selected_time_index = val + self._cbar.remove() + self.weather_map.remove() + self.draw_weather_heatmap(self._environment.weather.weather_layers[0].name, + cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), + label_colour='white') + self.redraw_plot() def add_slider(self): fig, ax_slider = plt.subplots(figsize=(8, 1)) - times = self._environment.weather.time + times = self._environment.weather.time # TODO make it universal self.slider = Slider(ax_slider, label='Time:', valmin=0, valmax=len(times) - 1, valinit=0, valstep=1) last_value = self.slider.val @@ -541,14 +552,8 @@ def onrelease(event): if event.button == 1 and event.inaxes == ax_slider: val = self.slider.val if val != last_value: - self._environment.weather.selected_time_index = val - self._cbar.remove() - self.weather_map.remove() + self._weather_slider_handle(val) last_value = val - self.draw_weather_heatmap(self._environment.weather.weather_layers[0].name, - cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), - label_colour='white') - self.redraw_plot() print(f"Slider value: {val}") fig.canvas.mpl_connect('button_release_event', onrelease) From 20225725dc1a1b659370fdca9d187d116b048510 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Tue, 2 Jul 2024 19:45:43 +0200 Subject: [PATCH 34/84] Update weather.py --- seacharts/environment/weather.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index 4d22207..ae865ec 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -18,6 +18,7 @@ class WeatherData(DataCollection): selected_time_index: int = None def __post_init__(self): + self.weather_names = list() self.weather_layers = list() if self.scope.weather: self.verify_scope() @@ -60,7 +61,6 @@ def parse_data(self, data: dict) -> None: parse data from weather service :param data: Dict with data from weather service """ - self.weather_names = [] self.time = data.pop("time_inter") self.latitude = data.pop("lat_inter") self.longitude = data.pop("lon_inter") From 03fec2f3c9b2c15398b147b7b0a0fe51c3c94b51 Mon Sep 17 00:00:00 2001 From: miqn Date: Tue, 2 Jul 2024 21:04:08 +0200 Subject: [PATCH 35/84] working on display fix --- seacharts/config.yaml | 14 +++++++------- seacharts/core/extent.py | 1 - seacharts/display/display.py | 9 ++++++++- seacharts/enc.py | 2 +- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index d33f791..9b41ab2 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,17 +1,17 @@ enc: # TODO: implement autosize # test config for S-57 - size: [ 6, 6 ] - origin: [ -96.7, 24.0 ] + size: [ 22.0, 15.5 ] + origin: [ -98.0, 18.0 ] depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] crs: "WGS84" S57_layers: ["DEPARE", "LNDARE"] resources: [ "data/", "data/db/" ] - weather: - PyThor_adress: "http://127.0.0.1:5000" - time_start: 1717931596 - time_end: 1717952909 - variables: [ "wave_height" ] +# weather: +# PyThor_adress: "http://127.0.0.1:5000" +# time_start: 1717931596 +# time_end: 1717952909 +# variables: [ "wave_height" ] # test config for FGDB # size: [ 9000, 5062 ] diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index 005b8f3..1d64290 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -25,7 +25,6 @@ def __init__(self, settings: dict): self.southern_hemisphere = False if self.center[1] >= 0 else True hemisphere_code = '7' if self.southern_hemisphere is True else '6' self.out_proj = 'epsg:32' + hemisphere_code + self.utm_zone - self.size = self._size_from_lat_long() self.origin = self.convert_lat_lon_to_utm(self.origin[1], self.origin[0]) self.center = self.origin[0] + self.size[0] / 2, self.origin[1] + self.size[1] / 2 diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 50b5009..e0bb12a 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -36,6 +36,11 @@ def __init__(self, settings: dict, environment: env.Environment): self._settings = settings self.crs = UTM(environment.scope.extent.utm_zone, southern_hemisphere=environment.scope.extent.southern_hemisphere) + + self._bbox = (max(environment.scope.extent.bbox[0], self.crs.x_limits[0]), # x-min + max(environment.scope.extent.bbox[1], self.crs.y_limits[0]), # y-min + min(environment.scope.extent.bbox[2], self.crs.x_limits[1]), # x-max + min(environment.scope.extent.bbox[3], self.crs.y_limits[1])) # y-max self._environment = environment self._background = None self._dark_mode = False @@ -543,7 +548,9 @@ def _weather_slider_handle(self,val): def add_slider(self): fig, ax_slider = plt.subplots(figsize=(8, 1)) - times = self._environment.weather.time # TODO make it universal + times = self._environment.weather.time + if times is None: + times = [0] self.slider = Slider(ax_slider, label='Time:', valmin=0, valmax=len(times) - 1, valinit=0, valstep=1) last_value = self.slider.val diff --git a/seacharts/enc.py b/seacharts/enc.py index bb55a90..9b944fa 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -107,7 +107,7 @@ def depth_bins(self) -> list[int]: return self._environment.scope.depths @property - def weather_names(self) -> list(str): + def weather_names(self) -> list[str]: return self._environment.weather.weather_names @property From 5dcdd9b9d74e03b49e50cc0398d3de2e275f94d7 Mon Sep 17 00:00:00 2001 From: miqn Date: Thu, 18 Jul 2024 20:27:32 +0200 Subject: [PATCH 36/84] v0.5 for displaying shoreline, layers display in S-57 fixed for light mode --- requirements.txt | 10 +++++++++ seacharts/config.yaml | 4 ++-- seacharts/core/parserS57.py | 3 ++- seacharts/display/features.py | 41 ++++++++++++++++++++++++++++++----- seacharts/layers/layer.py | 26 ++++++++++++++++++---- seacharts/shapes/shape.py | 3 +-- 6 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9471ecc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +Shapely~=1.8.2 +setuptools~=68.2.0 +PyYAML~=6.0.1 +Cerberus~=1.3.5 +pyproj~=3.6.1 +Fiona~=1.8.21 +matplotlib~=3.8.3 +numpy~=1.22.4+vanilla +Cartopy~=0.22.0 +requests~=2.31.0 \ No newline at end of file diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 9b41ab2..e56728f 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,8 +1,8 @@ enc: # TODO: implement autosize # test config for S-57 - size: [ 22.0, 15.5 ] - origin: [ -98.0, 18.0 ] + size: [ 6.0, 4.5 ] + origin: [ -85.0, 20.0 ] depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] crs: "WGS84" S57_layers: ["DEPARE", "LNDARE"] diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index 9a7767e..4931e94 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -87,7 +87,8 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: elif isinstance(region, Land): self.convert_s57_to_utm_shapefile(s57_path, dest_path, "LNDARE", self.epsg, self.bounding_box) elif isinstance(region, Shore): - self.convert_s57_to_utm_shapefile(s57_path, dest_path, "LNDARE", self.epsg, self.bounding_box) # TODO fix coastline + self.convert_s57_to_utm_shapefile(s57_path, dest_path, "COALNE", self.epsg, self.bounding_box) + records = list(self._read_shapefile(region.label)) region.records_as_geometry(records) end_time = round(time.time() - start_time, 1) diff --git a/seacharts/display/features.py b/seacharts/display/features.py index be81877..3968246 100644 --- a/seacharts/display/features.py +++ b/seacharts/display/features.py @@ -3,6 +3,8 @@ """ import shapely.geometry as geo from cartopy.feature import ShapelyFeature +from matplotlib.lines import Line2D +from shapely.geometry import MultiLineString from seacharts import shapes, core from .colors import color_picker @@ -26,14 +28,16 @@ def _init_layers(self): 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 + self._seabeds[rank] = self.assign_artist(seabed, rank, color) + shore = self._display._environment.map.shore color = color_picker(shore.__class__.__name__) - self._shore = self.new_artist(shore.geometry, color, -200) + self._shore = self.assign_artist(shore, -110, color) + land = self._display._environment.map.land color = color_picker(land.__class__.__name__) - self._land = self.new_artist(land.geometry, color, -100) + self._land = self.assign_artist(land, -100, color) + center = self._display._environment.scope.extent.center size = self._display._environment.scope.extent.size geometry = shapes.Rectangle( @@ -46,6 +50,15 @@ def _init_layers(self): def animated(self): return [a for a in [v["artist"] for v in self._vessels.values()] if a] + def assign_artist(self, layer, z_axis, color): + if isinstance(layer.geometry, MultiLineString): + artist = [] + for line in layer.geometry.geoms: + artist.append(self.new_line_artist(line, color, z_axis)) + else: + artist = self.new_artist(layer.geometry, color, z_axis) + return artist + def new_artist(self, geometry, color, z_order=None, **kwargs): kwargs["crs"] = self._display.crs if z_order is not None: @@ -60,6 +73,14 @@ def new_artist(self, geometry, color, z_order=None, **kwargs): artist.set_animated(True) return artist + def new_line_artist(self, line_geometry, color, z_order=None, animated=True, **kwargs): + x, y = line_geometry.xy + line = self._display.axes.add_line(Line2D(x, y, color=color, linewidth=kwargs.get('linewidth', 1))) + if z_order is None: + line.set_zorder(z_order) + line.set_animated(animated) + return line + def add_arrow( self, start, end, color_name, buffer, fill, head_size, linewidth, linestyle ): @@ -170,11 +191,19 @@ def toggle_vessels_visibility(self, new_state: bool = None): self.update_vessels() self._display.update_plot() + @staticmethod + def set_visibility(artist, new_state): + if not isinstance(artist, list): + artist.set_visible(new_state) + else: + for line in artist: + line.set_visible(new_state) + def toggle_topography_visibility(self, new_state: bool = None): if new_state is None: new_state = not self._land.get_visible() - self._land.set_visible(new_state) - self._shore.set_visible(new_state) + self.set_visibility(self._land, new_state) + self.set_visibility(self._shore, new_state) self._display.redraw_plot() def show_top_hidden_layer(self): diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py index e258b8a..fa2c4ed 100644 --- a/seacharts/layers/layer.py +++ b/seacharts/layers/layer.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from shapely import geometry as geo +from shapely.geometry import base as geobase +from shapely.ops import unary_union from seacharts.layers.types import ZeroDepth, SingleDepth, MultiDepth from seacharts.shapes import Shape @@ -12,23 +14,39 @@ @dataclass class Layer(Shape, ABC): - geometry: geo.MultiPolygon = field(default_factory=geo.MultiPolygon) + geometry: geobase.BaseMultipartGeometry = field(default_factory=geo.MultiPolygon) depth: int = None @property def label(self) -> str: return self.name.lower() - # return self.name def records_as_geometry(self, records: list[dict]) -> None: geometries = [] - + multi_geoms = [] + linestrings = [] + multi_linestrings = [] if len(records) > 0: for record in records: geom_tmp = self._record_to_geometry(record) if isinstance(geom_tmp, geo.Polygon): geometries.append(geom_tmp) - self.geometry = self.as_multi(geometries) + elif isinstance(geom_tmp, geo.MultiPolygon): + multi_geoms.append(geom_tmp) + elif isinstance(geom_tmp, geo.LineString): + linestrings.append(geom_tmp) + elif isinstance(geom_tmp, geo. MultiLineString): + multi_linestrings.append(geom_tmp) + + if len(geometries) + len(multi_geoms) > 0: + geometries = self.as_multi(geometries) + multi_geoms.append(geometries) + self.geometry = unary_union(multi_geoms) + + elif len(linestrings) + len(multi_linestrings) > 0: + linestrings = self.as_multi(linestrings) + multi_linestrings.append(linestrings) + self.geometry = unary_union(multi_linestrings) def unify(self, records: list[dict]) -> None: geometries = [self._record_to_geometry(r) for r in records] diff --git a/seacharts/shapes/shape.py b/seacharts/shapes/shape.py index 13411b6..1af9494 100644 --- a/seacharts/shapes/shape.py +++ b/seacharts/shapes/shape.py @@ -4,7 +4,6 @@ from abc import ABC from dataclasses import dataclass from typing import Any - from shapely import geometry as geo, ops @@ -44,7 +43,7 @@ def _record_to_geometry(record: dict) -> Any: return geo.shape(record["geometry"]) @staticmethod - def as_multi(geometry: [Any]) -> Any: + def as_multi(geometry) -> Any: if isinstance(geometry[0], geo.Point): return geo.MultiPoint(geometry) elif isinstance(geometry[0], geo.Polygon): From b284ce08354fb6865dc1addd3a011586942e4a44 Mon Sep 17 00:00:00 2001 From: miqn Date: Fri, 19 Jul 2024 12:44:23 +0200 Subject: [PATCH 37/84] minor fixes --- seacharts/display/display.py | 2 +- seacharts/layers/layer.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index e0bb12a..6b1220d 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -54,7 +54,7 @@ def __init__(self, settings: dict, environment: env.Environment): self.events = EventsManager(self) self.features = FeaturesManager(self) self._toggle_colorbar(self._colorbar_mode) - self._toggle_dark_mode(self._dark_mode) + # self._toggle_dark_mode(self._dark_mode) self._add_scalebar() self.add_slider() self.redraw_plot() diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py index fa2c4ed..bca5a1d 100644 --- a/seacharts/layers/layer.py +++ b/seacharts/layers/layer.py @@ -39,13 +39,15 @@ def records_as_geometry(self, records: list[dict]) -> None: multi_linestrings.append(geom_tmp) if len(geometries) + len(multi_geoms) > 0: - geometries = self.as_multi(geometries) - multi_geoms.append(geometries) + if len(geometries): + geometries = self.as_multi(geometries) + multi_geoms.append(geometries) self.geometry = unary_union(multi_geoms) elif len(linestrings) + len(multi_linestrings) > 0: - linestrings = self.as_multi(linestrings) - multi_linestrings.append(linestrings) + if len(linestrings): + linestrings = self.as_multi(linestrings) + multi_linestrings.append(linestrings) self.geometry = unary_union(multi_linestrings) def unify(self, records: list[dict]) -> None: From a1d81c00d001cb4a3013709eda9848de290dc84e Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Thu, 8 Aug 2024 19:32:53 +0200 Subject: [PATCH 38/84] minor display improvements --- seacharts/config.yaml | 10 +++++----- seacharts/display/display.py | 7 ++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index e56728f..ed8a68f 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -7,11 +7,11 @@ enc: crs: "WGS84" S57_layers: ["DEPARE", "LNDARE"] resources: [ "data/", "data/db/" ] -# weather: -# PyThor_adress: "http://127.0.0.1:5000" -# time_start: 1717931596 -# time_end: 1717952909 -# variables: [ "wave_height" ] + weather: + PyThor_adress: "http://127.0.0.1:5000" + time_start: 1723137483 + time_end: 1723137483 + variables: [ "wave_height" ] # test config for FGDB # size: [ 9000, 5062 ] diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 6b1220d..a205ddf 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -144,11 +144,12 @@ def _draw_weather_heatmap(self, variable_name: str, cmap: colors.Colormap, label weather_layer = self._environment.weather.find_by_name(variable_name) if weather_layer is None: return - heatmap_data = weather_layer.weather[self._environment.weather.selected_time_index].data - x_min, y_min, x_max, y_max = self._environment.scope.extent.bbox + + x_min, y_min, x_max, y_max = self._bbox extent = (x_min, x_max, y_min, y_max) + heatmap_data = weather_layer.weather[self._environment.weather.selected_time_index].data ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)), num=8) - self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5) + self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5, interpolation="bicubic") self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes, shrink=0.7) self._cbar.ax.yaxis.set_tick_params(color=label_colour) self._cbar.outline.set_edgecolor(label_colour) From 7290b20a55445daa2f3ddb5392e90fd9953e8dcc Mon Sep 17 00:00:00 2001 From: miqn Date: Thu, 8 Aug 2024 21:00:58 +0200 Subject: [PATCH 39/84] z_axis fixes --- seacharts/display/display.py | 4 ++-- seacharts/display/features.py | 17 +++++++++-------- seacharts/enc.py | 5 +++-- seacharts/layers/layer.py | 11 +++++++++-- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 6b1220d..9392442 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -131,10 +131,10 @@ def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100) -> colors.LinearSegme return new_cmap def draw_weather(self, variable_name): - None + ... def _draw_arrow_map(self): - None + ... def _draw_weather_heatmap(self, variable_name: str, cmap: colors.Colormap, label_colour: str) -> None: """ diff --git a/seacharts/display/features.py b/seacharts/display/features.py index 3968246..7e34a46 100644 --- a/seacharts/display/features.py +++ b/seacharts/display/features.py @@ -32,11 +32,11 @@ def _init_layers(self): shore = self._display._environment.map.shore color = color_picker(shore.__class__.__name__) - self._shore = self.assign_artist(shore, -110, color) + self._shore = self.assign_artist(shore, -100, color) land = self._display._environment.map.land color = color_picker(land.__class__.__name__) - self._land = self.assign_artist(land, -100, color) + self._land = self.assign_artist(land, -110, color) center = self._display._environment.scope.extent.center size = self._display._environment.scope.extent.size @@ -44,19 +44,19 @@ def _init_layers(self): *center, width=size[0] / 2, heading=0, height=size[1] / 2 ).geometry color = (color_picker("black")[0], "none") - self.new_artist(geometry, color, 10000, linewidth=3) + self.new_artist(geometry, color, z_order=10000, linewidth=3) @property def animated(self): return [a for a in [v["artist"] for v in self._vessels.values()] if a] - def assign_artist(self, layer, z_axis, color): + def assign_artist(self, layer, z_order, color): if isinstance(layer.geometry, MultiLineString): artist = [] for line in layer.geometry.geoms: - artist.append(self.new_line_artist(line, color, z_axis)) + artist.append(self.new_line_artist(line, color, z_order)) else: - artist = self.new_artist(layer.geometry, color, z_axis) + artist = self.new_artist(layer.geometry, color, z_order=z_order) return artist def new_artist(self, geometry, color, z_order=None, **kwargs): @@ -73,12 +73,13 @@ def new_artist(self, geometry, color, z_order=None, **kwargs): artist.set_animated(True) return artist - def new_line_artist(self, line_geometry, color, z_order=None, animated=True, **kwargs): + def new_line_artist(self, line_geometry, color, z_order=None, **kwargs): x, y = line_geometry.xy line = self._display.axes.add_line(Line2D(x, y, color=color, linewidth=kwargs.get('linewidth', 1))) if z_order is None: + line.set_animated(True) + else: line.set_zorder(z_order) - line.set_animated(animated) return line def add_arrow( diff --git a/seacharts/enc.py b/seacharts/enc.py index 9b944fa..becb909 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -28,11 +28,12 @@ def __init__(self, config: Config | Path | str = None): self._environment = Environment(self._config.settings) self._display = None - def get_depth_at_coord(self, easting, northing): + def get_depth_at_coord(self, easting, northing) -> int: point = Point(easting, northing) for seabed in reversed(self.seabed.values()): if any(polygon.contains(point) for polygon in seabed.geometry.geoms): - return seabed + return seabed.depth + return None def update(self) -> None: """ diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py index bca5a1d..8df790a 100644 --- a/seacharts/layers/layer.py +++ b/seacharts/layers/layer.py @@ -42,13 +42,20 @@ def records_as_geometry(self, records: list[dict]) -> None: if len(geometries): geometries = self.as_multi(geometries) multi_geoms.append(geometries) - self.geometry = unary_union(multi_geoms) + geom = unary_union(multi_geoms) + if not isinstance(geom, geo.MultiPolygon): + geom = geo.MultiPolygon([geom]) + self.geometry = geom + elif len(linestrings) + len(multi_linestrings) > 0: if len(linestrings): linestrings = self.as_multi(linestrings) multi_linestrings.append(linestrings) - self.geometry = unary_union(multi_linestrings) + geom = unary_union(multi_linestrings) + if not isinstance(geom, geo.MultiLineString): + geom = geo.MultiLineString([geom]) + self.geometry = geom def unify(self, records: list[dict]) -> None: geometries = [self._record_to_geometry(r) for r in records] From 19d2997543df50cde5cd2b86df70bd7577e9f481 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Tue, 13 Aug 2024 17:01:55 +0200 Subject: [PATCH 40/84] enable weather display correction for data cut by projection --- seacharts/display/display.py | 50 +++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index a205ddf..3a3beaa 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -131,25 +131,60 @@ def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100) -> colors.LinearSegme return new_cmap def draw_weather(self, variable_name): - None + lat = np.array(self._environment.weather.latitude) + lon = np.array(self._environment.weather.longitude) + print(lon) + weather_layer = self._environment.weather.find_by_name(variable_name) + x_min, y_min, x_max, y_max = self._bbox + lat_min,lon_min = self._environment.scope.extent.convert_utm_to_lat_lon(x_min,y_min) + lat_max, lon_max = self._environment.scope.extent.convert_utm_to_lat_lon(x_max, y_max) + print(lat_min, lat_max) + print(lon_min, lon_max) + if lon_min < 0: + lon_min = 180 + (180 + lon_min) + if lon_max < 0: + lon_max = 180 + (180 + lon_max) + lat_indxes = [None,None] + for i in range(len(lat)): + if lat[i] >= lat_min: + lat_indxes[0] = i + if lat[len(lat)-(i+1)] <= lat_max: + lat_indxes[1] = len(lat) - (i+1) + if None not in lat_indxes: + break + lon_indxes = [None, None] + for i in range(len(lon)): + if lon[i] >= lon_min: + lon_indxes[0] = i + print(lon[-(i + 1)]) + if lon[len(lon)-(i + 1)] <= lon_max: + lon_indxes[1] = len(lon) - (i + 1) + if None not in lon_indxes: + break + print(lat_indxes,lon_indxes) + # TODO choose correct display for variables + self._draw_weather_heatmap(weather_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]][lon_indxes[0]:lon_indxes[1]], + cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), label_colour='white') + def _draw_arrow_map(self): None - def _draw_weather_heatmap(self, variable_name: str, cmap: colors.Colormap, label_colour: str) -> None: + def _draw_weather_heatmap(self, weather_data: str, cmap: colors.Colormap, label_colour: str) -> None: """ Draws a heatmap and colorbar for specified weather variable using provided color map and label colour for color bar :return: None """ - weather_layer = self._environment.weather.find_by_name(variable_name) - if weather_layer is None: + + if weather_data is None: return x_min, y_min, x_max, y_max = self._bbox extent = (x_min, x_max, y_min, y_max) - heatmap_data = weather_layer.weather[self._environment.weather.selected_time_index].data + heatmap_data = weather_data ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)), num=8) - self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5, interpolation="bicubic") + self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5, + interpolation="bicubic") self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes, shrink=0.7) self._cbar.ax.yaxis.set_tick_params(color=label_colour) self._cbar.outline.set_edgecolor(label_colour) @@ -538,7 +573,8 @@ def add_slider(self): fig.show() """ - def _weather_slider_handle(self,val): + + def _weather_slider_handle(self, val): self._environment.weather.selected_time_index = val self._cbar.remove() self.weather_map.remove() From 3c3ee7fd3d8d5bcebb56e753ed1e2677113475c6 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Tue, 13 Aug 2024 18:36:45 +0200 Subject: [PATCH 41/84] Update display.py --- seacharts/display/display.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 3a3beaa..524d858 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -131,39 +131,42 @@ def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100) -> colors.LinearSegme return new_cmap def draw_weather(self, variable_name): - lat = np.array(self._environment.weather.latitude) - lon = np.array(self._environment.weather.longitude) - print(lon) + lat = self._environment.weather.latitude + lon = self._environment.weather.longitude weather_layer = self._environment.weather.find_by_name(variable_name) x_min, y_min, x_max, y_max = self._bbox + print(self._bbox) lat_min,lon_min = self._environment.scope.extent.convert_utm_to_lat_lon(x_min,y_min) lat_max, lon_max = self._environment.scope.extent.convert_utm_to_lat_lon(x_max, y_max) print(lat_min, lat_max) print(lon_min, lon_max) + if lon_min < 0: lon_min = 180 + (180 + lon_min) if lon_max < 0: lon_max = 180 + (180 + lon_max) + lon_min += 0.2 + lon_max -= 0.7 lat_indxes = [None,None] for i in range(len(lat)): - if lat[i] >= lat_min: + if lat[i] >= lat_min and lat_indxes[0] is None: lat_indxes[0] = i - if lat[len(lat)-(i+1)] <= lat_max: - lat_indxes[1] = len(lat) - (i+1) + if lat[len(lat)-(i+1)] <= lat_max and lat_indxes[1] is None: + lat_indxes[1] = len(lat) - i if None not in lat_indxes: break lon_indxes = [None, None] for i in range(len(lon)): - if lon[i] >= lon_min: + if lon[i] >= lon_min and lon_indxes[0] is None: lon_indxes[0] = i - print(lon[-(i + 1)]) - if lon[len(lon)-(i + 1)] <= lon_max: - lon_indxes[1] = len(lon) - (i + 1) + if lon[len(lon)-(i + 1)] <= lon_max and lon_indxes[1] is None: + lon_indxes[1] = len(lon) - i if None not in lon_indxes: break print(lat_indxes,lon_indxes) # TODO choose correct display for variables - self._draw_weather_heatmap(weather_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]][lon_indxes[0]:lon_indxes[1]], + data = [x[lon_indxes[0]:lon_indxes[1]] for x in weather_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]]] + self._draw_weather_heatmap(data, cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), label_colour='white') @@ -184,13 +187,10 @@ def _draw_weather_heatmap(self, weather_data: str, cmap: colors.Colormap, label_ heatmap_data = weather_data ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)), num=8) self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5, - interpolation="bicubic") + interpolation="bicubic",projection=self.crs) self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes, shrink=0.7) self._cbar.ax.yaxis.set_tick_params(color=label_colour) self._cbar.outline.set_edgecolor(label_colour) - print(heatmap_data) - print(np.nanmin(np.array(heatmap_data))) - print(np.nanmax(np.array(heatmap_data))) plt.setp(plt.getp(self._cbar.ax.axes, 'yticklabels'), color=label_colour) def draw_arrow( From c03eb134c784e8e3c04322bebfb5620b6742b3b6 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Tue, 13 Aug 2024 19:30:44 +0200 Subject: [PATCH 42/84] Update display.py --- seacharts/display/display.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 524d858..9828a7a 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -135,7 +135,6 @@ def draw_weather(self, variable_name): lon = self._environment.weather.longitude weather_layer = self._environment.weather.find_by_name(variable_name) x_min, y_min, x_max, y_max = self._bbox - print(self._bbox) lat_min,lon_min = self._environment.scope.extent.convert_utm_to_lat_lon(x_min,y_min) lat_max, lon_max = self._environment.scope.extent.convert_utm_to_lat_lon(x_max, y_max) print(lat_min, lat_max) @@ -145,8 +144,6 @@ def draw_weather(self, variable_name): lon_min = 180 + (180 + lon_min) if lon_max < 0: lon_max = 180 + (180 + lon_max) - lon_min += 0.2 - lon_max -= 0.7 lat_indxes = [None,None] for i in range(len(lat)): if lat[i] >= lat_min and lat_indxes[0] is None: @@ -184,10 +181,13 @@ def _draw_weather_heatmap(self, weather_data: str, cmap: colors.Colormap, label_ x_min, y_min, x_max, y_max = self._bbox extent = (x_min, x_max, y_min, y_max) - heatmap_data = weather_data + heatmap_data = np.array(weather_data) + lon = np.linspace(x_min, x_max, heatmap_data.shape[1]) + lat = np.linspace(y_min, y_max, heatmap_data.shape[0]) + lon, lat = np.meshgrid(lon, lat) ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)), num=8) - self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5, - interpolation="bicubic",projection=self.crs) + self.weather_map = self.axes.pcolormesh(lon, lat, heatmap_data, cmap=cmap, alpha=0.5, transform=self.crs) + self.axes.set_extent(extent, crs=self.crs) self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes, shrink=0.7) self._cbar.ax.yaxis.set_tick_params(color=label_colour) self._cbar.outline.set_edgecolor(label_colour) From d3054b4f2e6155e531147fade391efecb9e9b5d7 Mon Sep 17 00:00:00 2001 From: miqn Date: Fri, 23 Aug 2024 20:58:22 +0200 Subject: [PATCH 43/84] extra layers for S-57 maps, new config schema (with time separated from weather), control panel updated, some code maintenance --- seacharts/config.yaml | 17 +++++-- seacharts/config_schema.yaml | 34 ++++++++++++-- seacharts/core/files.py | 2 +- seacharts/core/parser.py | 29 +++++------- seacharts/core/parserS57.py | 9 ++-- seacharts/core/scope.py | 24 +++++----- seacharts/core/time.py | 43 ++++++++++++++++++ seacharts/display/display.py | 67 ++++++++++++++++++---------- seacharts/display/events.py | 17 ++++++- seacharts/display/features.py | 7 +++ seacharts/enc.py | 11 +++++ seacharts/environment/environment.py | 19 +++++--- seacharts/environment/extra.py | 48 ++++++++++++++++++++ seacharts/environment/map.py | 8 +++- seacharts/environment/user.py | 14 ------ seacharts/layers/__init__.py | 2 +- seacharts/layers/layer.py | 27 +++++------ seacharts/layers/layers.py | 7 +++ setup.cfg | 2 + 19 files changed, 287 insertions(+), 100 deletions(-) create mode 100644 seacharts/core/time.py create mode 100644 seacharts/environment/extra.py delete mode 100644 seacharts/environment/user.py diff --git a/seacharts/config.yaml b/seacharts/config.yaml index e56728f..919587f 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,12 +1,18 @@ enc: -# TODO: implement autosize # test config for S-57 size: [ 6.0, 4.5 ] origin: [ -85.0, 20.0 ] depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] crs: "WGS84" - S57_layers: ["DEPARE", "LNDARE"] - resources: [ "data/", "data/db/" ] + S57_layers: + "TSSLPT": "#8B0000" + "CTNARE": "#964B00" + resources: [ "data/db/US1GC09M" ] + time: + time_start: "22-01-2024" + time_end: "22-02-2024" + period: "day" + # weather: # PyThor_adress: "http://127.0.0.1:5000" # time_start: 1717931596 @@ -19,10 +25,13 @@ enc: # depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] # crs: "UTM33N" # resources: [ "/", "data/", "data/db/" ] + + display: colorbar: False - dark_mode: True + dark_mode: False fullscreen: False + controls: True resolution: 640 anchor: "center" dpi: 96 diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index c269bd8..7ca2c68 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -51,9 +51,11 @@ enc: #you can pick specific S-57 layers you want to extract, default required is LNDARE, DEPARE and COALNE S57_layers: required: False - type: list - minlength: 1 - schema: + type: dict + minlength: 0 + valuesrules: + type: string + keysrules: type: string #you must put paths to some resources @@ -84,6 +86,28 @@ enc: schema: type: string + time: + required: False + type: dict + schema: + time_start: + required: True + type: string + regex: "^(0[1-9]|[12][0-9]|3[01])-(0[1-9]|1[0-2])-[0-9]{4}$" # dd-mm-yyyy + time_end: + required: True + type: string + regex: "^(0[1-9]|[12][0-9]|3[01])-(0[1-9]|1[0-2])-[0-9]{4}$" # dd-mm-yyyy + period: + required: True + type: string + allowed: + - hour + - day + - week + - month + - year + display: required: False @@ -112,3 +136,7 @@ display: dpi: required: False type: integer + + controls: + required: False + type: boolean diff --git a/seacharts/core/files.py b/seacharts/core/files.py index d96b9ac..9398149 100644 --- a/seacharts/core/files.py +++ b/seacharts/core/files.py @@ -23,7 +23,7 @@ def build_directory_structure(features: list[str], resources: list[str]) -> None shapefile_dir.mkdir(parents=True, exist_ok=True) for resource in resources: path = Path(resource).resolve() - if not path.suffix == ".gdb": + if not path.suffix == ".gdb" or not path.suffix == ".000": path.mkdir(exist_ok=True) diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index 0ee1e1b..378af83 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -2,7 +2,6 @@ Contains the DataParser class for spatial data parsing. """ from abc import abstractmethod -import time import warnings from pathlib import Path from typing import Generator @@ -18,12 +17,10 @@ def __init__( self, bounding_box: tuple[int, int, int, int], path_strings: list[str], - autosize: bool ): self.bounding_box = bounding_box self.paths = set([p.resolve() for p in (map(Path, path_strings))]) self.paths.update(paths.default_resources) - self.autosize = autosize @staticmethod def _shapefile_path(label): @@ -38,12 +35,8 @@ def _read_spatial_file(self, path: Path, **kwargs) -> Generator: with fiona.open(path, "r", **kwargs) as source: with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=RuntimeWarning) - if self.autosize is True: - for record in source: - yield record - else: - for record in source.filter(bbox=self.bounding_box): - yield record + for record in source.filter(bbox=self.bounding_box): + yield record except ValueError as e: message = str(e) if "Null layer: " in message: @@ -74,15 +67,17 @@ def parse_resources( @abstractmethod def _is_map_type(self, path) -> bool: pass - + @property def _file_paths(self) -> Generator[Path, None, None]: for path in self.paths: if not path.is_absolute(): - path = paths.cwd / path - if self._is_map_type(path): - yield path - elif path.is_dir(): - for p in path.iterdir(): - if self._is_map_type(p): - yield p + path = Path.cwd() / path + yield from self._get_files_recursive(path) + + def _get_files_recursive(self, path: Path) -> Generator[Path, None, None]: + if self._is_map_type(path): + yield path + elif path.is_dir(): + for p in path.iterdir(): + yield from self._get_files_recursive(p) diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index 4931e94..f8ee75d 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -12,10 +12,9 @@ def __init__( self, bounding_box: tuple[int, int, int, int], path_strings: list[str], - autosize: bool, epsg: str ): - super().__init__(bounding_box, path_strings, autosize) + super().__init__(bounding_box, path_strings) self.epsg = epsg @staticmethod @@ -76,11 +75,11 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: s57_path = None for path in self._file_paths: s57_path = self.get_s57_file(path) - s57_path = r'' + s57_path.__str__() + s57_path = s57_path.__str__() for region in regions_list: start_time = time.time() - dest_path = r'' + self._shapefile_dir_path(region.label).__str__() + "\\" + region.label + ".shp" + dest_path = os.path.join(self._shapefile_dir_path(region.label), region.label + ".shp") if isinstance(region, Seabed): self.convert_s57_depth_to_utm_shapefile(s57_path, dest_path, region.depth, self.epsg, self.bounding_box) @@ -88,6 +87,8 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: self.convert_s57_to_utm_shapefile(s57_path, dest_path, "LNDARE", self.epsg, self.bounding_box) elif isinstance(region, Shore): self.convert_s57_to_utm_shapefile(s57_path, dest_path, "COALNE", self.epsg, self.bounding_box) + else: + self.convert_s57_to_utm_shapefile(s57_path, dest_path, region.name, self.epsg, self.bounding_box) records = list(self._read_shapefile(region.label)) region.records_as_geometry(records) diff --git a/seacharts/core/scope.py b/seacharts/core/scope.py index c9ae4c1..6cd51e9 100644 --- a/seacharts/core/scope.py +++ b/seacharts/core/scope.py @@ -5,8 +5,7 @@ from seacharts.core import files from .extent import Extent from .mapFormat import MapFormat - -default_depths = [0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500] #TODO move to separate file +from .time import Time @dataclass @@ -16,8 +15,8 @@ def __init__(self, settings: dict): self.extent = Extent(settings) self.settings = settings self.resources = settings["enc"].get("resources", []) - self.autosize = settings["enc"].get("autosize") - + + default_depths = [0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500] self.depths = settings["enc"].get("depths", default_depths) self.features = ["land", "shore"] for depth in self.depths: @@ -28,16 +27,21 @@ def __init__(self, settings: dict): else: self.type = MapFormat.FGDB + time_config = settings["enc"].get("time", {}) + if time_config: + self.time = Time( + time_start=time_config["time_start"], + time_end=time_config["time_end"], + period=time_config["period"] + ) + else: + self.time = None self.weather = settings["enc"].get("weather", []) files.build_directory_structure(self.features, self.resources) - # DEPARE --> depthsX - must be put into buffer dir first, then distributed between appropriate depths - # LNDARE --> land - # COALNE --> shore - # remaining layers --> ?? separate dir for all or shared dir like "info"? def __s57_init(self, settings: dict): - default_layers = ["LNDARE", "DEPARE", "COALNE"] #TODO move to separate file - self.layers = settings["enc"].get("S57_layers", default_layers) + self.extra_layers:dict[str,str] = settings["enc"].get("S57_layers", {}) + self.features.extend(self.extra_layers) self.type = MapFormat.S57 diff --git a/seacharts/core/time.py b/seacharts/core/time.py new file mode 100644 index 0000000..ac0605e --- /dev/null +++ b/seacharts/core/time.py @@ -0,0 +1,43 @@ +from datetime import datetime, timedelta + +class Time: + def __init__(self, time_start: str, time_end: str, period: str): + # Parse the start and end dates + self.time_start = datetime.strptime(time_start, "%d-%m-%Y") + self.time_end = datetime.strptime(time_end, "%d-%m-%Y") + self.period = period + + # Generate the list of datetimes and epoch times + self.datetimes = self._generate_datetimes() + self.epoch_times = [int(dt.timestamp()) for dt in self.datetimes] + + def _generate_datetimes(self): + """Generate a list of datetime objects based on the period.""" + current_time = self.time_start + datetimes = [] + + while current_time <= self.time_end: + datetimes.append(current_time) + current_time = self._increment_time(current_time) + + return datetimes + + def _increment_time(self, current_time): + """Increment the datetime based on the specified period.""" + if self.period == "hour": + return current_time + timedelta(hours=1) + elif self.period == "day": + return current_time + timedelta(days=1) + elif self.period == "week": + return current_time + timedelta(weeks=1) + elif self.period == "month": + new_month = current_time.month + 1 if current_time.month < 12 else 1 + new_year = current_time.year if current_time.month < 12 else current_time.year + 1 + return current_time.replace(year=new_year, month=new_month, day=1) + elif self.period == "year": + return current_time.replace(year=current_time.year + 1, month=1, day=1) + else: + raise ValueError(f"Unknown period: {self.period}") + + def get_datetimes_strings(self): + return [datetime.strftime("%d-%m-%Y") for datetime in self.datetimes] \ No newline at end of file diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 9392442..32674f3 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -9,8 +9,7 @@ import matplotlib.pyplot as plt import numpy as np from matplotlib import colors -from matplotlib.widgets import Slider -from matplotlib.widgets import Button +from matplotlib.widgets import Slider, RadioButtons from cartopy.crs import UTM from matplotlib.gridspec import GridSpec from matplotlib_scalebar.scalebar import ScaleBar @@ -46,6 +45,7 @@ def __init__(self, settings: dict, environment: env.Environment): self._dark_mode = False self._colorbar_mode = False self._fullscreen_mode = False + self._controls = True self._resolution = 720 self._dpi = 96 self._anchor_index = self._init_anchor_index(settings) @@ -54,9 +54,9 @@ def __init__(self, settings: dict, environment: env.Environment): self.events = EventsManager(self) self.features = FeaturesManager(self) self._toggle_colorbar(self._colorbar_mode) - # self._toggle_dark_mode(self._dark_mode) + self._toggle_dark_mode(self._dark_mode) self._add_scalebar() - self.add_slider() + self.add_control_panel(self._controls) self.redraw_plot() if self._fullscreen_mode: self._toggle_fullscreen(self._fullscreen_mode) @@ -350,6 +350,8 @@ def _init_figure(self, settings): self._resolution = d["resolution"] if "dpi" in d: self._dpi = d["dpi"] + if "controls" in d: + self._controls = d["controls"] if self._fullscreen_mode: plt.rcParams["toolbar"] = "None" @@ -528,15 +530,6 @@ def _is_active(self): def _terminate(self): plt.close(self.figure) - """ - def add_slider(self): - fig, (ax_slider, ax_button) = plt.subplots(1, 2, gridspec_kw={'width_ratios': [3, 1]}, figsize=(8, 1)) - self.slider = Slider(ax_slider, label='Slider', valmin=0, valmax=1, valinit=0.5) - button = Button(ax_button, 'Set', color='lightgray') - button.on_clicked(self.slider_changed_callback) - - fig.show() - """ def _weather_slider_handle(self,val): self._environment.weather.selected_time_index = val self._cbar.remove() @@ -546,22 +539,50 @@ def _weather_slider_handle(self,val): label_colour='white') self.redraw_plot() - def add_slider(self): - fig, ax_slider = plt.subplots(figsize=(8, 1)) - times = self._environment.weather.time - if times is None: - times = [0] - self.slider = Slider(ax_slider, label='Time:', valmin=0, valmax=len(times) - 1, valinit=0, valstep=1) + def _add_time_slider(self, ax_slider, fig): + times = self._environment.scope.time.get_datetimes_strings() + self.slider = Slider(ax=ax_slider, valmin=0, valmax=len(times) - 1, valinit=0, valstep=1, label="Time") + self.time_label = ax_slider.text(0.5, 1.2, times[0], transform=ax_slider.transAxes, + ha='center', va='center', fontsize=12) last_value = self.slider.val - def onrelease(event): + def __on_slider_change(event): nonlocal last_value if event.button == 1 and event.inaxes == ax_slider: val = self.slider.val if val != last_value: self._weather_slider_handle(val) last_value = val - print(f"Slider value: {val}") - fig.canvas.mpl_connect('button_release_event', onrelease) - fig.show() + def __update(val): + index = int(self.slider.val) + self.time_label.set_text(times[index]) + fig.canvas.draw_idle() + + fig.canvas.mpl_connect('button_release_event', __on_slider_change) + self.slider.on_changed(__update) + + + def add_control_panel(self, controls: bool): + if not controls: return + fig, (ax_slider, ax_radio) = plt.subplots(2, 1, figsize=(8, 2), gridspec_kw={'height_ratios': [1, 2]}) + if self._environment.scope.time is not None: + self._add_time_slider(ax_slider=ax_slider, fig=fig) + + # VISIBLE LAYER PICKER START + # if weather layers is not None -> add_radio_pick for weather layers + radio_labels = ['a', 'b', 'c'] + self.radio_buttons = RadioButtons(ax_radio, ['--'] + radio_labels, active=0) + def on_radio_change(label): + print(f"Radio button selected: {label}") + # Add handling code for radio button change here + self.radio_buttons.on_clicked(on_radio_change) + # VISIBLE LAYER PICKER END + + # TODO: layer picked in such way should be saved to variable + # then we can add, analogically to date slider, opacity slider for picked weather layer + + # Set the window title and show the figure + fig.canvas.manager.set_window_title('Controls') + plt.show() + diff --git a/seacharts/display/events.py b/seacharts/display/events.py index eff0603..1eb7d8a 100644 --- a/seacharts/display/events.py +++ b/seacharts/display/events.py @@ -54,35 +54,50 @@ def _handle_zoom(self, event: Any) -> None: def _key_press(self, event: Any) -> None: if event.key == "escape": self._display._terminate() + elif event.key == "d": self._display._toggle_dark_mode() + elif event.key == "t": self._display.features.show_top_hidden_layer() + elif event.key == "g": self._display.features.show_bottom_hidden_layer() + elif event.key == "h": self._display.features.hide_top_visible_layer() + elif event.key == "b": self._display.features.hide_bottom_visible_layer() + elif event.key == "u": self._display.features.update_vessels() self._display.update_plot() + elif event.key == "v": self._display.features.toggle_vessels_visibility() - elif event.key == "l": + + elif event.key == "p": # there were some issues with 'l' key self._display.features.toggle_topography_visibility() + elif event.key == "c": self._display._toggle_colorbar() + elif event.key == "ctrl+s": self._display._save_figure("svg", extension="svg") + elif event.key == "s": self._display._save_figure("low_res", scale=2.0) + elif event.key == "S": self._display._save_figure("high_res", scale=10.0) + elif event.key == "shift": self._shift_pressed = True + elif event.key == "control": self._control_pressed = True + elif event.key[:4] == "alt+": key = event.key[4:] if key in self._directions: diff --git a/seacharts/display/features.py b/seacharts/display/features.py index 7e34a46..f159bdf 100644 --- a/seacharts/display/features.py +++ b/seacharts/display/features.py @@ -19,6 +19,7 @@ def __init__(self, display): self._seabeds = {} self._land = None self._shore = None + self._extra_layers = {} self._init_layers() def _init_layers(self): @@ -38,6 +39,12 @@ def _init_layers(self): color = color_picker(land.__class__.__name__) self._land = self.assign_artist(land, -110, color) + extra_layers = self._display._environment.extra_layers.layers + for i, extra_layer in enumerate(extra_layers): + if not extra_layer.geometry.is_empty: + rank = -90 + i + self._extra_layers[rank] = self.assign_artist(extra_layer, rank, extra_layer.color) + center = self._display._environment.scope.extent.center size = self._display._environment.scope.extent.size geometry = shapes.Rectangle( diff --git a/seacharts/enc.py b/seacharts/enc.py index becb909..d10ef0d 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -34,6 +34,17 @@ def get_depth_at_coord(self, easting, northing) -> int: if any(polygon.contains(point) for polygon in seabed.geometry.geoms): return seabed.depth return None + + def is_coord_in_layer(self, easting, northing, layer_name:str): + layer_name = layer_name.lower() + layers = self._environment.get_layers() + point = Point(easting, northing) + for layer in layers: + if layer.label == layer_name: + if any(polygon.contains(point) for polygon in layer.geometry.geoms): + return True + return False + raise Exception("no such layer loaded") def update(self) -> None: """ diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index 6fe3a20..d0c21fd 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -3,8 +3,8 @@ """ from seacharts.core import Scope, MapFormat, S57Parser, FGDBParser, DataParser from .map import MapData -from .user import UserData from .weather import WeatherData +from .extra import ExtraLayers class Environment: @@ -12,15 +12,24 @@ def __init__(self, settings: dict): self.scope = Scope(settings) self.parser = self.set_parser() self.map = MapData(self.scope, self.parser) - self.user = UserData(self.scope, self.parser) self.weather = WeatherData(self.scope, self.parser) + self.extra_layers = ExtraLayers(self.scope, self.parser) self.map.load_existing_shapefiles() - if not self.map.loaded: + self.extra_layers.load_existing_shapefiles() + if len(self.map.not_loaded_regions) > 0: self.map.parse_resources_into_shapefiles() + if len(self.extra_layers.not_loaded_regions) > 0: + self.extra_layers.parse_resources_into_shapefiles() + + def get_layers(self): + return [ + *self.map.loaded_regions, + *self.extra_layers.loaded_regions + ] # TODO add weather layers def set_parser(self) -> DataParser: if self.scope.type is MapFormat.S57: - return S57Parser(self.scope.extent.bbox, self.scope.resources, self.scope.autosize, + return S57Parser(self.scope.extent.bbox, self.scope.resources, self.scope.extent.out_proj) elif self.scope.type is MapFormat.FGDB: - return FGDBParser(self.scope.extent.bbox, self.scope.resources, self.scope.autosize) + return FGDBParser(self.scope.extent.bbox, self.scope.resources) diff --git a/seacharts/environment/extra.py b/seacharts/environment/extra.py new file mode 100644 index 0000000..bf8a1c0 --- /dev/null +++ b/seacharts/environment/extra.py @@ -0,0 +1,48 @@ +from seacharts.layers import ExtraLayer +from seacharts.layers.layer import Layer +from .collection import DataCollection +from dataclasses import dataclass + +@dataclass +class ExtraLayers(DataCollection): + + def __post_init__(self): + self.extra_layers : list[ExtraLayer] = [] + for tag, color in self.scope.extra_layers.items(): + self.extra_layers.append(ExtraLayer(tag=tag, color=color)) + + @property + def layers(self) -> list[Layer]: + return self.extra_layers + + 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.not_loaded_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 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 not_loaded_regions(self) -> list[Layer]: + return [layer for layer in self.layers if layer.geometry.is_empty] + + @property + def featured_regions(self) -> list[Layer]: + return [x for x in self.layers if x.tag in self.scope.extra_layers.keys()] diff --git a/seacharts/environment/map.py b/seacharts/environment/map.py index 27b283b..d721bee 100644 --- a/seacharts/environment/map.py +++ b/seacharts/environment/map.py @@ -23,7 +23,7 @@ def load_existing_shapefiles(self) -> None: def parse_resources_into_shapefiles(self) -> None: self.parser.parse_resources( - self.featured_regions, self.scope.resources, self.scope.extent.area + self.not_loaded_regions, self.scope.resources, self.scope.extent.area ) if self.loaded: print("\nENC update complete.\n") @@ -41,7 +41,11 @@ def loaded(self) -> bool: @property def loaded_regions(self) -> list[Layer]: return [layer for layer in self.layers if not layer.geometry.is_empty] - + + @property + def not_loaded_regions(self) -> list[Layer]: + return [layer for layer in self.layers if 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 deleted file mode 100644 index 9d35034..0000000 --- a/seacharts/environment/user.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -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/layers/__init__.py b/seacharts/layers/__init__.py index 6beeb68..64ba729 100644 --- a/seacharts/layers/__init__.py +++ b/seacharts/layers/__init__.py @@ -2,4 +2,4 @@ Contains data classes for containing layered spatial data. """ from .layer import Layer, VirtualWeatherLayer, WeatherLayer -from .layers import Seabed, Land, Shore +from .layers import Seabed, Land, Shore, ExtraLayer diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py index 8df790a..a03cc1e 100644 --- a/seacharts/layers/layer.py +++ b/seacharts/layers/layer.py @@ -21,6 +21,15 @@ class Layer(Shape, ABC): def label(self) -> str: return self.name.lower() + def _geometries_to_multi(self, multi_geoms, geometries, geo_class): + if len(geometries): + geometries = self.as_multi(geometries) + multi_geoms.append(geometries) + geom = unary_union(multi_geoms) + if not isinstance(geom, geo_class): + geom = geo_class([geom]) + return geom + def records_as_geometry(self, records: list[dict]) -> None: geometries = [] multi_geoms = [] @@ -29,6 +38,7 @@ def records_as_geometry(self, records: list[dict]) -> None: if len(records) > 0: for record in records: geom_tmp = self._record_to_geometry(record) + if isinstance(geom_tmp, geo.Polygon): geometries.append(geom_tmp) elif isinstance(geom_tmp, geo.MultiPolygon): @@ -39,23 +49,10 @@ def records_as_geometry(self, records: list[dict]) -> None: multi_linestrings.append(geom_tmp) if len(geometries) + len(multi_geoms) > 0: - if len(geometries): - geometries = self.as_multi(geometries) - multi_geoms.append(geometries) - geom = unary_union(multi_geoms) - if not isinstance(geom, geo.MultiPolygon): - geom = geo.MultiPolygon([geom]) - self.geometry = geom - + self.geometry = self._geometries_to_multi(multi_geoms, geometries, geo.MultiPolygon) elif len(linestrings) + len(multi_linestrings) > 0: - if len(linestrings): - linestrings = self.as_multi(linestrings) - multi_linestrings.append(linestrings) - geom = unary_union(multi_linestrings) - if not isinstance(geom, geo.MultiLineString): - geom = geo.MultiLineString([geom]) - self.geometry = geom + self.geometry = self._geometries_to_multi(multi_linestrings, linestrings, geo.MultiLineString) def unify(self, records: list[dict]) -> None: geometries = [self._record_to_geometry(r) for r in records] diff --git a/seacharts/layers/layers.py b/seacharts/layers/layers.py index c6c9bf9..09c5f9e 100644 --- a/seacharts/layers/layers.py +++ b/seacharts/layers/layers.py @@ -19,3 +19,10 @@ class Land(ZeroDepthLayer): @dataclass class Shore(ZeroDepthLayer): ... + +@dataclass +class ExtraLayer(ZeroDepthLayer): + tag:str = None + @property + def name(self) -> str: + return self.tag diff --git a/setup.cfg b/setup.cfg index 6d96d88..154f0a8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,6 +31,8 @@ install_requires = matplotlib_scalebar cerberus pyyaml + requests + pyproj python_requires = >=3.11 test_suite = tests From 1113cd6301ab424e0fa208d2e727fce6eb82a055 Mon Sep 17 00:00:00 2001 From: miqn Date: Sat, 24 Aug 2024 13:54:01 +0200 Subject: [PATCH 44/84] added improvements for dynamic z_order, and some error proofing for FGDB configuration --- seacharts/config.yaml | 42 ++++++++++++++++------------ seacharts/core/scope.py | 9 +++--- seacharts/display/display.py | 2 +- seacharts/display/features.py | 26 +++++++++-------- seacharts/environment/environment.py | 9 ++++-- 5 files changed, 50 insertions(+), 38 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 919587f..2957e7d 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,17 +1,15 @@ enc: # test config for S-57 - size: [ 6.0, 4.5 ] - origin: [ -85.0, 20.0 ] - depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] - crs: "WGS84" - S57_layers: - "TSSLPT": "#8B0000" - "CTNARE": "#964B00" - resources: [ "data/db/US1GC09M" ] - time: - time_start: "22-01-2024" - time_end: "22-02-2024" - period: "day" + + # size: [ 6.0, 4.5 ] + # origin: [ -85.0, 20.0 ] + # depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] + # crs: "WGS84" + # S57_layers: + # "TSSLPT": "#8B0000" + # "CTNARE": "#964B00" + # resources: [ "data/db/US1GC09M" ] + # weather: # PyThor_adress: "http://127.0.0.1:5000" @@ -19,19 +17,27 @@ enc: # time_end: 1717952909 # variables: [ "wave_height" ] + + # test config for FGDB -# size: [ 9000, 5062 ] -# center: [ 44300, 6956450 ] -# depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] -# crs: "UTM33N" -# resources: [ "/", "data/", "data/db/" ] + + size: [ 9000, 5062 ] + center: [ 44300, 6956450 ] + depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] + crs: "UTM33N" + resources: [ "data/db/More_og_Romsdal_utm33.gdb" ] + + time: + time_start: "22-01-2024" + time_end: "22-02-2024" + period: "day" display: colorbar: False dark_mode: False fullscreen: False - controls: True + controls: False resolution: 640 anchor: "center" dpi: 96 diff --git a/seacharts/core/scope.py b/seacharts/core/scope.py index 6cd51e9..d78256b 100644 --- a/seacharts/core/scope.py +++ b/seacharts/core/scope.py @@ -23,7 +23,8 @@ def __init__(self, settings: dict): self.features.append(f"seabed{depth}m") if settings["enc"].get("S57_layers", []): - self.__s57_init(settings) + self.type = MapFormat.S57 + else: self.type = MapFormat.FGDB @@ -38,10 +39,8 @@ def __init__(self, settings: dict): self.time = None self.weather = settings["enc"].get("weather", []) - files.build_directory_structure(self.features, self.resources) - - def __s57_init(self, settings: dict): self.extra_layers:dict[str,str] = settings["enc"].get("S57_layers", {}) self.features.extend(self.extra_layers) - self.type = MapFormat.S57 + files.build_directory_structure(self.features, self.resources) + diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 32674f3..b0aedd7 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -407,7 +407,7 @@ def _add_scalebar(self): color="white", box_alpha=0.0, pad=0.5, - font_properties={"size": 12}, + font_properties={"size": 12} ) ) diff --git a/seacharts/display/features.py b/seacharts/display/features.py index f159bdf..d14fb67 100644 --- a/seacharts/display/features.py +++ b/seacharts/display/features.py @@ -20,30 +20,28 @@ def __init__(self, display): self._land = None self._shore = None self._extra_layers = {} + self._number_of_layers = len(self._display._environment.get_layers()) + self._next_z_order = self._number_of_layers * -1 self._init_layers() def _init_layers(self): 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) - self._seabeds[rank] = self.assign_artist(seabed, rank, color) + self._seabeds[i] = self.assign_artist(seabed, self._get_next_z_order(), color) shore = self._display._environment.map.shore color = color_picker(shore.__class__.__name__) - self._shore = self.assign_artist(shore, -100, color) + self._shore = self.assign_artist(shore, self._get_next_z_order(), color) land = self._display._environment.map.land color = color_picker(land.__class__.__name__) - self._land = self.assign_artist(land, -110, color) + self._land = self.assign_artist(land, self._get_next_z_order(), color) - extra_layers = self._display._environment.extra_layers.layers - for i, extra_layer in enumerate(extra_layers): - if not extra_layer.geometry.is_empty: - rank = -90 + i - self._extra_layers[rank] = self.assign_artist(extra_layer, rank, extra_layer.color) + for i, extra_layer in enumerate(self._display._environment.extra_layers.loaded_regions): + self._extra_layers[i] = self.assign_artist(extra_layer, self._get_next_z_order(), extra_layer.color) center = self._display._environment.scope.extent.center size = self._display._environment.scope.extent.size @@ -51,7 +49,12 @@ def _init_layers(self): *center, width=size[0] / 2, heading=0, height=size[1] / 2 ).geometry color = (color_picker("black")[0], "none") - self.new_artist(geometry, color, z_order=10000, linewidth=3) + self.new_artist(geometry, color, z_order=self._get_next_z_order(), linewidth=3) + + def _get_next_z_order(self) -> int: + z_order = self._next_z_order + self._next_z_order += 1 + return z_order @property def animated(self): @@ -82,7 +85,8 @@ def new_artist(self, geometry, color, z_order=None, **kwargs): def new_line_artist(self, line_geometry, color, z_order=None, **kwargs): x, y = line_geometry.xy - line = self._display.axes.add_line(Line2D(x, y, color=color, linewidth=kwargs.get('linewidth', 1))) + # TODO: confirm if linewidth 2 wont cause errors in research, linewidth=1 is not visible + line = self._display.axes.add_line(Line2D(x, y, color=color, linewidth=kwargs.get('linewidth', 2))) if z_order is None: line.set_animated(True) else: diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index d0c21fd..b223f0e 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -14,12 +14,15 @@ def __init__(self, settings: dict): self.map = MapData(self.scope, self.parser) self.weather = WeatherData(self.scope, self.parser) self.extra_layers = ExtraLayers(self.scope, self.parser) + self.map.load_existing_shapefiles() - self.extra_layers.load_existing_shapefiles() if len(self.map.not_loaded_regions) > 0: self.map.parse_resources_into_shapefiles() - if len(self.extra_layers.not_loaded_regions) > 0: - self.extra_layers.parse_resources_into_shapefiles() + + if self.scope.type is MapFormat.S57: + self.extra_layers.load_existing_shapefiles() + if len(self.extra_layers.not_loaded_regions) > 0: + self.extra_layers.parse_resources_into_shapefiles() def get_layers(self): return [ From 23c79295f6d13a591f888e36805a671988207d86 Mon Sep 17 00:00:00 2001 From: miqn Date: Sat, 24 Aug 2024 18:10:53 +0200 Subject: [PATCH 45/84] abstraction for DataClass and code improvements --- seacharts/core/parser.py | 4 ++++ seacharts/core/parserFGDB.py | 6 ++++- seacharts/core/parserS57.py | 17 ++++++++++---- seacharts/environment/collection.py | 30 +++++++++++++++++++++++++ seacharts/environment/environment.py | 6 +++-- seacharts/environment/extra.py | 28 ----------------------- seacharts/environment/map.py | 33 +++------------------------- seacharts/environment/weather.py | 4 ---- 8 files changed, 59 insertions(+), 69 deletions(-) diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index 378af83..dd705a7 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -68,6 +68,10 @@ def parse_resources( def _is_map_type(self, path) -> bool: pass + @abstractmethod + def get_source_root_name(self, path) -> str: + pass + @property def _file_paths(self) -> Generator[Path, None, None]: for path in self.paths: diff --git a/seacharts/core/parserFGDB.py b/seacharts/core/parserFGDB.py index 90a8ab6..184b201 100644 --- a/seacharts/core/parserFGDB.py +++ b/seacharts/core/parserFGDB.py @@ -75,8 +75,12 @@ def _read_file( records = self._parse_layers(gdb_path, external_labels, depth) yield from self._parse_records(records, name) - def _is_map_type(self, path): + def _is_map_type(self, path) -> bool: return path.is_dir() and path.suffix == ".gdb" + + def get_source_root_name(self, path) -> str: + for path in self._file_paths: + if self._is_map_type(path): return path def _parse_layers( self, path: Path, external_labels: list[str], depth: int diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index f8ee75d..5cb7dbd 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -17,6 +17,10 @@ def __init__( super().__init__(bounding_box, path_strings) self.epsg = epsg + def get_source_root_name(self, path) -> str: + for path in self._file_paths: + + @staticmethod def convert_s57_to_utm_shapefile(s57_file_path, shapefile_output_path, layer, epsg, bounding_box): x_min, y_min, x_max, y_max = map(str, bounding_box) @@ -74,8 +78,8 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: s57_path = None for path in self._file_paths: - s57_path = self.get_s57_file(path) - s57_path = s57_path.__str__() + s57_path = self.get_s57_file_path(path) + s57_path = str(s57_path) for region in regions_list: start_time = time.time() @@ -96,15 +100,20 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: print(f"\rSaved {region.name} to shapefile in {end_time} s.") @staticmethod - def get_s57_file(path) -> Path: + def get_s57_file_path(path: Path) -> Path: for p in path.iterdir(): if p.suffix == ".000": return p - def _is_map_type(self, path): + def _is_map_type(self, path: Path) -> bool: if path.is_dir(): for p in path.iterdir(): if p.suffix == ".000": return True + elif path.is_file(): + if p.suffix == ".000": + return True + return False + diff --git a/seacharts/environment/collection.py b/seacharts/environment/collection.py index 752b290..ad550c5 100644 --- a/seacharts/environment/collection.py +++ b/seacharts/environment/collection.py @@ -17,3 +17,33 @@ class DataCollection(ABC): @abstractmethod def layers(self) -> list[Layer]: raise NotImplementedError + + @property + def loaded_regions(self) -> list[Layer]: + return [layer for layer in self.layers if not layer.geometry.is_empty] + + @property + def not_loaded_regions(self) -> list[Layer]: + return [layer for layer in self.layers if layer.geometry.is_empty] + + @property + def loaded(self) -> bool: + return any(self.loaded_regions) + +@dataclass +class ShapefileBasedCollection(DataCollection, ABC): + 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.not_loaded_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") diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index b223f0e..9ac5196 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -5,6 +5,7 @@ from .map import MapData from .weather import WeatherData from .extra import ExtraLayers +from seacharts.core import files class Environment: @@ -27,8 +28,9 @@ def __init__(self, settings: dict): def get_layers(self): return [ *self.map.loaded_regions, - *self.extra_layers.loaded_regions - ] # TODO add weather layers + *self.extra_layers.loaded_regions, + *self.weather.loaded_regions + ] def set_parser(self) -> DataParser: if self.scope.type is MapFormat.S57: diff --git a/seacharts/environment/extra.py b/seacharts/environment/extra.py index bf8a1c0..86915a9 100644 --- a/seacharts/environment/extra.py +++ b/seacharts/environment/extra.py @@ -14,34 +14,6 @@ def __post_init__(self): @property def layers(self) -> list[Layer]: return self.extra_layers - - 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.not_loaded_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 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 not_loaded_regions(self) -> list[Layer]: - return [layer for layer in self.layers if layer.geometry.is_empty] @property def featured_regions(self) -> list[Layer]: diff --git a/seacharts/environment/map.py b/seacharts/environment/map.py index d721bee..3cecda0 100644 --- a/seacharts/environment/map.py +++ b/seacharts/environment/map.py @@ -4,48 +4,21 @@ from dataclasses import dataclass from seacharts.layers import Layer, Land, Shore, Seabed -from .collection import DataCollection +from .collection import DataCollection, ShapefileBasedCollection @dataclass -class MapData(DataCollection): +class MapData(ShapefileBasedCollection): def __post_init__(self): self.bathymetry = {d: Seabed(depth=d) for d in self.scope.depths} self.land = Land() self.shore = Shore() - 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.not_loaded_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 not_loaded_regions(self) -> list[Layer]: - return [layer for layer in self.layers if 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/weather.py b/seacharts/environment/weather.py index ae865ec..031a0a9 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -76,10 +76,6 @@ def parse_data(self, data: dict) -> None: def layers(self) -> list[VirtualWeatherLayer]: return self.weather_layers - @property - def loaded(self) -> bool: - return any(self.layers) - def find_by_name(self,name:str) -> VirtualWeatherLayer: for layer in self.layers: if layer.name == name: From 2039edd6f4c61268275f1e81d3cfd0d180ea13d2 Mon Sep 17 00:00:00 2001 From: miqn Date: Sat, 24 Aug 2024 19:00:50 +0200 Subject: [PATCH 46/84] initial commit adding functionality for having shapefiles for multiple maps saved --- seacharts/core/files.py | 11 ++++++++--- seacharts/core/parser.py | 2 +- seacharts/core/parserFGDB.py | 5 +++-- seacharts/core/parserS57.py | 10 +++++++--- seacharts/core/scope.py | 1 - seacharts/environment/environment.py | 1 + seacharts/environment/extra.py | 4 ++-- 7 files changed, 22 insertions(+), 12 deletions(-) diff --git a/seacharts/core/files.py b/seacharts/core/files.py index 9398149..4b5a779 100644 --- a/seacharts/core/files.py +++ b/seacharts/core/files.py @@ -1,10 +1,10 @@ """ Contains utility functions related to system files and directories. """ -import csv +import csv, shutil from collections.abc import Generator from pathlib import Path - +from seacharts.core.parser import DataParser from . import paths @@ -15,9 +15,14 @@ def verify_directory_exists(path_string: str) -> None: print(f"WARNING: {path_type} database path '{path}' not found.\n") -def build_directory_structure(features: list[str], resources: list[str]) -> None: +def build_directory_structure(features: list[str], resources: list[str], parser: DataParser) -> None: + map_dir_name = parser.get_source_root_name() + paths.shapefiles.mkdir(exist_ok=True) + paths.shapefiles = paths.shapefiles / map_dir_name paths.output.mkdir(exist_ok=True) paths.shapefiles.mkdir(exist_ok=True) + shutil.copy(paths.config, paths.shapefiles) + for feature in features: shapefile_dir = paths.shapefiles / feature.lower() shapefile_dir.mkdir(parents=True, exist_ok=True) diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index dd705a7..292a25e 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -69,7 +69,7 @@ def _is_map_type(self, path) -> bool: pass @abstractmethod - def get_source_root_name(self, path) -> str: + def get_source_root_name(self) -> str: pass @property diff --git a/seacharts/core/parserFGDB.py b/seacharts/core/parserFGDB.py index 184b201..ca1878c 100644 --- a/seacharts/core/parserFGDB.py +++ b/seacharts/core/parserFGDB.py @@ -78,9 +78,10 @@ def _read_file( def _is_map_type(self, path) -> bool: return path.is_dir() and path.suffix == ".gdb" - def get_source_root_name(self, path) -> str: + def get_source_root_name(self) -> str: for path in self._file_paths: - if self._is_map_type(path): return path + if self._is_map_type(path): + return path.stem def _parse_layers( self, path: Path, external_labels: list[str], depth: int diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index 5cb7dbd..1373c38 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -17,8 +17,11 @@ def __init__( super().__init__(bounding_box, path_strings) self.epsg = epsg - def get_source_root_name(self, path) -> str: + def get_source_root_name(self) -> str: for path in self._file_paths: + path = self.get_s57_file_path(path) + if path is not None: + return path.stem @staticmethod @@ -100,10 +103,11 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: print(f"\rSaved {region.name} to shapefile in {end_time} s.") @staticmethod - def get_s57_file_path(path: Path) -> Path: + def get_s57_file_path(path: Path) -> Path | None: for p in path.iterdir(): if p.suffix == ".000": return p + return None def _is_map_type(self, path: Path) -> bool: if path.is_dir(): @@ -111,7 +115,7 @@ def _is_map_type(self, path: Path) -> bool: if p.suffix == ".000": return True elif path.is_file(): - if p.suffix == ".000": + if path.suffix == ".000": return True return False diff --git a/seacharts/core/scope.py b/seacharts/core/scope.py index d78256b..5564c60 100644 --- a/seacharts/core/scope.py +++ b/seacharts/core/scope.py @@ -41,6 +41,5 @@ def __init__(self, settings: dict): self.extra_layers:dict[str,str] = settings["enc"].get("S57_layers", {}) self.features.extend(self.extra_layers) - files.build_directory_structure(self.features, self.resources) diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index 9ac5196..ba751d8 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -12,6 +12,7 @@ class Environment: def __init__(self, settings: dict): self.scope = Scope(settings) self.parser = self.set_parser() + files.build_directory_structure(self.scope.features, self.scope.resources, self.parser) self.map = MapData(self.scope, self.parser) self.weather = WeatherData(self.scope, self.parser) self.extra_layers = ExtraLayers(self.scope, self.parser) diff --git a/seacharts/environment/extra.py b/seacharts/environment/extra.py index 86915a9..98731c4 100644 --- a/seacharts/environment/extra.py +++ b/seacharts/environment/extra.py @@ -1,10 +1,10 @@ from seacharts.layers import ExtraLayer from seacharts.layers.layer import Layer -from .collection import DataCollection +from .collection import ShapefileBasedCollection from dataclasses import dataclass @dataclass -class ExtraLayers(DataCollection): +class ExtraLayers(ShapefileBasedCollection): def __post_init__(self): self.extra_layers : list[ExtraLayer] = [] From b83cd0e51da4bb6920051cf1edf9e875c103328f Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Thu, 8 Aug 2024 19:32:53 +0200 Subject: [PATCH 47/84] minor display improvements --- seacharts/display/display.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index b0aedd7..ef2054f 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -144,11 +144,12 @@ def _draw_weather_heatmap(self, variable_name: str, cmap: colors.Colormap, label weather_layer = self._environment.weather.find_by_name(variable_name) if weather_layer is None: return - heatmap_data = weather_layer.weather[self._environment.weather.selected_time_index].data - x_min, y_min, x_max, y_max = self._environment.scope.extent.bbox + + x_min, y_min, x_max, y_max = self._bbox extent = (x_min, x_max, y_min, y_max) + heatmap_data = weather_layer.weather[self._environment.weather.selected_time_index].data ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)), num=8) - self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5) + self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5, interpolation="bicubic") self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes, shrink=0.7) self._cbar.ax.yaxis.set_tick_params(color=label_colour) self._cbar.outline.set_edgecolor(label_colour) From ad6bb0384bb0e091899b9fd7b5da03792bed9d62 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Tue, 13 Aug 2024 17:01:55 +0200 Subject: [PATCH 48/84] enable weather display correction for data cut by projection --- seacharts/display/display.py | 47 +++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index ef2054f..8115cd8 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -131,25 +131,60 @@ def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100) -> colors.LinearSegme return new_cmap def draw_weather(self, variable_name): - ... + lat = np.array(self._environment.weather.latitude) + lon = np.array(self._environment.weather.longitude) + print(lon) + weather_layer = self._environment.weather.find_by_name(variable_name) + x_min, y_min, x_max, y_max = self._bbox + lat_min,lon_min = self._environment.scope.extent.convert_utm_to_lat_lon(x_min,y_min) + lat_max, lon_max = self._environment.scope.extent.convert_utm_to_lat_lon(x_max, y_max) + print(lat_min, lat_max) + print(lon_min, lon_max) + if lon_min < 0: + lon_min = 180 + (180 + lon_min) + if lon_max < 0: + lon_max = 180 + (180 + lon_max) + lat_indxes = [None,None] + for i in range(len(lat)): + if lat[i] >= lat_min: + lat_indxes[0] = i + if lat[len(lat)-(i+1)] <= lat_max: + lat_indxes[1] = len(lat) - (i+1) + if None not in lat_indxes: + break + lon_indxes = [None, None] + for i in range(len(lon)): + if lon[i] >= lon_min: + lon_indxes[0] = i + print(lon[-(i + 1)]) + if lon[len(lon)-(i + 1)] <= lon_max: + lon_indxes[1] = len(lon) - (i + 1) + if None not in lon_indxes: + break + print(lat_indxes,lon_indxes) + # TODO choose correct display for variables + self._draw_weather_heatmap(weather_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]][lon_indxes[0]:lon_indxes[1]], + cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), label_colour='white') + def _draw_arrow_map(self): ... - def _draw_weather_heatmap(self, variable_name: str, cmap: colors.Colormap, label_colour: str) -> None: + def _draw_weather_heatmap(self, weather_data: str, cmap: colors.Colormap, label_colour: str) -> None: """ Draws a heatmap and colorbar for specified weather variable using provided color map and label colour for color bar :return: None """ - weather_layer = self._environment.weather.find_by_name(variable_name) - if weather_layer is None: + + if weather_data is None: return x_min, y_min, x_max, y_max = self._bbox extent = (x_min, x_max, y_min, y_max) - heatmap_data = weather_layer.weather[self._environment.weather.selected_time_index].data + heatmap_data = weather_data ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)), num=8) - self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5, interpolation="bicubic") + self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5, + interpolation="bicubic") self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes, shrink=0.7) self._cbar.ax.yaxis.set_tick_params(color=label_colour) self._cbar.outline.set_edgecolor(label_colour) From 778ccf49b87ef5c6b74a59acf14b7e597c869e25 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Tue, 13 Aug 2024 18:36:45 +0200 Subject: [PATCH 49/84] Update display.py --- seacharts/display/display.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 8115cd8..9b11f5c 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -131,39 +131,42 @@ def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100) -> colors.LinearSegme return new_cmap def draw_weather(self, variable_name): - lat = np.array(self._environment.weather.latitude) - lon = np.array(self._environment.weather.longitude) - print(lon) + lat = self._environment.weather.latitude + lon = self._environment.weather.longitude weather_layer = self._environment.weather.find_by_name(variable_name) x_min, y_min, x_max, y_max = self._bbox + print(self._bbox) lat_min,lon_min = self._environment.scope.extent.convert_utm_to_lat_lon(x_min,y_min) lat_max, lon_max = self._environment.scope.extent.convert_utm_to_lat_lon(x_max, y_max) print(lat_min, lat_max) print(lon_min, lon_max) + if lon_min < 0: lon_min = 180 + (180 + lon_min) if lon_max < 0: lon_max = 180 + (180 + lon_max) + lon_min += 0.2 + lon_max -= 0.7 lat_indxes = [None,None] for i in range(len(lat)): - if lat[i] >= lat_min: + if lat[i] >= lat_min and lat_indxes[0] is None: lat_indxes[0] = i - if lat[len(lat)-(i+1)] <= lat_max: - lat_indxes[1] = len(lat) - (i+1) + if lat[len(lat)-(i+1)] <= lat_max and lat_indxes[1] is None: + lat_indxes[1] = len(lat) - i if None not in lat_indxes: break lon_indxes = [None, None] for i in range(len(lon)): - if lon[i] >= lon_min: + if lon[i] >= lon_min and lon_indxes[0] is None: lon_indxes[0] = i - print(lon[-(i + 1)]) - if lon[len(lon)-(i + 1)] <= lon_max: - lon_indxes[1] = len(lon) - (i + 1) + if lon[len(lon)-(i + 1)] <= lon_max and lon_indxes[1] is None: + lon_indxes[1] = len(lon) - i if None not in lon_indxes: break print(lat_indxes,lon_indxes) # TODO choose correct display for variables - self._draw_weather_heatmap(weather_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]][lon_indxes[0]:lon_indxes[1]], + data = [x[lon_indxes[0]:lon_indxes[1]] for x in weather_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]]] + self._draw_weather_heatmap(data, cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), label_colour='white') @@ -184,13 +187,10 @@ def _draw_weather_heatmap(self, weather_data: str, cmap: colors.Colormap, label_ heatmap_data = weather_data ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)), num=8) self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5, - interpolation="bicubic") + interpolation="bicubic",projection=self.crs) self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes, shrink=0.7) self._cbar.ax.yaxis.set_tick_params(color=label_colour) self._cbar.outline.set_edgecolor(label_colour) - print(heatmap_data) - print(np.nanmin(np.array(heatmap_data))) - print(np.nanmax(np.array(heatmap_data))) plt.setp(plt.getp(self._cbar.ax.axes, 'yticklabels'), color=label_colour) def draw_arrow( From a3dc595e7168e5e5944d97b70a0df1a176fd9dc3 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Tue, 13 Aug 2024 19:30:44 +0200 Subject: [PATCH 50/84] Update display.py --- seacharts/display/display.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 9b11f5c..4bb2967 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -135,7 +135,6 @@ def draw_weather(self, variable_name): lon = self._environment.weather.longitude weather_layer = self._environment.weather.find_by_name(variable_name) x_min, y_min, x_max, y_max = self._bbox - print(self._bbox) lat_min,lon_min = self._environment.scope.extent.convert_utm_to_lat_lon(x_min,y_min) lat_max, lon_max = self._environment.scope.extent.convert_utm_to_lat_lon(x_max, y_max) print(lat_min, lat_max) @@ -145,8 +144,6 @@ def draw_weather(self, variable_name): lon_min = 180 + (180 + lon_min) if lon_max < 0: lon_max = 180 + (180 + lon_max) - lon_min += 0.2 - lon_max -= 0.7 lat_indxes = [None,None] for i in range(len(lat)): if lat[i] >= lat_min and lat_indxes[0] is None: @@ -184,10 +181,13 @@ def _draw_weather_heatmap(self, weather_data: str, cmap: colors.Colormap, label_ x_min, y_min, x_max, y_max = self._bbox extent = (x_min, x_max, y_min, y_max) - heatmap_data = weather_data + heatmap_data = np.array(weather_data) + lon = np.linspace(x_min, x_max, heatmap_data.shape[1]) + lat = np.linspace(y_min, y_max, heatmap_data.shape[0]) + lon, lat = np.meshgrid(lon, lat) ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)), num=8) - self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5, - interpolation="bicubic",projection=self.crs) + self.weather_map = self.axes.pcolormesh(lon, lat, heatmap_data, cmap=cmap, alpha=0.5, transform=self.crs) + self.axes.set_extent(extent, crs=self.crs) self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes, shrink=0.7) self._cbar.ax.yaxis.set_tick_params(color=label_colour) self._cbar.outline.set_edgecolor(label_colour) From 3d24fb52b0f7df8907e71620c01025b42faeeb73 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Sun, 25 Aug 2024 19:25:16 +0200 Subject: [PATCH 51/84] Download slightly larger area to better fit the display --- seacharts/environment/weather.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index 031a0a9..705993b 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -49,10 +49,10 @@ def fetch_data(self,query_dict) -> dict: latitude_start,longitude_start = self.scope.extent.convert_utm_to_lat_lon(x_min,y_min) latitude_end, longitude_end = self.scope.extent.convert_utm_to_lat_lon(x_max, y_max) print(latitude_start,longitude_start,latitude_end, longitude_end) - api_query += "&latitude_start="+str(latitude_start) - api_query += "&longitude_start=" + str(longitude_start) - api_query += "&latitude_end=" + str(latitude_end) - api_query += "&longitude_end=" + str(longitude_end) + api_query += "&latitude_start="+str(latitude_start-0.5 if latitude_start-0.5 >= -90 else -90) + api_query += "&longitude_start=" + str(longitude_start-0.5 if longitude_start-0.5 >= -180 else -180) + api_query += "&latitude_end=" + str(latitude_end+0.5 if latitude_end+0.5 <= 90 else 90) + api_query += "&longitude_end=" + str(longitude_end+0.5 if longitude_end+0.5 <= 180 else 180) print(api_query) return requests.get(api_query).json() From c899e6b33534d65f97004f7e7cb02165417bc8a0 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Mon, 26 Aug 2024 14:01:31 +0200 Subject: [PATCH 52/84] implement basic arrowmap --- seacharts/config_schema.yaml | 9 +--- seacharts/display/display.py | 61 ++++++++++++++++++++-------- seacharts/environment/environment.py | 1 - seacharts/environment/weather.py | 5 ++- 4 files changed, 49 insertions(+), 27 deletions(-) diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index 7ca2c68..981d77b 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -70,16 +70,9 @@ enc: required: False type: dict schema: - PyThor_adress: + PyThor_address: required: False type: string - #in epoch time - time_start: - required: False - type: integer - time_end: - required: False - type: integer variables: type: list required: False diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 4bb2967..07a5b94 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -1,6 +1,7 @@ """ Contains the Display class for displaying and plotting maritime spatial data. """ +import math import tkinter as tk from pathlib import Path from typing import Any @@ -134,11 +135,12 @@ def draw_weather(self, variable_name): lat = self._environment.weather.latitude lon = self._environment.weather.longitude weather_layer = self._environment.weather.find_by_name(variable_name) + if variable_name == "wind": + weather_layer = self._environment.weather.find_by_name("ws") + direction_layer = self._environment.weather.find_by_name("wdir") x_min, y_min, x_max, y_max = self._bbox lat_min,lon_min = self._environment.scope.extent.convert_utm_to_lat_lon(x_min,y_min) lat_max, lon_max = self._environment.scope.extent.convert_utm_to_lat_lon(x_max, y_max) - print(lat_min, lat_max) - print(lon_min, lon_max) if lon_min < 0: lon_min = 180 + (180 + lon_min) @@ -160,15 +162,44 @@ def draw_weather(self, variable_name): lon_indxes[1] = len(lon) - i if None not in lon_indxes: break - print(lat_indxes,lon_indxes) + # TODO choose correct display for variables data = [x[lon_indxes[0]:lon_indxes[1]] for x in weather_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]]] - self._draw_weather_heatmap(data, - cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), label_colour='white') - - - def _draw_arrow_map(self): - ... + direction_data = [x[lon_indxes[0]:lon_indxes[1]] for x in direction_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]]] + print(data) + #TODO max value pick + self._draw_arrow_map(direction_data,data,10,latitudes=lat[lat_indxes[0]:lat_indxes[1]],longitude=lon[lon_indxes[0]:lon_indxes[1]]) + # self._draw_weather_heatmap(data, + # cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), label_colour='white' if self._dark_mode else 'black') + + + def _draw_arrow_map(self,direction_data,data,max,latitudes,longitude): + utm_east = [0] * len(longitude) + utm_north = [0] * len(latitudes) + print(self._environment.scope.extent.origin, (self._environment.scope.extent.center[0] + 700, self._environment.scope.extent.center[1] + 600)) + for i in range(len(latitudes)): + utm_east[0], utm_north[i] = self._environment.scope.extent.convert_lat_lon_to_utm(latitudes[i], longitude[0]) + for i in range(len(longitude)): + utm_east[i], utm_north[0] = self._environment.scope.extent.convert_lat_lon_to_utm(latitudes[0], longitude[i]) + print(utm_east,utm_north) + size = (abs(utm_east[1] - utm_east[0]) if len(utm_east) > 1 else (abs(utm_north[1] - utm_north[0]) if len(utm_north) > 1 else abs(self._bbox[0] - self._bbox[2]))) * 0.9 + if direction_data is None: + return + for i in range(len(direction_data)): + for j in range(len(direction_data[i])): + x = direction_data[i][j] + from math import isnan + if not isnan(direction_data[i][j]) and not data[i][j] == 0: + degree = math.radians(direction_data[i][j]) + res = size + if not isnan(data[i][j]): + res = size*(data[i][j]/max) if (data[i][j]/max) <= 1 else size + center = utm_east[j],utm_north[i] + start = [center[0], center[1] + res/2] + start = [center[0] + (start[0]-center[0]) * math.cos(degree) - (start[1]-center[1]) * math.sin(degree), center[1] + (start[0]-center[0]) * math.sin(degree) - (start[1]-center[1]) * math.cos(degree)] + end = [center[0] , center[1] - res / 2] + end = [center[0] + (end[0]-center[0]) * math.cos(degree) - (end[1]-center[1]) * math.sin(degree), center[1] + (end[0]-center[0]) * math.sin(degree) - (end[1]-center[1]) * math.cos(degree)] + self.draw_arrow(start,end,"black",head_size=res/4, width=size/20,fill=True) def _draw_weather_heatmap(self, weather_data: str, cmap: colors.Colormap, label_colour: str) -> None: """ @@ -181,15 +212,13 @@ def _draw_weather_heatmap(self, weather_data: str, cmap: colors.Colormap, label_ x_min, y_min, x_max, y_max = self._bbox extent = (x_min, x_max, y_min, y_max) - heatmap_data = np.array(weather_data) - lon = np.linspace(x_min, x_max, heatmap_data.shape[1]) - lat = np.linspace(y_min, y_max, heatmap_data.shape[0]) - lon, lat = np.meshgrid(lon, lat) - ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)), num=8) - self.weather_map = self.axes.pcolormesh(lon, lat, heatmap_data, cmap=cmap, alpha=0.5, transform=self.crs) - self.axes.set_extent(extent, crs=self.crs) + heatmap_data = weather_data + ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)), num=10) + self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5, + interpolation="bicubic") self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes, shrink=0.7) self._cbar.ax.yaxis.set_tick_params(color=label_colour) + self._cbar.set_ticks(ticks) self._cbar.outline.set_edgecolor(label_colour) plt.setp(plt.getp(self._cbar.ax.axes, 'yticklabels'), color=label_colour) diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index ba751d8..1effcb1 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -30,7 +30,6 @@ def get_layers(self): return [ *self.map.loaded_regions, *self.extra_layers.loaded_regions, - *self.weather.loaded_regions ] def set_parser(self) -> DataParser: diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index 705993b..1528314 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -34,8 +34,8 @@ def fetch_data(self,query_dict) -> dict: :param query_dict: Dict with API query data :return: Dictionary with weather data. """ - api_query = query_dict["PyThor_adress"] + "/api/weather?" - query_dict.pop("PyThor_adress") + api_query = query_dict["PyThor_address"] + "/api/weather?" + query_dict.pop("PyThor_address") for k, v in query_dict.items(): api_query += k + "=" if not isinstance(v, list): @@ -53,6 +53,7 @@ def fetch_data(self,query_dict) -> dict: api_query += "&longitude_start=" + str(longitude_start-0.5 if longitude_start-0.5 >= -180 else -180) api_query += "&latitude_end=" + str(latitude_end+0.5 if latitude_end+0.5 <= 90 else 90) api_query += "&longitude_end=" + str(longitude_end+0.5 if longitude_end+0.5 <= 180 else 180) + api_query += "&time_start="+ str(self.scope.time.epoch_times[0]) + "&time_end=" + str(self.scope.time.epoch_times[-1]) print(api_query) return requests.get(api_query).json() From 929285279370019e41eff811febccbd20a26ea3e Mon Sep 17 00:00:00 2001 From: miqn Date: Mon, 26 Aug 2024 19:39:49 +0200 Subject: [PATCH 53/84] added extension for time in config to specify hours and minutes, minutes are not yet used anywhere --- seacharts/config.yaml | 34 ++++++++++++++-------------- seacharts/config_schema.yaml | 4 ++-- seacharts/core/time.py | 7 +++--- seacharts/enc.py | 2 -- seacharts/environment/collection.py | 1 - seacharts/environment/environment.py | 3 +-- 6 files changed, 24 insertions(+), 27 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 2957e7d..0b2e5de 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,14 +1,14 @@ enc: # test config for S-57 - # size: [ 6.0, 4.5 ] - # origin: [ -85.0, 20.0 ] - # depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] - # crs: "WGS84" - # S57_layers: - # "TSSLPT": "#8B0000" - # "CTNARE": "#964B00" - # resources: [ "data/db/US1GC09M" ] + size: [ 6.0, 4.5 ] + origin: [ -85.0, 20.0 ] + depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] + crs: "WGS84" + S57_layers: + "TSSLPT": "#8B0000" + "CTNARE": "#964B00" + resources: [ "data/db/US1GC09M" ] # weather: @@ -21,23 +21,23 @@ enc: # test config for FGDB - size: [ 9000, 5062 ] - center: [ 44300, 6956450 ] - depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] - crs: "UTM33N" - resources: [ "data/db/More_og_Romsdal_utm33.gdb" ] + # size: [ 9000, 5062 ] + # center: [ 44300, 6956450 ] + # depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] + # crs: "UTM33N" + # resources: [ "data/db/More_og_Romsdal_utm33.gdb" ] time: - time_start: "22-01-2024" - time_end: "22-02-2024" - period: "day" + time_start: "22-02-2024 09:00" + time_end: "22-02-2024 15:00" + period: "hour" display: colorbar: False dark_mode: False fullscreen: False - controls: False + controls: True resolution: 640 anchor: "center" dpi: 96 diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index 7ca2c68..f860cd6 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -93,11 +93,11 @@ enc: time_start: required: True type: string - regex: "^(0[1-9]|[12][0-9]|3[01])-(0[1-9]|1[0-2])-[0-9]{4}$" # dd-mm-yyyy + regex: "^(0[1-9]|[12][0-9]|3[01])-(0[1-9]|1[0-2])-[0-9]{4} [0-2][0-9]:[0-5][0-9]$" # dd-mm-yyyy hh:mm time_end: required: True type: string - regex: "^(0[1-9]|[12][0-9]|3[01])-(0[1-9]|1[0-2])-[0-9]{4}$" # dd-mm-yyyy + regex: "^(0[1-9]|[12][0-9]|3[01])-(0[1-9]|1[0-2])-[0-9]{4} [0-2][0-9]:[0-5][0-9]$" # dd-mm-yyyy hh:mm period: required: True type: string diff --git a/seacharts/core/time.py b/seacharts/core/time.py index ac0605e..ce9a592 100644 --- a/seacharts/core/time.py +++ b/seacharts/core/time.py @@ -3,8 +3,9 @@ class Time: def __init__(self, time_start: str, time_end: str, period: str): # Parse the start and end dates - self.time_start = datetime.strptime(time_start, "%d-%m-%Y") - self.time_end = datetime.strptime(time_end, "%d-%m-%Y") + self._date_string_format = "%d-%m-%Y %H:%M" + self.time_start = datetime.strptime(time_start, self._date_string_format) + self.time_end = datetime.strptime(time_end, self._date_string_format) self.period = period # Generate the list of datetimes and epoch times @@ -40,4 +41,4 @@ def _increment_time(self, current_time): raise ValueError(f"Unknown period: {self.period}") def get_datetimes_strings(self): - return [datetime.strftime("%d-%m-%Y") for datetime in self.datetimes] \ No newline at end of file + return [datetime.strftime(self._date_string_format) for datetime in self.datetimes] \ No newline at end of file diff --git a/seacharts/enc.py b/seacharts/enc.py index d10ef0d..0dcb64a 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -2,9 +2,7 @@ Contains the ENC class for reading, storing and plotting maritime spatial data. """ from pathlib import Path - from shapely.geometry import Point - from seacharts.core import Config from seacharts.display import Display from seacharts.environment import Environment diff --git a/seacharts/environment/collection.py b/seacharts/environment/collection.py index ad550c5..922d1a1 100644 --- a/seacharts/environment/collection.py +++ b/seacharts/environment/collection.py @@ -3,7 +3,6 @@ """ from abc import abstractmethod, ABC from dataclasses import dataclass, field - from seacharts.core import Scope, DataParser from seacharts.layers import Layer diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index ba751d8..5f776d4 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -29,8 +29,7 @@ def __init__(self, settings: dict): def get_layers(self): return [ *self.map.loaded_regions, - *self.extra_layers.loaded_regions, - *self.weather.loaded_regions + *self.extra_layers.loaded_regions ] def set_parser(self) -> DataParser: From 3401541f38df85cc97154671eb29a723fed12773 Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Mon, 26 Aug 2024 19:51:18 +0200 Subject: [PATCH 54/84] Improve arrow map drawing --- seacharts/display/colors.py | 4 +++ seacharts/display/display.py | 47 +++++++++++++++++++++-------------- seacharts/display/features.py | 4 +-- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/seacharts/display/colors.py b/seacharts/display/colors.py index 84b38f0..bd7a98d 100644 --- a/seacharts/display/colors.py +++ b/seacharts/display/colors.py @@ -1,6 +1,8 @@ """ Contains functions and structures for color management. """ +import re + import matplotlib.colors as clr import matplotlib.pyplot as plt import numpy as np @@ -65,6 +67,8 @@ def color_picker(name: str, bins: int = None) -> tuple: return _layer_colors[name] elif name in clr.CSS4_COLORS: return clr.CSS4_COLORS[name] + elif re.match("^#(?:[0-9a-fA-F]{3,4}){1,2}$",name): + return name,name else: raise ValueError(f"{name} is not a valid color") diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 07a5b94..65cdb19 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -168,12 +168,23 @@ def draw_weather(self, variable_name): direction_data = [x[lon_indxes[0]:lon_indxes[1]] for x in direction_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]]] print(data) #TODO max value pick - self._draw_arrow_map(direction_data,data,10,latitudes=lat[lat_indxes[0]:lat_indxes[1]],longitude=lon[lon_indxes[0]:lon_indxes[1]]) + self._draw_arrow_map(direction_data,data,latitudes=lat[lat_indxes[0]:lat_indxes[1]],longitude=lon[lon_indxes[0]:lon_indxes[1]]) # self._draw_weather_heatmap(data, - # cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), label_colour='white' if self._dark_mode else 'black') + # cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), label_colour='white' if self._dark_mode else 'black') + class ArrowMap: + arrows: list = [] - def _draw_arrow_map(self,direction_data,data,max,latitudes,longitude): + def add_arrow(self, arrow): + self.arrows.append(arrow) + + def remove(self): + for arrow in self.arrows: + arrow.remove() + self.arrows = [] + + def _draw_arrow_map(self,direction_data,data,latitudes,longitude): + cmap = self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9) utm_east = [0] * len(longitude) utm_north = [0] * len(latitudes) print(self._environment.scope.extent.origin, (self._environment.scope.extent.center[0] + 700, self._environment.scope.extent.center[1] + 600)) @@ -183,23 +194,22 @@ def _draw_arrow_map(self,direction_data,data,max,latitudes,longitude): utm_east[i], utm_north[0] = self._environment.scope.extent.convert_lat_lon_to_utm(latitudes[0], longitude[i]) print(utm_east,utm_north) size = (abs(utm_east[1] - utm_east[0]) if len(utm_east) > 1 else (abs(utm_north[1] - utm_north[0]) if len(utm_north) > 1 else abs(self._bbox[0] - self._bbox[2]))) * 0.9 + self.weather_map = self.ArrowMap() if direction_data is None: return for i in range(len(direction_data)): for j in range(len(direction_data[i])): x = direction_data[i][j] from math import isnan - if not isnan(direction_data[i][j]) and not data[i][j] == 0: + if not isnan(direction_data[i][j]) and data!=0 and not isnan(data[i][j]): degree = math.radians(direction_data[i][j]) - res = size - if not isnan(data[i][j]): - res = size*(data[i][j]/max) if (data[i][j]/max) <= 1 else size center = utm_east[j],utm_north[i] - start = [center[0], center[1] + res/2] + start = [center[0], center[1] + size/2] start = [center[0] + (start[0]-center[0]) * math.cos(degree) - (start[1]-center[1]) * math.sin(degree), center[1] + (start[0]-center[0]) * math.sin(degree) - (start[1]-center[1]) * math.cos(degree)] - end = [center[0] , center[1] - res / 2] + end = [center[0] , center[1] - size / 2] end = [center[0] + (end[0]-center[0]) * math.cos(degree) - (end[1]-center[1]) * math.sin(degree), center[1] + (end[0]-center[0]) * math.sin(degree) - (end[1]-center[1]) * math.cos(degree)] - self.draw_arrow(start,end,"black",head_size=res/4, width=size/20,fill=True) + color = cmap(data[i][j]/ max([max(k) for k in data])) + self.weather_map.add_arrow(self.draw_arrow(start,end,color=str(colors.rgb2hex(color, keep_alpha=True)),head_size=size/4, width=size/20,fill=True)) def _draw_weather_heatmap(self, weather_data: str, cmap: colors.Colormap, label_colour: str) -> None: """ @@ -212,10 +222,13 @@ def _draw_weather_heatmap(self, weather_data: str, cmap: colors.Colormap, label_ x_min, y_min, x_max, y_max = self._bbox extent = (x_min, x_max, y_min, y_max) - heatmap_data = weather_data + heatmap_data = np.array(weather_data) + lon = np.linspace(x_min, x_max, heatmap_data.shape[1]) + lat = np.linspace(y_min, y_max, heatmap_data.shape[0]) + lon, lat = np.meshgrid(lon, lat) ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)), num=10) - self.weather_map = self.axes.imshow(heatmap_data, extent=extent, origin='lower', cmap=cmap, alpha=0.5, - interpolation="bicubic") + self.weather_map = self.axes.pcolormesh(lon, lat, heatmap_data, cmap=cmap, alpha=0.5, transform=self.crs) + self.axes.set_extent(extent, crs=self.crs) self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes, shrink=0.7) self._cbar.ax.yaxis.set_tick_params(color=label_colour) self._cbar.set_ticks(ticks) @@ -232,7 +245,7 @@ def draw_arrow( head_size: float = None, thickness: float = None, edge_style: str | tuple = None, - ) -> None: + ) -> Any: """ Add a straight arrow overlay to the environment plot. :param start: tuple of start point coordinate pair @@ -245,7 +258,7 @@ def draw_arrow( :param head_size: float of head size (length) in meters :return: None """ - self.features.add_arrow( + return self.features.add_arrow( start, end, color, width, fill, head_size, thickness, edge_style ) @@ -599,9 +612,7 @@ def _weather_slider_handle(self,val): self._environment.weather.selected_time_index = val self._cbar.remove() self.weather_map.remove() - self.draw_weather_heatmap(self._environment.weather.weather_layers[0].name, - cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), - label_colour='white') + self.draw_weather("wind") self.redraw_plot() def _add_time_slider(self, ax_slider, fig): diff --git a/seacharts/display/features.py b/seacharts/display/features.py index d14fb67..8bebd7a 100644 --- a/seacharts/display/features.py +++ b/seacharts/display/features.py @@ -101,7 +101,7 @@ def add_arrow( if head_size is None: head_size = 50 body = shapes.Arrow(start=start, end=end, width=buffer).body(head_size) - self.add_overlay(body, color_name, fill, linewidth, linestyle) + return self.add_overlay(body, color_name, fill, linewidth, linestyle) def add_circle(self, center, radius, color_name, fill, linewidth, linestyle, alpha): geometry = shapes.Circle(*center, radius).geometry @@ -161,7 +161,7 @@ def add_overlay(self, geometry, color_name, fill, linewidth, linestyle, alpha=1. if linestyle is not None: kwargs["linestyle"] = linestyle kwargs["alpha"] = alpha - self.new_artist(geometry, color, 0, **kwargs) + return self.new_artist(geometry, color, 0, **kwargs) def update_vessels(self): if self.show_vessels: From eeb6c585fe54bb35a2c18b82aa643ad2137c529a Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Mon, 26 Aug 2024 20:17:10 +0200 Subject: [PATCH 55/84] slider handling --- seacharts/config.yaml | 41 +++++++++++++++++------------------- seacharts/display/display.py | 10 +++++---- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 0b2e5de..3c1915b 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,35 +1,32 @@ enc: # test config for S-57 - size: [ 6.0, 4.5 ] - origin: [ -85.0, 20.0 ] - depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] - crs: "WGS84" - S57_layers: - "TSSLPT": "#8B0000" - "CTNARE": "#964B00" - resources: [ "data/db/US1GC09M" ] + size: [ 6.0, 4.5 ] + origin: [ -85.0, 20.0 ] + depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] + crs: "WGS84" + S57_layers: + "TSSLPT": "#8B0000" +# "CTNARE": "#964B00" + resources: [ "data/db/US1GC09M" ] - -# weather: -# PyThor_adress: "http://127.0.0.1:5000" -# time_start: 1717931596 -# time_end: 1717952909 -# variables: [ "wave_height" ] + weather: + PyThor_address: "http://127.0.0.1:5000" + variables: [ "wind_speed", "wind_direction" ] # test config for FGDB - # size: [ 9000, 5062 ] - # center: [ 44300, 6956450 ] - # depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] - # crs: "UTM33N" - # resources: [ "data/db/More_og_Romsdal_utm33.gdb" ] +# size: [ 9000, 5062 ] +# center: [ 44300, 6956450 ] +# depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] +# crs: "UTM33N" +# resources: [ "data/db/More_og_Romsdal_utm33.gdb" ] - time: - time_start: "22-02-2024 09:00" - time_end: "22-02-2024 15:00" + time: + time_start: "25-08-2024 09:00" + time_end: "25-08-2024 13:00" period: "hour" diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 65cdb19..8669185 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -173,7 +173,10 @@ def draw_weather(self, variable_name): # cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), label_colour='white' if self._dark_mode else 'black') class ArrowMap: - arrows: list = [] + arrows: list + + def __init__(self): + self.arrows = [] def add_arrow(self, arrow): self.arrows.append(arrow) @@ -181,7 +184,6 @@ def add_arrow(self, arrow): def remove(self): for arrow in self.arrows: arrow.remove() - self.arrows = [] def _draw_arrow_map(self,direction_data,data,latitudes,longitude): cmap = self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9) @@ -610,7 +612,7 @@ def _terminate(self): def _weather_slider_handle(self,val): self._environment.weather.selected_time_index = val - self._cbar.remove() + # self._cbar.remove() self.weather_map.remove() self.draw_weather("wind") self.redraw_plot() @@ -660,5 +662,5 @@ def on_radio_change(label): # Set the window title and show the figure fig.canvas.manager.set_window_title('Controls') - plt.show() + fig.show() From f3860cb33f79503552751cb336ac75aeac0121bd Mon Sep 17 00:00:00 2001 From: miqn Date: Tue, 27 Aug 2024 22:29:31 +0200 Subject: [PATCH 56/84] autoconfig for windows and minor fixes --- requirements.txt | 10 --------- seacharts/config_schema.yaml | 2 +- setup.ps1 | 39 ++++++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 11 deletions(-) delete mode 100644 requirements.txt create mode 100644 setup.ps1 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9471ecc..0000000 --- a/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -Shapely~=1.8.2 -setuptools~=68.2.0 -PyYAML~=6.0.1 -Cerberus~=1.3.5 -pyproj~=3.6.1 -Fiona~=1.8.21 -matplotlib~=3.8.3 -numpy~=1.22.4+vanilla -Cartopy~=0.22.0 -requests~=2.31.0 \ No newline at end of file diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index f860cd6..931c1a6 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -16,7 +16,7 @@ enc: type: float min: 1.0 - #that's where you want top-left corner to be + #that's where you want bottom-left corner to be origin: required: True excludes: center diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..07bc5b0 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,39 @@ +# script to quickly setup seacharts downloaded from github on Windows + +$baseDir = "data" +New-Item -ItemType Directory -Path $baseDir -Force +# Create 'shapefiles' and 'data' subdirectories inside 'db' +New-Item -ItemType Directory -Path "$baseDir\shapefiles" -Force +New-Item -ItemType Directory -Path "$baseDir\db" -Force + +# Create a virtual environment named 'venv' (if it doesn't exist) +python -m venv venv + +# Activate the virtual environment +& .\venv\Scripts\Activate.ps1 + +# Upgrade pip within the virtual environment +python -m pip install --upgrade pip + +# Install wheel package +pip install wheel + +# Install pipwin package +pip install pipwin + +# Use pipwin to install specific packages +pipwin install numpy +pipwin install gdal +pipwin install fiona +pipwin install shapely + +# Install additional Python packages +pip install cartopy +pip install setuptools +pip install requests +pip install pyyaml +pip install cerberus +pip install matplotlib-scalebar + +# Deactivate the virtual environment +Deactivate From 5a9e9c778019f215bc1205e238ad43499df0e43b Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Fri, 30 Aug 2024 17:25:45 +0200 Subject: [PATCH 57/84] display WIP --- seacharts/display/display.py | 49 ++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 8669185..ad93f71 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -134,10 +134,10 @@ def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100) -> colors.LinearSegme def draw_weather(self, variable_name): lat = self._environment.weather.latitude lon = self._environment.weather.longitude - weather_layer = self._environment.weather.find_by_name(variable_name) + if variable_name == "wind": - weather_layer = self._environment.weather.find_by_name("ws") - direction_layer = self._environment.weather.find_by_name("wdir") + weather_layer = self._environment.weather.find_by_name("wind_speed") + direction_layer = self._environment.weather.find_by_name("wind_direction") x_min, y_min, x_max, y_max = self._bbox lat_min,lon_min = self._environment.scope.extent.convert_utm_to_lat_lon(x_min,y_min) lat_max, lon_max = self._environment.scope.extent.convert_utm_to_lat_lon(x_max, y_max) @@ -163,14 +163,36 @@ def draw_weather(self, variable_name): if None not in lon_indxes: break + weather_layer = None + direction_layer = None + + match variable_name: + case "wind": + weather_layer = self._environment.weather.find_by_name("wind_speed") + direction_layer = self._environment.weather.find_by_name("wind_direction") + case "wave": + weather_layer = self._environment.weather.find_by_name("wave_height") + direction_layer = self._environment.weather.find_by_name("wave_direction") + case "sea_current": + weather_layer = self._environment.weather.find_by_name("sea_current_speed") + direction_layer = self._environment.weather.find_by_name("sea_current_direction") + case _: + if "direction" in variable_name: + direction_layer = self._environment.weather.find_by_name(variable_name) + else: + weather_layer = self._environment.weather.find_by_name(variable_name) + + # TODO choose correct display for variables - data = [x[lon_indxes[0]:lon_indxes[1]] for x in weather_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]]] - direction_data = [x[lon_indxes[0]:lon_indxes[1]] for x in direction_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]]] - print(data) - #TODO max value pick - self._draw_arrow_map(direction_data,data,latitudes=lat[lat_indxes[0]:lat_indxes[1]],longitude=lon[lon_indxes[0]:lon_indxes[1]]) - # self._draw_weather_heatmap(data, - # cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), label_colour='white' if self._dark_mode else 'black') + data = None + if weather_layer is not None: + data = [x[lon_indxes[0]:lon_indxes[1]] for x in weather_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]]] + if direction_layer is not None: + direction_data = [x[lon_indxes[0]:lon_indxes[1]] for x in direction_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]]] + self._draw_arrow_map(direction_data,data,latitudes=lat[lat_indxes[0]:lat_indxes[1]],longitude=lon[lon_indxes[0]:lon_indxes[1]]) + elif data is not None: + self._draw_weather_heatmap(weather_layer.weather[self._environment.weather.selected_time_index].data, + cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), label_colour='white' if self._dark_mode else 'black') class ArrowMap: arrows: list @@ -199,19 +221,20 @@ def _draw_arrow_map(self,direction_data,data,latitudes,longitude): self.weather_map = self.ArrowMap() if direction_data is None: return + draw_default = data is None for i in range(len(direction_data)): for j in range(len(direction_data[i])): x = direction_data[i][j] from math import isnan - if not isnan(direction_data[i][j]) and data!=0 and not isnan(data[i][j]): + if not isnan(direction_data[i][j]): degree = math.radians(direction_data[i][j]) center = utm_east[j],utm_north[i] start = [center[0], center[1] + size/2] start = [center[0] + (start[0]-center[0]) * math.cos(degree) - (start[1]-center[1]) * math.sin(degree), center[1] + (start[0]-center[0]) * math.sin(degree) - (start[1]-center[1]) * math.cos(degree)] end = [center[0] , center[1] - size / 2] end = [center[0] + (end[0]-center[0]) * math.cos(degree) - (end[1]-center[1]) * math.sin(degree), center[1] + (end[0]-center[0]) * math.sin(degree) - (end[1]-center[1]) * math.cos(degree)] - color = cmap(data[i][j]/ max([max(k) for k in data])) - self.weather_map.add_arrow(self.draw_arrow(start,end,color=str(colors.rgb2hex(color, keep_alpha=True)),head_size=size/4, width=size/20,fill=True)) + color = "black" if draw_default else str(colors.rgb2hex(cmap(data[i][j]/ max([max(k) for k in data])), keep_alpha=True)) + self.weather_map.add_arrow(self.draw_arrow(start,end,color=color,head_size=size/4, width=size/20,fill=True)) def _draw_weather_heatmap(self, weather_data: str, cmap: colors.Colormap, label_colour: str) -> None: """ From 8c28bb426cd661d95bf64c093b7eb2704de75add Mon Sep 17 00:00:00 2001 From: Konrad Drozd <94637117+SanityRemnants@users.noreply.github.com> Date: Sun, 1 Sep 2024 15:54:48 +0200 Subject: [PATCH 58/84] Weather + UI integration --- seacharts/config.yaml | 50 +++++----- seacharts/config_schema.yaml | 13 ++- seacharts/core/scope.py | 3 +- seacharts/core/time.py | 9 +- seacharts/display/display.py | 155 +++++++++++++++++++++---------- seacharts/environment/weather.py | 38 +++++--- 6 files changed, 181 insertions(+), 87 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 3c1915b..4c1e22d 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,33 +1,41 @@ enc: -# test config for S-57 - - size: [ 6.0, 4.5 ] - origin: [ -85.0, 20.0 ] - depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] - crs: "WGS84" - S57_layers: - "TSSLPT": "#8B0000" -# "CTNARE": "#964B00" - resources: [ "data/db/US1GC09M" ] - - weather: + # test config for S-57 + + size: [ 6.0, 4.5 ] + origin: [ -85.0, 20.0 ] + depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] + crs: "WGS84" + S57_layers: + "TSSLPT": "#8B0000" + # "CTNARE": "#964B00" + resources: [ "data/db/US1GC09M" ] + + weather: PyThor_address: "http://127.0.0.1:5000" - variables: [ "wind_speed", "wind_direction" ] + variables: [ "wave_direction", + "wave_height", + "wave_period", + "wind_direction", + "wind_speed", + "sea_current_speed", + "sea_current_direction", + "tide_height" ] -# test config for FGDB + # test config for FGDB -# size: [ 9000, 5062 ] -# center: [ 44300, 6956450 ] -# depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] -# crs: "UTM33N" -# resources: [ "data/db/More_og_Romsdal_utm33.gdb" ] + # size: [ 9000, 5062 ] + # center: [ 44300, 6956450 ] + # depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] + # crs: "UTM33N" + # resources: [ "data/db/More_og_Romsdal_utm33.gdb" ] - time: + time: time_start: "25-08-2024 09:00" - time_end: "25-08-2024 13:00" + time_end: "25-08-2024 11:00" period: "hour" + period_multiplier: 1 display: diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index 84977c9..86eae54 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -78,7 +78,15 @@ enc: required: False schema: type: string - + allowed: + - wave_direction + - wave_height + - wave_period + - wind_direction + - wind_speed + - sea_current_speed + - sea_current_direction + - tide_height time: required: False type: dict @@ -100,6 +108,9 @@ enc: - week - month - year + period_multiplier: + required: True + type: float display: diff --git a/seacharts/core/scope.py b/seacharts/core/scope.py index 5564c60..5111dd6 100644 --- a/seacharts/core/scope.py +++ b/seacharts/core/scope.py @@ -33,7 +33,8 @@ def __init__(self, settings: dict): self.time = Time( time_start=time_config["time_start"], time_end=time_config["time_end"], - period=time_config["period"] + period=time_config["period"], + period_mult=time_config["period_multiplier"] ) else: self.time = None diff --git a/seacharts/core/time.py b/seacharts/core/time.py index ce9a592..4890192 100644 --- a/seacharts/core/time.py +++ b/seacharts/core/time.py @@ -1,8 +1,9 @@ from datetime import datetime, timedelta class Time: - def __init__(self, time_start: str, time_end: str, period: str): + def __init__(self, time_start: str, time_end: str, period: str, period_mult: float): # Parse the start and end dates + self.period_mult = period_mult self._date_string_format = "%d-%m-%Y %H:%M" self.time_start = datetime.strptime(time_start, self._date_string_format) self.time_end = datetime.strptime(time_end, self._date_string_format) @@ -26,11 +27,11 @@ def _generate_datetimes(self): def _increment_time(self, current_time): """Increment the datetime based on the specified period.""" if self.period == "hour": - return current_time + timedelta(hours=1) + return current_time + timedelta(hours=int(1 * self.period_mult)) elif self.period == "day": - return current_time + timedelta(days=1) + return current_time + timedelta(days=int(1 * self.period_mult)) elif self.period == "week": - return current_time + timedelta(weeks=1) + return current_time + timedelta(weeks=int(1 * self.period_mult)) elif self.period == "month": new_month = current_time.month + 1 if current_time.month < 12 else 1 new_year = current_time.year if current_time.month < 12 else current_time.year + 1 diff --git a/seacharts/display/display.py b/seacharts/display/display.py index ad93f71..3669c8a 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -33,6 +33,9 @@ class Display: ) def __init__(self, settings: dict, environment: env.Environment): + self._selected_weather = None + self.weather_map = None + self._cbar = None self._settings = settings self.crs = UTM(environment.scope.extent.utm_zone, southern_hemisphere=environment.scope.extent.southern_hemisphere) @@ -132,25 +135,22 @@ def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100) -> colors.LinearSegme return new_cmap def draw_weather(self, variable_name): + lat = self._environment.weather.latitude lon = self._environment.weather.longitude - - if variable_name == "wind": - weather_layer = self._environment.weather.find_by_name("wind_speed") - direction_layer = self._environment.weather.find_by_name("wind_direction") x_min, y_min, x_max, y_max = self._bbox - lat_min,lon_min = self._environment.scope.extent.convert_utm_to_lat_lon(x_min,y_min) + lat_min, lon_min = self._environment.scope.extent.convert_utm_to_lat_lon(x_min, y_min) lat_max, lon_max = self._environment.scope.extent.convert_utm_to_lat_lon(x_max, y_max) if lon_min < 0: lon_min = 180 + (180 + lon_min) if lon_max < 0: lon_max = 180 + (180 + lon_max) - lat_indxes = [None,None] + lat_indxes = [None, None] for i in range(len(lat)): if lat[i] >= lat_min and lat_indxes[0] is None: lat_indxes[0] = i - if lat[len(lat)-(i+1)] <= lat_max and lat_indxes[1] is None: + if lat[len(lat) - (i + 1)] <= lat_max and lat_indxes[1] is None: lat_indxes[1] = len(lat) - i if None not in lat_indxes: break @@ -158,14 +158,14 @@ def draw_weather(self, variable_name): for i in range(len(lon)): if lon[i] >= lon_min and lon_indxes[0] is None: lon_indxes[0] = i - if lon[len(lon)-(i + 1)] <= lon_max and lon_indxes[1] is None: + if lon[len(lon) - (i + 1)] <= lon_max and lon_indxes[1] is None: lon_indxes[1] = len(lon) - i if None not in lon_indxes: break weather_layer = None direction_layer = None - + self._selected_weather = variable_name match variable_name: case "wind": weather_layer = self._environment.weather.find_by_name("wind_speed") @@ -182,17 +182,21 @@ def draw_weather(self, variable_name): else: weather_layer = self._environment.weather.find_by_name(variable_name) - # TODO choose correct display for variables data = None if weather_layer is not None: - data = [x[lon_indxes[0]:lon_indxes[1]] for x in weather_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]]] + data = [x[lon_indxes[0]:lon_indxes[1]] for x in + weather_layer.weather[self._environment.weather.selected_time_index].data[ + lat_indxes[0]:lat_indxes[1]]] if direction_layer is not None: - direction_data = [x[lon_indxes[0]:lon_indxes[1]] for x in direction_layer.weather[self._environment.weather.selected_time_index].data[lat_indxes[0]:lat_indxes[1]]] - self._draw_arrow_map(direction_data,data,latitudes=lat[lat_indxes[0]:lat_indxes[1]],longitude=lon[lon_indxes[0]:lon_indxes[1]]) + direction_data = [x[lon_indxes[0]:lon_indxes[1]] for x in + direction_layer.weather[self._environment.weather.selected_time_index].data[ + lat_indxes[0]:lat_indxes[1]]] + self._draw_arrow_map(direction_data, data, latitudes=lat[lat_indxes[0]:lat_indxes[1]], + longitude=lon[lon_indxes[0]:lon_indxes[1]]) elif data is not None: self._draw_weather_heatmap(weather_layer.weather[self._environment.weather.selected_time_index].data, - cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), label_colour='white' if self._dark_mode else 'black') + cmap=self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9), ) class ArrowMap: arrows: list @@ -207,17 +211,18 @@ def remove(self): for arrow in self.arrows: arrow.remove() - def _draw_arrow_map(self,direction_data,data,latitudes,longitude): + def _draw_arrow_map(self, direction_data, data, latitudes, longitude): cmap = self.truncate_colormap(plt.get_cmap('jet'), 0.35, 0.9) utm_east = [0] * len(longitude) utm_north = [0] * len(latitudes) - print(self._environment.scope.extent.origin, (self._environment.scope.extent.center[0] + 700, self._environment.scope.extent.center[1] + 600)) for i in range(len(latitudes)): - utm_east[0], utm_north[i] = self._environment.scope.extent.convert_lat_lon_to_utm(latitudes[i], longitude[0]) + utm_east[0], utm_north[i] = self._environment.scope.extent.convert_lat_lon_to_utm(latitudes[i], + longitude[0]) for i in range(len(longitude)): - utm_east[i], utm_north[0] = self._environment.scope.extent.convert_lat_lon_to_utm(latitudes[0], longitude[i]) - print(utm_east,utm_north) - size = (abs(utm_east[1] - utm_east[0]) if len(utm_east) > 1 else (abs(utm_north[1] - utm_north[0]) if len(utm_north) > 1 else abs(self._bbox[0] - self._bbox[2]))) * 0.9 + utm_east[i], utm_north[0] = self._environment.scope.extent.convert_lat_lon_to_utm(latitudes[0], + longitude[i]) + size = (abs(utm_east[1] - utm_east[0]) if len(utm_east) > 1 else ( + abs(utm_north[1] - utm_north[0]) if len(utm_north) > 1 else abs(self._bbox[0] - self._bbox[2]))) * 0.9 self.weather_map = self.ArrowMap() if direction_data is None: return @@ -228,36 +233,64 @@ def _draw_arrow_map(self,direction_data,data,latitudes,longitude): from math import isnan if not isnan(direction_data[i][j]): degree = math.radians(direction_data[i][j]) - center = utm_east[j],utm_north[i] - start = [center[0], center[1] + size/2] - start = [center[0] + (start[0]-center[0]) * math.cos(degree) - (start[1]-center[1]) * math.sin(degree), center[1] + (start[0]-center[0]) * math.sin(degree) - (start[1]-center[1]) * math.cos(degree)] - end = [center[0] , center[1] - size / 2] - end = [center[0] + (end[0]-center[0]) * math.cos(degree) - (end[1]-center[1]) * math.sin(degree), center[1] + (end[0]-center[0]) * math.sin(degree) - (end[1]-center[1]) * math.cos(degree)] - color = "black" if draw_default else str(colors.rgb2hex(cmap(data[i][j]/ max([max(k) for k in data])), keep_alpha=True)) - self.weather_map.add_arrow(self.draw_arrow(start,end,color=color,head_size=size/4, width=size/20,fill=True)) + center = utm_east[j], utm_north[i] + start = [center[0], center[1] + size / 2] + start = [center[0] + (start[0] - center[0]) * math.cos(degree) - (start[1] - center[1]) * math.sin( + degree), + center[1] + (start[0] - center[0]) * math.sin(degree) - (start[1] - center[1]) * math.cos( + degree)] + end = [center[0], center[1] - size / 2] + end = [ + center[0] + (end[0] - center[0]) * math.cos(degree) - (end[1] - center[1]) * math.sin(degree), + center[1] + (end[0] - center[0]) * math.sin(degree) - (end[1] - center[1]) * math.cos(degree)] + color = "black" if draw_default else str( + colors.rgb2hex(cmap(data[i][j] / max([max(k) for k in data])), keep_alpha=True)) + self.weather_map.add_arrow( + self.draw_arrow(start, end, color=color, head_size=size / 4, width=size / 20, fill=True)) + if not draw_default: + self._draw_cbar(data, cmap) + else: + self._cbar = None - def _draw_weather_heatmap(self, weather_data: str, cmap: colors.Colormap, label_colour: str) -> None: + def _draw_weather_heatmap(self, data, cmap: colors.Colormap) -> None: """ Draws a heatmap and colorbar for specified weather variable using provided color map and label colour for color bar :return: None """ - if weather_data is None: + if data is None: return x_min, y_min, x_max, y_max = self._bbox extent = (x_min, x_max, y_min, y_max) - heatmap_data = np.array(weather_data) + heatmap_data = np.array(data) lon = np.linspace(x_min, x_max, heatmap_data.shape[1]) lat = np.linspace(y_min, y_max, heatmap_data.shape[0]) lon, lat = np.meshgrid(lon, lat) - ticks = np.linspace(np.nanmin(np.array(heatmap_data)), np.nanmax(np.array(heatmap_data)), num=10) self.weather_map = self.axes.pcolormesh(lon, lat, heatmap_data, cmap=cmap, alpha=0.5, transform=self.crs) self.axes.set_extent(extent, crs=self.crs) - self._cbar = self.figure.colorbar(self.weather_map, ax=self.axes, shrink=0.7) + self._draw_cbar(data, cmap) + + def _draw_cbar(self, data, cmap): + min_v, max_v = float(np.nanmin(np.array(data))), float(np.nanmax(np.array(data))) + norm = plt.Normalize(min_v, max_v) + label_colour = 'white' if self._dark_mode else 'black' + ticks = np.linspace(min_v, max_v, num=10) + if self._selected_weather not in ["wind","wave","sea_current"]: + label = self._selected_weather + else: + label_dict = { + "wind": "wind_speed", + "wave": "wave_height", + "sea_current": "sea_current_speed" + } + label = label_dict[self._selected_weather] + self._cbar = self.figure.colorbar(plt.cm.ScalarMappable(norm, cmap), ax=self.axes, shrink=0.7, + use_gridspec=True, orientation="horizontal", label=label, + pad=0.05) self._cbar.ax.yaxis.set_tick_params(color=label_colour) - self._cbar.set_ticks(ticks) self._cbar.outline.set_edgecolor(label_colour) + self._cbar.set_ticks(ticks) plt.setp(plt.getp(self._cbar.ax.axes, 'yticklabels'), color=label_colour) def draw_arrow( @@ -633,18 +666,24 @@ def _is_active(self): def _terminate(self): plt.close(self.figure) - def _weather_slider_handle(self,val): + def _weather_slider_handle(self, val): self._environment.weather.selected_time_index = val - # self._cbar.remove() - self.weather_map.remove() - self.draw_weather("wind") + if self._cbar is not None: + self._cbar.remove() + gs = self.grid_spec # Recreate GridSpec without the colorbar space + self.axes.set_position(gs[0].get_position(self.figure)) # Adjust the position of the main plot + self.axes.set_subplotspec(gs[0]) # Update the subplot spec + if self.weather_map is not None: + self.weather_map.remove() + if self._selected_weather is not None: + self.draw_weather(self._selected_weather) self.redraw_plot() def _add_time_slider(self, ax_slider, fig): times = self._environment.scope.time.get_datetimes_strings() self.slider = Slider(ax=ax_slider, valmin=0, valmax=len(times) - 1, valinit=0, valstep=1, label="Time") self.time_label = ax_slider.text(0.5, 1.2, times[0], transform=ax_slider.transAxes, - ha='center', va='center', fontsize=12) + ha='center', va='center', fontsize=12) last_value = self.slider.val def __on_slider_change(event): @@ -662,28 +701,46 @@ def __update(val): fig.canvas.mpl_connect('button_release_event', __on_slider_change) self.slider.on_changed(__update) - def add_control_panel(self, controls: bool): + radio_labels = ['--'] + self._environment.weather.weather_names + if "wind_speed" and "wind_direction" in radio_labels: + radio_labels.append("wind") + if "wave_height" and "wave_direction" in radio_labels: + radio_labels.append("wave") + if "sea_current_speed" and "sea_current_direction" in radio_labels: + radio_labels.append("sea_current") if not controls: return - fig, (ax_slider, ax_radio) = plt.subplots(2, 1, figsize=(8, 2), gridspec_kw={'height_ratios': [1, 2]}) + fig, (ax_slider, ax_radio) = plt.subplots(2, 1, figsize=(8, 1 * len(radio_labels)), gridspec_kw={'height_ratios': [1, 2]}) if self._environment.scope.time is not None: self._add_time_slider(ax_slider=ax_slider, fig=fig) - + # VISIBLE LAYER PICKER START - # if weather layers is not None -> add_radio_pick for weather layers - radio_labels = ['a', 'b', 'c'] - self.radio_buttons = RadioButtons(ax_radio, ['--'] + radio_labels, active=0) + # if weather layers is not None -> add_radio_pick for weather layers + + self.radio_buttons = RadioButtons(ax_radio,radio_labels, active=0) + def on_radio_change(label): - print(f"Radio button selected: {label}") - # Add handling code for radio button change here + self._selected_weather = label if label != '--' else None + if self._cbar is not None: + self._cbar.remove() + gs = self.grid_spec # Recreate GridSpec without the colorbar space + self.axes.set_position(gs[0].get_position(self.figure)) # Adjust the position of the main plot + self.axes.set_subplotspec(gs[0]) # Update the subplot spec + if self.weather_map is not None: + self.weather_map.remove() + self._cbar = None + self.weather_map = None + if self._selected_weather is not None: + self.draw_weather(label) + self.redraw_plot() + self.radio_buttons.on_clicked(on_radio_change) # VISIBLE LAYER PICKER END - + # TODO: layer picked in such way should be saved to variable # then we can add, analogically to date slider, opacity slider for picked weather layer - + # Set the window title and show the figure fig.canvas.manager.set_window_title('Controls') fig.show() - diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index 1528314..2dac660 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -9,6 +9,14 @@ from seacharts.layers import VirtualWeatherLayer, WeatherLayer from .collection import DataCollection +time_dict = { + "hour": 60, + "day": 60 * 24, + "week": 60 * 24 * 7, + "month": None, + "year": None, +} + @dataclass class WeatherData(DataCollection): @@ -23,12 +31,13 @@ def __post_init__(self): if self.scope.weather: self.verify_scope() unformatted_data = self.fetch_data(self.scope.weather.copy()) - self.parse_data(unformatted_data) + if unformatted_data is not None: + self.parse_data(unformatted_data) def verify_scope(self): ... - def fetch_data(self,query_dict) -> dict: + def fetch_data(self, query_dict) -> dict: """ fetch data from PyThor service :param query_dict: Dict with API query data @@ -46,15 +55,22 @@ def fetch_data(self,query_dict) -> dict: api_query = api_query[:-1] + "&" api_query = api_query[:-1] x_min, y_min, x_max, y_max = self.scope.extent.bbox - latitude_start,longitude_start = self.scope.extent.convert_utm_to_lat_lon(x_min,y_min) + latitude_start, longitude_start = self.scope.extent.convert_utm_to_lat_lon(x_min, y_min) latitude_end, longitude_end = self.scope.extent.convert_utm_to_lat_lon(x_max, y_max) - print(latitude_start,longitude_start,latitude_end, longitude_end) - api_query += "&latitude_start="+str(latitude_start-0.5 if latitude_start-0.5 >= -90 else -90) - api_query += "&longitude_start=" + str(longitude_start-0.5 if longitude_start-0.5 >= -180 else -180) - api_query += "&latitude_end=" + str(latitude_end+0.5 if latitude_end+0.5 <= 90 else 90) - api_query += "&longitude_end=" + str(longitude_end+0.5 if longitude_end+0.5 <= 180 else 180) - api_query += "&time_start="+ str(self.scope.time.epoch_times[0]) + "&time_end=" + str(self.scope.time.epoch_times[-1]) - print(api_query) + api_query += "&latitude_start=" + str(latitude_start - 0.5 if latitude_start - 0.5 >= -90 else -90) + api_query += "&longitude_start=" + str(longitude_start - 0.5 if longitude_start - 0.5 >= -180 else -180) + api_query += "&latitude_end=" + str(latitude_end + 0.5 if latitude_end + 0.5 <= 90 else 90) + api_query += "&longitude_end=" + str(longitude_end + 0.5 if longitude_end + 0.5 <= 180 else 180) + api_query += "&time_start=" + str(self.scope.time.epoch_times[0]) + "&time_end=" + str( + self.scope.time.epoch_times[-1] + 3600) + if time_dict[self.scope.time.period] is not None: + api_query += "&interval=" + str(int(time_dict[self.scope.time.period] * self.scope.time.period_mult)) + else: + import warnings + warnings.warn( + "Specified time period is not supported by the weather module this may lead to unspecified behaviour. Weather won't be downloaded") + return None + print("PyThor request:" + api_query) return requests.get(api_query).json() def parse_data(self, data: dict) -> None: @@ -77,7 +93,7 @@ def parse_data(self, data: dict) -> None: def layers(self) -> list[VirtualWeatherLayer]: return self.weather_layers - def find_by_name(self,name:str) -> VirtualWeatherLayer: + def find_by_name(self, name: str) -> VirtualWeatherLayer: for layer in self.layers: if layer.name == name: return layer From 311c432e14707824e5449be297164d5f703e64fb Mon Sep 17 00:00:00 2001 From: miqn Date: Sun, 1 Sep 2024 22:18:32 +0200 Subject: [PATCH 59/84] some stylistic changes --- seacharts/config.yaml | 3 +-- seacharts/display/display.py | 6 +++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 4c1e22d..ec6892a 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,5 +1,4 @@ enc: - # test config for S-57 size: [ 6.0, 4.5 ] origin: [ -85.0, 20.0 ] @@ -7,7 +6,7 @@ enc: crs: "WGS84" S57_layers: "TSSLPT": "#8B0000" - # "CTNARE": "#964B00" + "CTNARE": "#964B00" resources: [ "data/db/US1GC09M" ] weather: diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 3669c8a..b55c080 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -711,7 +711,11 @@ def add_control_panel(self, controls: bool): if "sea_current_speed" and "sea_current_direction" in radio_labels: radio_labels.append("sea_current") if not controls: return - fig, (ax_slider, ax_radio) = plt.subplots(2, 1, figsize=(8, 1 * len(radio_labels)), gridspec_kw={'height_ratios': [1, 2]}) + + slider_height = 0.2 + fig_height = 1 * len(radio_labels) + slider_height + fig, (ax_slider, ax_radio) = plt.subplots(2, 1, figsize=(8, fig_height), height_ratios=[slider_height, 1]) + if self._environment.scope.time is not None: self._add_time_slider(ax_slider=ax_slider, fig=fig) From db447c73f8fc19a632bece9cffaa1e5bf4b9c75c Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Tue, 24 Sep 2024 18:20:23 +0200 Subject: [PATCH 60/84] added unit display to colorbar --- seacharts/config.yaml | 7 ++++--- seacharts/display/display.py | 13 ++++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 4c1e22d..bc46543 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -19,7 +19,8 @@ enc: "wind_speed", "sea_current_speed", "sea_current_direction", - "tide_height" ] + "tide_height" + ] @@ -32,8 +33,8 @@ enc: # resources: [ "data/db/More_og_Romsdal_utm33.gdb" ] time: - time_start: "25-08-2024 09:00" - time_end: "25-08-2024 11:00" + time_start: "24-09-2024 05:00" + time_end: "24-09-2024 09:00" period: "hour" period_multiplier: 1 diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 3669c8a..3770c7a 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -285,8 +285,19 @@ def _draw_cbar(self, data, cmap): "sea_current": "sea_current_speed" } label = label_dict[self._selected_weather] + label_units = { + "sea_current_speed": "[m/s]", + "sea_current_direction": "[deg]", + "wave_height": "[m]", + "wave_direction": "[deg]", + "wave_period": "[s]", + "wind_speed": "[m/s]", + "wind_direction": "[deg]", + "tide_height": "[m]", + + } self._cbar = self.figure.colorbar(plt.cm.ScalarMappable(norm, cmap), ax=self.axes, shrink=0.7, - use_gridspec=True, orientation="horizontal", label=label, + use_gridspec=True, orientation="horizontal", label=f"{label} {label_units[label]}", pad=0.05) self._cbar.ax.yaxis.set_tick_params(color=label_colour) self._cbar.outline.set_edgecolor(label_colour) From 9eb9dcecf38252e4b2ee1b39547f3f86b7b5d4ea Mon Sep 17 00:00:00 2001 From: Konrad Drozd <94637117+SanityRemnants@users.noreply.github.com> Date: Mon, 30 Sep 2024 12:03:30 +0200 Subject: [PATCH 61/84] Fix weather access --- seacharts/display/display.py | 15 ++++++++++++--- seacharts/enc.py | 7 ++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 117c90e..006e90e 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -276,7 +276,7 @@ def _draw_cbar(self, data, cmap): norm = plt.Normalize(min_v, max_v) label_colour = 'white' if self._dark_mode else 'black' ticks = np.linspace(min_v, max_v, num=10) - if self._selected_weather not in ["wind","wave","sea_current"]: + if self._selected_weather not in ["wind", "wave", "sea_current"]: label = self._selected_weather else: label_dict = { @@ -299,9 +299,9 @@ def _draw_cbar(self, data, cmap): self._cbar = self.figure.colorbar(plt.cm.ScalarMappable(norm, cmap), ax=self.axes, shrink=0.7, use_gridspec=True, orientation="horizontal", label=f"{label} {label_units[label]}", pad=0.05) - self._cbar.ax.yaxis.set_tick_params(color=label_colour) - self._cbar.outline.set_edgecolor(label_colour) self._cbar.set_ticks(ticks) + self._cbar.outline.set_edgecolor(label_colour) + self._cbar.ax.yaxis.set_tick_params(color=label_colour) plt.setp(plt.getp(self._cbar.ax.axes, 'yticklabels'), color=label_colour) def draw_arrow( @@ -618,6 +618,15 @@ def _toggle_dark_mode(self, state=None): self._colorbar.ax.set_facecolor(color) self.features.toggle_topography_visibility(not state) self._dark_mode = state + if self._cbar is not None: + self._cbar.remove() + gs = self.grid_spec # Recreate GridSpec without the colorbar space + self.axes.set_position(gs[0].get_position(self.figure)) # Adjust the position of the main plot + self.axes.set_subplotspec(gs[0]) # Update the subplot spec + if self.weather_map is not None: + self.weather_map.remove() + if self._selected_weather is not None: + self.draw_weather(self._selected_weather) self.redraw_plot() def _toggle_colorbar(self, state=None): diff --git a/seacharts/enc.py b/seacharts/enc.py index 0dcb64a..713b599 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -6,7 +6,8 @@ from seacharts.core import Config from seacharts.display import Display from seacharts.environment import Environment -from seacharts.layers import Layer, VirtualWeatherLayer +from seacharts.environment.weather import WeatherData +from seacharts.layers import Layer class ENC: @@ -121,5 +122,5 @@ def weather_names(self) -> list[str]: return self._environment.weather.weather_names @property - def weather_data(self, name) -> VirtualWeatherLayer: - return self._environment.weather.find_by_name(name) + def weather_data(self) -> WeatherData: + return self._environment.weather From 6c1d90787b2fadee74c3ae9a019e732c5d77e7ee Mon Sep 17 00:00:00 2001 From: miqn Date: Mon, 30 Sep 2024 15:23:29 +0200 Subject: [PATCH 62/84] path fix and label for slider deleted --- seacharts/core/parser.py | 2 +- seacharts/display/display.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index 292a25e..ac62561 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -20,7 +20,7 @@ def __init__( ): self.bounding_box = bounding_box self.paths = set([p.resolve() for p in (map(Path, path_strings))]) - self.paths.update(paths.default_resources) + # self.paths.update(paths.default_resources) @staticmethod def _shapefile_path(label): diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 117c90e..7bc3b25 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -695,6 +695,8 @@ def _add_time_slider(self, ax_slider, fig): self.slider = Slider(ax=ax_slider, valmin=0, valmax=len(times) - 1, valinit=0, valstep=1, label="Time") self.time_label = ax_slider.text(0.5, 1.2, times[0], transform=ax_slider.transAxes, ha='center', va='center', fontsize=12) + self.slider.valtext.set_text("") + last_value = self.slider.val def __on_slider_change(event): From ce727dd0524cbd5fa4805948d8bb2eff9bd599a1 Mon Sep 17 00:00:00 2001 From: miqn Date: Tue, 1 Oct 2024 12:30:25 +0200 Subject: [PATCH 63/84] shallow waters are not underlapping whole map now --- seacharts/core/parserS57.py | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index 1373c38..ef0619a 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -22,8 +22,8 @@ def get_source_root_name(self) -> str: path = self.get_s57_file_path(path) if path is not None: return path.stem - - + + @staticmethod def convert_s57_to_utm_shapefile(s57_file_path, shapefile_output_path, layer, epsg, bounding_box): x_min, y_min, x_max, y_max = map(str, bounding_box) @@ -44,14 +44,17 @@ def convert_s57_to_utm_shapefile(s57_file_path, shapefile_output_path, layer, ep print(f"Error during conversion: {e}") @staticmethod - def convert_s57_depth_to_utm_shapefile(s57_file_path, shapefile_output_path, depth, epsg:str, bounding_box): + def convert_s57_depth_to_utm_shapefile(s57_file_path, shapefile_output_path, depth, epsg:str, bounding_box, next_depth = None): x_min, y_min, x_max, y_max = map(str, bounding_box) + query = f'SELECT * FROM DEPARE WHERE DRVAL1 >= {depth.__str__()}' + if next_depth is not None: + query += f' AND DRVAL1 < {next_depth.__str__()}' ogr2ogr_cmd = [ 'ogr2ogr', '-f', 'ESRI Shapefile', # Output format shapefile_output_path, # Output shapefile s57_file_path, # Input S57 file - '-sql', 'SELECT * FROM DEPARE WHERE DRVAL1 >= ' + depth.__str__(), + '-sql', query, '-t_srs', epsg.upper(), '-clipdst', x_min, y_min, x_max, y_max, '-skipfailures' @@ -84,13 +87,27 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: s57_path = self.get_s57_file_path(path) s57_path = str(s57_path) - for region in regions_list: + seabeds = [region for region in regions_list if isinstance(region, Seabed)] + rest_of_regions = [region for region in regions_list if not isinstance(region, Seabed)] + + for index, region in enumerate(seabeds): start_time = time.time() dest_path = os.path.join(self._shapefile_dir_path(region.label), region.label + ".shp") - - if isinstance(region, Seabed): + if index < len(seabeds) - 1: + next_depth = seabeds[index + 1].depth + self.convert_s57_depth_to_utm_shapefile(s57_path, dest_path, region.depth, self.epsg, self.bounding_box, next_depth) + else: self.convert_s57_depth_to_utm_shapefile(s57_path, dest_path, region.depth, self.epsg, self.bounding_box) - elif isinstance(region, Land): + records = list(self._read_shapefile(region.label)) + region.records_as_geometry(records) + end_time = round(time.time() - start_time, 1) + print(f"\rSaved {region.name} to shapefile in {end_time} s.") + + for region in rest_of_regions: + start_time = time.time() + dest_path = os.path.join(self._shapefile_dir_path(region.label), region.label + ".shp") + + if isinstance(region, Land): self.convert_s57_to_utm_shapefile(s57_path, dest_path, "LNDARE", self.epsg, self.bounding_box) elif isinstance(region, Shore): self.convert_s57_to_utm_shapefile(s57_path, dest_path, "COALNE", self.epsg, self.bounding_box) From a15aca805879f6674841105d4ddc5e22b700b266 Mon Sep 17 00:00:00 2001 From: miqn Date: Tue, 1 Oct 2024 13:37:24 +0200 Subject: [PATCH 64/84] loading shapefiles simplified --- seacharts/core/parser.py | 7 +++---- seacharts/core/parserS57.py | 6 ++---- seacharts/environment/collection.py | 3 ++- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index ac62561..56293c2 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -49,10 +49,9 @@ def _read_shapefile(self, label: str) -> Generator: if file_path.exists(): yield from self._read_spatial_file(file_path) - 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 load_shapefiles(self, layer: Layer) -> None: + records = list(self._read_shapefile(layer.label)) + layer.records_as_geometry(records) # main method for parsing corresponding map format @abstractmethod diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index ef0619a..617ba38 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -98,8 +98,7 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: self.convert_s57_depth_to_utm_shapefile(s57_path, dest_path, region.depth, self.epsg, self.bounding_box, next_depth) else: self.convert_s57_depth_to_utm_shapefile(s57_path, dest_path, region.depth, self.epsg, self.bounding_box) - records = list(self._read_shapefile(region.label)) - region.records_as_geometry(records) + self.load_shapefiles(region) end_time = round(time.time() - start_time, 1) print(f"\rSaved {region.name} to shapefile in {end_time} s.") @@ -114,8 +113,7 @@ def parse_resources(self, regions_list: list[Layer], resources: list[str], area: else: self.convert_s57_to_utm_shapefile(s57_path, dest_path, region.name, self.epsg, self.bounding_box) - records = list(self._read_shapefile(region.label)) - region.records_as_geometry(records) + self.load_shapefiles(region) end_time = round(time.time() - start_time, 1) print(f"\rSaved {region.name} to shapefile in {end_time} s.") diff --git a/seacharts/environment/collection.py b/seacharts/environment/collection.py index 922d1a1..3f3a7ac 100644 --- a/seacharts/environment/collection.py +++ b/seacharts/environment/collection.py @@ -32,7 +32,8 @@ def loaded(self) -> bool: @dataclass class ShapefileBasedCollection(DataCollection, ABC): def load_existing_shapefiles(self) -> None: - self.parser.load_shapefiles(self.featured_regions) + for region in self.featured_regions: + self.parser.load_shapefiles(region) if self.loaded: print("INFO: ENC created using data from existing shapefiles.\n") else: From 28e4a73e67639b73b37502cccf65a865819a7c35 Mon Sep 17 00:00:00 2001 From: Natalia Czapla Date: Wed, 2 Oct 2024 15:46:10 +0200 Subject: [PATCH 65/84] get records --- seacharts/config.yaml | 22 +++++++++++----------- seacharts/core/parser.py | 10 +++++++++- seacharts/layers/layer.py | 4 ++-- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 602dd8b..4abc487 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -9,17 +9,17 @@ enc: "CTNARE": "#964B00" resources: [ "data/db/US1GC09M" ] - weather: - PyThor_address: "http://127.0.0.1:5000" - variables: [ "wave_direction", - "wave_height", - "wave_period", - "wind_direction", - "wind_speed", - "sea_current_speed", - "sea_current_direction", - "tide_height" - ] + # weather: + # PyThor_address: "http://127.0.0.1:5000" + # variables: [ "wave_direction", + # "wave_height", + # "wave_period", + # "wind_direction", + # "wind_speed", + # "sea_current_speed", + # "sea_current_direction", + # "tide_height" + # ] diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index 56293c2..b7251b3 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -52,7 +52,15 @@ def _read_shapefile(self, label: str) -> Generator: def load_shapefiles(self, layer: Layer) -> None: records = list(self._read_shapefile(layer.label)) layer.records_as_geometry(records) - + layer.records= records + #if layer.name=='TSSLPT': + #print(records[0]['properties']['ORIENT']) + # tss_orients={} + # for r in records: + # tss_orients[r['id']]: records[r]['properties']['ORIENT'] + # #print(records[0]['properties']['ORIENT']) + # print(tss_orients) + # main method for parsing corresponding map format @abstractmethod def parse_resources( diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py index a03cc1e..ef6d495 100644 --- a/seacharts/layers/layer.py +++ b/seacharts/layers/layer.py @@ -16,7 +16,7 @@ class Layer(Shape, ABC): geometry: geobase.BaseMultipartGeometry = field(default_factory=geo.MultiPolygon) depth: int = None - + @property def label(self) -> str: return self.name.lower() @@ -53,7 +53,7 @@ def records_as_geometry(self, records: list[dict]) -> None: elif len(linestrings) + len(multi_linestrings) > 0: self.geometry = self._geometries_to_multi(multi_linestrings, linestrings, geo.MultiLineString) - + def unify(self, records: list[dict]) -> None: geometries = [self._record_to_geometry(r) for r in records] self.geometry = self.collect(geometries) From 15da5acd92114034431cead2df9e3069c77d5a79 Mon Sep 17 00:00:00 2001 From: Natalia Czapla Date: Wed, 2 Oct 2024 15:47:49 +0200 Subject: [PATCH 66/84] get records --- seacharts/core/parser.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index b7251b3..72529a1 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -53,13 +53,6 @@ def load_shapefiles(self, layer: Layer) -> None: records = list(self._read_shapefile(layer.label)) layer.records_as_geometry(records) layer.records= records - #if layer.name=='TSSLPT': - #print(records[0]['properties']['ORIENT']) - # tss_orients={} - # for r in records: - # tss_orients[r['id']]: records[r]['properties']['ORIENT'] - # #print(records[0]['properties']['ORIENT']) - # print(tss_orients) # main method for parsing corresponding map format @abstractmethod From 01ca537973cda241a4ed82e646e3a21b341dc041 Mon Sep 17 00:00:00 2001 From: miqn Date: Wed, 2 Oct 2024 16:38:13 +0200 Subject: [PATCH 67/84] add warning for projection limit exceed --- seacharts/display/display.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 1e43ea6..efe90f1 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any +from colorama import Fore import matplotlib import matplotlib.pyplot as plt import numpy as np @@ -39,11 +40,7 @@ def __init__(self, settings: dict, environment: env.Environment): self._settings = settings self.crs = UTM(environment.scope.extent.utm_zone, southern_hemisphere=environment.scope.extent.southern_hemisphere) - - self._bbox = (max(environment.scope.extent.bbox[0], self.crs.x_limits[0]), # x-min - max(environment.scope.extent.bbox[1], self.crs.y_limits[0]), # y-min - min(environment.scope.extent.bbox[2], self.crs.x_limits[1]), # x-max - min(environment.scope.extent.bbox[3], self.crs.y_limits[1])) # y-max + self._bbox = self._set_bbox(environment) self._environment = environment self._background = None self._dark_mode = False @@ -67,6 +64,26 @@ def __init__(self, settings: dict, environment: env.Environment): else: self._set_figure_position() + def _set_bbox(self, environment: env.Environment) -> tuple[float, float, float, float]: + """ + Sets bounding box for the display taking projection's (crs's) x and y limits for display into account. + Making sure that bbox doesn't exceed limits prevents crashes. When such limit is exceeded, an appropriate message is displayed + to inform user about possibility of unexpeced display bound crop + """ + bbox = (max(environment.scope.extent.bbox[0], self.crs.x_limits[0]), # x-min + max(environment.scope.extent.bbox[1], self.crs.y_limits[0]), # y-min + min(environment.scope.extent.bbox[2], self.crs.x_limits[1]), # x-max + min(environment.scope.extent.bbox[3], self.crs.y_limits[1])) # y-max + changed = [] + for i in range(len(bbox)): + if (bbox[i] != environment.scope.extent.bbox[i]): + changed.append(i) + if len(changed)>0: + print(Fore.RED + f"WARNING: Bouding box for display has exceeded the limit of CRS axes and therefore been scaled down. Watch out for potentially cropped chart display!" + Fore.RESET) + for i in changed: + print(Fore.RED + f"index {i}: {environment.scope.extent.bbox[i]} changed to {bbox[i]}" + Fore.RESET) + return bbox + def start(self) -> None: self.started__ = """ Starts the display, if it is not already started. From 63105cf3d5bc10bd5bf192505cc065d625878fe5 Mon Sep 17 00:00:00 2001 From: miqn Date: Thu, 3 Oct 2024 11:48:31 +0200 Subject: [PATCH 68/84] . --- seacharts/core/files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seacharts/core/files.py b/seacharts/core/files.py index 4b5a779..d2679d8 100644 --- a/seacharts/core/files.py +++ b/seacharts/core/files.py @@ -21,7 +21,7 @@ def build_directory_structure(features: list[str], resources: list[str], parser: paths.shapefiles = paths.shapefiles / map_dir_name paths.output.mkdir(exist_ok=True) paths.shapefiles.mkdir(exist_ok=True) - shutil.copy(paths.config, paths.shapefiles) + shutil.copy(paths.config, paths.shapefiles) # used to save initial config for feature in features: shapefile_dir = paths.shapefiles / feature.lower() From 3983e824207098d4377a72316fb4c9944ee8456f Mon Sep 17 00:00:00 2001 From: miqn <109998643+meeqn@users.noreply.github.com> Date: Thu, 3 Oct 2024 13:54:16 +0200 Subject: [PATCH 69/84] import fix, comments clarified in config, config file for setting up conda (#21) --- conda_requirements.txt | 43 ++++++++++++++++++++++++++++++++++++ seacharts/config_schema.yaml | 17 +++++++------- seacharts/environment/map.py | 2 +- 3 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 conda_requirements.txt diff --git a/conda_requirements.txt b/conda_requirements.txt new file mode 100644 index 0000000..5bfcd7a --- /dev/null +++ b/conda_requirements.txt @@ -0,0 +1,43 @@ +# first, add conda forge to channel using: +# conda config --add channels conda-forge + +# when in directory with conda_requirements.txt file, use: +# conda create --name --file conda_requirements.txt + +# next, activate the environment (eg. select interpreter via Visual Studio Code UI or manually via CLI) + +attrs=23.2.0 +cartopy=0.22.0 +cerberus=1.3.5 +certifi=2024.8.30 +click=8.1.7 +click-plugins=1.1.1 +cligj=0.7.2 +colorama=0.4.6 +contourpy=1.2.0 +cycler=0.12.1 +fiona=1.9.6 +fonttools=4.50.0 +gdal=3.8.4 +ipython=8.26.0 +ipywidgets=8.1.5 +jedi=0.19.1 +js2py=0.74 +jupyterlab_widgets=3.0.13 +kiwisolver=1.4.5 +matplotlib=3.8.3 +matplotlib-scalebar=0.8.1 +numpy=1.26.4 +packaging=24.0 +parso=0.8.4 +pillow=10.2.0 +pyparsing=3.1.2 +pyproj=3.6.1 +pyshp=2.3.1 +python-dateutil=2.9.0 +pyyaml=6.0.1 +requests=2.31.0 +shapely=2.0.3 +six=1.16.0 +tzdata=2024a +widgetsnbextension=4.0.13 diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index 86eae54..727b070 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -2,12 +2,12 @@ enc: required: True type: dict schema: - #set True if you want to display whole uploaded map + # set True if you want to display whole uploaded map autosize: required: False type: boolean - #size of displayed map + # size of displayed map size: required: True type: list @@ -16,7 +16,7 @@ enc: type: float min: 1.0 - #that's where you want bottom-left corner to be + # that's where you want bottom-left corner to be origin: required: True excludes: center @@ -25,7 +25,7 @@ enc: schema: type: float - #that's where you want the center of a map to be + # that's where you want the center of a map to be center: required: True excludes: origin @@ -34,12 +34,12 @@ enc: schema: type: float - #UTM zone required for coordinates to work correctly + # UTM zone required for coordinates to work correctly crs: required: True type: string - #depths are required for both formats, if not set they will be assigned default values + # depths are required for both formats, if not set they will be assigned default values depths: required: False type: list @@ -48,7 +48,8 @@ enc: type: integer min: 0 - #you can pick specific S-57 layers you want to extract, default required is LNDARE, DEPARE and COALNE + # you can pick specific S-57 layers you want to extract + # WARNING: LNDARE, COALNE and DEPARE are loaded on default as Land, Shore and Bathymetry S57_layers: required: False type: dict @@ -58,7 +59,7 @@ enc: keysrules: type: string - #you must put paths to some resources + # you must put paths to some resources resources: required: True type: list diff --git a/seacharts/environment/map.py b/seacharts/environment/map.py index 3cecda0..89152ca 100644 --- a/seacharts/environment/map.py +++ b/seacharts/environment/map.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from seacharts.layers import Layer, Land, Shore, Seabed -from .collection import DataCollection, ShapefileBasedCollection +from .collection import ShapefileBasedCollection @dataclass From 117349f14e23c88351febbf9c0c43487472595b8 Mon Sep 17 00:00:00 2001 From: miqn <109998643+meeqn@users.noreply.github.com> Date: Thu, 3 Oct 2024 14:54:22 +0200 Subject: [PATCH 70/84] added some description for users running SC for the first time (#22) --- conda_requirements.txt | 4 ++-- seacharts/config.yaml | 24 ++++++++++-------------- setup.ps1 | 32 -------------------------------- tests/test_seacharts_4_0.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 48 deletions(-) create mode 100644 tests/test_seacharts_4_0.py diff --git a/conda_requirements.txt b/conda_requirements.txt index 5bfcd7a..1271e70 100644 --- a/conda_requirements.txt +++ b/conda_requirements.txt @@ -1,4 +1,4 @@ -# first, add conda forge to channel using: +# first, add conda-forge to channel using: # conda config --add channels conda-forge # when in directory with conda_requirements.txt file, use: @@ -40,4 +40,4 @@ requests=2.31.0 shapely=2.0.3 six=1.16.0 tzdata=2024a -widgetsnbextension=4.0.13 +widgetsnbextension=4.0.13 \ No newline at end of file diff --git a/seacharts/config.yaml b/seacharts/config.yaml index 4abc487..e52d57f 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -1,14 +1,20 @@ enc: - size: [ 6.0, 4.5 ] origin: [ -85.0, 20.0 ] depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] crs: "WGS84" S57_layers: - "TSSLPT": "#8B0000" - "CTNARE": "#964B00" + "TSSLPT": "#8B0000" # extra layer (traffic separation scheme with color we want to display it in) resources: [ "data/db/US1GC09M" ] + # old config, working with More og Romsdal map and SeaCharts 4.0 + # size: [ 9000, 5062 ] + # center: [ 44300, 6956450 ] + # depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] + # crs: "UTM33N" # remember to specify N or S for correct part of the globe + # resources: [ "data/db/More_og_Romsdal_utm33.gdb" ] + + # uncomment this section if PyThor is up and running # weather: # PyThor_address: "http://127.0.0.1:5000" # variables: [ "wave_direction", @@ -21,16 +27,6 @@ enc: # "tide_height" # ] - - - # test config for FGDB - - # size: [ 9000, 5062 ] - # center: [ 44300, 6956450 ] - # depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] - # crs: "UTM33N" - # resources: [ "data/db/More_og_Romsdal_utm33.gdb" ] - time: time_start: "24-09-2024 05:00" time_end: "24-09-2024 09:00" @@ -42,7 +38,7 @@ display: colorbar: False dark_mode: False fullscreen: False - controls: True + controls: True # use this for controls window to stop appearing resolution: 640 anchor: "center" dpi: 96 diff --git a/setup.ps1 b/setup.ps1 index 07bc5b0..8145a0b 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -5,35 +5,3 @@ New-Item -ItemType Directory -Path $baseDir -Force # Create 'shapefiles' and 'data' subdirectories inside 'db' New-Item -ItemType Directory -Path "$baseDir\shapefiles" -Force New-Item -ItemType Directory -Path "$baseDir\db" -Force - -# Create a virtual environment named 'venv' (if it doesn't exist) -python -m venv venv - -# Activate the virtual environment -& .\venv\Scripts\Activate.ps1 - -# Upgrade pip within the virtual environment -python -m pip install --upgrade pip - -# Install wheel package -pip install wheel - -# Install pipwin package -pip install pipwin - -# Use pipwin to install specific packages -pipwin install numpy -pipwin install gdal -pipwin install fiona -pipwin install shapely - -# Install additional Python packages -pip install cartopy -pip install setuptools -pip install requests -pip install pyyaml -pip install cerberus -pip install matplotlib-scalebar - -# Deactivate the virtual environment -Deactivate diff --git a/tests/test_seacharts_4_0.py b/tests/test_seacharts_4_0.py new file mode 100644 index 0000000..a1678a4 --- /dev/null +++ b/tests/test_seacharts_4_0.py @@ -0,0 +1,29 @@ +# file made to showcase functionalities added in seacharts 4.0 integrated within old seacharts functionalities +# for + +if __name__ == "__main__": + from seacharts import ENC + + enc = ENC() + enc.display.start() + + coords = [111000, 2482000] + layer_label = "TSSLPT" #requires TSSLPT listed in S-57 layers in config.yaml + + print(f"depth at coordinates {coords}: {enc.get_depth_at_coord(coords[0], coords[1])}") + print(f"coordinates {coords} in layer {layer_label}: {enc.is_coord_in_layer(coords[0], coords[1], 'TSSLPT')}") + + + center = enc.center + enc.display.draw_circle( + center, 20000, "yellow", thickness=2, edge_style="--", alpha=0.5 + ) + enc.display.draw_rectangle(center, (6000, 12000), "blue", rotation=20, alpha=0.5) + enc.display.draw_circle( + center, 10000, "green", edge_style=(0, (5, 8)), thickness=3, fill=False + ) + enc.display.draw_line( + [(center[0], center[1] + 800), center, (center[0] - 300, center[1] - 400)], + "white", + ) + enc.display.show() \ No newline at end of file From 293dbb927dc3610585eeda419d931411cb85a04b Mon Sep 17 00:00:00 2001 From: Natalia Czapla <126421760+nanatalia1@users.noreply.github.com> Date: Thu, 3 Oct 2024 14:55:24 +0200 Subject: [PATCH 71/84] Readme update (#20) * Update README.md - features * Update README.md * Update README.md - usage * Update README.md - image * image added * image * image * Update README.md * Update README.md --------- Co-authored-by: miqn <109998643+meeqn@users.noreply.github.com> --- README.md | 21 ++++++++++++++------- images/example3.png | Bin 0 -> 143681 bytes 2 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 images/example3.png diff --git a/README.md b/README.md index 775ecb8..a80596e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Python-based API for Electronic Navigational Charts (ENC) - Read and process spatial depth data from [FileGDB](https://gdal.org/drivers/vector/filegdb.html) files into shapefiles. +- Read and process spatial depth data from [S-57](https://gdal.org/en/latest/drivers/vector/s57.html) files into shapefiles. +- Visualize S-57 [layers](https://www.teledynecaris.com/s-57/frames/S57catalog.htm). - Access and manipulate standard geometric shapes such as points and polygon collections. - Visualize colorful seacharts features and vessels. @@ -103,13 +105,13 @@ or locally inside the SeaCharts root folder as an editable package with `pip ins ## Usage -This module supports reading and processing `FGDB` files for sea depth data, -such as the Norwegian coastal data set used for demonstration purposes, found -[here]( -https://kartkatalog.geonorge.no/metadata/2751aacf-5472-4850-a208-3532a51c529a). +This module supports reading and processing `FGDB` and 'S-57' files for sea depth data. -### Downloading regional datasets +### Downloading regional datasets - FGDB +The Norwegian coastal data set used for demonstration purposes, found +[here]( +https://kartkatalog.geonorge.no/metadata/2751aacf-5472-4850-a208-3532a51c529a). To visualize and access coastal data of Norway, follow the above link to download the `Depth data` (`Sjøkart - Dybdedata`) dataset from the [Norwegian Mapping Authority]( https://kartkatalog.geonorge.no/?organization=Norwegian%20Mapping%20Authority) by adding @@ -122,7 +124,7 @@ format. Finally, select your appropriate user group and purpose, and click ### Configuration and startup -Unpack the downloaded file(s) and place the extracted `.gdb` in a suitable location, +Unpack the downloaded file(s) and place the extracted `.gdb` or 'S-57' folder in a suitable location, in which the SeaCharts setup may be configured to search. The current working directory as well as the relative `data/` and `data/db/` folders are included by default. @@ -141,7 +143,7 @@ if __name__ == '__main__': enc.display.show() ``` -The `config.yaml` file specifies which file paths to open and which area to load. +The `config.yaml` file specifies which file paths to open and which area to load. In the configuration file the desired map type can be specified by listring data to display - depths for 'FDGB', and [layers](https://www.teledynecaris.com/s-57/frames/S57catalog.htm) for 'S-57'. The corresponding `config_schema.yaml` specifies how the required setup parameters must be provided, using `cerberus`. @@ -173,9 +175,14 @@ Note how custom settings may be set in a user-defined .yaml-file, if its path is provided to the ENC during initialization. One may also import and create an instance of the `seacharts.Config` dataclass, and provide it directly to the ENC. +### FGDB demonstration ![](images/example2.svg "Example visualization of vessels and a colorbar with depth values in light mode.") +### S-57 demonstration +![](images/example3.png "Example visualization of S-57 map with TSS layer and a +colorbar with depth values in light mode.") + ### Environment visualization The `ENC.start_display` method is used to show a Matplotlib figure plot of the loaded sea charts features. Zoom and pan the environment view using the mouse diff --git a/images/example3.png b/images/example3.png new file mode 100644 index 0000000000000000000000000000000000000000..407fb31ae5cfe2f984fa6009ccd3c2537e365efc GIT binary patch literal 143681 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%0A#bWu~XnHpgI0F<% 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Μ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 literal 0 HcmV?d00001 From e6b38a05e982eba6b9b814578dfbabde3da50f76 Mon Sep 17 00:00:00 2001 From: miqn <109998643+meeqn@users.noreply.github.com> Date: Thu, 3 Oct 2024 15:47:50 +0200 Subject: [PATCH 72/84] Display tweaks (#23) * added some description for users running SC for the first time * upgraded test file and parametrized coastline display --- seacharts/display/features.py | 24 +++++++++++++++++++----- tests/test_seacharts_4_0.py | 15 +++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/seacharts/display/features.py b/seacharts/display/features.py index 8bebd7a..6d70321 100644 --- a/seacharts/display/features.py +++ b/seacharts/display/features.py @@ -4,7 +4,7 @@ import shapely.geometry as geo from cartopy.feature import ShapelyFeature from matplotlib.lines import Line2D -from shapely.geometry import MultiLineString +from shapely.geometry import MultiLineString, MultiPolygon from seacharts import shapes, core from .colors import color_picker @@ -32,17 +32,32 @@ def _init_layers(self): color = color_picker(i, bins) self._seabeds[i] = self.assign_artist(seabed, self._get_next_z_order(), color) + # get two z-orders + z_orders = [self._get_next_z_order(), self._get_next_z_order()] shore = self._display._environment.map.shore + if isinstance(shore.geometry, MultiPolygon): + # if shore is multipolygon (for fgdb) we draw it beneath the land + shore_z_order = z_orders[0] + land_z_order = z_orders[1] + else: + # if shore is multilinestring we draw it as edges for land + shore_z_order = z_orders[1] + land_z_order = z_orders[0] + + # creating shore color = color_picker(shore.__class__.__name__) - self._shore = self.assign_artist(shore, self._get_next_z_order(), color) + self._shore = self.assign_artist(shore, shore_z_order, color) + # creating land land = self._display._environment.map.land color = color_picker(land.__class__.__name__) - self._land = self.assign_artist(land, self._get_next_z_order(), color) + self._land = self.assign_artist(land, land_z_order, color) + # creating extra layers for i, extra_layer in enumerate(self._display._environment.extra_layers.loaded_regions): self._extra_layers[i] = self.assign_artist(extra_layer, self._get_next_z_order(), extra_layer.color) + # creating borders of bounding box center = self._display._environment.scope.extent.center size = self._display._environment.scope.extent.size geometry = shapes.Rectangle( @@ -85,8 +100,7 @@ def new_artist(self, geometry, color, z_order=None, **kwargs): def new_line_artist(self, line_geometry, color, z_order=None, **kwargs): x, y = line_geometry.xy - # TODO: confirm if linewidth 2 wont cause errors in research, linewidth=1 is not visible - line = self._display.axes.add_line(Line2D(x, y, color=color, linewidth=kwargs.get('linewidth', 2))) + line = self._display.axes.add_line(Line2D(x, y, color=color, linewidth=kwargs.get('linewidth', 0.5))) if z_order is None: line.set_animated(True) else: diff --git a/tests/test_seacharts_4_0.py b/tests/test_seacharts_4_0.py index a1678a4..ddf9d01 100644 --- a/tests/test_seacharts_4_0.py +++ b/tests/test_seacharts_4_0.py @@ -2,6 +2,7 @@ # for if __name__ == "__main__": + import shapely.geometry as geo from seacharts import ENC enc = ENC() @@ -15,6 +16,9 @@ center = enc.center + x, y = center + width = 2000 + height = 2000 enc.display.draw_circle( center, 20000, "yellow", thickness=2, edge_style="--", alpha=0.5 ) @@ -26,4 +30,15 @@ [(center[0], center[1] + 800), center, (center[0] - 300, center[1] - 400)], "white", ) + box = geo.Polygon( + ( + (x - width, y - height), + (x + width, y - height), + (x + width, y + height), + (x - width, y + height), + ) + ) + areas = list(box.difference(enc.seabed[10].geometry).geoms) + for area in areas[3:8] + [areas[14], areas[17]] + areas[18:21]: + enc.display.draw_polygon(area, "red", alpha=0.5) enc.display.show() \ No newline at end of file From a842b4885d98491c8c2ca7a5157722c499f631f7 Mon Sep 17 00:00:00 2001 From: miqn <109998643+meeqn@users.noreply.github.com> Date: Thu, 3 Oct 2024 16:40:32 +0200 Subject: [PATCH 73/84] Documentation update (#24) * added some description for users running SC for the first time * upgraded test file and parametrized coastline display * seacharts 4 setup tips added --- README.md | 26 +- images/test_results.svg | 39501 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 39523 insertions(+), 4 deletions(-) create mode 100644 images/test_results.svg diff --git a/README.md b/README.md index a80596e..11b1b52 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ This module follows the [PEP8](https://www.python.org/dev/peps/pep-0008/) convention for Python code. -## Prerequisites +## Prerequisites - For SeaCharts 4.0 see [this](#seacharts-40-setup-tips) section -### Linux (Virtual Environment) +### DEPRECATED - Linux (Virtual Environment) First, ensure that you have the GDAL and GEOS libraries installed, as these are required in order to successfully install GDAL and Cartopy: @@ -45,7 +45,7 @@ pip install -e . This should preferably be done inside a virtual environment in order to prevent Python packaging conflicts. -### Anaconda +### DEPRECATED - Anaconda Install an edition of the [Anaconda]( https://www.anaconda.com/products/individual-d) package manager, and then create a new @@ -65,7 +65,7 @@ conda install -c conda-forge fiona cartopy matplotlib conda install matplotlib-scalebar cerberys pyyaml ``` -### Windows (Pipwin) +### DEPRECATED - Windows (Pipwin) First, ensure that [Python 3.11](https://www.python.org/downloads/) or higher is installed. Next, install all required packages using @@ -193,6 +193,24 @@ the various depth legends may be toggled using the `c` key. Images of the currently shown display may be saved in various resolutions by pressing Control + `s`, Shift + `s` or `s`. +### SeaCharts 4.0 setup tips +``` +Please be aware that these setup tips require setting up Conda environment. +Possible support for pip installation will be resolved in the future. +``` + +This is a short to-do list that might come useful when setting up SeaCharts 4.0 for the first time: +1. Set up conda environment as instructed in `conda_requirements.txt` file +2. Use `setup.ps1` (WINDOWS ONLY) to setup directory structure needed by SeaCharts or manually create directories: `data`, `data/db` and `data/shapefiles` +3. Download US1GC09M map via [this link](https://www.charts.noaa.gov/ENCs/US1GC09M.zip), and put the `US1GC09M` directory (found in ENC_ROOT directory) inside data/db folder. +4. Run `test_seacharts_4_0.py` code either by pasting code into some main.py file in root of your project directory or by running it directly (needs fixing the issues with importing seacharts in the test file) +5. After execution you can expect such image to be displayed: +![](images/test_results.svg +"Example visualization with vessels and geometric shapes in dark mode.") + +``` +For further experimentation options, look into files: `enc.py`, `config.yaml` and `config-schema.yaml` (for reference) +``` ## License This project uses the [MIT](https://choosealicense.com/licenses/mit/) license. diff --git a/images/test_results.svg b/images/test_results.svg new file mode 100644 index 0000000..42bbaef --- /dev/null +++ b/images/test_results.svg @@ -0,0 +1,39501 @@ + + + + + + + + 2024-10-03T16:37:32.584831 + image/svg+xml + + + Matplotlib v3.8.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 8df73e7cdc9a9d914fd680c23a31a37ed11c34e6 Mon Sep 17 00:00:00 2001 From: miqn <109998643+meeqn@users.noreply.github.com> Date: Sun, 3 Nov 2024 15:42:54 +0100 Subject: [PATCH 74/84] Documentation update (#26) * added some description for users running SC for the first time * upgraded test file and parametrized coastline display * seacharts 4 setup tips added * Generated descriptions for most of the code and few refactors --- seacharts/core/config.py | 53 ++++++- seacharts/core/extent.py | 130 ++++++++++++++-- seacharts/core/files.py | 32 ++++ seacharts/core/mapFormat.py | 10 ++ seacharts/core/parser.py | 94 ++++++++++- seacharts/core/parserFGDB.py | 17 +- seacharts/core/parserS57.py | 214 +++++++++++++++++-------- seacharts/core/paths.py | 13 +- seacharts/core/scope.py | 25 ++- seacharts/core/time.py | 46 +++++- seacharts/display/events.py | 62 ++++++++ seacharts/display/features.py | 223 ++++++++++++++++++++++++++- seacharts/enc.py | 22 +++ seacharts/environment/collection.py | 53 ++++++- seacharts/environment/environment.py | 25 +++ seacharts/environment/extra.py | 34 +++- seacharts/environment/map.py | 31 ++++ seacharts/layers/layer.py | 82 +++++++++- seacharts/layers/layers.py | 8 + seacharts/layers/types.py | 3 + 20 files changed, 1060 insertions(+), 117 deletions(-) diff --git a/seacharts/core/config.py b/seacharts/core/config.py index e5387a7..6e83f54 100644 --- a/seacharts/core/config.py +++ b/seacharts/core/config.py @@ -12,10 +12,20 @@ class Config: """ - Class for maintaining Electronic Navigational Charts configuration settings. + Class for managing Electronic Navigational Charts (ENC) configuration settings. + + This class handles loading, validating, and modifying ENC settings + defined in a YAML configuration file. """ def __init__(self, config_path: Path | str = None): + """ + Initializes the Config object. + + If no config_path is provided, it defaults to the path defined in the 'paths' module. + + :param config_path: Path to the configuration file (YAML format). + """ if config_path is None: config_path = dcp.config self._schema = read_yaml_into_dict(dcp.config_schema) @@ -26,13 +36,29 @@ def __init__(self, config_path: Path | str = None): @property def settings(self) -> dict: + """ + Gets the current settings. + + :return: A dictionary containing the current configuration settings. + """ return self._settings @settings.setter def settings(self, new_settings: dict) -> None: + """ + Sets new settings. + + :param new_settings: A dictionary containing the new configuration settings. + """ self._settings = new_settings def _extract_valid_sections(self) -> list[str]: + """ + Extracts the valid sections defined in the configuration schema. + + :return: A list of valid section names from the configuration schema. + :raises ValueError: If the schema is not provided. + """ if self._schema is None: raise ValueError("No configuration schema provided!") sections = [] @@ -41,6 +67,13 @@ def _extract_valid_sections(self) -> list[str]: return sections def validate(self, settings: dict) -> None: + """ + Validates the provided settings against the schema. + + :param settings: A dictionary containing the settings to be validated. + :raises ValueError: If the settings are empty, schema is empty, + or validation fails. + """ if not settings: raise ValueError("Empty settings!") @@ -55,10 +88,22 @@ def validate(self, settings: dict) -> None: files.verify_directory_exists(file_name) def parse(self, file_name: Path | str = dcp.config) -> None: + """ + Parses the YAML configuration file and validates the settings. + + :param file_name: Path to the YAML configuration file. Defaults to the path defined in 'paths'. + """ self._settings = read_yaml_into_dict(file_name) self.validate(self._settings) def override(self, section: str = "enc", **kwargs) -> None: + """ + Overrides settings in a specified section with new values. + + :param section: The section of the configuration to override (default is "enc"). + :param kwargs: Key-value pairs representing settings to be updated. + :raises ValueError: If no kwargs are provided or if the section does not exist. + """ if not kwargs: return if section not in self._valid_sections: @@ -72,6 +117,12 @@ def override(self, section: str = "enc", **kwargs) -> None: def read_yaml_into_dict(file_name: Path | str = dcp.config) -> dict: + """ + Reads a YAML file and converts it into a dictionary. + + :param file_name: Path to the YAML file to read. + :return: A dictionary containing the contents of the YAML file. + """ with open(file_name, encoding="utf-8") as config_file: output_dict = yaml.safe_load(config_file) return output_dict diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index 1d64290..2e2cefb 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -1,5 +1,6 @@ """ -Contains the Extent class for defining the span of spatial data. +Contains the Extent class for defining the spatial boundaries, projections, and coordinate +transformations needed to work with ENC spatial data. """ import math import re @@ -8,42 +9,113 @@ class Extent: + """ + Extent class defines the spatial area, origin, center, size, and coordinates transformations + for a specified spatial data region. It manages coordinate reference systems (CRS) and conversions + between geographic (latitude/longitude) and projected (UTM) coordinates. + + :param settings: Dictionary containing configuration settings for ENC extent. + """ def __init__(self, settings: dict): + """ + Initializes the Extent object with given settings, setting properties such as size, + origin, center, and CRS based on configuration. + + :param settings: Dictionary of ENC configuration settings including size, origin, CRS, + and center of the extent. + """ + + # Set the size of the extent, defaulting to (0, 0) if not specified in settings self.size = tuple(settings["enc"].get("size", (0, 0))) crs: str = settings["enc"].get("crs") + # Set origin and center based on settings; if origin is given, calculate center, and vice versa if "origin" in settings["enc"]: self.origin = tuple(settings["enc"].get("origin", (0, 0))) self.center = self._center_from_origin() - - if "center" in settings["enc"]: + elif "center" in settings["enc"]: self.center = tuple(settings["enc"].get("center", (0, 0))) self.origin = self._origin_from_center() if crs.__eq__("WGS84"): - self.utm_zone = self.wgs2utm(self.center[0]) - self.southern_hemisphere = False if self.center[1] >= 0 else True - hemisphere_code = '7' if self.southern_hemisphere is True else '6' - self.out_proj = 'epsg:32' + hemisphere_code + self.utm_zone + # If CRS is WGS84, convert latitude/longitude to UTM + self.utm_zone_num = self.wgs2utm(self.center[0]) + self.southern_hemisphere = Extent._is_southern_hemisphere(center_east=self.center[1]) + self.out_proj = Extent._get_epsg_proj_code(self.utm_zone_num, self.southern_hemisphere) self.size = self._size_from_lat_long() + # Convert origin from lat/lon to UTM, recalculate center in UTM coordinates self.origin = self.convert_lat_lon_to_utm(self.origin[1], self.origin[0]) self.center = self.origin[0] + self.size[0] / 2, self.origin[1] + self.size[1] / 2 - elif re.match(r'^UTM\d{2}[NS]', crs): + # For UTM CRS, extract zone and hemisphere, and set EPSG projection code accordingly crs = re.search(r'\d+[A-Z]', crs).group(0) - self.utm_zone = crs[0:2] - self.southern_hemisphere = False if crs[2] == 'N' else True - hemisphere_code = '7' if self.southern_hemisphere is True else '6' - self.out_proj = 'epsg:32' + hemisphere_code + self.utm_zone - + # eg. UTM33N: + # utm_zone_num = 33 + # crs_hemisphere_code = 'N' + self.utm_zone_num = crs[0:2] + crs_hemisphere_code = crs[2] + self.southern_hemisphere = Extent._is_southern_hemisphere(crs_hemisphere_sym=crs_hemisphere_code) + self.out_proj = Extent._get_epsg_proj_code(self.utm_zone_num, self.southern_hemisphere) + + # Calculate bounding box and area based on origin and size self.bbox = self._bounding_box_from_origin_size() self.area: int = self.size[0] * self.size[1] + @staticmethod + def _is_southern_hemisphere(center_east: int = None, crs_hemisphere_sym: str = None) -> bool: + """ + Determines if the hemisphere is southern based on either 'center_east' (UTM) or + 'crs_hemisphere_sym' ('N' for Northern, 'S' for Southern hemisphere). + + :param center_east: Integer value for the center's easting coordinate; if negative, the + center is in the southern hemisphere. + :param crs_hemisphere_sym: String, either 'N' or 'S', indicating the UTM CRS hemisphere. + :return: Boolean indicating if the southern hemisphere is determined. + :raises ValueError: If neither or both arguments are provided. + """ + if (center_east is not None) == (crs_hemisphere_sym is not None): + raise ValueError("Specify only one of 'center_east' or 'crs_hemisphere_sym'.") + + # Determine hemisphere based on the provided parameter + if center_east is not None: + return center_east < 0 + elif crs_hemisphere_sym is not None: + return crs_hemisphere_sym == 'S' + + @staticmethod + def __get_hemisphere_epsg_code(is_southern_hemisphere: bool) -> str: + return '7' if is_southern_hemisphere is True else '6' + + @staticmethod + def _get_epsg_proj_code(utm_zone: str, is_southern_hemisphere: bool) -> str: + """ + Constructs the EPSG projection code for the given UTM zone and hemisphere. + + :param utm_zone: String representing UTM zone. + :param is_southern_hemisphere: Boolean indicating if the zone is in the southern hemisphere. + :return: EPSG code as a string. + """ + hemisphere_code = Extent.__get_hemisphere_epsg_code(is_southern_hemisphere) + return 'epsg:32' + hemisphere_code + utm_zone + @staticmethod def wgs2utm(longitude): + """ + Calculates the UTM zone based on the given longitude. + + :param longitude: Longitude in decimal degrees. + :return: String representing the UTM zone number. + """ return str(math.floor(longitude / 6 + 31)) def convert_lat_lon_to_utm(self, latitude, longitude): + """ + Converts latitude and longitude coordinates to UTM coordinates. + + :param latitude: Latitude in decimal degrees. + :param longitude: Longitude in decimal degrees. + :return: Tuple of UTM east and north coordinates. + """ in_proj = 'epsg:4326' # WGS84 transformer = Transformer.from_crs(in_proj, self.out_proj, always_xy=True) @@ -54,6 +126,13 @@ def convert_lat_lon_to_utm(self, latitude, longitude): return utm_east, utm_north def convert_utm_to_lat_lon(self, utm_east, utm_north): + """ + Converts UTM coordinates to latitude and longitude. + + :param utm_east: UTM easting coordinate. + :param utm_north: UTM northing coordinate. + :return: Tuple of latitude and longitude. + """ out_proj = 'epsg:4326' # WGS84 in_proj = self.out_proj transformer = Transformer.from_crs(in_proj, out_proj, always_xy=True) @@ -62,23 +141,43 @@ def convert_utm_to_lat_lon(self, utm_east, utm_north): return latitude, longitude def _origin_from_center(self) -> tuple[int, int]: + """ + Calculates the origin coordinates based on the center and size. + + :return: Tuple of origin x and y coordinates. + """ return ( int(self.center[0] - self.size[0] / 2), int(self.center[1] - self.size[1] / 2), ) def _center_from_origin(self) -> tuple[int, int]: + """ + Calculates the center coordinates based on the origin and size. + + :return: Tuple of center x and y coordinates. + """ return ( int(self.origin[0] + self.size[0] / 2), int(self.origin[1] + self.size[1] / 2), ) def _bounding_box_from_origin_size(self) -> tuple[int, int, int, int]: + """ + Calculates the bounding box based on the origin and size. + + :return: Tuple of bounding box coordinates (x_min, y_min, x_max, y_max). + """ x_min, y_min = self.origin x_max, y_max = x_min + self.size[0], y_min + self.size[1] return x_min, y_min, x_max, y_max def _size_from_lat_long(self) -> tuple[int, int]: + """ + Converts geographic size (latitude/longitude) to UTM size. + + :return: Tuple of width and height in UTM coordinates. + """ x_min, y_min = self.origin x_max, y_max = x_min + self.size[0], y_min + self.size[1] converted_x_min, converted_y_min = self.convert_lat_lon_to_utm(y_min, x_min) @@ -86,6 +185,11 @@ def _size_from_lat_long(self) -> tuple[int, int]: return converted_x_max - converted_x_min, converted_y_max - converted_y_min def _bounding_box_from_origin_size_lat_long(self) -> tuple[int, int, int, int]: + """ + Calculates the bounding box in UTM coordinates based on origin and geographic size. + + :return: Tuple of bounding box coordinates (x_min, y_min, x_max, y_max) in UTM. + """ x_min, y_min = self.origin x_max, y_max = x_min + self.size[0], y_min + self.size[1] converted_x_min, converted_y_min = self.convert_lat_lon_to_utm(y_min, x_min) diff --git a/seacharts/core/files.py b/seacharts/core/files.py index d2679d8..436c270 100644 --- a/seacharts/core/files.py +++ b/seacharts/core/files.py @@ -9,6 +9,14 @@ def verify_directory_exists(path_string: str) -> None: + """ + Checks if a directory exists at the given path. + + If the directory does not exist, it prints a warning indicating whether the + path is absolute or relative. + + :param path_string: The path to the directory as a string. + """ path = Path(path_string) if not path.is_dir(): path_type = "Absolute" if path.is_absolute() else "Relative" @@ -16,6 +24,14 @@ def verify_directory_exists(path_string: str) -> None: def build_directory_structure(features: list[str], resources: list[str], parser: DataParser) -> None: + """ + Creates the directory structure for shapefiles and outputs based on the provided features + and resources. It also copies the initial configuration file to the map's shapefile directory. + + :param features: A list of feature names for which directories will be created. + :param resources: A list of resource paths to validate and create directories for. + :param parser: An instance of DataParser used to get the source root name. + """ map_dir_name = parser.get_source_root_name() paths.shapefiles.mkdir(exist_ok=True) paths.shapefiles = paths.shapefiles / map_dir_name @@ -33,12 +49,28 @@ def build_directory_structure(features: list[str], resources: list[str], parser: def write_rows_to_csv(rows: list[tuple], file_path: Path) -> None: + """ + Writes a list of rows to a CSV file at the specified path. + + Each row should be a tuple representing a single entry. + + :param rows: A list of tuples containing the data to write to the CSV file. + :param file_path: The path where the CSV file will be created. + """ with open(file_path, "w") as csv_file: writer = csv.writer(csv_file, delimiter=",", lineterminator="\n") writer.writerows(rows) def read_ship_poses() -> Generator[tuple]: + """ + Reads ship positions from a CSV file and yields each position as a tuple. + + The first line of the CSV file is considered as a header and will be skipped. + + :return: A generator that yields tuples containing ship position data. + Each tuple consists of (int id, int x, int y, float speed, list additional_data). + """ try: with open(paths.vessels) as csv_file: reader = csv.reader(csv_file, delimiter=",") diff --git a/seacharts/core/mapFormat.py b/seacharts/core/mapFormat.py index 0ff8eba..7e7d75f 100644 --- a/seacharts/core/mapFormat.py +++ b/seacharts/core/mapFormat.py @@ -2,5 +2,15 @@ class MapFormat(Enum): + """ + Enumeration representing various map formats supported by the application. + + The enumeration includes the following formats: + + - FGDB: File Geodatabase format, used primarily in Esri's GIS software. + - S57: IHO S-57 format, used for nautical chart data exchange. + + This enum can be used to specify the desired map format when working with spatial data. + """ FGDB = auto() S57 = auto() diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index 72529a1..d911cb3 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -13,6 +13,13 @@ class DataParser: + """ + Base class for parsing spatial data sources, providing common functionality for + file handling and shapefile processing. + + :param bounding_box: Tuple defining bounding box coordinates as (xmin, ymin, xmax, ymax). + :param path_strings: List of paths to spatial data sources. + """ def __init__( self, bounding_box: tuple[int, int, int, int], @@ -20,17 +27,35 @@ def __init__( ): self.bounding_box = bounding_box self.paths = set([p.resolve() for p in (map(Path, path_strings))]) - # self.paths.update(paths.default_resources) @staticmethod def _shapefile_path(label): + """ + Constructs the path for a shapefile based on the given label. + + :param label: The label of the shapefile. + :return: Path to the shapefile. + """ return paths.shapefiles / label / (label + ".shp") @staticmethod def _shapefile_dir_path(label): + """ + Constructs the directory path for shapefiles based on the given label. + + :param label: The label of the shapefile directory. + :return: Path to the shapefile directory. + """ return paths.shapefiles / label def _read_spatial_file(self, path: Path, **kwargs) -> Generator: + """ + Reads a spatial file (shapefile) and yields records that fall within the bounding box. + + :param path: Path to the spatial file to be read. + :param kwargs: Additional arguments for reading the file. + :yield: Records from the spatial file that are within the bounding box. + """ try: with fiona.open(path, "r", **kwargs) as source: with warnings.catch_warnings(): @@ -45,14 +70,52 @@ def _read_spatial_file(self, path: Path, **kwargs) -> Generator: return def _read_shapefile(self, label: str) -> Generator: + """ + Reads records from a specified shapefile if it exists. + + :param label: Label of the shapefile to read. + :yield: Records from the shapefile if it exists. + """ file_path = self._shapefile_path(label) if file_path.exists(): yield from self._read_spatial_file(file_path) def load_shapefiles(self, layer: Layer) -> None: + """ + Loads records from shapefiles into the specified layer. + + :param layer: Layer object to load the records into. + """ records = list(self._read_shapefile(layer.label)) layer.records_as_geometry(records) layer.records= records + + def _valid_paths_and_resources(paths: set[Path], resources: list[str], area: float)-> bool: + """ + Validates the provided paths and resources, checking if they exist and are usable. + + :param paths: Set of paths to validate. + :param resources: List of resource names to validate. + :param area: Area being processed, used for logging. + :return: True if paths and resources are valid, otherwise False. + """ + if not list(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 False + else: + print("INFO: Updating ENC with data from available resources...\n") + print(f"Processing {area // 10 ** 6} km^2 of ENC features:") + return True # main method for parsing corresponding map format @abstractmethod @@ -62,24 +125,53 @@ def parse_resources( resources: list[str], area: float ) -> None: + """ + Abstract method for parsing resources, to be implemented by subclasses. + + :param regions_list: List of Layer objects representing different regions to be processed. + :param resources: List of resource paths to be considered for processing. + :param area: Area of the region being processed. + """ pass @abstractmethod def _is_map_type(self, path) -> bool: + """ + Abstract method to check if the provided path corresponds to a valid map type. + + :param path: Path to be checked. + :return: True if the path is a valid map type, otherwise False. + """ pass @abstractmethod def get_source_root_name(self) -> str: + """ + Abstract method to retrieve the root name of the source data. + + :return: The root name of the source data. + """ pass @property def _file_paths(self) -> Generator[Path, None, None]: + """ + Generator that yields valid file paths from the configured paths. + + :yield: Valid file paths for spatial data sources. + """ for path in self.paths: if not path.is_absolute(): path = Path.cwd() / path yield from self._get_files_recursive(path) def _get_files_recursive(self, path: Path) -> Generator[Path, None, None]: + """ + Recursively retrieves valid file paths for the specified path. + + :param path: Path to be searched for valid files. + :yield: Valid file paths found in the directory. + """ if self._is_map_type(path): yield path elif path.is_dir(): diff --git a/seacharts/core/parserFGDB.py b/seacharts/core/parserFGDB.py index ca1878c..816c0c2 100644 --- a/seacharts/core/parserFGDB.py +++ b/seacharts/core/parserFGDB.py @@ -21,21 +21,8 @@ def parse_resources( resources: list[str], area: float ) -> None: - if not list(self.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:") + if not self._valid_paths_and_resources(self.paths, resources, area): + return # interrupt parsing if paths are not valid for regions in regions_list: start_time = time.time() records = self._load_from_file(regions) diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index 617ba38..c60f98e 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -8,6 +8,15 @@ class S57Parser(DataParser): + """ + Parser for S57 maritime spatial data. This class manages data parsing, + conversion to shapefiles, and filtering by depth using a specified bounding box + and EPSG code for the coordinate reference system. + + :param bounding_box: Tuple defining bounding box coordinates as (xmin, ymin, xmax, ymax). + :param path_strings: List of paths to data sources. + :param epsg: EPSG code for the desired coordinate reference system. + """ def __init__( self, bounding_box: tuple[int, int, int, int], @@ -18,113 +27,186 @@ def __init__( self.epsg = epsg def get_source_root_name(self) -> str: + """ + Returns the stem (base filename without suffix) of the first valid S57 file + path in the given data paths. + + :return: The stem of the first valid S57 file. + """ for path in self._file_paths: path = self.get_s57_file_path(path) if path is not None: return path.stem - @staticmethod - def convert_s57_to_utm_shapefile(s57_file_path, shapefile_output_path, layer, epsg, bounding_box): - x_min, y_min, x_max, y_max = map(str, bounding_box) - ogr2ogr_cmd = [ - 'ogr2ogr', - '-f', 'ESRI Shapefile', # Output format - shapefile_output_path, # Output shapefile - s57_file_path, # Input S57 file - layer, - '-t_srs', epsg.upper(), - '-clipdst', x_min, y_min, x_max, y_max, - '-skipfailures' - ] + def __run_org2ogr(ogr2ogr_cmd, s57_file_path, shapefile_output_path) -> None: + """ + Executes the ogr2ogr command to convert S57 files to shapefiles. + + :param ogr2ogr_cmd: Command to be executed for conversion. + :param s57_file_path: Path to the input S57 file. + :param shapefile_output_path: Path where the output shapefile will be saved. + """ try: subprocess.run(ogr2ogr_cmd, check=True) print(f"Conversion successful: {s57_file_path} -> {shapefile_output_path}") except subprocess.CalledProcessError as e: print(f"Error during conversion: {e}") + @staticmethod + def convert_s57_to_utm_shapefile(s57_file_path, shapefile_output_path, layer: str, epsg:str, bounding_box): + """ + Converts a given layer from a S57 file to a UTM shapefile, clipping to the specified bounding box. + + :param s57_file_path: Path to the input S57 file. + :param shapefile_output_path: Path where the output shapefile will be saved. + :param layer: Layer type to be extracted (e.g., "LNDARE"). + :param epsg: EPSG code for the desired coordinate reference system. + :param bounding_box: Tuple defining bounding box coordinates as (xmin, ymin, xmax, ymax). + """ + x_min, y_min, x_max, y_max = map(str, bounding_box) + ogr2ogr_cmd = [ + 'ogr2ogr', + '-f', 'ESRI Shapefile', # Output format + shapefile_output_path, # Output shapefile + s57_file_path, # Input S57 file + layer, # Converted layer name + '-t_srs', epsg.upper(), # Target spatial reference system + '-clipdst', x_min, y_min, x_max, y_max, # Clipping to bounding box + '-skipfailures' # Skip failures in processing + ] + S57Parser.__run_org2ogr(ogr2ogr_cmd, s57_file_path, shapefile_output_path) + + @staticmethod def convert_s57_depth_to_utm_shapefile(s57_file_path, shapefile_output_path, depth, epsg:str, bounding_box, next_depth = None): + """ + Converts a S57 file DEPARE layer to a UTM shapefile based on specified depth criteria. + + :param s57_file_path: Path to the input S57 file. + :param shapefile_output_path: Path where the output shapefile will be saved. + :param depth: Minimum depth for filtering the data. + :param epsg: EPSG code for the desired coordinate reference system. + :param bounding_box: Tuple defining bounding box coordinates as (xmin, ymin, xmax, ymax). + :param next_depth: Optional; maximum depth for filtering the data. + """ x_min, y_min, x_max, y_max = map(str, bounding_box) query = f'SELECT * FROM DEPARE WHERE DRVAL1 >= {depth.__str__()}' if next_depth is not None: query += f' AND DRVAL1 < {next_depth.__str__()}' ogr2ogr_cmd = [ 'ogr2ogr', - '-f', 'ESRI Shapefile', # Output format - shapefile_output_path, # Output shapefile - s57_file_path, # Input S57 file - '-sql', query, - '-t_srs', epsg.upper(), - '-clipdst', x_min, y_min, x_max, y_max, - '-skipfailures' + '-f', 'ESRI Shapefile', # Output format + shapefile_output_path, # Output shapefile + s57_file_path, # Input S57 file + '-sql', query, # SQL query for depth filtering + '-t_srs', epsg.upper(), # Target spatial reference system + '-clipdst', x_min, y_min, x_max, y_max, # Clipping to bounding box + '-skipfailures' # Skip failures in processing ] - try: - subprocess.run(ogr2ogr_cmd, check=True) - print(f"Conversion successful: {s57_file_path} -> {shapefile_output_path}") - except subprocess.CalledProcessError as e: - print(f"Error during conversion: {e}") - - def parse_resources(self, regions_list: list[Layer], resources: list[str], area: float) -> None: - if not list(self.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:") + S57Parser.__run_org2ogr(ogr2ogr_cmd, s57_file_path, shapefile_output_path) + def parse_resources( + self, + regions_list: list[Layer], + resources: list[str], + area: float + ) -> None: + """ + Parses the provided resources for specified regions and processes them into shapefiles. + + :param regions_list: List of Layer objects representing different regions to be processed. + :param resources: List of resource paths to be considered for processing. + :param area: Area of the region being processed, used for validation. + """ + if not self._valid_paths_and_resources(self.paths, resources, area): + return # interrupt parsing if paths are not valid s57_path = None for path in self._file_paths: s57_path = self.get_s57_file_path(path) s57_path = str(s57_path) + # Separate Seabeds from rest of regions to extract depths from DEPARE correctly seabeds = [region for region in regions_list if isinstance(region, Seabed)] rest_of_regions = [region for region in regions_list if not isinstance(region, Seabed)] - + for index, region in enumerate(seabeds): - start_time = time.time() - dest_path = os.path.join(self._shapefile_dir_path(region.label), region.label + ".shp") - if index < len(seabeds) - 1: - next_depth = seabeds[index + 1].depth - self.convert_s57_depth_to_utm_shapefile(s57_path, dest_path, region.depth, self.epsg, self.bounding_box, next_depth) - else: - self.convert_s57_depth_to_utm_shapefile(s57_path, dest_path, region.depth, self.epsg, self.bounding_box) - self.load_shapefiles(region) - end_time = round(time.time() - start_time, 1) - print(f"\rSaved {region.name} to shapefile in {end_time} s.") - + self._parse_S57_depth(index, region, s57_path, seabeds) for region in rest_of_regions: - start_time = time.time() - dest_path = os.path.join(self._shapefile_dir_path(region.label), region.label + ".shp") + self._parse_S57_region(region, s57_path) + print(f"\rFinished processing {len(regions_list)} layers for S57 map at {s57_path}") + + def _parse_S57_region(self, region: Layer, s57_path: str): + """ + Parses a region from the S57 file and converts it to a shapefile. + + :param region: Layer object representing the region to be parsed. + :param s57_path: Path to the input S57 file. + """ + start_time = time.time() + dest_path = self.__get_dest_path(region.label) + + if isinstance(region, Land): + self.convert_s57_to_utm_shapefile(s57_path, dest_path, "LNDARE", self.epsg, self.bounding_box) + elif isinstance(region, Shore): + self.convert_s57_to_utm_shapefile(s57_path, dest_path, "COALNE", self.epsg, self.bounding_box) + else: + self.convert_s57_to_utm_shapefile(s57_path, dest_path, region.name, self.epsg, self.bounding_box) + + self.load_shapefiles(region) + end_time = round(time.time() - start_time, 1) + print(f"\rSaved {region.name} to shapefile in {end_time} s.") + + def _parse_S57_depth(self, index: int, region: Seabed, s57_path: str, seabeds: list[Seabed]): + """ + Parses a seabed region (DEPARE) from the S57 file and converts it to a shapefile based on depth. + + :param index: Index of the seabed region in the list. + :param region: Seabed object representing the region to be parsed. + :param s57_path: Path to the input S57 file. + :param seabeds: List of all seabed regions. + """ + start_time = time.time() + dest_path = self.__get_dest_path(region.label) + if index < len(seabeds) - 1: + next_depth = seabeds[index + 1].depth + self.convert_s57_depth_to_utm_shapefile(s57_path, dest_path, region.depth, self.epsg, self.bounding_box, next_depth) + else: + self.convert_s57_depth_to_utm_shapefile(s57_path, dest_path, region.depth, self.epsg, self.bounding_box) + self.load_shapefiles(region) + end_time = round(time.time() - start_time, 1) + print(f"\rSaved {region.name} to shapefile in {end_time} s.") + + def __get_dest_path(self, region_label): + """ + Generates the destination path for saving the shapefile based on the region label. + + :param region_label: Label of the region for which the shapefile path is being generated. + :return: Path for the shapefile destination. + """ + return os.path.join(self._shapefile_dir_path(region_label), region_label + ".shp") - if isinstance(region, Land): - self.convert_s57_to_utm_shapefile(s57_path, dest_path, "LNDARE", self.epsg, self.bounding_box) - elif isinstance(region, Shore): - self.convert_s57_to_utm_shapefile(s57_path, dest_path, "COALNE", self.epsg, self.bounding_box) - else: - self.convert_s57_to_utm_shapefile(s57_path, dest_path, region.name, self.epsg, self.bounding_box) - - self.load_shapefiles(region) - end_time = round(time.time() - start_time, 1) - print(f"\rSaved {region.name} to shapefile in {end_time} s.") @staticmethod def get_s57_file_path(path: Path) -> Path | None: + """ + Retrieves the path of the first S57 file (with .000 extension) in the given directory. + + :param path: Path to the directory to be searched. + :return: Path to the found S57 file, or None if no valid file is found. + """ for p in path.iterdir(): if p.suffix == ".000": return p return None def _is_map_type(self, path: Path) -> bool: + """ + Determines if the specified path corresponds to a valid S57 map type (file or directory). + + :param path: Path to be checked. + :return: True if the path is a valid map type, otherwise False. + """ if path.is_dir(): for p in path.iterdir(): if p.suffix == ".000": diff --git a/seacharts/core/paths.py b/seacharts/core/paths.py index c1d6ad1..04644a7 100644 --- a/seacharts/core/paths.py +++ b/seacharts/core/paths.py @@ -3,19 +3,30 @@ """ from pathlib import Path - +# Get the root directory of the project by going up two levels from the current file's location root = Path(__file__).parents[2] +# Define the main package directory for "seacharts" package = root / "seacharts" +# Default path to the main configuration file for the project config = package / "config.yaml" +# Path to the schema file that validates the structure of the configuration file config_schema = package / "config_schema.yaml" +# Get the current working directory, where this script is being executed cwd = Path.cwd() +# Define a 'data' directory within the current working directory, used to store source ENC data files data = cwd / "data" +# Define the database directory within the 'data' folder, used for storing database files db = data / "db" + default_resources = cwd, data, db + +# Directory to store shapefiles, which are used for geographic and spatial data shapefiles = data / "shapefiles" +# Path to the CSV file containing vessel data vessels = data / "vessels.csv" +# Define the output directory, where results and generated files will be saved output = root / "output" diff --git a/seacharts/core/scope.py b/seacharts/core/scope.py index 5111dd6..8c708dd 100644 --- a/seacharts/core/scope.py +++ b/seacharts/core/scope.py @@ -1,5 +1,6 @@ """ -Contains the Extent class for defining details related to files of spatial data. +Contains the Scope class for defining the extent, layers, depth bins, and other +settings for spatial data files in Electronic Navigational Charts (ENC). """ from dataclasses import dataclass from seacharts.core import files @@ -10,24 +11,40 @@ @dataclass class Scope: + """ + Scope class to configure spatial data settings, including the geographic extent, + resources, depth layers, map format, and optional temporal configuration. + It parses ENC configuration details and prepares features for use. + :param settings: A dictionary containing configuration settings for ENC. + """ def __init__(self, settings: dict): + """ + Initializes the Scope instance based on settings provided in the configuration dictionary. + + :param settings: Dictionary of ENC configuration settings, including resources, + depths, time configuration, and layer format information. + """ self.extent = Extent(settings) self.settings = settings self.resources = settings["enc"].get("resources", []) + # Set default depth bins if not provided in settings default_depths = [0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500] self.depths = settings["enc"].get("depths", default_depths) + + # Define core features for ENC, adding "seabed" layers based on depth bins self.features = ["land", "shore"] for depth in self.depths: self.features.append(f"seabed{depth}m") + # Set map format type based on provided layer information (S57 or FGDB) if settings["enc"].get("S57_layers", []): self.type = MapFormat.S57 - else: self.type = MapFormat.FGDB + # Configure temporal settings if specified in configuration time_config = settings["enc"].get("time", {}) if time_config: self.time = Time( @@ -38,9 +55,11 @@ def __init__(self, settings: dict): ) else: self.time = None - self.weather = settings["enc"].get("weather", []) + # Set weather data sources and any extra S57 layers + self.weather = settings["enc"].get("weather", []) self.extra_layers:dict[str,str] = settings["enc"].get("S57_layers", {}) + # Extend features to include any extra layers specified self.features.extend(self.extra_layers) diff --git a/seacharts/core/time.py b/seacharts/core/time.py index 4890192..1969eb3 100644 --- a/seacharts/core/time.py +++ b/seacharts/core/time.py @@ -1,31 +1,58 @@ from datetime import datetime, timedelta class Time: + """ + Time class that generates a sequence of datetime objects and their corresponding + epoch times based on a specified start and end time, a time period (e.g., hour, day), + and a period multiplier. + + :param time_start: String representing the start datetime in "DD-MM-YYYY HH:MM" format. + :param time_end: String representing the end datetime in "DD-MM-YYYY HH:MM" format. + :param period: String representing the time increment (e.g., 'hour', 'day', 'month'). + :param period_mult: Multiplier for the period to extend or reduce intervals. + """ def __init__(self, time_start: str, time_end: str, period: str, period_mult: float): - # Parse the start and end dates + # Store the multiplier for the period self.period_mult = period_mult + + # Define the format for parsing date strings self._date_string_format = "%d-%m-%Y %H:%M" + + # Parse start and end times using the defined format self.time_start = datetime.strptime(time_start, self._date_string_format) self.time_end = datetime.strptime(time_end, self._date_string_format) + + # Store the period type (e.g., 'hour', 'day', etc.) self.period = period # Generate the list of datetimes and epoch times self.datetimes = self._generate_datetimes() self.epoch_times = [int(dt.timestamp()) for dt in self.datetimes] - def _generate_datetimes(self): - """Generate a list of datetime objects based on the period.""" + def _generate_datetimes(self) -> list[datetime]: + """ + Generates a list of datetime objects within the time range from 'time_start' + to 'time_end' based on the specified period and multiplier. + + :return: List of datetime objects from start to end. + """ current_time = self.time_start - datetimes = [] + datetimes: list[datetime] = [] + # Generate datetimes by incrementing until reaching the end time while current_time <= self.time_end: datetimes.append(current_time) current_time = self._increment_time(current_time) return datetimes - def _increment_time(self, current_time): - """Increment the datetime based on the specified period.""" + def _increment_time(self, current_time: datetime) -> datetime: + """ + Increment the given datetime by the specified period and multiplier. + + :param current_time: The datetime to increment. + :return: A new datetime object incremented by the specified period. + """ if self.period == "hour": return current_time + timedelta(hours=int(1 * self.period_mult)) elif self.period == "day": @@ -41,5 +68,10 @@ def _increment_time(self, current_time): else: raise ValueError(f"Unknown period: {self.period}") - def get_datetimes_strings(self): + def get_datetimes_strings(self) -> list[str]: + """ + Returns a list of formatted datetime strings for all generated datetime objects. + + :return: List of datetime strings in "DD-MM-YYYY HH:MM" format. + """ return [datetime.strftime(self._date_string_format) for datetime in self.datetimes] \ No newline at end of file diff --git a/seacharts/display/events.py b/seacharts/display/events.py index 1eb7d8a..a9400d2 100644 --- a/seacharts/display/events.py +++ b/seacharts/display/events.py @@ -8,10 +8,34 @@ # noinspection PyProtectedMember class EventsManager: + """ + The EventsManager class is responsible for handling various user interaction events + within a Matplotlib display. It manages zooming, panning, and keyboard shortcuts + for controlling the display of data. This class captures events such as mouse scrolls, + key presses, and mouse clicks, enabling users to manipulate the visualization + dynamically. + + Attributes: + _zoom_scale (float): A factor by which to scale the zoom operation. + _directions (dict): A mapping of direction keys to their corresponding numeric values + for movement (up, down, left, right). + _display (object): The display object that this EventsManager interacts with. + _canvas (object): The canvas from the display where the events are connected. + _view_limits (dict): A dictionary storing the current view limits for x and y axes. + _direction_keys (dict): A dictionary tracking the state of direction keys. + _control_pressed (bool): A flag indicating if the control key is currently pressed. + _shift_pressed (bool): A flag indicating if the shift key is currently pressed. + _mouse_press (dict): A dictionary storing the mouse press event coordinates. + """ _zoom_scale = 0.9 _directions = {"up": 1, "down": -1, "left": -1, "right": 1} def __init__(self, display): + """ + Initializes the EventsManager with a specified display object. + + :param display: The display object that will be managed by this EventsManager. + """ self._display = display self._canvas = display.figure.canvas self._view_limits = dict(x=None, y=None) @@ -22,6 +46,9 @@ def __init__(self, display): self._connect_canvas_events() def _connect_canvas_events(self) -> None: + """ + Connects various event handlers to the canvas for mouse and keyboard events. + """ self._canvas.mpl_connect("scroll_event", self._handle_zoom) self._canvas.mpl_connect("key_press_event", self._key_press) self._canvas.mpl_connect("key_release_event", self._key_release) @@ -30,6 +57,11 @@ def _connect_canvas_events(self) -> None: self._canvas.mpl_connect("motion_notify_event", self._mouse_motion) def _handle_zoom(self, event: Any) -> None: + """ + Handles mouse scroll events to zoom in or out on the display. + + :param event: The scroll event containing the scroll direction and coordinates. + """ if event.button == "down": scale_factor = 1 / self._zoom_scale elif event.button == "up": @@ -52,6 +84,12 @@ def _handle_zoom(self, event: Any) -> None: self._display.redraw_plot() def _key_press(self, event: Any) -> None: + """ + Handles keyboard press events to trigger various actions such as toggling visibility, + saving figures, or moving the display. + + :param event: The key press event containing the key that was pressed. + """ if event.key == "escape": self._display._terminate() @@ -104,6 +142,12 @@ def _key_press(self, event: Any) -> None: self._move_figure_position(key) def _key_release(self, event: Any) -> None: + """ + Handles keyboard release events, updating the state of direction + and modifier keys. + + :param event: The key release event containing the key that was released. + """ if event.key in self._directions: self._direction_keys[event.key] = False elif event.key == "shift": @@ -112,6 +156,11 @@ def _key_release(self, event: Any) -> None: self._control_pressed = False def _move_figure_position(self, key: str) -> None: + """ + Moves the display figure based on the arrow key pressed. + + :param key: The direction key pressed ('left', 'right', 'up', 'down'). + """ matrix = self._display.window_anchors j, i = self._display._anchor_index if key == "left" or key == "right": @@ -122,6 +171,11 @@ def _move_figure_position(self, key: str) -> None: self._display._set_figure_position() def _click_press(self, event: Any) -> None: + """ + Captures the mouse press event and stores the current view limits. + + :param event: The mouse button press event containing the coordinates. + """ if event.inaxes != self._display.axes: return if event.button == plt.MouseButton.LEFT: @@ -130,10 +184,18 @@ def _click_press(self, event: Any) -> None: self._mouse_press = dict(x=event.xdata, y=event.ydata) def _click_release(self, _) -> None: + """ + Resets mouse press tracking and refreshes the display. + """ self._mouse_press = None self._display.redraw_plot() def _mouse_motion(self, event: Any) -> None: + """ + Handles mouse movement events to enable panning of the display. + + :param event: The mouse motion event containing the current coordinates. + """ if self._mouse_press is None: return if event.inaxes != self._display.axes: diff --git a/seacharts/display/features.py b/seacharts/display/features.py index 6d70321..f230fe2 100644 --- a/seacharts/display/features.py +++ b/seacharts/display/features.py @@ -12,7 +12,30 @@ # noinspection PyProtectedMember class FeaturesManager: + """ + The FeaturesManager class is responsible for managing and rendering spatial features + on a given display. This includes seabeds, land, shorelines, and vessels. It also + provides methods for adding various geometric shapes, updating vessel positions, + and managing the visibility of different layers. + + Attributes: + _display (object): The display object that this FeaturesManager interacts with. + show_vessels (bool): A flag indicating whether vessel features should be displayed. + _vessels (dict): A dictionary storing vessel features keyed by their IDs. + _seabeds (dict): A dictionary storing seabed features. + _land (object): The land feature object. + _shore (object): The shore feature object. + _extra_layers (dict): A dictionary for additional layers beyond seabeds and land. + _number_of_layers (int): The total number of layers in the display's environment. + _next_z_order (int): The next z-order value to be used for layering features. + """ def __init__(self, display): + """ + Initializes the FeaturesManager with a specified display object and + prepares spatial features for rendering. + + :param display: The display object that will be managed by this FeaturesManager. + """ self._display = display self.show_vessels = True self._vessels = {} @@ -25,6 +48,11 @@ def __init__(self, display): self._init_layers() def _init_layers(self): + """ + Initializes the spatial feature layers such as seabeds, land, shore, and + any extra layers from the display's environment. It also sets their + corresponding z-orders for rendering. + """ seabeds = list(self._display._environment.map.bathymetry.values()) for i, seabed in enumerate(seabeds): if not seabed.geometry.is_empty: @@ -32,16 +60,16 @@ def _init_layers(self): color = color_picker(i, bins) self._seabeds[i] = self.assign_artist(seabed, self._get_next_z_order(), color) - # get two z-orders + # Determine z-orders for shore and land z_orders = [self._get_next_z_order(), self._get_next_z_order()] shore = self._display._environment.map.shore if isinstance(shore.geometry, MultiPolygon): # if shore is multipolygon (for fgdb) we draw it beneath the land - shore_z_order = z_orders[0] + shore_z_order = z_orders[0] # Shore drawn beneath land land_z_order = z_orders[1] else: # if shore is multilinestring we draw it as edges for land - shore_z_order = z_orders[1] + shore_z_order = z_orders[1] # Shore drawn as edges for land land_z_order = z_orders[0] # creating shore @@ -67,15 +95,34 @@ def _init_layers(self): self.new_artist(geometry, color, z_order=self._get_next_z_order(), linewidth=3) def _get_next_z_order(self) -> int: + """ + Retrieves the next z-order for layering features and increments the z-order counter. + + :return: The next z-order value. + """ z_order = self._next_z_order self._next_z_order += 1 return z_order @property def animated(self): + """ + Returns a list of currently animated vessel artists. + + :return: A list of animated artists corresponding to vessels. + """ return [a for a in [v["artist"] for v in self._vessels.values()] if a] def assign_artist(self, layer, z_order, color): + """ + Assigns an artist to a layer based on its geometry type. + + :param layer: The spatial layer for which the artist is being assigned. + :param z_order: The z-order for rendering the layer. + :param color: The color for the layer's artist. + + :return: The created artist for the layer. + """ if isinstance(layer.geometry, MultiLineString): artist = [] for line in layer.geometry.geoms: @@ -85,6 +132,16 @@ def assign_artist(self, layer, z_order, color): return artist def new_artist(self, geometry, color, z_order=None, **kwargs): + """ + Creates a new artist for a given geometry and adds it to the display. + + :param geometry: The geometry to be rendered. + :param color: The color of the geometry. + :param z_order: The z-order for rendering. + :param kwargs: Additional arguments to be passed to the artist creation. + + :return: The created artist for the geometry. + """ kwargs["crs"] = self._display.crs if z_order is not None: kwargs["zorder"] = z_order @@ -99,6 +156,16 @@ def new_artist(self, geometry, color, z_order=None, **kwargs): return artist def new_line_artist(self, line_geometry, color, z_order=None, **kwargs): + """ + Creates a new line artist for a given line geometry. + + :param line_geometry: The line geometry to be rendered. + :param color: The color of the line. + :param z_order: The z-order for rendering. + :param kwargs: Additional arguments for line customization. + + :return: The created line artist. + """ x, y = line_geometry.xy line = self._display.axes.add_line(Line2D(x, y, color=color, linewidth=kwargs.get('linewidth', 0.5))) if z_order is None: @@ -110,6 +177,20 @@ def new_line_artist(self, line_geometry, color, z_order=None, **kwargs): def add_arrow( self, start, end, color_name, buffer, fill, head_size, linewidth, linestyle ): + """ + Adds an arrow overlay from a start point to an end point. + + :param start: The starting coordinates of the arrow. + :param end: The ending coordinates of the arrow. + :param color_name: The name of the color for the arrow. + :param buffer: The buffer size for the arrow. + :param fill: Whether the arrow should be filled. + :param head_size: The size of the arrow head. + :param linewidth: The width of the arrow line. + :param linestyle: The style of the arrow line. + + :return: The created arrow artist. + """ if buffer is None: buffer = 5 if head_size is None: @@ -118,10 +199,35 @@ def add_arrow( return self.add_overlay(body, color_name, fill, linewidth, linestyle) def add_circle(self, center, radius, color_name, fill, linewidth, linestyle, alpha): + """ + Adds a circle overlay to the display. + + :param center: The center coordinates of the circle. + :param radius: The radius of the circle. + :param color_name: The name of the color for the circle. + :param fill: Whether the circle should be filled. + :param linewidth: The width of the circle outline. + :param linestyle: The style of the circle outline. + :param alpha: The transparency level of the circle. + + :return: The created circle artist. + """ 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): + """ + Adds a line overlay to the display using a list of points. + + :param points: The list of (x, y) points for the line. + :param color_name: The name of the color for the line. + :param buffer: The buffer size for the line. + :param linewidth: The width of the line. + :param linestyle: The style of the line. + :param marker: The marker style for the line. + + :return: The created line artist. + """ if buffer is None: buffer = 5 if buffer == 0: @@ -142,6 +248,18 @@ def add_line(self, points, color_name, buffer, linewidth, linestyle, marker): def add_polygon( self, shape, color, interiors, fill, linewidth, linestyle, alpha=1.0 ): + """ + Adds an overlay geometry to the display. + + :param geometry: The geometry to overlay. + :param color_name: The name of the color for the overlay. + :param fill: Whether the overlay should be filled. + :param linewidth: The width of the overlay line. + :param linestyle: The style of the overlay line. + :param alpha: The transparency level of the overlay. + + :return: The created overlay artist. + """ try: if isinstance(shape, geo.MultiPolygon) or isinstance( shape, geo.GeometryCollection @@ -160,12 +278,38 @@ def add_polygon( def add_rectangle( self, center, size, color_name, rotation, fill, linewidth, linestyle, alpha ): + """ + Adds a rectangle overlay to the display. + + :param center: The center coordinates of the rectangle. + :param size: The width and height of the rectangle. + :param color_name: The name of the color for the rectangle. + :param rotation: The rotation angle of the rectangle. + :param fill: Whether the rectangle should be filled. + :param linewidth: The width of the rectangle outline. + :param linestyle: The style of the rectangle outline. + :param alpha: The transparency of the rectangle. + + :return: The created rectangle artist. + """ geometry = shapes.Rectangle( *center, heading=rotation, width=size[0], height=size[1] ).geometry self.add_overlay(geometry, color_name, fill, linewidth, linestyle, alpha) def add_overlay(self, geometry, color_name, fill, linewidth, linestyle, alpha=1.0): + """ + Adds an overlay geometry to the display. + + :param geometry: The geometry to overlay. + :param color_name: The name of the color for the overlay. + :param fill: Whether the overlay should be filled. + :param linewidth: The width of the overlay line. + :param linestyle: The style of the overlay line. + :param alpha: The transparency level of the overlay. + + :return: The created overlay artist. + """ color = color_picker(color_name) if fill is False: color = color[0], "none" @@ -178,6 +322,10 @@ def add_overlay(self, geometry, color_name, fill, linewidth, linestyle, alpha=1. return self.new_artist(geometry, color, 0, **kwargs) def update_vessels(self): + """ + Updates the vessels displayed on the plot by reading ship positions + from a data source and replacing the existing vessel artists. + """ if self.show_vessels: entries = list(core.files.read_ship_poses()) if entries is not None: @@ -203,11 +351,22 @@ def update_vessels(self): self.replace_vessels(new_vessels) def replace_vessels(self, new_artists): + """ + Replaces the currently displayed vessel artists with new ones. + + :param new_artists: A dictionary of new vessel artists keyed by their IDs. + """ for vessel in self._vessels.values(): vessel["artist"].remove() self._vessels = new_artists def toggle_vessels_visibility(self, new_state: bool = None): + """ + Toggles the visibility of vessel features on the display. + + :param new_state: If provided, sets the visibility state; + otherwise toggles the current state. + """ if new_state is None: new_state = not self.show_vessels self.show_vessels = new_state @@ -219,6 +378,12 @@ def toggle_vessels_visibility(self, new_state: bool = None): @staticmethod def set_visibility(artist, new_state): + """ + Sets the visibility of an artist or a list of artists. + + :param artist: The artist or list of artists to set visibility for. + :param new_state: The visibility state to set. + """ if not isinstance(artist, list): artist.set_visible(new_state) else: @@ -226,6 +391,12 @@ def set_visibility(artist, new_state): line.set_visible(new_state) def toggle_topography_visibility(self, new_state: bool = None): + """ + Toggles the visibility of land and shore features. + + :param new_state: If provided, sets the visibility state; + otherwise toggles the current state. + """ if new_state is None: new_state = not self._land.get_visible() self.set_visibility(self._land, new_state) @@ -233,41 +404,87 @@ def toggle_topography_visibility(self, new_state: bool = None): self._display.redraw_plot() def show_top_hidden_layer(self): + """ + Shows the next hidden seabed layer that is above the current top layer. + """ artists = self._z_sorted_seabeds(descending=False) self._toggle_next_visibility_layer(artists, visibility=True) def hide_top_visible_layer(self): + """ + Hides the topmost visible seabed layer. + """ artists = self._z_sorted_seabeds(descending=False) self._toggle_next_visibility_layer(artists, visibility=False) def hide_bottom_visible_layer(self): + """ + Hides the bottommost visible seabed layer. + """ artists = self._z_sorted_seabeds(descending=True) self._toggle_next_visibility_layer(artists, visibility=False) def show_bottom_hidden_layer(self): + """ + Shows the next hidden seabed layer that is below the current bottom layer. + """ artists = self._z_sorted_seabeds(descending=True) self._toggle_next_visibility_layer(artists, visibility=True) def _toggle_next_visibility_layer(self, artists, visibility): + """ + Toggles the visibility of the next layer in the provided list of artists. + + :param artists: A list of artists representing seabed layers. + :param visibility: The visibility state to set for the layer. + """ if self._any_toggleable_layer(artists, not visibility): artist = self._next_visibility_layer(artists, not visibility) artist.set_visible(visibility) self._display.redraw_plot() def _z_sorted_seabeds(self, descending=False): + """ + Retrieves the seabed artists sorted by their z-order. + + :param descending (bool): If True, sorts in descending order. + + :return: A list of seabed artists sorted by z-order. + """ artist_keys = sorted(self._seabeds, reverse=descending) return [self._seabeds[key] for key in artist_keys] @staticmethod def _any_toggleable_layer(artists, visible): + """ + Checks if any artist in the list has the specified visibility state. + + :param artists: A list of artists to check. + :param visible: The visibility state to check against. + + :return: True if any artist has the specified visibility state, False otherwise. + """ return any([a.get_visible() is visible for a in artists]) @staticmethod def _next_visibility_layer(artists, visibility): + """ + Finds the next artist in the list with the specified visibility state. + + :param artists: A list of artists to search. + :param visibility: The visibility state to look for. + + :return: The next artist that matches the specified visibility state. + """ return next(a for a in artists if a.get_visible() is visibility) @staticmethod def vessels_to_file(vessel_poses: list[tuple]) -> None: + """ + Writes vessel positions to a CSV file. + + :param vessel_poses: A list of tuples containing vessel data to write. + """ core.files.write_rows_to_csv( [("id", "x", "y", "heading", "color")] + vessel_poses, core.paths.vessels, diff --git a/seacharts/enc.py b/seacharts/enc.py index 713b599..307cb7d 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -28,6 +28,13 @@ def __init__(self, config: Config | Path | str = None): self._display = None def get_depth_at_coord(self, easting, northing) -> int: + """ + Retrieves the seabed depth at a given coordinate. + + :param easting: The easting (x-coordinate) in the coordinate system used by ENC. + :param northing: The northing (y-coordinate) in the coordinate system used by ENC. + :return: Depth as an integer if the point is within a seabed polygon, else None. + """ point = Point(easting, northing) for seabed in reversed(self.seabed.values()): if any(polygon.contains(point) for polygon in seabed.geometry.geoms): @@ -35,6 +42,15 @@ def get_depth_at_coord(self, easting, northing) -> int: return None def is_coord_in_layer(self, easting, northing, layer_name:str): + """ + Checks if a coordinate is within a specified layer. + + :param easting: The easting (x-coordinate) in the coordinate system used by ENC. + :param northing: The northing (y-coordinate) in the coordinate system used by ENC. + :param layer_name: The name of the layer to check, as a string. + :return: True if the coordinate is in the specified layer; False otherwise. + :raises Exception: If the specified layer is not found. + """ layer_name = layer_name.lower() layers = self._environment.get_layers() point = Point(easting, northing) @@ -119,8 +135,14 @@ def depth_bins(self) -> list[int]: @property def weather_names(self) -> list[str]: + """ + :return: #TODO + """ return self._environment.weather.weather_names @property def weather_data(self) -> WeatherData: + """ + :return: #TODO + """ return self._environment.weather diff --git a/seacharts/environment/collection.py b/seacharts/environment/collection.py index 3f3a7ac..2d96ed5 100644 --- a/seacharts/environment/collection.py +++ b/seacharts/environment/collection.py @@ -9,29 +9,73 @@ @dataclass class DataCollection(ABC): + """ + Abstract base class for collections of parsed spatial data. + + This class serves as a blueprint for managing spatial data collections, + providing methods to retrieve loaded and unloaded regions. + + :param scope: The scope object defining the context and extent of the data collection. + :param parser: The DataParser instance responsible for parsing the spatial data. + """ scope: Scope - parser: DataParser #= field(init=False) + parser: DataParser @property @abstractmethod def layers(self) -> list[Layer]: + """ + Abstract property that should return a list of layers contained in the data collection. + + :return: A list of Layer instances. + """ raise NotImplementedError @property def loaded_regions(self) -> list[Layer]: + """ + Retrieves the regions that have been successfully loaded and contain geometry. + + :return: A list of loaded Layer instances (regions). + """ return [layer for layer in self.layers if not layer.geometry.is_empty] @property def not_loaded_regions(self) -> list[Layer]: + """ + Retrieves the regions that have not been loaded or contain no geometry. + + :return: A list of Layer instances that are empty. + """ return [layer for layer in self.layers if layer.geometry.is_empty] @property def loaded(self) -> bool: + """ + Checks if any regions in the collection have been loaded. + + :return: True if at least one region is loaded; otherwise, False. + """ return any(self.loaded_regions) @dataclass class ShapefileBasedCollection(DataCollection, ABC): + """ + Abstract class for collections of spatial data that are based on shapefiles. + + This class provides methods for loading existing shapefiles and parsing + resources into shapefiles. + + :param scope: The scope object defining the context and extent of the data collection. + :param parser: The DataParser instance responsible for parsing the spatial data. + """ def load_existing_shapefiles(self) -> None: + """ + Loads existing shapefiles for the featured regions using the specified parser. + + If any spatial data is found, it prints a confirmation message; + otherwise, it indicates that no data was found. + """ for region in self.featured_regions: self.parser.load_shapefiles(region) if self.loaded: @@ -40,6 +84,13 @@ def load_existing_shapefiles(self) -> None: print("INFO: No existing spatial data was found.\n") def parse_resources_into_shapefiles(self) -> None: + """ + Parses resources into shapefiles for regions that have not been loaded. + + This method utilizes the parser to process the resources defined in the scope + and updates the ENC based on the results. It prints a completion message + based on the loading status of the regions. + """ self.parser.parse_resources( self.not_loaded_regions, self.scope.resources, self.scope.extent.area ) diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index 1effcb1..d7254d4 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -9,7 +9,19 @@ class Environment: + """ + Environment class to manage spatial data resources, parsing, and loading layers + for ENC. This class handles the setup of various spatial data components + and supports loading additional layers, weather data, and different map formats. + + :param settings: A dictionary containing configuration settings for the environment. + """ def __init__(self, settings: dict): + """ + Initializes the Environment instance with spatial data setup and parsing. + :param settings: A dictionary of configuration settings used to initialize Scope. + """ + self.scope = Scope(settings) self.parser = self.set_parser() files.build_directory_structure(self.scope.features, self.scope.resources, self.parser) @@ -27,14 +39,27 @@ def __init__(self, settings: dict): self.extra_layers.parse_resources_into_shapefiles() def get_layers(self): + """ + Retrieves all loaded map and extra layers in the environment. + + :return: A list of all loaded layers, including both map and extra layers. + """ return [ *self.map.loaded_regions, *self.extra_layers.loaded_regions, ] def set_parser(self) -> DataParser: + """ + Sets the appropriate parser based on the map format specified in the scope. + + :return: A DataParser instance specific to the map format (S57 or FGDB). + :raises ValueError: If the map format is not supported. + """ if self.scope.type is MapFormat.S57: return S57Parser(self.scope.extent.bbox, self.scope.resources, self.scope.extent.out_proj) elif self.scope.type is MapFormat.FGDB: return FGDBParser(self.scope.extent.bbox, self.scope.resources) + else: + raise ValueError("Unsupported map format") diff --git a/seacharts/environment/extra.py b/seacharts/environment/extra.py index 98731c4..61eb7fc 100644 --- a/seacharts/environment/extra.py +++ b/seacharts/environment/extra.py @@ -2,19 +2,49 @@ from seacharts.layers.layer import Layer from .collection import ShapefileBasedCollection from dataclasses import dataclass - +""" +Contains the ExtraLayers class for managing additional layers from S57 maps (may be expanded for other formats in the future). +""" @dataclass class ExtraLayers(ShapefileBasedCollection): - + """ + Class for managing extra layers derived from S57 maritime maps. + + This class extends the ShapefileBasedCollection to handle additional layers + specified in the scope, enabling the loading and processing of extra layers + such as those beyond the standard DEPARE, LNDARE, and COALNE (stored in respectively: bathymetry, land and shoreline). + + :param scope: The scope object that includes information about extra layers + and their corresponding colors. + """ def __post_init__(self): + """ + Initializes the ExtraLayers instance by creating ExtraLayer instances + based on the extra layer specifications in the scope. + """ self.extra_layers : list[ExtraLayer] = [] for tag, color in self.scope.extra_layers.items(): self.extra_layers.append(ExtraLayer(tag=tag, color=color)) @property def layers(self) -> list[Layer]: + """ + Retrieves the list of extra layers. + + :return: A list of ExtraLayer instances that have been initialized + from the scope. + """ return self.extra_layers @property def featured_regions(self) -> list[Layer]: + """ + Retrieves the featured regions that are included in the extra layers. + + This property filters the layers to return only those that are + recognized as featured based on their tags in the scope. + + :return: A list of Layer instances that correspond to the featured + regions defined in the extra layers. + """ return [x for x in self.layers if x.tag in self.scope.extra_layers.keys()] diff --git a/seacharts/environment/map.py b/seacharts/environment/map.py index 89152ca..381506f 100644 --- a/seacharts/environment/map.py +++ b/seacharts/environment/map.py @@ -9,16 +9,47 @@ @dataclass class MapData(ShapefileBasedCollection): + """ + Class for managing parsed map data including bathymetry, land, and shore layers. + + This class extends ShapefileBasedCollection to encapsulate the data related + to navigational charts. It initializes layers for land, shore, and + bathymetric data based on the specified depths. + + :param scope: The scope object that defines the depth levels and features + relevant to the navigational charts. + """ def __post_init__(self): + """ + Initializes the MapData instance by creating Seabed instances for each + depth specified in the scope. Also initializes land and shore layers. + """ self.bathymetry = {d: Seabed(depth=d) for d in self.scope.depths} self.land = Land() self.shore = Shore() @property def layers(self) -> list[Layer]: + """ + Retrieves all layers associated with the map data. + + This includes the land layer, shore layer, and all bathymetry layers. + + :return: A list of Layer instances representing land, shore, and + bathymetric data. + """ return [self.land, self.shore, *self.bathymetry.values()] @property def featured_regions(self) -> list[Layer]: + """ + Retrieves the featured regions based on the specified features in the scope. + + This filters the layers to return only those that are recognized as + featured in the navigational charts. + + :return: A list of Layer instances that correspond to the featured + regions defined in the scope. + """ return [x for x in self.layers if x.label in self.scope.features] diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py index ef6d495..7c29fa4 100644 --- a/seacharts/layers/layer.py +++ b/seacharts/layers/layer.py @@ -14,14 +14,37 @@ @dataclass class Layer(Shape, ABC): + """ + Abstract base class representing a geometric layer with spatial data. + + This class serves as a foundation for all specific layer types, + providing common geometry handling and methods to unify geometries + from records. + + :param geometry: The geometry of the layer, defaulting to an empty MultiPolygon. + :param depth: An optional depth associated with the layer. + """ geometry: geobase.BaseMultipartGeometry = field(default_factory=geo.MultiPolygon) depth: int = None @property def label(self) -> str: + """ + Returns the label of the layer, derived from its name. + + :return: A string representing the lowercase name of the layer. + """ return self.name.lower() def _geometries_to_multi(self, multi_geoms, geometries, geo_class): + """ + Combines geometries into a single MultiGeometry. + + :param multi_geoms: A list of MultiGeometries to combine. + :param geometries: A list of geometries to add to the MultiGeometry. + :param geo_class: The class type for the resulting geometry (MultiPolygon or MultiLineString). + :return: A unified geometry of the specified type. + """ if len(geometries): geometries = self.as_multi(geometries) multi_geoms.append(geometries) @@ -31,22 +54,48 @@ def _geometries_to_multi(self, multi_geoms, geometries, geo_class): return geom def records_as_geometry(self, records: list[dict]) -> None: + """ + Converts a list of geometric data records into geometries for the layer. + + This method processes each record in the provided list, extracting geometric + representations based on the geometry type specified in each record. The + resulting geometries are organized into appropriate collections, which are + then unified into a single geometry representation for the layer. + + :param records: A list of dictionaries representing geometrical data. Each + dictionary is expected to contain information necessary for + constructing a geometry, which is handled by the + _record_to_geometry method. + + The method distinguishes between different types of geometries: + - Polygons and MultiPolygons are stored for area representations. + - LineStrings and MultiLineStrings are stored for linear representations. + + If any geometries are found, they are combined into a MultiGeometry format + appropriate for the layer's type (either MultiPolygon or MultiLineString). + """ + + # Initialize lists to store geometries by type geometries = [] multi_geoms = [] linestrings = [] multi_linestrings = [] + + # Process each record to convert it to a geometry if len(records) > 0: for record in records: + # Convert the record to a geometry using a helper method geom_tmp = self._record_to_geometry(record) + # Classify the geometry type and append it to the corresponding list if isinstance(geom_tmp, geo.Polygon): - geometries.append(geom_tmp) + geometries.append(geom_tmp) # For area geometries elif isinstance(geom_tmp, geo.MultiPolygon): - multi_geoms.append(geom_tmp) + multi_geoms.append(geom_tmp) # For multiple area geometries elif isinstance(geom_tmp, geo.LineString): - linestrings.append(geom_tmp) + linestrings.append(geom_tmp) # For linear geometries elif isinstance(geom_tmp, geo. MultiLineString): - multi_linestrings.append(geom_tmp) + multi_linestrings.append(geom_tmp) # For multiple linear geometries if len(geometries) + len(multi_geoms) > 0: self.geometry = self._geometries_to_multi(multi_geoms, geometries, geo.MultiPolygon) @@ -55,34 +104,59 @@ def records_as_geometry(self, records: list[dict]) -> None: self.geometry = self._geometries_to_multi(multi_linestrings, linestrings, geo.MultiLineString) def unify(self, records: list[dict]) -> None: + """ + Unifies geometries from a list of records into the layer's geometry. + + :param records: A list of dictionaries representing geometrical data. + """ geometries = [self._record_to_geometry(r) for r in records] self.geometry = self.collect(geometries) @dataclass class ZeroDepthLayer(Layer, ZeroDepth, ABC): + """Layer type representing geometries at zero depth.""" ... @dataclass class SingleDepthLayer(Layer, SingleDepth, ABC): + """Layer type representing geometries at a single depth.""" @property def name(self) -> str: + """ + Returns the name of the layer including its depth. + + :return: A string representing the name of the layer with depth. + """ return self.__class__.__name__ + f"{self.depth}m" @dataclass class MultiDepthLayer(Layer, MultiDepth, ABC): + """Layer type representing geometries at multiple depths.""" ... @dataclass class WeatherLayer: + """ + Class representing weather data at a specific time. + + :param time: The time associated with the weather data. + :param data: A list of weather data points. + """ time: int data: list[list[float]] @dataclass class VirtualWeatherLayer: + """ + Class representing a collection of weather data. + + :param name: The name of the virtual weather layer. + :param weather: A list of WeatherLayer instances. + """ name: str weather: list[WeatherLayer] diff --git a/seacharts/layers/layers.py b/seacharts/layers/layers.py index 09c5f9e..067d6c3 100644 --- a/seacharts/layers/layers.py +++ b/seacharts/layers/layers.py @@ -8,20 +8,28 @@ @dataclass class Seabed(SingleDepthLayer): + """Layer representing seabed geometries at a single depth.""" ... @dataclass class Land(ZeroDepthLayer): + """Layer representing land geometries at zero depth.""" ... @dataclass class Shore(ZeroDepthLayer): + """Layer representing shore geometries at zero depth.""" ... @dataclass class ExtraLayer(ZeroDepthLayer): + """ + Class for defining extra layers with additional attributes. + + :param tag: A tag associated with the extra layer - originates from S57 tags, will be later treated as name. + """ tag:str = None @property def name(self) -> str: diff --git a/seacharts/layers/types.py b/seacharts/layers/types.py index 86a4f18..69db9bb 100644 --- a/seacharts/layers/types.py +++ b/seacharts/layers/types.py @@ -6,16 +6,19 @@ @dataclass class ZeroDepth: + """Class for layers that exist at zero depth.""" depth = 0 @dataclass class SingleDepth: + """Class for layers that exist at a single specified depth.""" depth: int @dataclass class MultiDepth: + """Class for layers that can exist at multiple depths.""" @property def depth(self) -> None: raise AttributeError("Multi-depth shapes have no single depth.") From cf237a3e2a46a59bc2dece7094205164c913df5f Mon Sep 17 00:00:00 2001 From: miqn Date: Sun, 3 Nov 2024 16:01:25 +0100 Subject: [PATCH 75/84] fixes --- seacharts/core/extent.py | 10 +++++----- seacharts/core/parser.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index 2e2cefb..93e605f 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -39,9 +39,9 @@ def __init__(self, settings: dict): if crs.__eq__("WGS84"): # If CRS is WGS84, convert latitude/longitude to UTM - self.utm_zone_num = self.wgs2utm(self.center[0]) + self.utm_zone = self.wgs2utm(self.center[0]) self.southern_hemisphere = Extent._is_southern_hemisphere(center_east=self.center[1]) - self.out_proj = Extent._get_epsg_proj_code(self.utm_zone_num, self.southern_hemisphere) + self.out_proj = Extent._get_epsg_proj_code(self.utm_zone, self.southern_hemisphere) self.size = self._size_from_lat_long() # Convert origin from lat/lon to UTM, recalculate center in UTM coordinates self.origin = self.convert_lat_lon_to_utm(self.origin[1], self.origin[0]) @@ -50,12 +50,12 @@ def __init__(self, settings: dict): # For UTM CRS, extract zone and hemisphere, and set EPSG projection code accordingly crs = re.search(r'\d+[A-Z]', crs).group(0) # eg. UTM33N: - # utm_zone_num = 33 + # utm_zone = 33 # crs_hemisphere_code = 'N' - self.utm_zone_num = crs[0:2] + self.utm_zone = crs[0:2] crs_hemisphere_code = crs[2] self.southern_hemisphere = Extent._is_southern_hemisphere(crs_hemisphere_sym=crs_hemisphere_code) - self.out_proj = Extent._get_epsg_proj_code(self.utm_zone_num, self.southern_hemisphere) + self.out_proj = Extent._get_epsg_proj_code(self.utm_zone, self.southern_hemisphere) # Calculate bounding box and area based on origin and size self.bbox = self._bounding_box_from_origin_size() diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index d911cb3..7918075 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -90,7 +90,7 @@ def load_shapefiles(self, layer: Layer) -> None: layer.records_as_geometry(records) layer.records= records - def _valid_paths_and_resources(paths: set[Path], resources: list[str], area: float)-> bool: + def _valid_paths_and_resources(self, paths: set[Path], resources: list[str], area: float)-> bool: """ Validates the provided paths and resources, checking if they exist and are usable. From 330a94980f2ae73c4a38d13e52c7561887fbafa7 Mon Sep 17 00:00:00 2001 From: miqn <109998643+meeqn@users.noreply.github.com> Date: Mon, 4 Nov 2024 19:32:31 +0100 Subject: [PATCH 76/84] pushing user_doc to remote (#27) --- user_doc/images/test_results.svg | 39501 +++++++++++++++++++++++++++++ user_doc/seacharts_user_doc.md | 406 + 2 files changed, 39907 insertions(+) create mode 100644 user_doc/images/test_results.svg create mode 100644 user_doc/seacharts_user_doc.md diff --git a/user_doc/images/test_results.svg b/user_doc/images/test_results.svg new file mode 100644 index 0000000..42bbaef --- /dev/null +++ b/user_doc/images/test_results.svg @@ -0,0 +1,39501 @@ + + + + + + + + 2024-10-03T16:37:32.584831 + image/svg+xml + + + Matplotlib v3.8.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/user_doc/seacharts_user_doc.md b/user_doc/seacharts_user_doc.md new file mode 100644 index 0000000..46dec38 --- /dev/null +++ b/user_doc/seacharts_user_doc.md @@ -0,0 +1,406 @@ +# SeaCharts Library - Version 4.0, User Documentation + +> **Important**: Current version requires Conda environment setup. Pip installation support is planned for future releases. + +## Table of Contents +1. [Initial Setup](#initial-setup) +2. [Configuration Setup](#configuration-setup) +3. [ENC Class for Maritime Spatial Data](#enc-class-for-maritime-spatial-data) +4. [Display Features](#display-features) +5. [Weather Visualization](#weather-visualization) +6. [Interactive Controls](#interactive-controls) +7. [Drawing Functions](#drawing-functions) +8. [Image Export](#image-export) + +## Initial Setup + +1. Clone the repository: +```bash +git clone https://github.com/meeqn/seacharts_s57 +``` + +2. Set up the Conda environment: + * Use the provided `conda_requirements.txt` file: + ```bash + conda create --name --file conda_requirements.txt + conda activate + ``` + +3. Set up directory structure: + * **Windows users**: Use the provided `setup.ps1` PowerShell script + * **Other platforms**: Manually create these directories: + * `data` + * `data/db` + * `data/shapefiles` + +4. Download map data: + * Download the `US1GC09M` map from [here](https://www.charts.noaa.gov/ENCs/US1GC09M.zip) + * Extract and place the `US1GC09M` directory (found in map's ENC_ROOT directory) inside `data/db` folder + +5. Test the installation: + * Either run `test_seacharts_4_0.py` directly (may need manual setting up PYTHONPATH for run configuration) + * Or copy the test code into a `main.py` file in your project root directory + + + +![Expected output](images/test_results.svg "Expected test_seacharts_4_0.py output") + +### Weather Module Setup (Optional) + +If you need weather functionality: + +```bash +git clone https://github.com/SanityRemnants/PyThor +cd PyThor +conda create --name --file requirements.txt +conda activate -n +python app.py +``` + +## Configuration Setup + +### Configuration File Structure + +The SeaCharts library is configured via `config.yaml` located in the seacharts directory (by default). Below are the detailed configuration options: + +### ENC (Electronic Navigation Chart) Configuration + +```yaml +enc: + size: [width, height] # Size of the chart in chosen CRS unit + origin: [lon, lat] # Origin coordinates in chosen CRS unit (excludes center) + center: [lon, lat] # Center point coordinates in chosen CRS unit (excludes origin) + crs: "coordinate_system" # Coordinate reference system + S57_layers: # List of additional S-57 layers with display colors given in hex as value + "LAYER_NAME": "#COLOR_IN_HEX" # e.g., "TSSLPT": "#8B0000" + resources: [data_paths] # Path to ENC data root, is currently a list but expects one argument +``` + +#### Important Notes on ENC Configuration: +- `origin` and `center` are mutually exclusive - use only one +- For `CRS`, you can use: + - "WGS84" for **latitude/longitude coordinates** (required for `S57 maps`) + - "UTM" with zone and hemisphere, for **easting/northing coordinates** (e.g., "UTM33N" for UTM zone 33 North, used for `FGDB maps`) +- `S57_layers` field is **required** for S57 maps (can be empty list) +- Default S-57 layers (automatically included, dont need to be specified in `S57_layers` field): + - `LNDARE` (Land) + - `DEPARE` (Depth Areas) + - `COALNE` (Coastline) +- The resources directory should contain path to **only one** map +- A useful S57 layer catalogue can be found at: https://www.teledynecaris.com/s-57/frames/S57catalog.htm + +### Weather Configuration + +```yaml +weather: + PyThor_address: "http://127.0.0.1:5000" # PyThor server address + variables: [ # Weather variables to display + "wave_direction", + "wave_height", + "wave_period", + "wind_direction", + "wind_speed", + "sea_current_speed", + "sea_current_direction", + "tide_height" + ] +``` + +### Time Configuration + +```yaml +time: + time_start: "DD-MM-YYYY HH:MM" # Start time (must match format exactly) + time_end: "DD-MM-YYYY HH:MM" # End time + period: String # Time period unit + period_multiplier: Integer # Multiplier for time period +``` + +#### Time Configuration Notes: +- Valid period values: + - "hour" + - "day" + - "week" + - "month" + - "year" +- Period multiplier works with hour, day, and week periods +- For example, for 2-hour intervals: + - period: "hour" + - period_multiplier: 2 +- Month and year periods **don't support** multipliers + +### Display Configuration + +```yaml +display: + colorbar: Boolean # Enable/disable depth colorbar (default: False) + dark_mode: Boolean # Enable/disable dark mode (default: False) + fullscreen: Boolean # Enable/disable fullscreen mode (default: False) + controls: Boolean # Show/hide controls window (default: True) + resolution: Integer # Display resolution (default: 640) + anchor: String # Window position ("center", "top_left", etc.) + dpi: Integer # Display DPI (default: 96) +``` + +### PyThor Configuration +For Copernicus marine data access (sea currents and tides), configure PyThor: + +```yaml +coppernicus_account: + username: "your_username" # Copernicus marine account username + password: "your_password" # Copernicus marine account password +resolution: float # Output data resolution in degrees +``` + +## ENC Class for Maritime Spatial Data + +> **Important API Note**: All SeaCharts API functions expect coordinates in **UTM CRS** (easting and northing), regardless of the CRS set in *config.yaml*. + +The ENC class provides methods for handling and visualizing maritime spatial data, including reading, storing, and plotting from specified regions. + +### Key Functionalities + +* **Initialization** + * The ENC object can be initialized with a path to a config.yaml file or a Config object. + +* **Geometric Data Access** + * The ENC provides attributes for accessing spatial layers: + * `land`: Contains land shapes. + * `shore`: Contains shorelines. + * `seabed`: Contains bathymetric (seafloor) data by depth. + +* **Coordinate and Depth Retrieval** + * `get_depth_at_coord(easting, northing)`: Returns the depth at a specific coordinate. + * `is_coord_in_layer(easting, northing, layer_name)`: Checks if a coordinate falls within a specified layer. + +* **Visualization** + * `display`: Returns a Display instance to visualize marine geometric data and vessels. + +* **Spatial Data Update** + * `update()`: Parses and updates ENC data from specified resources. + +### Example Usage + +```python +from seacharts import ENC + +# Initialize ENC with configuration +enc = ENC("config.yaml") + +# Get depth at specific UTM coordinates +depth = enc.get_depth_at_coord(easting, northing) + +# Check if coordinates are in a specific layer (e.g., TSSLPT) +in_traffic_lane = enc.is_coord_in_layer(easting, northing, "TSSLPT") + +# Add a vessel and display +display = enc.display +display.add_vessels((1, easting, northing, 45, "red")) +display.show() +``` + +## Display Features + +The Display class provides various methods to control the visualization: + +### Basic Display Controls + +```python +display.start() # Start the display +display.show(duration=0.0) # Show display for specified duration (0 = indefinite) +display.close() # Close the display window +``` + +### View Modes + +```python +display.dark_mode(enabled=True) # Toggle dark mode +display.fullscreen(enabled=True) # Toggle fullscreen mode +display.colorbar(enabled=True) # Toggle depth colorbar +``` + +### Plot Management + +```python +display.redraw_plot() # Redraw the entire plot +display.update_plot() # Update only animated elements +``` + +## Weather Visualization + +Weather data can be visualized using various variables: + +* Wind (speed and direction) +* Waves (height, direction, period) +* Sea currents (speed and direction) +* Tide height + +The visualization type is automatically selected based on the variable: + +* Scalar values: displayed as heatmaps +* Vector values: displayed as arrow maps with direction indicators + +## Interactive Controls + +When `controls: True` in the config, a control panel provides: + +* **Time Slider** + * Allows navigation through different timestamps with labels for date and time. +* **Layer Selection** + * Includes radio buttons to select weather variables such as wind, waves, and sea current. + +## Drawing Functions + +The Display class offers various drawing functions for maritime shapes: + +### Vessel Management + +```python +display.add_vessels(*vessels) # Add vessels to display +# vessels format: (id, x, y, heading, color) +display.clear_vessels() # Remove all vessels +``` + +### Shape Drawing + +#### Lines + +```python +display.draw_line( + points=[(x1,y1), ...], # List of coordinate pairs + color="color_string", # Line color + width=float, # Optional: line width + thickness=float, # Optional: line thickness + edge_style=str|tuple, # Optional: line style + marker_type=str # Optional: point marker style +) +``` +#### Circles + +```python +display.draw_circle( + center=(x, y), # Center coordinates + radius=float, # Circle radius + color="color_string", # Circle color + fill=Boolean, # Optional: fill circle (default: True) + thickness=float, # Optional: line thickness + edge_style=str|tuple, # Optional: line style + alpha=float # Optional: transparency (0-1) +) +``` + +#### Rectangles + +```python +display.draw_rectangle( + center=(x, y), # Center coordinates + size=(width, height), # Rectangle dimensions + color="color_string", # Rectangle color + rotation=float, # Optional: rotation in degrees + fill=Boolean, # Optional: fill rectangle (default: True) + thickness=float, # Optional: line thickness + edge_style=str|tuple, # Optional: line style + alpha=float # Optional: transparency (0-1) +) +``` + +#### Polygons + +```python +display.draw_polygon( + geometry=shape_geometry, # Shapely geometry or coordinate list + color="color_string", # Polygon color + interiors=[[coords]], # Optional: interior polygon coordinates + fill=Boolean, # Optional: fill polygon (default: True) + thickness=float, # Optional: line thickness + edge_style=str|tuple, # Optional: line style + alpha=float # Optional: transparency (0-1) +) +``` + +#### Arrows + +```python +display.draw_arrow( + start=(x1, y1), # Start coordinates + end=(x2, y2), # End coordinates + color="color_string", # Arrow color + width=float, # Optional: line width + fill=Boolean, # Optional: fill arrow (default: False) + head_size=float, # Optional: arrow head size + thickness=float, # Optional: line thickness + edge_style=str|tuple # Optional: line style +) +``` + +### Available color options +1. **Ship Colors**: Predefined colors specifically intended for objects or ship-related overlays. These colors have both solid and semi-transparent options: + + - **Red**: `"red"` or `#ff0000`, semi-transparent: `#ff000055` + - **Blue**: `"blue"` or `#0000ff`, semi-transparent: `#0000ff55` + - **Green**: `"green"` or `#00ff00`, semi-transparent: `#00ff0055` + - **Yellow**: `"yellow"` or `#ffff00`, semi-transparent: `#ffff0055` + - **Cyan**: `"cyan"` or `#00ffff`, semi-transparent: `#00ffff55` + - **Magenta**: `"magenta"` or `#ff00ff`, semi-transparent: `#ff00ff55` + - **Pink**: `"pink"` or `#ff88ff`, semi-transparent: `#ff88ff55` + - **Purple**: `"purple"` or `#bb22ff`, semi-transparent: `#bb22ff55` + - **Orange**: `"orange"` or `#ff9900`, semi-transparent: `#ff990055` + - **White**: `"white"` or `#ffffff`, semi-transparent: `#ffffff77` + - **Grey Variants**: + - Light Grey: `"lightgrey"` or `#b7b7b7`, semi-transparent: `#b7b7b755` + - Grey: `"grey"` or `#666666`, semi-transparent: `#66666655` + - Dark Grey: `"darkgrey"` or `#333333`, semi-transparent: `#33333355` + - **Black**: `"black"` or `#000000`, semi-transparent: `#00000077` + +2. **Horizon Colors**: Specialized colors for visualizing different regions of a horizon. Each area has a unique color, with both solid and semi-transparent options: + + - **Full Horizon**: `"full_horizon"` or `#ffffff55`, very transparent: `#ffffff11` + - **Starboard Bow**: `"starboard_bow"` or `#00ff0099`, semi-transparent: `#00ff0055` + - **Starboard Side**: `"starboard_side"` or `#33ff3399`, semi-transparent: `#33ff3355` + - **Starboard Aft**: `"starboard_aft"` or `#ccffcc99`, semi-transparent: `#ccffcc55` + - **Rear Aft**: `"rear_aft"` or `#eeeeee99`, semi-transparent: `#eeeeee55` + - **Port Aft**: `"port_aft"` or `#ffcccc99`, semi-transparent: `#ffcccc55` + - **Port Side**: `"port_side"` or `#ff333388`, semi-transparent: `#ff333355` + - **Port Bow**: `"port_bow"` or `#ff000066`, semi-transparent: `#ff000055` + +3. **Layer Colors**: Colors that represent specific environmental layers, created by sampling from colormaps. These can be useful for visualizing different regions of a plot: + + - **Seabed**: `"Seabed"`, a deep shade of blue from the "Blues" colormap + - **Land**: `"Land"`, a rich green from the "Greens" colormap + - **Shore**: `"Shore"`, a lighter shade of green from the "Greens" colormap + - **Highlight**: `"highlight"`, a semi-transparent white: `#ffffff44` + - **Blank**: `"blank"`, an opaque white: `#ffffffff` + +4. **CSS4 Color Names**: Any color name from the CSS4 color set is supported. These are standard color names recognized by most plotting libraries, including `matplotlib`. Examples include: + - `"aliceblue"` + - `"antiquewhite"` + - `"aqua"` + - `"aquamarine"` + - `"azure"` + - (and many more CSS4 color names...) + +5. **Hexadecimal Color Codes**: Custom colors can be specified directly using hexadecimal color codes. Accepted formats include: + - **RGB**: `"#RRGGBB"` or `"#RGB"` + - **RGBA**: `"#RRGGBBAA"` or `"#RGBA"` + + For example: + - Solid blue: `"#0000FF"` + - Semi-transparent red: `"#FF000080"` + +## Image Export + +The `save_image` function allows you to export the current display or plot as an image file. This function offers several customizable options for setting the file name, location, resolution, and file format. It wraps around the internal `_save_figure` method, which handles the actual saving and setting of default values when specific parameters are not provided. + +### Usage + +```python +display.save_image( + name=str, # Optional: output filename (default: current window title) + path=Path, # Optional: output directory path (default: "reports/") + scale=float, # Optional: resolution scaling factor (default: 1.0) + extension=str # Optional: file format extension (default: "png") +) +``` + +## End note +We recommend checking out files placed in `tests` directory as reference, to get familiar with the SeaCharts usage. \ No newline at end of file From abc2b257addc04e22e1f6e52bbde86f84bec6e6f Mon Sep 17 00:00:00 2001 From: Konrad Drozd Date: Thu, 7 Nov 2024 08:46:03 +0100 Subject: [PATCH 77/84] Added get_value for weather data (#25) * add weather utility function * Update config.yaml * Update config.yaml * Update config.yaml --- seacharts/environment/weather.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/seacharts/environment/weather.py b/seacharts/environment/weather.py index 2dac660..554fc0f 100644 --- a/seacharts/environment/weather.py +++ b/seacharts/environment/weather.py @@ -98,3 +98,19 @@ def find_by_name(self, name: str) -> VirtualWeatherLayer: if layer.name == name: return layer return None + + def get_value(self, name, time, lat, lon): + lon = 360 + lon if lon < 0 else lon + from scipy.interpolate import RegularGridInterpolator as rgi + time_epoch = time.timestamp() + grid = [] + times = [] + layers = self.find_by_name(name).weather + layers.sort(key = lambda layer: layer.time) + for i in range(len(layers)): + if layers[i].time >= time_epoch and i>0: + times = [layers[i - 1].time, layers[i].time] + grid = [layers[i - 1].data, layers[i].data] + break + fn = rgi((times,self.latitude,self.longitude), grid) + return fn((time_epoch,lat,lon)) From 5753843f0d3b8860446d5a0c3183670d7fe88c67 Mon Sep 17 00:00:00 2001 From: miqn <109998643+meeqn@users.noreply.github.com> Date: Wed, 13 Nov 2024 21:02:43 +0100 Subject: [PATCH 78/84] Minor fixes & tests update (#29) * minor fixes regarding setup and documentation * simplified testing files --- seacharts/config.yaml | 25 +- seacharts/core/files.py | 2 +- seacharts/enc.py | 5 +- setup.ps1 | 2 - tests/config_classic.yaml | 15 + tests/config_sc4.yaml | 17 + tests/test_seacharts.py | 8 +- tests/test_seacharts_4_0.py | 25 +- user_doc/images/test_results.svg | 13033 ++++++++++++++++++----------- user_doc/seacharts_user_doc.md | 63 +- 10 files changed, 8272 insertions(+), 4923 deletions(-) create mode 100644 tests/config_classic.yaml create mode 100644 tests/config_sc4.yaml diff --git a/seacharts/config.yaml b/seacharts/config.yaml index e52d57f..c44fcc8 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -4,41 +4,20 @@ enc: depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] crs: "WGS84" S57_layers: - "TSSLPT": "#8B0000" # extra layer (traffic separation scheme with color we want to display it in) + "TSSLPT": "#8B0000" resources: [ "data/db/US1GC09M" ] - # old config, working with More og Romsdal map and SeaCharts 4.0 - # size: [ 9000, 5062 ] - # center: [ 44300, 6956450 ] - # depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] - # crs: "UTM33N" # remember to specify N or S for correct part of the globe - # resources: [ "data/db/More_og_Romsdal_utm33.gdb" ] - - # uncomment this section if PyThor is up and running - # weather: - # PyThor_address: "http://127.0.0.1:5000" - # variables: [ "wave_direction", - # "wave_height", - # "wave_period", - # "wind_direction", - # "wind_speed", - # "sea_current_speed", - # "sea_current_direction", - # "tide_height" - # ] - time: time_start: "24-09-2024 05:00" time_end: "24-09-2024 09:00" period: "hour" period_multiplier: 1 - display: colorbar: False dark_mode: False fullscreen: False - controls: True # use this for controls window to stop appearing + controls: True resolution: 640 anchor: "center" dpi: 96 diff --git a/seacharts/core/files.py b/seacharts/core/files.py index 436c270..d38f97f 100644 --- a/seacharts/core/files.py +++ b/seacharts/core/files.py @@ -37,7 +37,7 @@ def build_directory_structure(features: list[str], resources: list[str], parser: paths.shapefiles = paths.shapefiles / map_dir_name paths.output.mkdir(exist_ok=True) paths.shapefiles.mkdir(exist_ok=True) - shutil.copy(paths.config, paths.shapefiles) # used to save initial config + # shutil.copy(paths.config, paths.shapefiles) # used to save initial config for feature in features: shapefile_dir = paths.shapefiles / feature.lower() diff --git a/seacharts/enc.py b/seacharts/enc.py index 307cb7d..6c96fc0 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -48,8 +48,7 @@ def is_coord_in_layer(self, easting, northing, layer_name:str): :param easting: The easting (x-coordinate) in the coordinate system used by ENC. :param northing: The northing (y-coordinate) in the coordinate system used by ENC. :param layer_name: The name of the layer to check, as a string. - :return: True if the coordinate is in the specified layer; False otherwise. - :raises Exception: If the specified layer is not found. + :return: True if the coordinate is in the specified layer; False if not. Returns None if no matching layer was found. """ layer_name = layer_name.lower() layers = self._environment.get_layers() @@ -59,7 +58,7 @@ def is_coord_in_layer(self, easting, northing, layer_name:str): if any(polygon.contains(point) for polygon in layer.geometry.geoms): return True return False - raise Exception("no such layer loaded") + return None def update(self) -> None: """ diff --git a/setup.ps1 b/setup.ps1 index 8145a0b..58aa55c 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -2,6 +2,4 @@ $baseDir = "data" New-Item -ItemType Directory -Path $baseDir -Force -# Create 'shapefiles' and 'data' subdirectories inside 'db' -New-Item -ItemType Directory -Path "$baseDir\shapefiles" -Force New-Item -ItemType Directory -Path "$baseDir\db" -Force diff --git a/tests/config_classic.yaml b/tests/config_classic.yaml new file mode 100644 index 0000000..1093a22 --- /dev/null +++ b/tests/config_classic.yaml @@ -0,0 +1,15 @@ +enc: + size: [ 9000, 5062 ] + center: [ 44300, 6956450 ] + depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] + crs: "UTM33N" + resources: [ "data/db/More_og_Romsdal_utm33.gdb" ] + +display: + colorbar: False + dark_mode: True + fullscreen: False + controls: False + resolution: 640 + anchor: "center" + dpi: 96 \ No newline at end of file diff --git a/tests/config_sc4.yaml b/tests/config_sc4.yaml new file mode 100644 index 0000000..6f3f114 --- /dev/null +++ b/tests/config_sc4.yaml @@ -0,0 +1,17 @@ +enc: + size: [ 6.0, 4.5 ] + origin: [ -85.0, 20.0 ] + depths: [ 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500 ] + crs: "WGS84" + S57_layers: + "TSSLPT": "#8B0000" # extra layer (traffic separation scheme with color we want to display it in) + resources: [ "data/db/US1GC09M" ] + +display: + colorbar: False + dark_mode: False + fullscreen: False + controls: False + resolution: 640 + anchor: "center" + dpi: 96 \ No newline at end of file diff --git a/tests/test_seacharts.py b/tests/test_seacharts.py index a26b9b1..0ee12e5 100644 --- a/tests/test_seacharts.py +++ b/tests/test_seacharts.py @@ -1,10 +1,16 @@ +import sys, os + if __name__ == "__main__": + root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + sys.path.insert(0, root_path) + import shapely.geometry as geo from seacharts.enc import ENC size = 9000, 5062 center = 44300, 6956450 - enc = ENC() + config_path = os.path.join('tests','config_classic.yaml') + enc = ENC(config_path) enc.display.start() # (id, easting, northing, heading, color) diff --git a/tests/test_seacharts_4_0.py b/tests/test_seacharts_4_0.py index ddf9d01..a684c13 100644 --- a/tests/test_seacharts_4_0.py +++ b/tests/test_seacharts_4_0.py @@ -1,11 +1,16 @@ +import sys, os +from shapely.geometry import MultiPolygon, Polygon # file made to showcase functionalities added in seacharts 4.0 integrated within old seacharts functionalities # for if __name__ == "__main__": + root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + sys.path.insert(0, root_path) + import shapely.geometry as geo from seacharts import ENC - - enc = ENC() + config_path = os.path.join('tests','config_sc4.yaml') + enc = ENC(config_path) enc.display.start() coords = [111000, 2482000] @@ -17,8 +22,8 @@ center = enc.center x, y = center - width = 2000 - height = 2000 + width = 50000 + height = 50000 enc.display.draw_circle( center, 20000, "yellow", thickness=2, edge_style="--", alpha=0.5 ) @@ -38,7 +43,15 @@ (x - width, y + height), ) ) - areas = list(box.difference(enc.seabed[10].geometry).geoms) - for area in areas[3:8] + [areas[14], areas[17]] + areas[18:21]: + difference_result = box.difference(enc.seabed[0].geometry) + + if isinstance(difference_result, MultiPolygon): + areas = list(difference_result.geoms) + elif isinstance(difference_result, Polygon): + areas = [difference_result] + else: + areas = [] + + for area in areas: enc.display.draw_polygon(area, "red", alpha=0.5) enc.display.show() \ No newline at end of file diff --git a/user_doc/images/test_results.svg b/user_doc/images/test_results.svg index 42bbaef..a3236d8 100644 --- a/user_doc/images/test_results.svg +++ b/user_doc/images/test_results.svg @@ -6,7 +6,7 @@ - 2024-10-03T16:37:32.584831 + 2024-11-13T20:54:27.805109 image/svg+xml @@ -109,7 +109,7 @@ L 56.917803 274.097078 L 58.540978 275.476948 L 59.232105 276.756934 z -" clip-path="url(#pf887194ec1)" style="fill: #4a98c9; stroke: #4a98c9"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4a98c9; stroke: #4a98c9"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4a98c9; stroke: #4a98c9"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4a98c9; stroke: #4a98c9"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4a98c9; stroke: #4a98c9"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4a98c9; stroke: #4a98c9"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4a98c9; stroke: #4a98c9"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4a98c9; stroke: #4a98c9"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4a98c9; stroke: #4a98c9"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4a98c9; stroke: #4a98c9"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4a98c9; stroke: #4a98c9"/> +" clip-path="url(#pfc28b0988f)" style="fill: #3e8ec4; stroke: #3e8ec4"/> +" clip-path="url(#pfc28b0988f)" style="fill: #3e8ec4; stroke: #3e8ec4"/> +" clip-path="url(#pfc28b0988f)" style="fill: #3e8ec4; stroke: #3e8ec4"/> +" clip-path="url(#pfc28b0988f)" style="fill: #3e8ec4; stroke: #3e8ec4"/> +" clip-path="url(#pfc28b0988f)" style="fill: #3e8ec4; stroke: #3e8ec4"/> +" clip-path="url(#pfc28b0988f)" style="fill: #3e8ec4; stroke: #3e8ec4"/> +" clip-path="url(#pfc28b0988f)" style="fill: #3e8ec4; stroke: #3e8ec4"/> +" clip-path="url(#pfc28b0988f)" style="fill: #3e8ec4; stroke: #3e8ec4"/> +" clip-path="url(#pfc28b0988f)" style="fill: #3e8ec4; stroke: #3e8ec4"/> +" clip-path="url(#pfc28b0988f)" style="fill: #3e8ec4; stroke: #3e8ec4"/> - - - + - - - - - - + - - + + + + + + + + + + + + + + + + + + + + +" clip-path="url(#pfc28b0988f)" style="fill: #1f6eb3; stroke: #1f6eb3"/> +" clip-path="url(#pfc28b0988f)" style="fill: #1f6eb3; stroke: #1f6eb3"/> +" clip-path="url(#pfc28b0988f)" style="fill: #1f6eb3; stroke: #1f6eb3"/> +" clip-path="url(#pfc28b0988f)" style="fill: #1f6eb3; stroke: #1f6eb3"/> +" clip-path="url(#pfc28b0988f)" style="fill: #1f6eb3; stroke: #1f6eb3"/> +" clip-path="url(#pfc28b0988f)" style="fill: #1f6eb3; stroke: #1f6eb3"/> +" clip-path="url(#pfc28b0988f)" style="fill: #1f6eb3; stroke: #1f6eb3"/> +" clip-path="url(#pfc28b0988f)" style="fill: #1f6eb3; stroke: #1f6eb3"/> +" clip-path="url(#pfc28b0988f)" style="fill: #1f6eb3; stroke: #1f6eb3"/> - + - - - + + - + +" clip-path="url(#pfc28b0988f)" style="fill: #0e59a2; stroke: #0e59a2"/> +" clip-path="url(#pfc28b0988f)" style="fill: #0e59a2; stroke: #0e59a2"/> +" clip-path="url(#pfc28b0988f)" style="fill: #0e59a2; stroke: #0e59a2"/> - + +" clip-path="url(#pfc28b0988f)" style="fill: #084f99; stroke: #084f99"/> +" clip-path="url(#pfc28b0988f)" style="fill: #084f99; stroke: #084f99"/> +" clip-path="url(#pfc28b0988f)" style="fill: #084f99; stroke: #084f99"/> + + + + + + + + - + - - - +" clip-path="url(#pfc28b0988f)" style="fill: #08458a; stroke: #08458a"/> - + +" clip-path="url(#pfc28b0988f)" style="fill: #083a7a; stroke: #083a7a"/> +" clip-path="url(#pfc28b0988f)" style="fill: #083a7a; stroke: #083a7a"/> +" clip-path="url(#pfc28b0988f)" style="fill: #083a7a; stroke: #083a7a"/> - + +" clip-path="url(#pfc28b0988f)" style="fill: #08306b; stroke: #08306b"/> +" clip-path="url(#pfc28b0988f)" style="fill: #08306b; stroke: #08306b"/> +" clip-path="url(#pfc28b0988f)" style="fill: #08306b; stroke: #08306b"/> - + +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: #4bb062; stroke: #4bb062"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #6abf71; stroke-width: 0.5; stroke-linecap: square"/> - + +" clip-path="url(#pfc28b0988f)" style="fill: #8b0000; stroke: #8b0000"/> +" clip-path="url(#pfc28b0988f)" style="fill: #8b0000; stroke: #8b0000"/> - + - - - - - - - - - - - - - - - - +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke: #000000; stroke-width: 3"/> - - - - - - +" clip-path="url(#pfc28b0988f)" style="fill: #ffff00; fill-opacity: 0.5; stroke-dasharray: 7.4,3.2; stroke-dashoffset: 0; stroke: #ffff00; stroke-opacity: 0.5; stroke-width: 2"/> - +" style="stroke: #0000ff; stroke-opacity: 0.5"/> - - + + - - - - - - +" clip-path="url(#pfc28b0988f)" style="fill: none; stroke-dasharray: 15,24; stroke-dashoffset: 0; stroke: #00ff00; stroke-width: 3"/> - +" style="stroke: #ffffff"/> - - + + - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - + + - + - - - + + - + - - - - - - - - - - - + + - + - - - - - - - - - - - + + - + - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - + + - + - - - - - - - - - - - + + - + - - - + + - + - - - + + - + - - - + + - + - - - - - - - - - - - - - - - - - - - + + - + - - - + + - + - - - - - - - - - - - + + - + - - - + + - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + - - - + + - + - - - - - - - - - - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - + - - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - + - - - + + - + - - - - - - - - - - - - - - - - - - - + + - + - - - - - - - - - - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + - - - + + - + - - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + @@ -39137,73 +42514,73 @@ L 703.79364 40 L 703.79364 80 L 624.598637 80 L 624.598637 40 -" clip-path="url(#p17bf072963)" style="fill: #6abf71"/> +" clip-path="url(#pd52b0e94dd)" style="fill: #6abf71"/> +" clip-path="url(#pd52b0e94dd)" style="fill: #4a98c9"/> +" clip-path="url(#pd52b0e94dd)" style="fill: #3e8ec4"/> +" clip-path="url(#pd52b0e94dd)" style="fill: #3383be"/> +" clip-path="url(#pd52b0e94dd)" style="fill: #2979b9"/> +" clip-path="url(#pd52b0e94dd)" style="fill: #1f6eb3"/> +" clip-path="url(#pd52b0e94dd)" style="fill: #1764ab"/> +" clip-path="url(#pd52b0e94dd)" style="fill: #0e59a2"/> +" clip-path="url(#pd52b0e94dd)" style="fill: #084f99"/> +" clip-path="url(#pd52b0e94dd)" style="fill: #083a7a"/> - - + @@ -39218,7 +42595,7 @@ L 5 0 - + @@ -39233,7 +42610,7 @@ L 5 0 - + @@ -39248,7 +42625,7 @@ L 5 0 - + @@ -39289,7 +42666,7 @@ z - + @@ -39331,7 +42708,7 @@ z - + @@ -39347,7 +42724,7 @@ z - + @@ -39363,7 +42740,7 @@ z - + @@ -39379,7 +42756,7 @@ z - + @@ -39396,7 +42773,7 @@ z - + @@ -39413,7 +42790,7 @@ z - + @@ -39464,7 +42841,7 @@ z - + @@ -39486,7 +42863,7 @@ z - + - + diff --git a/user_doc/seacharts_user_doc.md b/user_doc/seacharts_user_doc.md index 46dec38..a8e51fe 100644 --- a/user_doc/seacharts_user_doc.md +++ b/user_doc/seacharts_user_doc.md @@ -31,15 +31,14 @@ git clone https://github.com/meeqn/seacharts_s57 * **Other platforms**: Manually create these directories: * `data` * `data/db` - * `data/shapefiles` 4. Download map data: * Download the `US1GC09M` map from [here](https://www.charts.noaa.gov/ENCs/US1GC09M.zip) * Extract and place the `US1GC09M` directory (found in map's ENC_ROOT directory) inside `data/db` folder 5. Test the installation: - * Either run `test_seacharts_4_0.py` directly (may need manual setting up PYTHONPATH for run configuration) - * Or copy the test code into a `main.py` file in your project root directory + * Run `test_seacharts_4_0.py` + * Expected result is shown below @@ -68,8 +67,8 @@ The SeaCharts library is configured via `config.yaml` located in the seacharts d ```yaml enc: size: [width, height] # Size of the chart in chosen CRS unit - origin: [lon, lat] # Origin coordinates in chosen CRS unit (excludes center) - center: [lon, lat] # Center point coordinates in chosen CRS unit (excludes origin) + origin: [x, y] # Origin coordinates in chosen CRS unit (excludes center) + center: [x, y] # Center point coordinates in chosen CRS unit (excludes origin) crs: "coordinate_system" # Coordinate reference system S57_layers: # List of additional S-57 layers with display colors given in hex as value "LAYER_NAME": "#COLOR_IN_HEX" # e.g., "TSSLPT": "#8B0000" @@ -333,60 +332,6 @@ display.draw_arrow( ) ``` -### Available color options -1. **Ship Colors**: Predefined colors specifically intended for objects or ship-related overlays. These colors have both solid and semi-transparent options: - - - **Red**: `"red"` or `#ff0000`, semi-transparent: `#ff000055` - - **Blue**: `"blue"` or `#0000ff`, semi-transparent: `#0000ff55` - - **Green**: `"green"` or `#00ff00`, semi-transparent: `#00ff0055` - - **Yellow**: `"yellow"` or `#ffff00`, semi-transparent: `#ffff0055` - - **Cyan**: `"cyan"` or `#00ffff`, semi-transparent: `#00ffff55` - - **Magenta**: `"magenta"` or `#ff00ff`, semi-transparent: `#ff00ff55` - - **Pink**: `"pink"` or `#ff88ff`, semi-transparent: `#ff88ff55` - - **Purple**: `"purple"` or `#bb22ff`, semi-transparent: `#bb22ff55` - - **Orange**: `"orange"` or `#ff9900`, semi-transparent: `#ff990055` - - **White**: `"white"` or `#ffffff`, semi-transparent: `#ffffff77` - - **Grey Variants**: - - Light Grey: `"lightgrey"` or `#b7b7b7`, semi-transparent: `#b7b7b755` - - Grey: `"grey"` or `#666666`, semi-transparent: `#66666655` - - Dark Grey: `"darkgrey"` or `#333333`, semi-transparent: `#33333355` - - **Black**: `"black"` or `#000000`, semi-transparent: `#00000077` - -2. **Horizon Colors**: Specialized colors for visualizing different regions of a horizon. Each area has a unique color, with both solid and semi-transparent options: - - - **Full Horizon**: `"full_horizon"` or `#ffffff55`, very transparent: `#ffffff11` - - **Starboard Bow**: `"starboard_bow"` or `#00ff0099`, semi-transparent: `#00ff0055` - - **Starboard Side**: `"starboard_side"` or `#33ff3399`, semi-transparent: `#33ff3355` - - **Starboard Aft**: `"starboard_aft"` or `#ccffcc99`, semi-transparent: `#ccffcc55` - - **Rear Aft**: `"rear_aft"` or `#eeeeee99`, semi-transparent: `#eeeeee55` - - **Port Aft**: `"port_aft"` or `#ffcccc99`, semi-transparent: `#ffcccc55` - - **Port Side**: `"port_side"` or `#ff333388`, semi-transparent: `#ff333355` - - **Port Bow**: `"port_bow"` or `#ff000066`, semi-transparent: `#ff000055` - -3. **Layer Colors**: Colors that represent specific environmental layers, created by sampling from colormaps. These can be useful for visualizing different regions of a plot: - - - **Seabed**: `"Seabed"`, a deep shade of blue from the "Blues" colormap - - **Land**: `"Land"`, a rich green from the "Greens" colormap - - **Shore**: `"Shore"`, a lighter shade of green from the "Greens" colormap - - **Highlight**: `"highlight"`, a semi-transparent white: `#ffffff44` - - **Blank**: `"blank"`, an opaque white: `#ffffffff` - -4. **CSS4 Color Names**: Any color name from the CSS4 color set is supported. These are standard color names recognized by most plotting libraries, including `matplotlib`. Examples include: - - `"aliceblue"` - - `"antiquewhite"` - - `"aqua"` - - `"aquamarine"` - - `"azure"` - - (and many more CSS4 color names...) - -5. **Hexadecimal Color Codes**: Custom colors can be specified directly using hexadecimal color codes. Accepted formats include: - - **RGB**: `"#RRGGBB"` or `"#RGB"` - - **RGBA**: `"#RRGGBBAA"` or `"#RGBA"` - - For example: - - Solid blue: `"#0000FF"` - - Semi-transparent red: `"#FF000080"` - ## Image Export The `save_image` function allows you to export the current display or plot as an image file. This function offers several customizable options for setting the file name, location, resolution, and file format. It wraps around the internal `_save_figure` method, which handles the actual saving and setting of default values when specific parameters are not provided. From 87f142694d6b81370d617fd3a5e50a786412952b Mon Sep 17 00:00:00 2001 From: miqn <109998643+meeqn@users.noreply.github.com> Date: Thu, 28 Nov 2024 13:39:41 +0100 Subject: [PATCH 79/84] getting parameters from layer (#30) --- seacharts/core/parser.py | 1 + seacharts/enc.py | 29 +++++++++++++++++----------- seacharts/environment/environment.py | 15 ++++++++++++-- seacharts/layers/layer.py | 11 +++++++++-- tests/test_seacharts_4_0.py | 2 +- 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index 7918075..09a4df1 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -89,6 +89,7 @@ def load_shapefiles(self, layer: Layer) -> None: records = list(self._read_shapefile(layer.label)) layer.records_as_geometry(records) layer.records= records + def _valid_paths_and_resources(self, paths: set[Path], resources: list[str], area: float)-> bool: """ diff --git a/seacharts/enc.py b/seacharts/enc.py index 6c96fc0..f8b272a 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -1,15 +1,15 @@ """ Contains the ENC class for reading, storing and plotting maritime spatial data. """ +import _warnings from pathlib import Path -from shapely.geometry import Point +from shapely.geometry import Point, Polygon from seacharts.core import Config from seacharts.display import Display from seacharts.environment import Environment from seacharts.environment.weather import WeatherData from seacharts.layers import Layer - class ENC: """ Electronic Navigational Charts @@ -27,7 +27,7 @@ def __init__(self, config: Config | Path | str = None): self._environment = Environment(self._config.settings) self._display = None - def get_depth_at_coord(self, easting, northing) -> int: + def get_depth_at_coord(self, easting: int, northing: int) -> int: """ Retrieves the seabed depth at a given coordinate. @@ -41,7 +41,7 @@ def get_depth_at_coord(self, easting, northing) -> int: return seabed.depth return None - def is_coord_in_layer(self, easting, northing, layer_name:str): + def is_coord_in_layer(self, easting: int, northing: int, layer_name: str): """ Checks if a coordinate is within a specified layer. @@ -50,14 +50,21 @@ def is_coord_in_layer(self, easting, northing, layer_name:str): :param layer_name: The name of the layer to check, as a string. :return: True if the coordinate is in the specified layer; False if not. Returns None if no matching layer was found. """ - layer_name = layer_name.lower() - layers = self._environment.get_layers() + layer = self._environment.get_layer_by_name(layer_name) point = Point(easting, northing) - for layer in layers: - if layer.label == layer_name: - if any(polygon.contains(point) for polygon in layer.geometry.geoms): - return True - return False + if layer is not None: + if any(polygon.contains(point) for polygon in layer.geometry.geoms): + return True + return False + + def get_param_value_at_coords(self, easting: int, northing: int, layer_name: str, param_name: str): + param_name = param_name.upper() + if self.is_coord_in_layer(easting, northing, layer_name): + layer: Layer = self._environment.get_layer_by_name(layer_name) + parameters: dict | None = layer.get_params_at_coord(easting, northing) + if parameters is not None: + return parameters[param_name] + _warnings.warn(f"Couldn't find any value for parameter {param_name} in layer {layer_name}") return None def update(self) -> None: diff --git a/seacharts/environment/environment.py b/seacharts/environment/environment.py index d7254d4..2ab6800 100644 --- a/seacharts/environment/environment.py +++ b/seacharts/environment/environment.py @@ -1,12 +1,13 @@ """ Contains the Environment class for collecting and manipulating loaded spatial data. """ +import _warnings from seacharts.core import Scope, MapFormat, S57Parser, FGDBParser, DataParser from .map import MapData from .weather import WeatherData from .extra import ExtraLayers from seacharts.core import files - +from seacharts.layers import Layer class Environment: """ @@ -38,7 +39,7 @@ def __init__(self, settings: dict): if len(self.extra_layers.not_loaded_regions) > 0: self.extra_layers.parse_resources_into_shapefiles() - def get_layers(self): + def get_layers(self) -> list[Layer]: """ Retrieves all loaded map and extra layers in the environment. @@ -48,6 +49,16 @@ def get_layers(self): *self.map.loaded_regions, *self.extra_layers.loaded_regions, ] + + def get_layer_by_name(self, layer_name: str) -> Layer | None: + layer_name = layer_name.lower() + layers = self.get_layers() + for layer in layers: + if layer.label == layer_name: + return layer + _warnings.warn(f"Layer {layer_name} not found in the enc!") + return None + def set_parser(self) -> DataParser: """ diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py index 7c29fa4..a2ec3a7 100644 --- a/seacharts/layers/layer.py +++ b/seacharts/layers/layer.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from shapely import geometry as geo -from shapely.geometry import base as geobase +from shapely.geometry import base as geobase, Polygon, Point from shapely.ops import unary_union from seacharts.layers.types import ZeroDepth, SingleDepth, MultiDepth @@ -26,6 +26,7 @@ class Layer(Shape, ABC): """ geometry: geobase.BaseMultipartGeometry = field(default_factory=geo.MultiPolygon) depth: int = None + records: list[dict] = None @property def label(self) -> str: @@ -112,7 +113,13 @@ def unify(self, records: list[dict]) -> None: geometries = [self._record_to_geometry(r) for r in records] self.geometry = self.collect(geometries) - + def get_params_at_coord(self, easting: int, northing: int) -> dict | None: + point = Point(easting, northing) + for record in self.records: + if record['geometry']['type'] == 'Polygon' and Polygon(record['geometry']['coordinates'][0]).contains(point): + return record['properties'] + return None + @dataclass class ZeroDepthLayer(Layer, ZeroDepth, ABC): """Layer type representing geometries at zero depth.""" diff --git a/tests/test_seacharts_4_0.py b/tests/test_seacharts_4_0.py index a684c13..2808e42 100644 --- a/tests/test_seacharts_4_0.py +++ b/tests/test_seacharts_4_0.py @@ -18,7 +18,7 @@ print(f"depth at coordinates {coords}: {enc.get_depth_at_coord(coords[0], coords[1])}") print(f"coordinates {coords} in layer {layer_label}: {enc.is_coord_in_layer(coords[0], coords[1], 'TSSLPT')}") - + print(f"ORIENT field val at {coords} is {enc.get_param_value_at_coords(coords[0], coords[1], 'TSSLPT', 'ORIENT')}") center = enc.center x, y = center From 06c65b4f24afcf379a56523872366d61466b8582 Mon Sep 17 00:00:00 2001 From: miqn <109998643+meeqn@users.noreply.github.com> Date: Mon, 12 May 2025 21:04:44 +0200 Subject: [PATCH 80/84] removed the min depth constraint from schema (#31) --- seacharts/config_schema.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index 727b070..946568c 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -46,7 +46,6 @@ enc: minlength: 1 schema: type: integer - min: 0 # you can pick specific S-57 layers you want to extract # WARNING: LNDARE, COALNE and DEPARE are loaded on default as Land, Shore and Bathymetry From abe7e431937496672840661014b9ebab53e7cd40 Mon Sep 17 00:00:00 2001 From: miqn <109998643+meeqn@users.noreply.github.com> Date: Mon, 3 Nov 2025 22:00:41 +0100 Subject: [PATCH 81/84] 2025 premerge tweaks (#32) * Refactor configuration schema and core classes for improved clarity and functionality. Use CoordTuple for XY paired data in Extent class - it notably improves readability and thus maintainability. In the future these types of coordinates and bounding boxes could be replaced with similar, more readable containers. Changelog: - Updated comments in config_schema.yaml for better understanding of parameters. - Enhanced Config class to support optional config_path parameter. - Introduced CoordTuple class in extent.py for better handling of 2D coordinates. - Modified methods in extent.py to utilize CoordTuple for size, origin, and center. - Adjusted ENC class to return coordinates as tuples from extent. - Cleaned up imports and removed unused code in various files. - Improved type hints across multiple classes for better code readability. * Enhance directory structure creation and improve error handling for layer retrieval * Remove unused autosize option from config schema --- seacharts/config_schema.yaml | 27 +++++----- seacharts/core/config.py | 28 +++++----- seacharts/core/extent.py | 84 ++++++++++++++++++----------- seacharts/core/files.py | 9 ++-- seacharts/core/parserFGDB.py | 9 +++- seacharts/core/paths.py | 10 ---- seacharts/core/scope.py | 2 +- seacharts/display/colors.py | 2 +- seacharts/display/display.py | 2 +- seacharts/display/features.py | 7 +-- seacharts/enc.py | 19 ++++--- seacharts/environment/collection.py | 5 ++ seacharts/environment/extra.py | 2 +- seacharts/layers/layer.py | 2 +- seacharts/shapes/shape.py | 4 +- 15 files changed, 125 insertions(+), 87 deletions(-) diff --git a/seacharts/config_schema.yaml b/seacharts/config_schema.yaml index 946568c..b9a9943 100644 --- a/seacharts/config_schema.yaml +++ b/seacharts/config_schema.yaml @@ -2,44 +2,47 @@ enc: required: True type: dict schema: - # set True if you want to display whole uploaded map - autosize: - required: False - type: boolean - - # size of displayed map + # Size of displayed map + # Vertical, horizontal in degrees or UTM units size: required: True type: list + minlength: 2 maxlength: 2 schema: type: float min: 1.0 - # that's where you want bottom-left corner to be + # That's where you want bottom-left corner to be + # Vertical, horizontal in degrees or UTM units origin: required: True excludes: center type: list + minlength: 2 maxlength: 2 schema: type: float - # that's where you want the center of a map to be + # That's where you want the center of a map to be + # Vertical, horizontal in degrees or UTM units center: required: True excludes: origin type: list + minlength: 2 maxlength: 2 schema: type: float # UTM zone required for coordinates to work correctly + # Set to WGS84 for lat/lon coordinates, UTM otherwise crs: required: True type: string - # depths are required for both formats, if not set they will be assigned default values + # Depths are required for both S57 and FGDB + # If not set they will be assigned default values, can be negative depths: required: False type: list @@ -47,8 +50,8 @@ enc: schema: type: integer - # you can pick specific S-57 layers you want to extract - # WARNING: LNDARE, COALNE and DEPARE are loaded on default as Land, Shore and Bathymetry + # Add specific S-57 layers (key) you want to extract, and assign them colors in HEX format (value) + # KEEP IN MIND that LNDARE, COALNE and DEPARE are loaded on default as Land, Shore and Bathymetry S57_layers: required: False type: dict @@ -58,7 +61,7 @@ enc: keysrules: type: string - # you must put paths to some resources + # You must put paths to some resources resources: required: True type: list diff --git a/seacharts/core/config.py b/seacharts/core/config.py index 6e83f54..d61a9d6 100644 --- a/seacharts/core/config.py +++ b/seacharts/core/config.py @@ -18,7 +18,7 @@ class Config: defined in a YAML configuration file. """ - def __init__(self, config_path: Path | str = None): + def __init__(self, config_path: Path | str | None = None): """ Initializes the Config object. @@ -34,6 +34,7 @@ def __init__(self, config_path: Path | str = None): self._settings = None self.parse(config_path) + @property def settings(self) -> dict: """ @@ -43,6 +44,7 @@ def settings(self) -> dict: """ return self._settings + @settings.setter def settings(self, new_settings: dict) -> None: """ @@ -52,6 +54,7 @@ def settings(self, new_settings: dict) -> None: """ self._settings = new_settings + def _extract_valid_sections(self) -> list[str]: """ Extracts the valid sections defined in the configuration schema. @@ -66,27 +69,27 @@ def _extract_valid_sections(self) -> list[str]: sections.append(section) return sections - def validate(self, settings: dict) -> None: - """ - Validates the provided settings against the schema. - :param settings: A dictionary containing the settings to be validated. + def validate_settings(self) -> None: + """ + Validates the provided assigned settings against the schema. :raises ValueError: If the settings are empty, schema is empty, or validation fails. """ - if not settings: + if not self._settings: raise ValueError("Empty settings!") if not self._schema: raise ValueError("Empty schema!") - if not self.validator.validate(settings): + if not self.validator.validate(self._settings): raise ValueError(f"Cerberus validation Error: " f"{self.validator.errors}") - self._settings["enc"].get("depths", []).sort() - for file_name in self._settings["enc"].get("files", []): + self._settings.get("enc", {}).get("depths", []).sort() + for file_name in self._settings.get("enc", {}).get("files", []): files.verify_directory_exists(file_name) + def parse(self, file_name: Path | str = dcp.config) -> None: """ Parses the YAML configuration file and validates the settings. @@ -94,9 +97,10 @@ def parse(self, file_name: Path | str = dcp.config) -> None: :param file_name: Path to the YAML configuration file. Defaults to the path defined in 'paths'. """ self._settings = read_yaml_into_dict(file_name) - self.validate(self._settings) + self.validate_settings() + - def override(self, section: str = "enc", **kwargs) -> None: + def override(self, section: str = "enc", **kwargs) -> None: # Can be deleted? Never used """ Overrides settings in a specified section with new values. @@ -112,7 +116,7 @@ def override(self, section: str = "enc", **kwargs) -> None: new_settings = self._settings for key, value in kwargs.items(): new_settings[section][key] = value - self.validate(new_settings) + self.validate_settings() self._settings = new_settings diff --git a/seacharts/core/extent.py b/seacharts/core/extent.py index 93e605f..37f63ce 100644 --- a/seacharts/core/extent.py +++ b/seacharts/core/extent.py @@ -7,7 +7,20 @@ from pyproj import Transformer +class CoordTuple(): + """ + Helper class holding 2D plane data pair for x and y dimensions (like size or position). + """ + x :float + y :float + + def __init__(self, x: float, y: float): + self.x = x + self.y = y + def to_tuple(self) -> tuple[float, float]: + return (self.x, self.y) + class Extent: """ Extent class defines the spatial area, origin, center, size, and coordinates transformations @@ -16,6 +29,7 @@ class Extent: :param settings: Dictionary containing configuration settings for ENC extent. """ + def __init__(self, settings: dict): """ Initializes the Extent object with given settings, setting properties such as size, @@ -26,55 +40,62 @@ def __init__(self, settings: dict): """ # Set the size of the extent, defaulting to (0, 0) if not specified in settings - self.size = tuple(settings["enc"].get("size", (0, 0))) + self.size = CoordTuple(*settings["enc"].get("size", (0, 0))) crs: str = settings["enc"].get("crs") # Set origin and center based on settings; if origin is given, calculate center, and vice versa if "origin" in settings["enc"]: - self.origin = tuple(settings["enc"].get("origin", (0, 0))) + self.origin = CoordTuple(*settings["enc"].get("origin", (0, 0))) self.center = self._center_from_origin() elif "center" in settings["enc"]: - self.center = tuple(settings["enc"].get("center", (0, 0))) + self.center = CoordTuple(*settings["enc"].get("center", (0, 0))) self.origin = self._origin_from_center() if crs.__eq__("WGS84"): # If CRS is WGS84, convert latitude/longitude to UTM - self.utm_zone = self.wgs2utm(self.center[0]) - self.southern_hemisphere = Extent._is_southern_hemisphere(center_east=self.center[1]) + self.utm_zone = self.wgs2utm(self.center.x) + self.southern_hemisphere = Extent._is_southern_hemisphere(center_east=self.center.y) self.out_proj = Extent._get_epsg_proj_code(self.utm_zone, self.southern_hemisphere) self.size = self._size_from_lat_long() # Convert origin from lat/lon to UTM, recalculate center in UTM coordinates - self.origin = self.convert_lat_lon_to_utm(self.origin[1], self.origin[0]) - self.center = self.origin[0] + self.size[0] / 2, self.origin[1] + self.size[1] / 2 + self.origin = CoordTuple(*self.convert_lat_lon_to_utm(self.origin.y, self.origin.x)) + self.center = CoordTuple(self.origin.x + self.size.x / 2, self.origin.y + self.size.y / 2) elif re.match(r'^UTM\d{2}[NS]', crs): # For UTM CRS, extract zone and hemisphere, and set EPSG projection code accordingly - crs = re.search(r'\d+[A-Z]', crs).group(0) + crs_raw = re.search(r'\d+[A-Z]', crs) + if crs_raw: + crs = crs_raw.group(0) + else: + raise ValueError(f"Invalid CRS format: {crs_raw}") + # eg. UTM33N: # utm_zone = 33 # crs_hemisphere_code = 'N' - self.utm_zone = crs[0:2] + self.utm_zone = crs[:2] crs_hemisphere_code = crs[2] self.southern_hemisphere = Extent._is_southern_hemisphere(crs_hemisphere_sym=crs_hemisphere_code) self.out_proj = Extent._get_epsg_proj_code(self.utm_zone, self.southern_hemisphere) # Calculate bounding box and area based on origin and size self.bbox = self._bounding_box_from_origin_size() - self.area: int = self.size[0] * self.size[1] + self.area: int = int(self.size.x * self.size.y) @staticmethod - def _is_southern_hemisphere(center_east: int = None, crs_hemisphere_sym: str = None) -> bool: + def _is_southern_hemisphere(center_east: float | None = None, crs_hemisphere_sym: str | None = None) -> bool: """ Determines if the hemisphere is southern based on either 'center_east' (UTM) or 'crs_hemisphere_sym' ('N' for Northern, 'S' for Southern hemisphere). - :param center_east: Integer value for the center's easting coordinate; if negative, the + :param center_east: Float value for the center's easting coordinate; if negative, the center is in the southern hemisphere. :param crs_hemisphere_sym: String, either 'N' or 'S', indicating the UTM CRS hemisphere. :return: Boolean indicating if the southern hemisphere is determined. :raises ValueError: If neither or both arguments are provided. """ - if (center_east is not None) == (crs_hemisphere_sym is not None): + if center_east is not None and crs_hemisphere_sym is not None: raise ValueError("Specify only one of 'center_east' or 'crs_hemisphere_sym'.") + elif center_east is None and crs_hemisphere_sym is None: + raise ValueError("One of 'center_east' or 'crs_hemisphere_sym' must be specified.") # Determine hemisphere based on the provided parameter if center_east is not None: @@ -82,6 +103,7 @@ def _is_southern_hemisphere(center_east: int = None, crs_hemisphere_sym: str = N elif crs_hemisphere_sym is not None: return crs_hemisphere_sym == 'S' + @staticmethod def __get_hemisphere_epsg_code(is_southern_hemisphere: bool) -> str: return '7' if is_southern_hemisphere is True else '6' @@ -108,7 +130,7 @@ def wgs2utm(longitude): """ return str(math.floor(longitude / 6 + 31)) - def convert_lat_lon_to_utm(self, latitude, longitude): + def convert_lat_lon_to_utm(self, latitude, longitude) -> tuple[int, int]: """ Converts latitude and longitude coordinates to UTM coordinates. @@ -140,26 +162,26 @@ def convert_utm_to_lat_lon(self, utm_east, utm_north): return latitude, longitude - def _origin_from_center(self) -> tuple[int, int]: + def _origin_from_center(self) -> CoordTuple: """ Calculates the origin coordinates based on the center and size. :return: Tuple of origin x and y coordinates. """ - return ( - int(self.center[0] - self.size[0] / 2), - int(self.center[1] - self.size[1] / 2), + return CoordTuple( + int(self.center.x - self.size.x / 2), + int(self.center.y - self.size.y / 2), ) - def _center_from_origin(self) -> tuple[int, int]: + def _center_from_origin(self) -> CoordTuple: """ Calculates the center coordinates based on the origin and size. :return: Tuple of center x and y coordinates. """ - return ( - int(self.origin[0] + self.size[0] / 2), - int(self.origin[1] + self.size[1] / 2), + return CoordTuple( + int(self.origin.x + self.size.x / 2), + int(self.origin.y + self.size.y / 2), ) def _bounding_box_from_origin_size(self) -> tuple[int, int, int, int]: @@ -168,21 +190,21 @@ def _bounding_box_from_origin_size(self) -> tuple[int, int, int, int]: :return: Tuple of bounding box coordinates (x_min, y_min, x_max, y_max). """ - x_min, y_min = self.origin - x_max, y_max = x_min + self.size[0], y_min + self.size[1] + x_min, y_min = self.origin.to_tuple() + x_max, y_max = x_min + self.size.x, y_min + self.size.y return x_min, y_min, x_max, y_max - def _size_from_lat_long(self) -> tuple[int, int]: + def _size_from_lat_long(self) -> CoordTuple: """ Converts geographic size (latitude/longitude) to UTM size. :return: Tuple of width and height in UTM coordinates. """ - x_min, y_min = self.origin - x_max, y_max = x_min + self.size[0], y_min + self.size[1] + x_min, y_min = self.origin.to_tuple() + x_max, y_max = x_min + self.size.x, y_min + self.size.y converted_x_min, converted_y_min = self.convert_lat_lon_to_utm(y_min, x_min) converted_x_max, converted_y_max = self.convert_lat_lon_to_utm(y_max, x_max) - return converted_x_max - converted_x_min, converted_y_max - converted_y_min + return CoordTuple(converted_x_max - converted_x_min, converted_y_max - converted_y_min) def _bounding_box_from_origin_size_lat_long(self) -> tuple[int, int, int, int]: """ @@ -190,9 +212,9 @@ def _bounding_box_from_origin_size_lat_long(self) -> tuple[int, int, int, int]: :return: Tuple of bounding box coordinates (x_min, y_min, x_max, y_max) in UTM. """ - x_min, y_min = self.origin - x_max, y_max = x_min + self.size[0], y_min + self.size[1] + x_min, y_min = self.origin.to_tuple() + x_max, y_max = x_min + self.size.x, y_min + self.size.y converted_x_min, converted_y_min = self.convert_lat_lon_to_utm(y_min, x_min) converted_x_max, converted_y_max = self.convert_lat_lon_to_utm(y_max, x_max) - self.size = tuple([converted_x_max - converted_x_min, converted_y_max - converted_y_min]) + self.size = CoordTuple(converted_x_max - converted_x_min, converted_y_max - converted_y_min) return converted_x_min, converted_y_min, converted_x_max, converted_y_max diff --git a/seacharts/core/files.py b/seacharts/core/files.py index d38f97f..76cf3fa 100644 --- a/seacharts/core/files.py +++ b/seacharts/core/files.py @@ -1,10 +1,12 @@ """ Contains utility functions related to system files and directories. """ -import csv, shutil +import csv from collections.abc import Generator from pathlib import Path + from seacharts.core.parser import DataParser + from . import paths @@ -26,7 +28,7 @@ def verify_directory_exists(path_string: str) -> None: def build_directory_structure(features: list[str], resources: list[str], parser: DataParser) -> None: """ Creates the directory structure for shapefiles and outputs based on the provided features - and resources. It also copies the initial configuration file to the map's shapefile directory. + and resources. :param features: A list of feature names for which directories will be created. :param resources: A list of resource paths to validate and create directories for. @@ -37,14 +39,13 @@ def build_directory_structure(features: list[str], resources: list[str], parser: paths.shapefiles = paths.shapefiles / map_dir_name paths.output.mkdir(exist_ok=True) paths.shapefiles.mkdir(exist_ok=True) - # shutil.copy(paths.config, paths.shapefiles) # used to save initial config for feature in features: shapefile_dir = paths.shapefiles / feature.lower() shapefile_dir.mkdir(parents=True, exist_ok=True) for resource in resources: path = Path(resource).resolve() - if not path.suffix == ".gdb" or not path.suffix == ".000": + if path.suffix not in [".gdb", ".000"]: path.mkdir(exist_ok=True) diff --git a/seacharts/core/parserFGDB.py b/seacharts/core/parserFGDB.py index 816c0c2..74a7eda 100644 --- a/seacharts/core/parserFGDB.py +++ b/seacharts/core/parserFGDB.py @@ -66,9 +66,16 @@ def _is_map_type(self, path) -> bool: return path.is_dir() and path.suffix == ".gdb" def get_source_root_name(self) -> str: + """ + Returns concatenated names of all FGDB directories found in resources (without .gdb suffix). + """ + names = [] for path in self._file_paths: if self._is_map_type(path): - return path.stem + name = path.stem + if name not in names: + names.append(name) + return "_".join(names) if names else "unknown" def _parse_layers( self, path: Path, external_labels: list[str], depth: int diff --git a/seacharts/core/paths.py b/seacharts/core/paths.py index 04644a7..5e0ed4e 100644 --- a/seacharts/core/paths.py +++ b/seacharts/core/paths.py @@ -3,30 +3,20 @@ """ from pathlib import Path -# Get the root directory of the project by going up two levels from the current file's location root = Path(__file__).parents[2] -# Define the main package directory for "seacharts" package = root / "seacharts" -# Default path to the main configuration file for the project config = package / "config.yaml" -# Path to the schema file that validates the structure of the configuration file config_schema = package / "config_schema.yaml" -# Get the current working directory, where this script is being executed cwd = Path.cwd() -# Define a 'data' directory within the current working directory, used to store source ENC data files data = cwd / "data" -# Define the database directory within the 'data' folder, used for storing database files db = data / "db" default_resources = cwd, data, db -# Directory to store shapefiles, which are used for geographic and spatial data shapefiles = data / "shapefiles" -# Path to the CSV file containing vessel data vessels = data / "vessels.csv" -# Define the output directory, where results and generated files will be saved output = root / "output" diff --git a/seacharts/core/scope.py b/seacharts/core/scope.py index 8c708dd..03c55e6 100644 --- a/seacharts/core/scope.py +++ b/seacharts/core/scope.py @@ -31,7 +31,7 @@ def __init__(self, settings: dict): # Set default depth bins if not provided in settings default_depths = [0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500] - self.depths = settings["enc"].get("depths", default_depths) + self.depths: list[int] = settings["enc"].get("depths", default_depths) # Define core features for ENC, adding "seabed" layers based on depth bins self.features = ["land", "shore"] diff --git a/seacharts/display/colors.py b/seacharts/display/colors.py index bd7a98d..fd6beef 100644 --- a/seacharts/display/colors.py +++ b/seacharts/display/colors.py @@ -56,7 +56,7 @@ def _greens(bins: int = 9) -> np.ndarray: ) -def color_picker(name: str, bins: int = None) -> tuple: +def color_picker(name: str | int, bins: int = None) -> tuple: if isinstance(name, int): return _blues(bins)[name] elif name in _ship_colors: diff --git a/seacharts/display/display.py b/seacharts/display/display.py index efe90f1..9600064 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -520,7 +520,7 @@ def _init_figure(self, settings): if self._fullscreen_mode: plt.rcParams["toolbar"] = "None" - width, height = self._environment.scope.extent.size + width, height = self._environment.scope.extent.size.to_tuple() window_height, ratio = self._resolution / self._dpi, width / height figure_width1, figure_height1 = ratio * window_height, window_height axes1_width, axes2_width, width_space = figure_width1, 1.1, 0.3 diff --git a/seacharts/display/features.py b/seacharts/display/features.py index f230fe2..4295a1e 100644 --- a/seacharts/display/features.py +++ b/seacharts/display/features.py @@ -6,7 +6,8 @@ from matplotlib.lines import Line2D from shapely.geometry import MultiLineString, MultiPolygon -from seacharts import shapes, core +from seacharts import core, display, shapes + from .colors import color_picker @@ -29,7 +30,7 @@ class FeaturesManager: _number_of_layers (int): The total number of layers in the display's environment. _next_z_order (int): The next z-order value to be used for layering features. """ - def __init__(self, display): + def __init__(self, display: "display.Display"): """ Initializes the FeaturesManager with a specified display object and prepares spatial features for rendering. @@ -89,7 +90,7 @@ def _init_layers(self): center = self._display._environment.scope.extent.center size = self._display._environment.scope.extent.size geometry = shapes.Rectangle( - *center, width=size[0] / 2, heading=0, height=size[1] / 2 + x=center.x, y=center.y, width=size.x / 2, heading=0, height=size.y / 2 ).geometry color = (color_picker("black")[0], "none") self.new_artist(geometry, color, z_order=self._get_next_z_order(), linewidth=3) diff --git a/seacharts/enc.py b/seacharts/enc.py index f8b272a..608794a 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -22,12 +22,12 @@ class ENC: :param config: Config object or a valid path to a .yaml config file """ - def __init__(self, config: Config | Path | str = None): + def __init__(self, config: Config | Path | str | None = None): self._config = config if isinstance(config, Config) else Config(config) self._environment = Environment(self._config.settings) self._display = None - def get_depth_at_coord(self, easting: int, northing: int) -> int: + def get_depth_at_coord(self, easting: float, northing: float) -> float | None: """ Retrieves the seabed depth at a given coordinate. @@ -61,6 +61,9 @@ def get_param_value_at_coords(self, easting: int, northing: int, layer_name: str param_name = param_name.upper() if self.is_coord_in_layer(easting, northing, layer_name): layer: Layer = self._environment.get_layer_by_name(layer_name) + if layer is None: + _warnings.warn(f"Layer {layer_name} not found in ENC") + return None parameters: dict | None = layer.get_params_at_coord(easting, northing) if parameters is not None: return parameters[param_name] @@ -105,25 +108,25 @@ def seabed(self) -> dict[int, Layer]: return self._environment.map.bathymetry @property - def size(self) -> tuple[int, int]: + def size(self) -> tuple[float, float]: """ :return: tuple of ENC bounding box size """ - return self._environment.scope.extent.size + return self._environment.scope.extent.size.to_tuple() @property - def origin(self) -> tuple[int, int]: + def origin(self) -> tuple[float, float]: """ :return: tuple of ENC origin (lower left) coordinates. """ - return self._environment.scope.extent.origin + return self._environment.scope.extent.origin.to_tuple() @property - def center(self) -> tuple[int, int]: + def center(self) -> tuple[float, float]: """ :return: tuple of ENC center coordinates """ - return self._environment.scope.extent.center + return self._environment.scope.extent.center.to_tuple() @property def bbox(self) -> tuple[int, int, int, int]: diff --git a/seacharts/environment/collection.py b/seacharts/environment/collection.py index 2d96ed5..0b63c1a 100644 --- a/seacharts/environment/collection.py +++ b/seacharts/environment/collection.py @@ -69,6 +69,11 @@ class ShapefileBasedCollection(DataCollection, ABC): :param scope: The scope object defining the context and extent of the data collection. :param parser: The DataParser instance responsible for parsing the spatial data. """ + + @property + def featured_regions(self) -> list[Layer]: + return [] + def load_existing_shapefiles(self) -> None: """ Loads existing shapefiles for the featured regions using the specified parser. diff --git a/seacharts/environment/extra.py b/seacharts/environment/extra.py index 61eb7fc..5f6b7ae 100644 --- a/seacharts/environment/extra.py +++ b/seacharts/environment/extra.py @@ -47,4 +47,4 @@ def featured_regions(self) -> list[Layer]: :return: A list of Layer instances that correspond to the featured regions defined in the extra layers. """ - return [x for x in self.layers if x.tag in self.scope.extra_layers.keys()] + return [x for x in self.extra_layers if x.tag in self.scope.extra_layers.keys()] diff --git a/seacharts/layers/layer.py b/seacharts/layers/layer.py index a2ec3a7..c33b89d 100644 --- a/seacharts/layers/layer.py +++ b/seacharts/layers/layer.py @@ -37,7 +37,7 @@ def label(self) -> str: """ return self.name.lower() - def _geometries_to_multi(self, multi_geoms, geometries, geo_class): + def _geometries_to_multi(self, multi_geoms, geometries, geo_class: type): """ Combines geometries into a single MultiGeometry. diff --git a/seacharts/shapes/shape.py b/seacharts/shapes/shape.py index 1af9494..1c5239e 100644 --- a/seacharts/shapes/shape.py +++ b/seacharts/shapes/shape.py @@ -43,7 +43,9 @@ def _record_to_geometry(record: dict) -> Any: return geo.shape(record["geometry"]) @staticmethod - def as_multi(geometry) -> Any: + def as_multi(geometry: list) -> geo.base.BaseMultipartGeometry | None: + if len(geometry) == 0: + return None if isinstance(geometry[0], geo.Point): return geo.MultiPoint(geometry) elif isinstance(geometry[0], geo.Polygon): From 68d094f7281040b197407259518cf1ed1d20fdef Mon Sep 17 00:00:00 2001 From: miqn <109998643+meeqn@users.noreply.github.com> Date: Thu, 27 Nov 2025 19:09:24 +0100 Subject: [PATCH 82/84] Fixed some issues based on Copilot-generated review. (#34) * Fixed some issues based on Copilot-generated review. - Added a check in `build_directory_structure` to raise a ValueError if the source root name is None. - Updated `get_source_root_name` method signatures in `DataParser`, `FGDBParser`, and `S57Parser` to indicate they can return None. - Modified return values in `FGDBParser` and `S57Parser` to return None when no valid data is found. - Improved docstrings for clarity on return types and conditions. * Fix the false positive scenarios in control panel creation --- seacharts/core/files.py | 2 ++ seacharts/core/parser.py | 6 +++--- seacharts/core/parserFGDB.py | 10 ++++++---- seacharts/core/parserS57.py | 7 +++++-- seacharts/display/display.py | 24 ++++++++++-------------- seacharts/display/features.py | 20 ++++++++++---------- seacharts/enc.py | 7 +++++-- tests/test_seacharts_4_0.py | 1 - 8 files changed, 41 insertions(+), 36 deletions(-) diff --git a/seacharts/core/files.py b/seacharts/core/files.py index 76cf3fa..a31be13 100644 --- a/seacharts/core/files.py +++ b/seacharts/core/files.py @@ -35,6 +35,8 @@ def build_directory_structure(features: list[str], resources: list[str], parser: :param parser: An instance of DataParser used to get the source root name. """ map_dir_name = parser.get_source_root_name() + if map_dir_name is None: + raise ValueError("Cannot build directory structure: source root name is None.") paths.shapefiles.mkdir(exist_ok=True) paths.shapefiles = paths.shapefiles / map_dir_name paths.output.mkdir(exist_ok=True) diff --git a/seacharts/core/parser.py b/seacharts/core/parser.py index 09a4df1..68d07fc 100644 --- a/seacharts/core/parser.py +++ b/seacharts/core/parser.py @@ -146,11 +146,11 @@ def _is_map_type(self, path) -> bool: pass @abstractmethod - def get_source_root_name(self) -> str: + def get_source_root_name(self) -> str | None: """ - Abstract method to retrieve the root name of the source data. + Abstract method to retrieve the root name of the source data. Returns None if not found. - :return: The root name of the source data. + :return: The root name of the source data or None. """ pass diff --git a/seacharts/core/parserFGDB.py b/seacharts/core/parserFGDB.py index 74a7eda..913f730 100644 --- a/seacharts/core/parserFGDB.py +++ b/seacharts/core/parserFGDB.py @@ -4,7 +4,7 @@ import fiona -from seacharts.core import DataParser, paths +from seacharts.core import DataParser from seacharts.layers import Layer, labels @@ -65,9 +65,11 @@ def _read_file( def _is_map_type(self, path) -> bool: return path.is_dir() and path.suffix == ".gdb" - def get_source_root_name(self) -> str: + def get_source_root_name(self) -> str | None: """ - Returns concatenated names of all FGDB directories found in resources (without .gdb suffix). + Returns concatenated names of all FGDB directories found in resources (without .gdb suffix) or None if not found. + + :return: The root name of the source data or None. """ names = [] for path in self._file_paths: @@ -75,7 +77,7 @@ def get_source_root_name(self) -> str: name = path.stem if name not in names: names.append(name) - return "_".join(names) if names else "unknown" + return "_".join(names) if names else None def _parse_layers( self, path: Path, external_labels: list[str], depth: int diff --git a/seacharts/core/parserS57.py b/seacharts/core/parserS57.py index c60f98e..274c376 100644 --- a/seacharts/core/parserS57.py +++ b/seacharts/core/parserS57.py @@ -4,7 +4,7 @@ from pathlib import Path from seacharts.core import DataParser -from seacharts.layers import Layer, Land, Shore, Seabed +from seacharts.layers import Land, Layer, Seabed, Shore class S57Parser(DataParser): @@ -25,18 +25,21 @@ def __init__( ): super().__init__(bounding_box, path_strings) self.epsg = epsg + def get_source_root_name(self) -> str: """ Returns the stem (base filename without suffix) of the first valid S57 file path in the given data paths. + Returns None if no valid S57 file is found. - :return: The stem of the first valid S57 file. + :return: The stem of the first valid S57 file or None. """ for path in self._file_paths: path = self.get_s57_file_path(path) if path is not None: return path.stem + raise FileNotFoundError("No valid S57 file found in the provided paths.") @staticmethod def __run_org2ogr(ogr2ogr_cmd, s57_file_path, shapefile_output_path) -> None: diff --git a/seacharts/display/display.py b/seacharts/display/display.py index 9600064..c69bb24 100644 --- a/seacharts/display/display.py +++ b/seacharts/display/display.py @@ -6,17 +6,18 @@ from pathlib import Path from typing import Any -from colorama import Fore import matplotlib import matplotlib.pyplot as plt import numpy as np -from matplotlib import colors -from matplotlib.widgets import Slider, RadioButtons from cartopy.crs import UTM +from colorama import Fore +from matplotlib import colors from matplotlib.gridspec import GridSpec +from matplotlib.widgets import RadioButtons, Slider from matplotlib_scalebar.scalebar import ScaleBar import seacharts.environment as env + from .colors import colorbar from .events import EventsManager from .features import FeaturesManager @@ -68,7 +69,7 @@ def _set_bbox(self, environment: env.Environment) -> tuple[float, float, float, """ Sets bounding box for the display taking projection's (crs's) x and y limits for display into account. Making sure that bbox doesn't exceed limits prevents crashes. When such limit is exceeded, an appropriate message is displayed - to inform user about possibility of unexpeced display bound crop + to inform user about possibility of unexpected display bound crop """ bbox = (max(environment.scope.extent.bbox[0], self.crs.x_limits[0]), # x-min max(environment.scope.extent.bbox[1], self.crs.y_limits[0]), # y-min @@ -79,13 +80,13 @@ def _set_bbox(self, environment: env.Environment) -> tuple[float, float, float, if (bbox[i] != environment.scope.extent.bbox[i]): changed.append(i) if len(changed)>0: - print(Fore.RED + f"WARNING: Bouding box for display has exceeded the limit of CRS axes and therefore been scaled down. Watch out for potentially cropped chart display!" + Fore.RESET) + print(Fore.RED + f"WARNING: Bounding box for display has exceeded the limit of CRS axes and therefore been scaled down. Watch out for potentially cropped chart display!" + Fore.RESET) for i in changed: print(Fore.RED + f"index {i}: {environment.scope.extent.bbox[i]} changed to {bbox[i]}" + Fore.RESET) return bbox def start(self) -> None: - self.started__ = """ + """ Starts the display, if it is not already started. """ if self._is_active: @@ -199,7 +200,6 @@ def draw_weather(self, variable_name): else: weather_layer = self._environment.weather.find_by_name(variable_name) - # TODO choose correct display for variables data = None if weather_layer is not None: data = [x[lon_indxes[0]:lon_indxes[1]] for x in @@ -246,7 +246,6 @@ def _draw_arrow_map(self, direction_data, data, latitudes, longitude): draw_default = data is None for i in range(len(direction_data)): for j in range(len(direction_data[i])): - x = direction_data[i][j] from math import isnan if not isnan(direction_data[i][j]): degree = math.radians(direction_data[i][j]) @@ -743,11 +742,11 @@ def __update(val): def add_control_panel(self, controls: bool): radio_labels = ['--'] + self._environment.weather.weather_names - if "wind_speed" and "wind_direction" in radio_labels: + if any(x in radio_labels for x in ["wind_speed", "wind_direction"]): radio_labels.append("wind") - if "wave_height" and "wave_direction" in radio_labels: + if any(x in radio_labels for x in ["wave_height", "wave_direction"]): radio_labels.append("wave") - if "sea_current_speed" and "sea_current_direction" in radio_labels: + if any(x in radio_labels for x in ["sea_current_speed", "sea_current_direction"]): radio_labels.append("sea_current") if not controls: return @@ -781,9 +780,6 @@ def on_radio_change(label): self.radio_buttons.on_clicked(on_radio_change) # VISIBLE LAYER PICKER END - # TODO: layer picked in such way should be saved to variable - # then we can add, analogically to date slider, opacity slider for picked weather layer - # Set the window title and show the figure fig.canvas.manager.set_window_title('Controls') fig.show() diff --git a/seacharts/display/features.py b/seacharts/display/features.py index 4295a1e..764c663 100644 --- a/seacharts/display/features.py +++ b/seacharts/display/features.py @@ -250,16 +250,16 @@ def add_polygon( self, shape, color, interiors, fill, linewidth, linestyle, alpha=1.0 ): """ - Adds an overlay geometry to the display. - - :param geometry: The geometry to overlay. - :param color_name: The name of the color for the overlay. - :param fill: Whether the overlay should be filled. - :param linewidth: The width of the overlay line. - :param linestyle: The style of the overlay line. - :param alpha: The transparency level of the overlay. - - :return: The created overlay artist. + Adds a polygon overlay to the display. + :param shape: The exterior coordinates of the polygon, or a collection of polygons. + :param color: The color to use for the polygon. + :param interiors: A list of interior coordinates (holes) for the polygon. + :param fill: Whether the polygon should be filled. + :param linewidth: The width of the polygon outline. + :param linestyle: The style of the polygon outline. + :param alpha: The transparency level of the polygon. + + :return: The created overlay artist. """ try: if isinstance(shape, geo.MultiPolygon) or isinstance( diff --git a/seacharts/enc.py b/seacharts/enc.py index 608794a..73da151 100644 --- a/seacharts/enc.py +++ b/seacharts/enc.py @@ -1,15 +1,18 @@ """ Contains the ENC class for reading, storing and plotting maritime spatial data. """ -import _warnings from pathlib import Path -from shapely.geometry import Point, Polygon + +import _warnings +from shapely.geometry import Point + from seacharts.core import Config from seacharts.display import Display from seacharts.environment import Environment from seacharts.environment.weather import WeatherData from seacharts.layers import Layer + class ENC: """ Electronic Navigational Charts diff --git a/tests/test_seacharts_4_0.py b/tests/test_seacharts_4_0.py index 2808e42..3b14dbc 100644 --- a/tests/test_seacharts_4_0.py +++ b/tests/test_seacharts_4_0.py @@ -1,7 +1,6 @@ import sys, os from shapely.geometry import MultiPolygon, Polygon # file made to showcase functionalities added in seacharts 4.0 integrated within old seacharts functionalities -# for if __name__ == "__main__": root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) From 0ef97516ee2f4eebd7c14b5637e08fe865c19578 Mon Sep 17 00:00:00 2001 From: miqn <109998643+meeqn@users.noreply.github.com> Date: Sun, 11 Jan 2026 20:25:46 +0100 Subject: [PATCH 83/84] Commit premerge suggestions (#35) * Remove unused 'override' method * Change config to default to darkmode * Remove redundant markdown, update main README file --- README.md | 412 +- seacharts/config.yaml | 2 +- seacharts/core/config.py | 20 - user_doc/images/test_results.svg | 42878 ----------------------------- user_doc/seacharts_user_doc.md | 351 - 5 files changed, 283 insertions(+), 43380 deletions(-) delete mode 100644 user_doc/images/test_results.svg delete mode 100644 user_doc/seacharts_user_doc.md diff --git a/README.md b/README.md index 11b1b52..662dbc4 100644 --- a/README.md +++ b/README.md @@ -19,198 +19,350 @@ Python-based API for Electronic Navigational Charts (ENC) - Access and manipulate standard geometric shapes such as points and polygon collections. - Visualize colorful seacharts features and vessels. +- Integration with [PyThor](https://github.com/SanityRemnants/PyThor) library allowing for weather data access and display. ## Code style This module follows the [PEP8](https://www.python.org/dev/peps/pep-0008/) convention for Python code. +## Prerequisites -## Prerequisites - For SeaCharts 4.0 see [this](#seacharts-40-setup-tips) section +> **Important**: Current version requires Conda environment setup. Pip installation is currently not supported. -### DEPRECATED - Linux (Virtual Environment) +## Initial Setup -First, ensure that you have the GDAL and GEOS libraries installed, as these are -required in order to successfully install GDAL and Cartopy: -``` -sudo apt-get install libgeos-dev libgdal-dev +1. Clone the repository: +```bash +git clone https://github.com/meeqn/seacharts_s57 ``` -From the root folder, one may then install an editable version of the package as -follows: -``` -pip install -e . +2. Set up the Conda environment: + * Use the provided `conda_requirements.txt` file: + ```bash + conda create --name --file conda_requirements.txt + conda activate + ``` + +3. Set up directory structure: + * **Windows users**: Use the provided `setup.ps1` PowerShell script + * **Other platforms**: Manually create these directories: + * `data` + * `data/db` + +4. Download map data: + * Download the `US1GC09M` map from [here](https://www.charts.noaa.gov/ENCs/US1GC09M.zip) + * Extract and place the `US1GC09M` directory (found in map's ENC_ROOT directory) inside `data/db` folder + +5. Test the installation: + * Run `test_seacharts_4_0.py` + * Expected result is shown below + + + +![Expected output](images/test_results.svg "Expected test_seacharts_4_0.py output") + + +### Weather Module Setup (Optional) + +If you need weather functionality: + +```bash +git clone https://github.com/SanityRemnants/PyThor +cd PyThor +conda create --name --file requirements.txt +conda activate -n +# Remember to configure config.yaml file according to PyThor README +python app.py ``` -This should preferably be done inside a virtual environment in order to prevent -Python packaging conflicts. -### DEPRECATED - Anaconda -Install an edition of the [Anaconda]( -https://www.anaconda.com/products/individual-d) package manager, and then create a new -_conda environment_ -with [Python 3.11](https://www.python.org/downloads/) or higher using e.g. the -graphical user interface of [PyCharm Professional]( -https://www.jetbrains.com/lp/pycharm-anaconda/) as detailed [here]( -https://www.jetbrains.com/help/pycharm/conda-support-creating-conda-virtual-environment.html -). +## Configuration Setup + +### Configuration File Structure + +The SeaCharts library is configured via `config.yaml` located in the seacharts directory (by default). Below are the detailed configuration options: -The required data processing libraries for spatial calculations and -visualization may subsequently be installed simply by running the following -commands in the terminal of your chosen environment: +### ENC (Electronic Navigation Chart) Configuration +```yaml +enc: + size: [width, height] # Size of the chart in chosen CRS unit + origin: [x, y] # Origin coordinates in chosen CRS unit (excludes center) + center: [x, y] # Center point coordinates in chosen CRS unit (excludes origin) + crs: "coordinate_system" # Coordinate reference system + S57_layers: # List of additional S-57 layers with display colors given in hex as value + "LAYER_NAME": "#COLOR_IN_HEX" # e.g., "TSSLPT": "#8B0000" + resources: [data_paths] # Path to ENC data root, is currently a list but expects one argument ``` -conda install -c conda-forge fiona cartopy matplotlib -conda install matplotlib-scalebar cerberys pyyaml + +#### Important Notes on ENC Configuration: +- `origin` and `center` are mutually exclusive - use only one +- For `CRS`, you can use: + - "WGS84" for **latitude/longitude coordinates** (required for `S57 maps`) + - "UTM" with zone and hemisphere, for **easting/northing coordinates** (e.g., "UTM33N" for UTM zone 33 North, used for `FGDB maps`) +- `S57_layers` field is **required** for S57 maps (can be empty list) +- Default S-57 layers (automatically included, dont need to be specified in `S57_layers` field): + - `LNDARE` (Land) + - `DEPARE` (Depth Areas) + - `COALNE` (Coastline) +- A useful S57 layer catalogue can be found at: https://www.teledynecaris.com/s-57/frames/S57catalog.htm + +### Weather Configuration + +```yaml +weather: + PyThor_address: "http://127.0.0.1:5000" # PyThor server address + variables: [ # Weather variables to display + "wave_direction", + "wave_height", + "wave_period", + "wind_direction", + "wind_speed", + "sea_current_speed", + "sea_current_direction", + "tide_height" + ] ``` -### DEPRECATED - Windows (Pipwin) +### Time Configuration -First, ensure that [Python 3.11](https://www.python.org/downloads/) or higher -is installed. Next, install all required packages using -[Pipwin](https://pypi.org/project/pipwin/): +```yaml +time: + time_start: "DD-MM-YYYY HH:MM" # Start time (must match format exactly) + time_end: "DD-MM-YYYY HH:MM" # End time + period: String # Time period unit + period_multiplier: Integer # Multiplier for time period ``` -python -m pip install --upgrade pip -pip install wheel -pip install pipwin -pipwin install numpy -pipwin install gdal -pipwin install fiona -pipwin install shapely -pip install cartopy -pip install pyyaml -pip install cerberus -pip install matplotlib-scalebar +#### Time Configuration Notes: +- Valid period values: + - "hour" + - "day" + - "week" + - "month" + - "year" +- Period multiplier works with hour, day, and week periods +- For example, for 2-hour intervals: + - period: "hour" + - period_multiplier: 2 +- Month and year periods **don't support** multipliers + +### Display Configuration + +```yaml +display: + colorbar: Boolean # Enable/disable depth colorbar (default: False) + dark_mode: Boolean # Enable/disable dark mode (default: False) + fullscreen: Boolean # Enable/disable fullscreen mode (default: False) + controls: Boolean # Show/hide controls window (default: True) + resolution: Integer # Display resolution (default: 640) + anchor: String # Window position ("center", "top_left", etc.) + dpi: Integer # Display DPI (default: 96) ``` -Simply copy and paste the entire block above (including the empty line) into -the terminal of your virtual environment, and go get a cup of coffee while it -does its thing. - -## Installation -After the necessary dependencies have been correctly installed, the SeaCharts -package may be installed directly through the Python Package Index ([PyPI]( -https://pypi.org/ -)) by running the following command in the terminal: +## ENC Class for Maritime Spatial Data -``` -pip install seacharts -``` +> **Important API Note**: All SeaCharts API functions expect coordinates in **UTM CRS** (easting and northing), regardless of the CRS set in *config.yaml*. -or locally inside the SeaCharts root folder as an editable package with `pip install --e .` +The ENC class provides methods for handling and visualizing maritime spatial data, including reading, storing, and plotting from specified regions. -## Usage +### Key Functionalities -This module supports reading and processing `FGDB` and 'S-57' files for sea depth data. +* **Initialization** + * The ENC object can be initialized with a path to a config.yaml file or a Config object. -### Downloading regional datasets - FGDB +* **Geometric Data Access** + * The ENC provides attributes for accessing spatial layers: + * `land`: Contains land shapes. + * `shore`: Contains shorelines. + * `seabed`: Contains bathymetric (seafloor) data by depth. -The Norwegian coastal data set used for demonstration purposes, found -[here]( -https://kartkatalog.geonorge.no/metadata/2751aacf-5472-4850-a208-3532a51c529a). -To visualize and access coastal data of Norway, follow the above link to download -the `Depth data` (`Sjøkart - Dybdedata`) dataset from the [Norwegian Mapping Authority]( -https://kartkatalog.geonorge.no/?organization=Norwegian%20Mapping%20Authority) by adding -it to the Download queue and navigating to the separate -[download page](https://kartkatalog.geonorge.no/nedlasting). Choose one or more -county areas (e.g. `Møre og Romsdal`), and select the -`EUREF89 UTM sone 33, 2d` (`UTM zone 33N`) projection and `FGDB 10.0` -format. Finally, select your appropriate user group and purpose, and click -`Download` to obtain the ZIP file(s). +* **Coordinate and Depth Retrieval** + * `get_depth_at_coord(easting, northing)`: Returns the depth at a specific coordinate. + * `is_coord_in_layer(easting, northing, layer_name)`: Checks if a coordinate falls within a specified layer. -### Configuration and startup +* **Visualization** + * `display`: Returns a Display instance to visualize marine geometric data and vessels. -Unpack the downloaded file(s) and place the extracted `.gdb` or 'S-57' folder in a suitable location, -in which the SeaCharts setup may be configured to search. The current -working directory as well as the relative `data/` and `data/db/` folders are -included by default. +* **Spatial Data Update** + * `update()`: Parses and updates ENC data from specified resources. -The minimal example below imports the `ENC` class from `seacharts.enc` with the -default configuration found in `seacharts/config.yaml`, and shows the interactive -SeaCharts display. Note that at least one database with spatial data (e.g. `Møre og -Romsdal` from the Norwegian Mapping Authority) is required. +### Example Usage ```python -if __name__ == '__main__': +from seacharts import ENC - from seacharts.enc import ENC +# Initialize ENC with configuration +enc = ENC("config.yaml") - enc = ENC() - enc.display.show() +# Get depth at specific UTM coordinates +depth = enc.get_depth_at_coord(easting, northing) + +# Check if coordinates are in a specific layer (e.g., TSSLPT) +in_traffic_lane = enc.is_coord_in_layer(easting, northing, "TSSLPT") + +# Add a vessel and display +display = enc.display +display.add_vessels((1, easting, northing, 45, "red")) +display.show() ``` -The `config.yaml` file specifies which file paths to open and which area to load. In the configuration file the desired map type can be specified by listring data to display - depths for 'FDGB', and [layers](https://www.teledynecaris.com/s-57/frames/S57catalog.htm) for 'S-57'. -The corresponding `config_schema.yaml` specifies how the required setup parameters -must be provided, using `cerberus`. +## Display Features + +The Display class provides various methods to control the visualization: +### Basic Display Controls -### API usage and accessing geometric shapes +```python +display.start() # Start the display +display.show(duration=0.0) # Show display for specified duration (0 = indefinite) +display.close() # Close the display window +``` -After the spatial data is parsed into shapefiles during setup, geometric -shapes based on the [Shapely](https://pypi.org/project/Shapely/) library may be -accessed and manipulated through various `ENC` attributes. The seacharts -feature layers are stored in `seabed`, `shore` and `land`. +### View Modes ```python -if __name__ == '__main__': - from seacharts.enc import ENC +display.dark_mode(enabled=True) # Toggle dark mode +display.fullscreen(enabled=True) # Toggle fullscreen mode +display.colorbar(enabled=True) # Toggle depth colorbar +``` + +### Plot Management + +```python +display.redraw_plot() # Redraw the entire plot +display.update_plot() # Update only animated elements +``` - # Values set in user-defined 'seacharts.yaml' - # size = 9000, 5062 - # center = 44300, 6956450 - enc = ENC("seacharts.yaml") +## Weather Visualization - print(enc.seabed[10]) - print(enc.shore) - print(enc.land) +Weather data can be visualized using various variables: - enc.display.show() +* Wind (speed and direction) +* Waves (height, direction, period) +* Sea currents (speed and direction) +* Tide height + +The visualization type is automatically selected based on the variable: + +* Scalar values: displayed as heatmaps +* Vector values: displayed as arrow maps with direction indicators + +## Interactive Controls + +When `controls: True` in the config, a control panel provides: + +* **Time Slider** + * Allows navigation through different timestamps with labels for date and time. +* **Layer Selection** + * Includes radio buttons to select weather variables such as wind, waves, and sea current. + +## Drawing Functions + +The Display class offers various drawing functions for maritime shapes: + +### Vessel Management + +```python +display.add_vessels(*vessels) # Add vessels to display +# vessels format: (id, x, y, heading, color) +display.clear_vessels() # Remove all vessels ``` -Note how custom settings may be set in a user-defined .yaml-file, if its path is -provided to the ENC during initialization. One may also import and create an -instance of the `seacharts.Config` dataclass, and provide it directly to the ENC. +### Shape Drawing -### FGDB demonstration -![](images/example2.svg "Example visualization of vessels and a -colorbar with depth values in light mode.") +#### Lines -### S-57 demonstration -![](images/example3.png "Example visualization of S-57 map with TSS layer and a -colorbar with depth values in light mode.") +```python +display.draw_line( + points=[(x1,y1), ...], # List of coordinate pairs + color="color_string", # Line color + width=float, # Optional: line width + thickness=float, # Optional: line thickness + edge_style=str|tuple, # Optional: line style + marker_type=str # Optional: point marker style +) +``` +#### Circles -### Environment visualization -The `ENC.start_display` method is used to show a Matplotlib figure plot of the -loaded sea charts features. Zoom and pan the environment view using the mouse -scroll button, and holding and dragging the plot with left click, respectively. +```python +display.draw_circle( + center=(x, y), # Center coordinates + radius=float, # Circle radius + color="color_string", # Circle color + fill=Boolean, # Optional: fill circle (default: True) + thickness=float, # Optional: line thickness + edge_style=str|tuple, # Optional: line style + alpha=float # Optional: transparency (0-1) +) +``` -Dark mode may be toggled using the `d` key, and an optional colorbar showing -the various depth legends may be toggled using the `c` key. Images of the -currently shown display may be saved in various resolutions by pressing -Control + `s`, Shift + `s` or `s`. +#### Rectangles -### SeaCharts 4.0 setup tips +```python +display.draw_rectangle( + center=(x, y), # Center coordinates + size=(width, height), # Rectangle dimensions + color="color_string", # Rectangle color + rotation=float, # Optional: rotation in degrees + fill=Boolean, # Optional: fill rectangle (default: True) + thickness=float, # Optional: line thickness + edge_style=str|tuple, # Optional: line style + alpha=float # Optional: transparency (0-1) +) ``` -Please be aware that these setup tips require setting up Conda environment. -Possible support for pip installation will be resolved in the future. + +#### Polygons + +```python +display.draw_polygon( + geometry=shape_geometry, # Shapely geometry or coordinate list + color="color_string", # Polygon color + interiors=[[coords]], # Optional: interior polygon coordinates + fill=Boolean, # Optional: fill polygon (default: True) + thickness=float, # Optional: line thickness + edge_style=str|tuple, # Optional: line style + alpha=float # Optional: transparency (0-1) +) ``` -This is a short to-do list that might come useful when setting up SeaCharts 4.0 for the first time: -1. Set up conda environment as instructed in `conda_requirements.txt` file -2. Use `setup.ps1` (WINDOWS ONLY) to setup directory structure needed by SeaCharts or manually create directories: `data`, `data/db` and `data/shapefiles` -3. Download US1GC09M map via [this link](https://www.charts.noaa.gov/ENCs/US1GC09M.zip), and put the `US1GC09M` directory (found in ENC_ROOT directory) inside data/db folder. -4. Run `test_seacharts_4_0.py` code either by pasting code into some main.py file in root of your project directory or by running it directly (needs fixing the issues with importing seacharts in the test file) -5. After execution you can expect such image to be displayed: -![](images/test_results.svg -"Example visualization with vessels and geometric shapes in dark mode.") +#### Arrows +```python +display.draw_arrow( + start=(x1, y1), # Start coordinates + end=(x2, y2), # End coordinates + color="color_string", # Arrow color + width=float, # Optional: line width + fill=Boolean, # Optional: fill arrow (default: False) + head_size=float, # Optional: arrow head size + thickness=float, # Optional: line thickness + edge_style=str|tuple # Optional: line style +) ``` -For further experimentation options, look into files: `enc.py`, `config.yaml` and `config-schema.yaml` (for reference) + +## Image Export + +The `save_image` function allows you to export the current display or plot as an image file. This function offers several customizable options for setting the file name, location, resolution, and file format. It wraps around the internal `_save_figure` method, which handles the actual saving and setting of default values when specific parameters are not provided. + +### Usage + +```python +display.save_image( + name=str, # Optional: output filename (default: current window title) + path=Path, # Optional: output directory path (default: "reports/") + scale=float, # Optional: resolution scaling factor (default: 1.0) + extension=str # Optional: file format extension (default: "png") +) ``` + +## End note + +We recommend checking out files placed in `tests` directory as reference, to get familiar with the SeaCharts usage. + ## License This project uses the [MIT](https://choosealicense.com/licenses/mit/) license. diff --git a/seacharts/config.yaml b/seacharts/config.yaml index c44fcc8..b93052d 100644 --- a/seacharts/config.yaml +++ b/seacharts/config.yaml @@ -15,7 +15,7 @@ enc: display: colorbar: False - dark_mode: False + dark_mode: True fullscreen: False controls: True resolution: 640 diff --git a/seacharts/core/config.py b/seacharts/core/config.py index d61a9d6..8547dc5 100644 --- a/seacharts/core/config.py +++ b/seacharts/core/config.py @@ -100,26 +100,6 @@ def parse(self, file_name: Path | str = dcp.config) -> None: self.validate_settings() - def override(self, section: str = "enc", **kwargs) -> None: # Can be deleted? Never used - """ - Overrides settings in a specified section with new values. - - :param section: The section of the configuration to override (default is "enc"). - :param kwargs: Key-value pairs representing settings to be updated. - :raises ValueError: If no kwargs are provided or if the section does not exist. - """ - if not kwargs: - return - if section not in self._valid_sections: - raise ValueError("Override settings in non-existing section!") - - new_settings = self._settings - for key, value in kwargs.items(): - new_settings[section][key] = value - self.validate_settings() - self._settings = new_settings - - def read_yaml_into_dict(file_name: Path | str = dcp.config) -> dict: """ Reads a YAML file and converts it into a dictionary. diff --git a/user_doc/images/test_results.svg b/user_doc/images/test_results.svg deleted file mode 100644 index a3236d8..0000000 --- a/user_doc/images/test_results.svg +++ /dev/null @@ -1,42878 +0,0 @@ - - - - - - - - 2024-11-13T20:54:27.805109 - image/svg+xml - - - Matplotlib v3.8.3, https://matplotlib.org/ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/user_doc/seacharts_user_doc.md b/user_doc/seacharts_user_doc.md deleted file mode 100644 index a8e51fe..0000000 --- a/user_doc/seacharts_user_doc.md +++ /dev/null @@ -1,351 +0,0 @@ -# SeaCharts Library - Version 4.0, User Documentation - -> **Important**: Current version requires Conda environment setup. Pip installation support is planned for future releases. - -## Table of Contents -1. [Initial Setup](#initial-setup) -2. [Configuration Setup](#configuration-setup) -3. [ENC Class for Maritime Spatial Data](#enc-class-for-maritime-spatial-data) -4. [Display Features](#display-features) -5. [Weather Visualization](#weather-visualization) -6. [Interactive Controls](#interactive-controls) -7. [Drawing Functions](#drawing-functions) -8. [Image Export](#image-export) - -## Initial Setup - -1. Clone the repository: -```bash -git clone https://github.com/meeqn/seacharts_s57 -``` - -2. Set up the Conda environment: - * Use the provided `conda_requirements.txt` file: - ```bash - conda create --name --file conda_requirements.txt - conda activate - ``` - -3. Set up directory structure: - * **Windows users**: Use the provided `setup.ps1` PowerShell script - * **Other platforms**: Manually create these directories: - * `data` - * `data/db` - -4. Download map data: - * Download the `US1GC09M` map from [here](https://www.charts.noaa.gov/ENCs/US1GC09M.zip) - * Extract and place the `US1GC09M` directory (found in map's ENC_ROOT directory) inside `data/db` folder - -5. Test the installation: - * Run `test_seacharts_4_0.py` - * Expected result is shown below - - - -![Expected output](images/test_results.svg "Expected test_seacharts_4_0.py output") - -### Weather Module Setup (Optional) - -If you need weather functionality: - -```bash -git clone https://github.com/SanityRemnants/PyThor -cd PyThor -conda create --name --file requirements.txt -conda activate -n -python app.py -``` - -## Configuration Setup - -### Configuration File Structure - -The SeaCharts library is configured via `config.yaml` located in the seacharts directory (by default). Below are the detailed configuration options: - -### ENC (Electronic Navigation Chart) Configuration - -```yaml -enc: - size: [width, height] # Size of the chart in chosen CRS unit - origin: [x, y] # Origin coordinates in chosen CRS unit (excludes center) - center: [x, y] # Center point coordinates in chosen CRS unit (excludes origin) - crs: "coordinate_system" # Coordinate reference system - S57_layers: # List of additional S-57 layers with display colors given in hex as value - "LAYER_NAME": "#COLOR_IN_HEX" # e.g., "TSSLPT": "#8B0000" - resources: [data_paths] # Path to ENC data root, is currently a list but expects one argument -``` - -#### Important Notes on ENC Configuration: -- `origin` and `center` are mutually exclusive - use only one -- For `CRS`, you can use: - - "WGS84" for **latitude/longitude coordinates** (required for `S57 maps`) - - "UTM" with zone and hemisphere, for **easting/northing coordinates** (e.g., "UTM33N" for UTM zone 33 North, used for `FGDB maps`) -- `S57_layers` field is **required** for S57 maps (can be empty list) -- Default S-57 layers (automatically included, dont need to be specified in `S57_layers` field): - - `LNDARE` (Land) - - `DEPARE` (Depth Areas) - - `COALNE` (Coastline) -- The resources directory should contain path to **only one** map -- A useful S57 layer catalogue can be found at: https://www.teledynecaris.com/s-57/frames/S57catalog.htm - -### Weather Configuration - -```yaml -weather: - PyThor_address: "http://127.0.0.1:5000" # PyThor server address - variables: [ # Weather variables to display - "wave_direction", - "wave_height", - "wave_period", - "wind_direction", - "wind_speed", - "sea_current_speed", - "sea_current_direction", - "tide_height" - ] -``` - -### Time Configuration - -```yaml -time: - time_start: "DD-MM-YYYY HH:MM" # Start time (must match format exactly) - time_end: "DD-MM-YYYY HH:MM" # End time - period: String # Time period unit - period_multiplier: Integer # Multiplier for time period -``` - -#### Time Configuration Notes: -- Valid period values: - - "hour" - - "day" - - "week" - - "month" - - "year" -- Period multiplier works with hour, day, and week periods -- For example, for 2-hour intervals: - - period: "hour" - - period_multiplier: 2 -- Month and year periods **don't support** multipliers - -### Display Configuration - -```yaml -display: - colorbar: Boolean # Enable/disable depth colorbar (default: False) - dark_mode: Boolean # Enable/disable dark mode (default: False) - fullscreen: Boolean # Enable/disable fullscreen mode (default: False) - controls: Boolean # Show/hide controls window (default: True) - resolution: Integer # Display resolution (default: 640) - anchor: String # Window position ("center", "top_left", etc.) - dpi: Integer # Display DPI (default: 96) -``` - -### PyThor Configuration -For Copernicus marine data access (sea currents and tides), configure PyThor: - -```yaml -coppernicus_account: - username: "your_username" # Copernicus marine account username - password: "your_password" # Copernicus marine account password -resolution: float # Output data resolution in degrees -``` - -## ENC Class for Maritime Spatial Data - -> **Important API Note**: All SeaCharts API functions expect coordinates in **UTM CRS** (easting and northing), regardless of the CRS set in *config.yaml*. - -The ENC class provides methods for handling and visualizing maritime spatial data, including reading, storing, and plotting from specified regions. - -### Key Functionalities - -* **Initialization** - * The ENC object can be initialized with a path to a config.yaml file or a Config object. - -* **Geometric Data Access** - * The ENC provides attributes for accessing spatial layers: - * `land`: Contains land shapes. - * `shore`: Contains shorelines. - * `seabed`: Contains bathymetric (seafloor) data by depth. - -* **Coordinate and Depth Retrieval** - * `get_depth_at_coord(easting, northing)`: Returns the depth at a specific coordinate. - * `is_coord_in_layer(easting, northing, layer_name)`: Checks if a coordinate falls within a specified layer. - -* **Visualization** - * `display`: Returns a Display instance to visualize marine geometric data and vessels. - -* **Spatial Data Update** - * `update()`: Parses and updates ENC data from specified resources. - -### Example Usage - -```python -from seacharts import ENC - -# Initialize ENC with configuration -enc = ENC("config.yaml") - -# Get depth at specific UTM coordinates -depth = enc.get_depth_at_coord(easting, northing) - -# Check if coordinates are in a specific layer (e.g., TSSLPT) -in_traffic_lane = enc.is_coord_in_layer(easting, northing, "TSSLPT") - -# Add a vessel and display -display = enc.display -display.add_vessels((1, easting, northing, 45, "red")) -display.show() -``` - -## Display Features - -The Display class provides various methods to control the visualization: - -### Basic Display Controls - -```python -display.start() # Start the display -display.show(duration=0.0) # Show display for specified duration (0 = indefinite) -display.close() # Close the display window -``` - -### View Modes - -```python -display.dark_mode(enabled=True) # Toggle dark mode -display.fullscreen(enabled=True) # Toggle fullscreen mode -display.colorbar(enabled=True) # Toggle depth colorbar -``` - -### Plot Management - -```python -display.redraw_plot() # Redraw the entire plot -display.update_plot() # Update only animated elements -``` - -## Weather Visualization - -Weather data can be visualized using various variables: - -* Wind (speed and direction) -* Waves (height, direction, period) -* Sea currents (speed and direction) -* Tide height - -The visualization type is automatically selected based on the variable: - -* Scalar values: displayed as heatmaps -* Vector values: displayed as arrow maps with direction indicators - -## Interactive Controls - -When `controls: True` in the config, a control panel provides: - -* **Time Slider** - * Allows navigation through different timestamps with labels for date and time. -* **Layer Selection** - * Includes radio buttons to select weather variables such as wind, waves, and sea current. - -## Drawing Functions - -The Display class offers various drawing functions for maritime shapes: - -### Vessel Management - -```python -display.add_vessels(*vessels) # Add vessels to display -# vessels format: (id, x, y, heading, color) -display.clear_vessels() # Remove all vessels -``` - -### Shape Drawing - -#### Lines - -```python -display.draw_line( - points=[(x1,y1), ...], # List of coordinate pairs - color="color_string", # Line color - width=float, # Optional: line width - thickness=float, # Optional: line thickness - edge_style=str|tuple, # Optional: line style - marker_type=str # Optional: point marker style -) -``` -#### Circles - -```python -display.draw_circle( - center=(x, y), # Center coordinates - radius=float, # Circle radius - color="color_string", # Circle color - fill=Boolean, # Optional: fill circle (default: True) - thickness=float, # Optional: line thickness - edge_style=str|tuple, # Optional: line style - alpha=float # Optional: transparency (0-1) -) -``` - -#### Rectangles - -```python -display.draw_rectangle( - center=(x, y), # Center coordinates - size=(width, height), # Rectangle dimensions - color="color_string", # Rectangle color - rotation=float, # Optional: rotation in degrees - fill=Boolean, # Optional: fill rectangle (default: True) - thickness=float, # Optional: line thickness - edge_style=str|tuple, # Optional: line style - alpha=float # Optional: transparency (0-1) -) -``` - -#### Polygons - -```python -display.draw_polygon( - geometry=shape_geometry, # Shapely geometry or coordinate list - color="color_string", # Polygon color - interiors=[[coords]], # Optional: interior polygon coordinates - fill=Boolean, # Optional: fill polygon (default: True) - thickness=float, # Optional: line thickness - edge_style=str|tuple, # Optional: line style - alpha=float # Optional: transparency (0-1) -) -``` - -#### Arrows - -```python -display.draw_arrow( - start=(x1, y1), # Start coordinates - end=(x2, y2), # End coordinates - color="color_string", # Arrow color - width=float, # Optional: line width - fill=Boolean, # Optional: fill arrow (default: False) - head_size=float, # Optional: arrow head size - thickness=float, # Optional: line thickness - edge_style=str|tuple # Optional: line style -) -``` - -## Image Export - -The `save_image` function allows you to export the current display or plot as an image file. This function offers several customizable options for setting the file name, location, resolution, and file format. It wraps around the internal `_save_figure` method, which handles the actual saving and setting of default values when specific parameters are not provided. - -### Usage - -```python -display.save_image( - name=str, # Optional: output filename (default: current window title) - path=Path, # Optional: output directory path (default: "reports/") - scale=float, # Optional: resolution scaling factor (default: 1.0) - extension=str # Optional: file format extension (default: "png") -) -``` - -## End note -We recommend checking out files placed in `tests` directory as reference, to get familiar with the SeaCharts usage. \ No newline at end of file From 080ff20acbdbc12deaa6d755db5ebdf2e9799407 Mon Sep 17 00:00:00 2001 From: miqn <109998643+meeqn@users.noreply.github.com> Date: Sun, 11 Jan 2026 20:28:12 +0100 Subject: [PATCH 84/84] Add Table of Contents in README.md for improved navigation (#36) --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 662dbc4..af487e0 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,40 @@ Python-based API for Electronic Navigational Charts (ENC) ![](images/example1.svg "Example visualization with vessels and geometric shapes in dark mode.") +## Table of Contents + +- [Features](#features) +- [Code Style](#code-style) +- [Prerequisites](#prerequisites) + - [Initial Setup](#initial-setup) + - [Weather Module Setup (Optional)](#weather-module-setup-optional) +- [Configuration Setup](#configuration-setup) + - [Configuration File Structure](#configuration-file-structure) + - [ENC (Electronic Navigation Chart) Configuration](#enc-electronic-navigation-chart-configuration) + - [Weather Configuration](#weather-configuration) + - [Time Configuration](#time-configuration) + - [Display Configuration](#display-configuration) +- [ENC Class for Maritime Spatial Data](#enc-class-for-maritime-spatial-data) + - [Key Functionalities](#key-functionalities) + - [Example Usage](#example-usage) +- [Display Features](#display-features) + - [Basic Display Controls](#basic-display-controls) + - [View Modes](#view-modes) + - [Plot Management](#plot-management) +- [Weather Visualization](#weather-visualization) +- [Interactive Controls](#interactive-controls) +- [Drawing Functions](#drawing-functions) + - [Vessel Management](#vessel-management) + - [Shape Drawing](#shape-drawing) + - [Lines](#lines) + - [Circles](#circles) + - [Rectangles](#rectangles) + - [Polygons](#polygons) + - [Arrows](#arrows) +- [Image Export](#image-export) +- [End Note](#end-note) +- [License](#license) + ## Features - Read and process spatial depth data from