maps4fs 1.8.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+ }