maps4fs 1.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,637 @@
1
+ """This module contains the DTMProvider class and its subclasses. DTMProvider class is used to
2
+ define different providers of digital terrain models (DTM) data. Each provider has its own URL
3
+ and specific settings for downloading and processing the data."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from abc import ABC, abstractmethod
9
+ from typing import TYPE_CHECKING, Type
10
+ from zipfile import ZipFile
11
+
12
+ import numpy as np
13
+ import osmnx as ox # type: ignore
14
+ import rasterio # type: ignore
15
+ import requests
16
+ from pydantic import BaseModel
17
+ from rasterio.enums import Resampling
18
+ from rasterio.merge import merge
19
+ from rasterio.warp import calculate_default_transform, reproject
20
+ from tqdm import tqdm
21
+
22
+ from maps4fs.logger import Logger
23
+
24
+ if TYPE_CHECKING:
25
+ from maps4fs.generator.map import Map
26
+
27
+
28
+ class DTMProviderSettings(BaseModel):
29
+ """Base class for DTM provider settings models."""
30
+
31
+ easy_mode: bool = True
32
+ power_factor: int = 0
33
+
34
+
35
+ # pylint: disable=too-many-public-methods, too-many-instance-attributes
36
+ class DTMProvider(ABC):
37
+ """Base class for DTM providers."""
38
+
39
+ _code: str | None = None
40
+ _name: str | None = None
41
+ _region: str | None = None
42
+ _icon: str | None = None
43
+ _resolution: float | str | None = None
44
+
45
+ _url: str | None = None
46
+
47
+ _author: str | None = None
48
+ _contributors: str | None = None
49
+ _is_community: bool = False
50
+ _is_base: bool = False
51
+ _settings: Type[DTMProviderSettings] | None = DTMProviderSettings
52
+
53
+ """Bounding box of the provider in the format (north, south, east, west)."""
54
+ _extents: tuple[float, float, float, float] | None = None
55
+
56
+ _instructions: str | None = None
57
+
58
+ _base_instructions = (
59
+ "ℹ️ Using **Easy mode** is recommended, as it automatically adjusts the values in the "
60
+ "image, so the terrain elevation in Giants Editor will match real world "
61
+ "elevation in meters. \n"
62
+ "ℹ️ If the terrain height difference in the real world is bigger than 255 meters, "
63
+ "the [Height scale](https://github.com/iwatkot/maps4fs/blob/main/docs/dem.md#height-scale)"
64
+ " parameter in the **map.i3d** file will be automatically adjusted. \n"
65
+ "⚡ If the **Easy mode** option is disabled, you will probably get completely flat "
66
+ "terrain, unless you adjust the DEM Multiplier Setting or the Height scale parameter in "
67
+ "the Giants Editor. \n"
68
+ "💡 You can use the **Power factor** setting to make the difference between heights "
69
+ "bigger. Be extremely careful with this setting, and use only low values, otherwise your "
70
+ "terrain may be completely broken. \n"
71
+ )
72
+
73
+ # pylint: disable=R0913, R0917
74
+ def __init__(
75
+ self,
76
+ coordinates: tuple[float, float],
77
+ user_settings: DTMProviderSettings | None,
78
+ size: int,
79
+ directory: str,
80
+ logger: Logger,
81
+ map: Map | None = None, # pylint: disable=W0622
82
+ ):
83
+ self._coordinates = coordinates
84
+ self._user_settings = user_settings
85
+ self._size = size
86
+
87
+ if not self._code:
88
+ raise ValueError("Provider code must be defined.")
89
+ self._tile_directory = os.path.join(directory, self._code)
90
+ os.makedirs(self._tile_directory, exist_ok=True)
91
+
92
+ self.logger = logger
93
+ self.map = map
94
+
95
+ self._data_info: dict[str, int | str | float] | None = None
96
+
97
+ @property
98
+ def data_info(self) -> dict[str, int | str | float] | None:
99
+ """Information about the DTM data.
100
+
101
+ Returns:
102
+ dict: Information about the DTM data.
103
+ """
104
+ return self._data_info
105
+
106
+ @data_info.setter
107
+ def data_info(self, value: dict[str, int | str | float] | None) -> None:
108
+ """Set information about the DTM data.
109
+
110
+ Arguments:
111
+ value (dict): Information about the DTM data.
112
+ """
113
+ self._data_info = value
114
+
115
+ @property
116
+ def coordinates(self) -> tuple[float, float]:
117
+ """Coordinates of the center point of the DTM data.
118
+
119
+ Returns:
120
+ tuple: Latitude and longitude of the center point.
121
+ """
122
+ return self._coordinates
123
+
124
+ @property
125
+ def size(self) -> int:
126
+ """Size of the DTM data in meters.
127
+
128
+ Returns:
129
+ int: Size of the DTM data.
130
+ """
131
+ return self._size
132
+
133
+ @property
134
+ def url(self) -> str | None:
135
+ """URL of the provider."""
136
+ return self._url
137
+
138
+ def formatted_url(self, **kwargs) -> str:
139
+ """Formatted URL of the provider."""
140
+ if not self.url:
141
+ raise ValueError("URL must be defined.")
142
+ return self.url.format(**kwargs)
143
+
144
+ @classmethod
145
+ def author(cls) -> str | None:
146
+ """Author of the provider.
147
+
148
+ Returns:
149
+ str: Author of the provider.
150
+ """
151
+ return cls._author
152
+
153
+ @classmethod
154
+ def contributors(cls) -> str | None:
155
+ """Contributors of the provider.
156
+
157
+ Returns:
158
+ str: Contributors of the provider.
159
+ """
160
+ return cls._contributors
161
+
162
+ @classmethod
163
+ def is_base(cls) -> bool:
164
+ """Is the provider a base provider.
165
+
166
+ Returns:
167
+ bool: True if the provider is a base provider, False otherwise.
168
+ """
169
+ return cls._is_base
170
+
171
+ @classmethod
172
+ def is_community(cls) -> bool:
173
+ """Is the provider a community-driven project.
174
+
175
+ Returns:
176
+ bool: True if the provider is a community-driven project, False otherwise.
177
+ """
178
+ return cls._is_community
179
+
180
+ @classmethod
181
+ def settings(cls) -> Type[DTMProviderSettings] | None:
182
+ """Settings model of the provider.
183
+
184
+ Returns:
185
+ Type[DTMProviderSettings]: Settings model of the provider.
186
+ """
187
+ return cls._settings
188
+
189
+ @classmethod
190
+ def instructions(cls) -> str | None:
191
+ """Instructions for using the provider.
192
+
193
+ Returns:
194
+ str: Instructions for using the provider.
195
+ """
196
+ return cls._instructions
197
+
198
+ @classmethod
199
+ def base_instructions(cls) -> str | None:
200
+ """Instructions for using any provider.
201
+
202
+ Returns:
203
+ str: Instructions for using any provider.
204
+ """
205
+ return cls._base_instructions
206
+
207
+ @property
208
+ def user_settings(self) -> DTMProviderSettings | None:
209
+ """User settings of the provider.
210
+
211
+ Returns:
212
+ DTMProviderSettings: User settings of the provider.
213
+ """
214
+ return self._user_settings
215
+
216
+ @classmethod
217
+ def description(cls) -> str:
218
+ """Description of the provider.
219
+
220
+ Returns:
221
+ str: Provider description.
222
+ """
223
+ return f"{cls._icon} {cls._region} [{cls._resolution} m/px] {cls._name}"
224
+
225
+ @classmethod
226
+ def get_provider_by_code(cls, code: str) -> Type[DTMProvider] | None:
227
+ """Get a provider by its code.
228
+
229
+ Arguments:
230
+ code (str): Provider code.
231
+
232
+ Returns:
233
+ DTMProvider: Provider class or None if not found.
234
+ """
235
+ for provider in cls.__subclasses__():
236
+ if provider._code == code: # pylint: disable=W0212
237
+ return provider
238
+ return None
239
+
240
+ @classmethod
241
+ def get_valid_provider_descriptions(cls, lat_lon: tuple[float, float]) -> dict[str, str]:
242
+ """Get descriptions of all providers, where keys are provider codes and
243
+ values are provider descriptions.
244
+
245
+ Returns:
246
+ dict: Provider descriptions.
247
+ """
248
+ providers = {}
249
+ for provider in cls.__subclasses__():
250
+ # pylint: disable=W0212
251
+ if not provider._is_base and provider.inside_bounding_box(lat_lon):
252
+ providers[provider._code] = provider.description() # pylint: disable=W0212
253
+ return providers # type: ignore
254
+
255
+ @classmethod
256
+ def inside_bounding_box(cls, lat_lon: tuple[float, float]) -> bool:
257
+ """Check if the coordinates are inside the bounding box of the provider.
258
+
259
+ Returns:
260
+ bool: True if the coordinates are inside the bounding box, False otherwise.
261
+ """
262
+ lat, lon = lat_lon
263
+ extents = cls._extents
264
+ return extents is None or (
265
+ extents[0] >= lat >= extents[1] and extents[2] >= lon >= extents[3]
266
+ )
267
+
268
+ @abstractmethod
269
+ def download_tiles(self) -> list[str]:
270
+ """Download tiles from the provider.
271
+
272
+ Returns:
273
+ list: List of paths to the downloaded tiles.
274
+ """
275
+ raise NotImplementedError
276
+
277
+ def get_numpy(self) -> np.ndarray:
278
+ """Get numpy array of the tile.
279
+ Resulting array must be 16 bit (signed or unsigned) integer and it should be already
280
+ windowed to the bounding box of ROI. It also must have only one channel.
281
+
282
+ Returns:
283
+ np.ndarray: Numpy array of the tile.
284
+ """
285
+ # download tiles using DTM provider implementation
286
+ tiles = self.download_tiles()
287
+ self.logger.debug(f"Downloaded {len(tiles)} DEM tiles")
288
+
289
+ # merge tiles if necessary
290
+ if len(tiles) > 1:
291
+ self.logger.debug("Multiple tiles downloaded. Merging tiles")
292
+ tile, _ = self.merge_geotiff(tiles)
293
+ else:
294
+ tile = tiles[0]
295
+
296
+ # determine CRS of the resulting tile and reproject if necessary
297
+ with rasterio.open(tile) as src:
298
+ crs = src.crs
299
+ if crs != "EPSG:4326":
300
+ print("crs:", crs)
301
+ print("reprojecting to EPSG:4326")
302
+ self.logger.debug(f"Reprojecting GeoTIFF from {crs} to EPSG:4326...")
303
+ tile = self.reproject_geotiff(tile)
304
+
305
+ # extract region of interest from the tile
306
+ data = self.extract_roi(tile)
307
+
308
+ # process elevation data to be compatible with the game
309
+ data = self.process_elevation(data)
310
+
311
+ return data
312
+
313
+ def process_elevation(self, data: np.ndarray) -> np.ndarray:
314
+ """Process elevation data.
315
+
316
+ Arguments:
317
+ data (np.ndarray): Elevation data.
318
+
319
+ Returns:
320
+ np.ndarray: Processed elevation data.
321
+ """
322
+ self.data_info = {}
323
+ self.add_numpy_params(data, "original")
324
+
325
+ data = self.ground_height_data(data)
326
+ self.add_numpy_params(data, "grounded")
327
+
328
+ original_deviation = int(self.data_info["original_deviation"])
329
+ in_game_maximum_height = 65535 // 255
330
+ if original_deviation > in_game_maximum_height:
331
+ suggested_height_scale_multiplier = (
332
+ original_deviation / in_game_maximum_height # type: ignore
333
+ )
334
+ suggested_height_scale_value = int(255 * suggested_height_scale_multiplier)
335
+ else:
336
+ suggested_height_scale_multiplier = 1
337
+ suggested_height_scale_value = 255
338
+
339
+ self.data_info["suggested_height_scale_multiplier"] = suggested_height_scale_multiplier
340
+ self.data_info["suggested_height_scale_value"] = suggested_height_scale_value
341
+
342
+ self.map.shared_settings.height_scale_multiplier = ( # type: ignore
343
+ suggested_height_scale_multiplier
344
+ )
345
+ self.map.shared_settings.height_scale_value = suggested_height_scale_value # type: ignore
346
+
347
+ if self.user_settings.easy_mode: # type: ignore
348
+ try:
349
+ data = self.normalize_dem(data)
350
+ self.add_numpy_params(data, "normalized")
351
+
352
+ normalized_deviation = self.data_info["normalized_deviation"]
353
+ z_scaling_factor = normalized_deviation / original_deviation # type: ignore
354
+ self.data_info["z_scaling_factor"] = z_scaling_factor
355
+
356
+ self.map.shared_settings.mesh_z_scaling_factor = z_scaling_factor # type: ignore
357
+ self.map.shared_settings.change_height_scale = True # type: ignore
358
+
359
+ except Exception as e: # pylint: disable=W0718
360
+ self.logger.error(
361
+ "Failed to normalize DEM data. Error: %s. Using original data.", e
362
+ )
363
+
364
+ return data.astype(np.uint16)
365
+
366
+ def info_sequence(self) -> dict[str, int | str | float] | None:
367
+ """Returns the information sequence for the component. Must be implemented in the child
368
+ class. If the component does not have an information sequence, an empty dictionary must be
369
+ returned.
370
+
371
+ Returns:
372
+ dict[str, int | str | float] | None: Information sequence for the component.
373
+ """
374
+ return self.data_info
375
+
376
+ # region helpers
377
+ def get_bbox(self) -> tuple[float, float, float, float]:
378
+ """Get bounding box of the tile based on the center point and size.
379
+
380
+ Returns:
381
+ tuple: Bounding box of the tile (north, south, east, west).
382
+ """
383
+ west, south, east, north = ox.utils_geo.bbox_from_point( # type: ignore
384
+ self.coordinates, dist=self.size // 2, project_utm=False
385
+ )
386
+ bbox = float(north), float(south), float(east), float(west)
387
+ return bbox
388
+
389
+ def download_tif_files(self, urls: list[str], output_path: str) -> list[str]:
390
+ """Download GeoTIFF files from the given URLs.
391
+
392
+ Arguments:
393
+ urls (list): List of URLs to download GeoTIFF files from.
394
+ output_path (str): Path to save the downloaded GeoTIFF files.
395
+
396
+ Returns:
397
+ list: List of paths to the downloaded GeoTIFF files.
398
+ """
399
+ tif_files: list[str] = []
400
+ for url in tqdm(urls, desc="Downloading tiles", unit="tile"):
401
+ file_name = os.path.basename(url)
402
+ self.logger.debug("Retrieving TIFF: %s", file_name)
403
+ file_path = os.path.join(output_path, file_name)
404
+ if not os.path.exists(file_path):
405
+ try:
406
+ # Send a GET request to the file URL
407
+ response = requests.get(url, stream=True, timeout=60)
408
+ response.raise_for_status() # Raise an error for HTTP status codes 4xx/5xx
409
+
410
+ # Write the content of the response to the file
411
+ with open(file_path, "wb") as file:
412
+ for chunk in response.iter_content(chunk_size=8192): # Download in chunks
413
+ file.write(chunk)
414
+ self.logger.debug("File downloaded successfully: %s", file_path)
415
+ except requests.exceptions.RequestException as e:
416
+ self.logger.error("Failed to download file: %s", e)
417
+ else:
418
+ self.logger.debug("File already exists: %s", file_name)
419
+ if file_name.endswith(".zip"):
420
+ file_path = self.unzip_img_from_tif(file_name, output_path)
421
+ tif_files.append(file_path)
422
+ return tif_files
423
+
424
+ def unzip_img_from_tif(self, file_name: str, output_path: str) -> str:
425
+ """Unpacks the .img file from the zip file.
426
+
427
+ Arguments:
428
+ file_name (str): Name of the file to unzip.
429
+ output_path (str): Path to the output directory.
430
+
431
+ Returns:
432
+ str: Path to the unzipped file.
433
+ """
434
+ file_path = os.path.join(output_path, file_name)
435
+ img_file_name = file_name.replace(".zip", ".img")
436
+ img_file_path = os.path.join(output_path, img_file_name)
437
+ if not os.path.exists(img_file_path):
438
+ with ZipFile(file_path, "r") as f_in:
439
+ f_in.extract(img_file_name, output_path)
440
+ self.logger.debug("Unzipped file %s to %s", file_name, img_file_name)
441
+ else:
442
+ self.logger.debug("File already exists: %s", img_file_name)
443
+ return img_file_path
444
+
445
+ def reproject_geotiff(self, input_tiff: str) -> str:
446
+ """Reproject a GeoTIFF file to a new coordinate reference system (CRS).
447
+
448
+ Arguments:
449
+ input_tiff (str): Path to the input GeoTIFF file.
450
+
451
+ Returns:
452
+ str: Path to the reprojected GeoTIFF file.
453
+ """
454
+ output_tiff = os.path.join(self._tile_directory, "reprojected.tif")
455
+
456
+ # Open the source GeoTIFF
457
+ self.logger.debug("Reprojecting GeoTIFF to EPSG:4326 CRS...")
458
+ with rasterio.open(input_tiff) as src:
459
+ # Get the transform, width, and height of the target CRS
460
+ transform, width, height = calculate_default_transform(
461
+ src.crs, "EPSG:4326", src.width, src.height, *src.bounds
462
+ )
463
+
464
+ # Update the metadata for the target GeoTIFF
465
+ kwargs = src.meta.copy()
466
+ kwargs.update(
467
+ {
468
+ "crs": "EPSG:4326",
469
+ "transform": transform,
470
+ "width": width,
471
+ "height": height,
472
+ "nodata": None,
473
+ }
474
+ )
475
+
476
+ # Open the destination GeoTIFF file and reproject
477
+ with rasterio.open(output_tiff, "w", **kwargs) as dst:
478
+ for i in range(1, src.count + 1): # Iterate over all raster bands
479
+ reproject(
480
+ source=rasterio.band(src, i),
481
+ destination=rasterio.band(dst, i),
482
+ src_transform=src.transform,
483
+ src_crs=src.crs,
484
+ dst_transform=transform,
485
+ dst_crs="EPSG:4326",
486
+ resampling=Resampling.average, # Choose resampling method
487
+ )
488
+
489
+ self.logger.debug("Reprojected GeoTIFF saved to %s", output_tiff)
490
+ return output_tiff
491
+
492
+ def merge_geotiff(self, input_files: list[str]) -> tuple[str, str]:
493
+ """Merge multiple GeoTIFF files into a single GeoTIFF file.
494
+
495
+ Arguments:
496
+ input_files (list): List of input GeoTIFF files to merge.
497
+ """
498
+ output_file = os.path.join(self._tile_directory, "merged.tif")
499
+ # Open all input GeoTIFF files as datasets
500
+ self.logger.debug("Merging tiff files...")
501
+ datasets = [rasterio.open(file) for file in input_files]
502
+ print("datasets:", datasets)
503
+
504
+ # Merge datasets
505
+ crs = datasets[0].crs
506
+ mosaic, out_transform = merge(datasets, nodata=0)
507
+ print("mosaic:", mosaic)
508
+
509
+ # Get metadata from the first file and update it for the output
510
+ out_meta = datasets[0].meta.copy()
511
+ out_meta.update(
512
+ {
513
+ "driver": "GTiff",
514
+ "height": mosaic.shape[1],
515
+ "width": mosaic.shape[2],
516
+ "transform": out_transform,
517
+ "count": mosaic.shape[0], # Number of bands
518
+ }
519
+ )
520
+
521
+ # Write merged GeoTIFF to the output file
522
+ with rasterio.open(output_file, "w", **out_meta) as dest:
523
+ dest.write(mosaic)
524
+
525
+ self.logger.debug("GeoTIFF images merged successfully into %s", output_file)
526
+ return output_file, crs
527
+
528
+ def extract_roi(self, tile_path: str) -> np.ndarray:
529
+ """Extract region of interest (ROI) from the GeoTIFF file.
530
+
531
+ Arguments:
532
+ tile_path (str): Path to the GeoTIFF file.
533
+
534
+ Raises:
535
+ ValueError: If the tile does not contain any data.
536
+
537
+ Returns:
538
+ np.ndarray: Numpy array of the ROI.
539
+ """
540
+ north, south, east, west = self.get_bbox()
541
+ with rasterio.open(tile_path) as src:
542
+ self.logger.debug("Opened tile, shape: %s, dtype: %s.", src.shape, src.dtypes[0])
543
+ window = rasterio.windows.from_bounds(west, south, east, north, src.transform)
544
+ self.logger.debug(
545
+ "Window parameters. Column offset: %s, row offset: %s, width: %s, height: %s.",
546
+ window.col_off,
547
+ window.row_off,
548
+ window.width,
549
+ window.height,
550
+ )
551
+ data = src.read(1, window=window, masked=True)
552
+ if not data.size > 0:
553
+ raise ValueError("No data in the tile.")
554
+
555
+ return data
556
+
557
+ def normalize_dem(self, data: np.ndarray) -> np.ndarray:
558
+ """Normalize DEM data to 16-bit unsigned integer using max height from settings.
559
+
560
+ Arguments:
561
+ data (np.ndarray): DEM data from SRTM file after cropping.
562
+
563
+ Returns:
564
+ np.ndarray: Normalized DEM data.
565
+ """
566
+ maximum_height = int(data.max())
567
+ minimum_height = int(data.min())
568
+ deviation = maximum_height - minimum_height
569
+ self.logger.debug(
570
+ "Maximum height: %s. Minimum height: %s. Deviation: %s.",
571
+ maximum_height,
572
+ minimum_height,
573
+ deviation,
574
+ )
575
+ self.logger.debug("Number of unique values in original DEM data: %s.", np.unique(data).size)
576
+
577
+ adjusted_maximum_height = maximum_height * 255
578
+ adjusted_maximum_height = min(adjusted_maximum_height, 65535)
579
+ scaling_factor = adjusted_maximum_height / maximum_height
580
+ self.logger.debug(
581
+ "Adjusted maximum height: %s. Scaling factor: %s.",
582
+ adjusted_maximum_height,
583
+ scaling_factor,
584
+ )
585
+
586
+ if self.user_settings.power_factor: # type: ignore
587
+ power_factor = 1 + self.user_settings.power_factor / 10 # type: ignore
588
+ self.logger.debug(
589
+ "Applying power factor: %s to the DEM data.",
590
+ power_factor,
591
+ )
592
+ data = np.power(data, power_factor).astype(np.uint16)
593
+
594
+ normalized_data = np.round(data * scaling_factor).astype(np.uint16)
595
+ self.logger.debug(
596
+ "Normalized data maximum height: %s. Minimum height: %s. Number of unique values: %s.",
597
+ normalized_data.max(),
598
+ normalized_data.min(),
599
+ np.unique(normalized_data).size,
600
+ )
601
+ return normalized_data
602
+
603
+ @staticmethod
604
+ def ground_height_data(data: np.ndarray, add_one: bool = True) -> np.ndarray:
605
+ """Shift the data to ground level (0 meter).
606
+ Optionally add one meter to the data to leave some room
607
+ for the water level and pit modifications.
608
+
609
+ Arguments:
610
+ data (np.ndarray): DEM data after cropping.
611
+ add_one (bool): Add one meter to the data
612
+
613
+ Returns:
614
+ np.ndarray: Unsigned DEM data.
615
+ """
616
+ data = data - data.min()
617
+ if add_one:
618
+ data = data + 1
619
+ return data
620
+
621
+ def add_numpy_params(
622
+ self,
623
+ data: np.ndarray,
624
+ prefix: str,
625
+ ) -> None:
626
+ """Add numpy array parameters to the data_info dictionary.
627
+
628
+ Arguments:
629
+ data (np.ndarray): Numpy array of the tile.
630
+ prefix (str): Prefix for the parameters.
631
+ """
632
+ self.data_info[f"{prefix}_minimum_height"] = int(data.min()) # type: ignore
633
+ self.data_info[f"{prefix}_maximum_height"] = int(data.max()) # type: ignore
634
+ self.data_info[f"{prefix}_deviation"] = int(data.max() - data.min()) # type: ignore
635
+ self.data_info[f"{prefix}_unique_values"] = int(np.unique(data).size) # type: ignore
636
+
637
+ # endregion
@@ -0,0 +1,31 @@
1
+ """This module contains provider of England data."""
2
+
3
+ from maps4fs.generator.dtm.base.wcs import WCSProvider
4
+ from maps4fs.generator.dtm.dtm import DTMProvider
5
+
6
+
7
+ class England1MProvider(WCSProvider, DTMProvider):
8
+ """Provider of England data."""
9
+
10
+ _code = "england1m"
11
+ _name = "England DGM1"
12
+ _region = "UK"
13
+ _icon = "🏴󠁧󠁢󠁥󠁮󠁧󠁿"
14
+ _resolution = 1
15
+ _author = "[kbrandwijk](https://github.com/kbrandwijk)"
16
+ _is_community = True
17
+ _instructions = None
18
+ _is_base = False
19
+ _extents = (55.87708724246775, 49.85060473351981, 2.0842821419111135, -7.104775741839742)
20
+
21
+ _url = "https://environment.data.gov.uk/geoservices/datasets/13787b9a-26a4-4775-8523-806d13af58fc/wcs" # pylint: disable=line-too-long
22
+ _wcs_version = "2.0.1"
23
+ _source_crs = "EPSG:27700"
24
+ _tile_size = 1000
25
+
26
+ def get_wcs_parameters(self, tile):
27
+ return {
28
+ "identifier": ["13787b9a-26a4-4775-8523-806d13af58fc:Lidar_Composite_Elevation_DTM_1m"],
29
+ "subsets": [("E", str(tile[1]), str(tile[3])), ("N", str(tile[0]), str(tile[2]))],
30
+ "format": "tiff",
31
+ }
@@ -0,0 +1,31 @@
1
+ """This module contains provider of Hessen data."""
2
+
3
+ from maps4fs.generator.dtm.base.wcs import WCSProvider
4
+ from maps4fs.generator.dtm.dtm import DTMProvider
5
+
6
+
7
+ class HessenProvider(WCSProvider, DTMProvider):
8
+ """Provider of Hessen data."""
9
+
10
+ _code = "hessen"
11
+ _name = "Hessen DGM1"
12
+ _region = "DE"
13
+ _icon = "🇩🇪󠁥"
14
+ _resolution = 1
15
+ _author = "[kbrandwijk](https://github.com/kbrandwijk)"
16
+ _is_community = True
17
+ _is_base = False
18
+ _extents = (51.66698, 49.38533, 10.25780, 7.72773)
19
+
20
+ _url = "https://inspire-hessen.de/raster/dgm1/ows"
21
+ _wcs_version = "2.0.1"
22
+ _source_crs = "EPSG:25832"
23
+ _tile_size = 1000
24
+
25
+ def get_wcs_parameters(self, tile: tuple[float, float, float, float]) -> dict:
26
+ return {
27
+ "identifier": ["he_dgm1"],
28
+ "subsets": [("N", str(tile[0]), str(tile[2])), ("E", str(tile[1]), str(tile[3]))],
29
+ "format": "image/gtiff",
30
+ "timeout": 600,
31
+ }