pycontrails 0.58.0__cp314-cp314-macosx_10_13_x86_64.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.

Potentially problematic release.


This version of pycontrails might be problematic. Click here for more details.

Files changed (122) hide show
  1. pycontrails/__init__.py +70 -0
  2. pycontrails/_version.py +34 -0
  3. pycontrails/core/__init__.py +30 -0
  4. pycontrails/core/aircraft_performance.py +679 -0
  5. pycontrails/core/airports.py +228 -0
  6. pycontrails/core/cache.py +889 -0
  7. pycontrails/core/coordinates.py +174 -0
  8. pycontrails/core/fleet.py +483 -0
  9. pycontrails/core/flight.py +2185 -0
  10. pycontrails/core/flightplan.py +228 -0
  11. pycontrails/core/fuel.py +140 -0
  12. pycontrails/core/interpolation.py +702 -0
  13. pycontrails/core/met.py +2931 -0
  14. pycontrails/core/met_var.py +387 -0
  15. pycontrails/core/models.py +1321 -0
  16. pycontrails/core/polygon.py +549 -0
  17. pycontrails/core/rgi_cython.cpython-314-darwin.so +0 -0
  18. pycontrails/core/vector.py +2249 -0
  19. pycontrails/datalib/__init__.py +12 -0
  20. pycontrails/datalib/_met_utils/metsource.py +746 -0
  21. pycontrails/datalib/ecmwf/__init__.py +73 -0
  22. pycontrails/datalib/ecmwf/arco_era5.py +345 -0
  23. pycontrails/datalib/ecmwf/common.py +114 -0
  24. pycontrails/datalib/ecmwf/era5.py +554 -0
  25. pycontrails/datalib/ecmwf/era5_model_level.py +490 -0
  26. pycontrails/datalib/ecmwf/hres.py +804 -0
  27. pycontrails/datalib/ecmwf/hres_model_level.py +466 -0
  28. pycontrails/datalib/ecmwf/ifs.py +287 -0
  29. pycontrails/datalib/ecmwf/model_levels.py +435 -0
  30. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  31. pycontrails/datalib/ecmwf/variables.py +268 -0
  32. pycontrails/datalib/geo_utils.py +261 -0
  33. pycontrails/datalib/gfs/__init__.py +28 -0
  34. pycontrails/datalib/gfs/gfs.py +656 -0
  35. pycontrails/datalib/gfs/variables.py +104 -0
  36. pycontrails/datalib/goes.py +757 -0
  37. pycontrails/datalib/himawari/__init__.py +27 -0
  38. pycontrails/datalib/himawari/header_struct.py +266 -0
  39. pycontrails/datalib/himawari/himawari.py +667 -0
  40. pycontrails/datalib/landsat.py +589 -0
  41. pycontrails/datalib/leo_utils/__init__.py +5 -0
  42. pycontrails/datalib/leo_utils/correction.py +266 -0
  43. pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
  44. pycontrails/datalib/leo_utils/search.py +250 -0
  45. pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
  46. pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
  47. pycontrails/datalib/leo_utils/vis.py +59 -0
  48. pycontrails/datalib/sentinel.py +650 -0
  49. pycontrails/datalib/spire/__init__.py +5 -0
  50. pycontrails/datalib/spire/exceptions.py +62 -0
  51. pycontrails/datalib/spire/spire.py +604 -0
  52. pycontrails/ext/bada.py +42 -0
  53. pycontrails/ext/cirium.py +14 -0
  54. pycontrails/ext/empirical_grid.py +140 -0
  55. pycontrails/ext/synthetic_flight.py +431 -0
  56. pycontrails/models/__init__.py +1 -0
  57. pycontrails/models/accf.py +425 -0
  58. pycontrails/models/apcemm/__init__.py +8 -0
  59. pycontrails/models/apcemm/apcemm.py +983 -0
  60. pycontrails/models/apcemm/inputs.py +226 -0
  61. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  62. pycontrails/models/apcemm/utils.py +437 -0
  63. pycontrails/models/cocip/__init__.py +29 -0
  64. pycontrails/models/cocip/cocip.py +2742 -0
  65. pycontrails/models/cocip/cocip_params.py +305 -0
  66. pycontrails/models/cocip/cocip_uncertainty.py +291 -0
  67. pycontrails/models/cocip/contrail_properties.py +1530 -0
  68. pycontrails/models/cocip/output_formats.py +2270 -0
  69. pycontrails/models/cocip/radiative_forcing.py +1260 -0
  70. pycontrails/models/cocip/radiative_heating.py +520 -0
  71. pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
  72. pycontrails/models/cocip/wake_vortex.py +396 -0
  73. pycontrails/models/cocip/wind_shear.py +120 -0
  74. pycontrails/models/cocipgrid/__init__.py +9 -0
  75. pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
  76. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  77. pycontrails/models/dry_advection.py +602 -0
  78. pycontrails/models/emissions/__init__.py +21 -0
  79. pycontrails/models/emissions/black_carbon.py +599 -0
  80. pycontrails/models/emissions/emissions.py +1353 -0
  81. pycontrails/models/emissions/ffm2.py +336 -0
  82. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  83. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  84. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  85. pycontrails/models/extended_k15.py +1327 -0
  86. pycontrails/models/humidity_scaling/__init__.py +37 -0
  87. pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
  88. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  89. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  90. pycontrails/models/issr.py +210 -0
  91. pycontrails/models/pcc.py +326 -0
  92. pycontrails/models/pcr.py +154 -0
  93. pycontrails/models/ps_model/__init__.py +18 -0
  94. pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
  95. pycontrails/models/ps_model/ps_grid.py +701 -0
  96. pycontrails/models/ps_model/ps_model.py +1000 -0
  97. pycontrails/models/ps_model/ps_operational_limits.py +525 -0
  98. pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
  99. pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
  100. pycontrails/models/sac.py +442 -0
  101. pycontrails/models/tau_cirrus.py +183 -0
  102. pycontrails/physics/__init__.py +1 -0
  103. pycontrails/physics/constants.py +117 -0
  104. pycontrails/physics/geo.py +1138 -0
  105. pycontrails/physics/jet.py +968 -0
  106. pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
  107. pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
  108. pycontrails/physics/thermo.py +551 -0
  109. pycontrails/physics/units.py +472 -0
  110. pycontrails/py.typed +0 -0
  111. pycontrails/utils/__init__.py +1 -0
  112. pycontrails/utils/dependencies.py +66 -0
  113. pycontrails/utils/iteration.py +13 -0
  114. pycontrails/utils/json.py +187 -0
  115. pycontrails/utils/temp.py +50 -0
  116. pycontrails/utils/types.py +163 -0
  117. pycontrails-0.58.0.dist-info/METADATA +180 -0
  118. pycontrails-0.58.0.dist-info/RECORD +122 -0
  119. pycontrails-0.58.0.dist-info/WHEEL +6 -0
  120. pycontrails-0.58.0.dist-info/licenses/LICENSE +178 -0
  121. pycontrails-0.58.0.dist-info/licenses/NOTICE +43 -0
  122. pycontrails-0.58.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,549 @@
1
+ """Algorithm support for grid to polygon conversion.
2
+
3
+ See Also
4
+ --------
5
+ :meth:`pycontrails.MetDataArray.to_polygon_feature`
6
+ :meth:`pycontrails.MetDataArray.to_polygon_feature_collection`
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import warnings
12
+ from collections.abc import Sequence
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+ import numpy.typing as npt
17
+
18
+ from pycontrails.utils import dependencies
19
+
20
+ try:
21
+ import cv2
22
+ except ModuleNotFoundError as exc:
23
+ dependencies.raise_module_not_found_error(
24
+ name="polygon module",
25
+ package_name="opencv-python",
26
+ module_not_found_error=exc,
27
+ pycontrails_optional_package="vis",
28
+ )
29
+
30
+ try:
31
+ import shapely
32
+ import shapely.errors
33
+ import shapely.geometry
34
+ import shapely.validation
35
+ except ModuleNotFoundError as exc:
36
+ dependencies.raise_module_not_found_error(
37
+ name="polygon module",
38
+ package_name="shapely",
39
+ module_not_found_error=exc,
40
+ pycontrails_optional_package="vis",
41
+ )
42
+
43
+
44
+ def buffer_and_clean(
45
+ contour: npt.NDArray[np.floating],
46
+ min_area: float,
47
+ convex_hull: bool,
48
+ epsilon: float,
49
+ precision: int | None,
50
+ buffer: float,
51
+ is_exterior: bool,
52
+ ) -> shapely.Polygon | None:
53
+ """Buffer and clean a contour.
54
+
55
+ Parameters
56
+ ----------
57
+ contour : npt.NDArray[np.floating]
58
+ Contour to buffer and clean. A 2d array of shape (n, 2) where n is the number
59
+ of vertices in the contour.
60
+ min_area : float
61
+ Minimum area of the polygon. If the area of the buffered contour is less than
62
+ this, return None.
63
+ convex_hull : bool
64
+ Whether to take the convex hull of the buffered contour.
65
+ epsilon : float
66
+ Epsilon value for polygon simplification. If 0, no simplification is performed.
67
+ precision : int | None
68
+ Precision of the output polygon. If None, no rounding is performed.
69
+ buffer : float
70
+ Buffer distance.
71
+ is_exterior : bool, optional
72
+ Whether the contour is an exterior contour. If True, the contour is buffered
73
+ with a larger buffer distance. The polygon orientation is CCW iff this is True.
74
+
75
+ Returns
76
+ -------
77
+ shapely.Polygon | None
78
+ Buffered and cleaned polygon. If the area of the buffered contour is less than
79
+ ``min_area``, return None.
80
+ """
81
+ if len(contour) == 1:
82
+ base = shapely.Point(contour)
83
+ elif len(contour) < 4:
84
+ base = shapely.LineString(contour)
85
+ else:
86
+ base = shapely.Polygon(contour)
87
+
88
+ if is_exterior:
89
+ # The contours computed by openCV go directly over array points
90
+ # with value 1. With marching squares, we expect the contours to
91
+ # be the midpoint between the 0-1 boundary. Apply a small buffer
92
+ # to the exterior contours to account for this.
93
+ polygon = base.buffer(buffer, quad_segs=1)
94
+ else:
95
+ try:
96
+ polygon = shapely.Polygon(base)
97
+ except shapely.errors.TopologicalError:
98
+ return None
99
+
100
+ if not polygon.is_valid:
101
+ polygon = polygon.buffer(buffer / 10.0, quad_segs=1)
102
+ assert polygon.is_valid, "Fail to make polygon valid after buffer"
103
+
104
+ if isinstance(polygon, shapely.MultiPolygon):
105
+ # In this case, there is often one large polygon and several small polygons
106
+ # Just extract the largest polygon, ignoring the others
107
+ polygon = max(polygon.geoms, key=lambda x: x.area)
108
+
109
+ # Remove all interior rings
110
+ if polygon.interiors:
111
+ polygon = shapely.Polygon(polygon.exterior)
112
+
113
+ if polygon.area < min_area:
114
+ return None
115
+
116
+ # Exterior polygons should have CCW orientation
117
+ if is_exterior != polygon.exterior.is_ccw:
118
+ polygon = polygon.reverse()
119
+ if convex_hull:
120
+ polygon = _take_convex_hull(polygon)
121
+ if epsilon:
122
+ polygon = _buffer_simplify_iterate(polygon, epsilon)
123
+
124
+ if precision is not None:
125
+ while precision < 10:
126
+ out = _round_polygon(polygon, precision)
127
+ if out.is_valid:
128
+ return out
129
+ precision += 1
130
+
131
+ warnings.warn("Could not round polygon to a valid geometry.")
132
+
133
+ return polygon
134
+
135
+
136
+ def _round_polygon(polygon: shapely.Polygon, precision: int) -> shapely.Polygon:
137
+ """Round the coordinates of a polygon.
138
+
139
+ Parameters
140
+ ----------
141
+ polygon : shapely.Polygon
142
+ Polygon to round.
143
+ precision : int
144
+ Precision to use when rounding.
145
+
146
+ Returns
147
+ -------
148
+ shapely.Polygon
149
+ Polygon with rounded coordinates.
150
+ """
151
+ if polygon.is_empty:
152
+ return polygon
153
+
154
+ exterior = np.round(np.asarray(polygon.exterior.coords), precision)
155
+ interiors = [np.round(np.asarray(i.coords), precision) for i in polygon.interiors]
156
+ return shapely.Polygon(exterior, interiors)
157
+
158
+
159
+ def _contours_to_polygons(
160
+ contours: Sequence[npt.NDArray[np.floating]],
161
+ hierarchy: npt.NDArray[np.int_],
162
+ min_area: float,
163
+ convex_hull: bool,
164
+ epsilon: float,
165
+ longitude: npt.NDArray[np.floating] | None,
166
+ latitude: npt.NDArray[np.floating] | None,
167
+ precision: int | None,
168
+ buffer: float,
169
+ i: int = 0,
170
+ ) -> list[shapely.Polygon]:
171
+ """Convert the outputs of :func:`cv2.findContours` to :class:`shapely.Polygon`.
172
+
173
+ Parameters
174
+ ----------
175
+ contours : Sequence[npt.NDArray[np.floating]]
176
+ The contours output from :func:`cv2.findContours`.
177
+ hierarchy : npt.NDArray[np.int_]
178
+ The hierarchy output from :func:`cv2.findContours`.
179
+ min_area : float
180
+ Minimum area of a polygon to be included in the output.
181
+ convex_hull : bool
182
+ Whether to take the convex hull of each polygon.
183
+ epsilon : float
184
+ Epsilon value to use when simplifying the polygons.
185
+ longitude : npt.NDArray[np.floating] | None
186
+ Longitude values for the grid.
187
+ latitude : npt.NDArray[np.floating] | None
188
+ Latitude values for the grid.
189
+ precision : int | None
190
+ Precision to use when rounding the coordinates.
191
+ buffer : float
192
+ Buffer to apply to the contours when converting to polygons.
193
+ i : int, optional
194
+ The index of the contour to start with. Defaults to 0.
195
+
196
+ Returns
197
+ -------
198
+ list[shapely.Polygon]
199
+ A list of polygons. Polygons with a parent-child relationship are merged into
200
+ a single polygon.
201
+ """
202
+ out = []
203
+ while i != -1:
204
+ child_i, parent_i = hierarchy[i, 2:]
205
+ is_exterior = parent_i == -1
206
+
207
+ contour = contours[i][:, 0, ::-1]
208
+ i = hierarchy[i, 0]
209
+ if longitude is not None and latitude is not None:
210
+ lon_idx = contour[:, 0]
211
+ lat_idx = contour[:, 1]
212
+
213
+ # Calculate interpolated longitude and latitude values and recreate contour
214
+ lon = np.interp(lon_idx, np.arange(longitude.shape[0]), longitude)
215
+ lat = np.interp(lat_idx, np.arange(latitude.shape[0]), latitude)
216
+ contour = np.stack([lon, lat], axis=1)
217
+
218
+ polygon = buffer_and_clean(
219
+ contour,
220
+ min_area,
221
+ convex_hull,
222
+ epsilon,
223
+ precision,
224
+ buffer,
225
+ is_exterior,
226
+ )
227
+ if polygon is None:
228
+ continue
229
+
230
+ if child_i != -1:
231
+ holes = _contours_to_polygons(
232
+ contours,
233
+ hierarchy,
234
+ min_area=min_area,
235
+ convex_hull=False,
236
+ epsilon=epsilon,
237
+ longitude=longitude,
238
+ latitude=latitude,
239
+ precision=precision,
240
+ buffer=buffer,
241
+ i=child_i,
242
+ )
243
+
244
+ candidate = shapely.Polygon(polygon.exterior, [h.exterior for h in holes])
245
+ # Abundance of caution: check if the candidate is valid
246
+ # If the candidate isn't valid, ignore all the holes
247
+ # This can happen if there are many holes and the buffer operation
248
+ # causes the holes to overlap
249
+ if candidate.is_valid:
250
+ polygon = candidate
251
+
252
+ out.append(polygon)
253
+ return out
254
+
255
+
256
+ def determine_buffer(
257
+ longitude: npt.NDArray[np.floating], latitude: npt.NDArray[np.floating]
258
+ ) -> float:
259
+ """Determine the proper buffer size to use when converting to polygons."""
260
+
261
+ ndigits = 6
262
+
263
+ try:
264
+ d_lon = round(longitude[1] - longitude[0], ndigits)
265
+ d_lat = round(latitude[1] - latitude[0], ndigits)
266
+ except IndexError as e:
267
+ raise ValueError("Longitude and latitude must each have at least 2 elements.") from e
268
+
269
+ if d_lon != d_lat:
270
+ warnings.warn(
271
+ "Longitude and latitude are not evenly spaced. Buffer size may be inaccurate."
272
+ )
273
+ if not np.all(np.diff(longitude).round(ndigits) == d_lon):
274
+ warnings.warn("Longitude is not evenly spaced. Buffer size may be inaccurate.")
275
+ if not np.all(np.diff(latitude).round(ndigits) == d_lat):
276
+ warnings.warn("Latitude is not evenly spaced. Buffer size may be inaccurate.")
277
+
278
+ return min(d_lon, d_lat) / 2.0
279
+
280
+
281
+ def find_multipolygon(
282
+ arr: npt.NDArray[np.floating],
283
+ threshold: float,
284
+ min_area: float,
285
+ epsilon: float,
286
+ lower_bound: bool = True,
287
+ interiors: bool = True,
288
+ convex_hull: bool = False,
289
+ longitude: npt.NDArray[np.floating] | None = None,
290
+ latitude: npt.NDArray[np.floating] | None = None,
291
+ precision: int | None = None,
292
+ ) -> shapely.MultiPolygon:
293
+ """Compute a multipolygon from a 2d array.
294
+
295
+ Parameters
296
+ ----------
297
+ arr : npt.NDArray[np.floating]
298
+ Array to convert to a multipolygon. The array will be converted to a binary
299
+ array by comparing each element to ``threshold``. This binary array is then
300
+ passed into :func:`cv2.findContours` to find the contours.
301
+ threshold : float
302
+ Threshold to use when converting ``arr`` to a binary array.
303
+ min_area : float
304
+ Minimum area of a polygon to be included in the output.
305
+ epsilon : float
306
+ Epsilon value to use when simplifying the polygons. Passed into shapely's
307
+ :meth:`shapely.geometry.Polygon.simplify` method.
308
+ lower_bound : bool, optional
309
+ Whether to treat ``threshold`` as a lower or upper bound on values in polygon interiors.
310
+ By default, True.
311
+ interiors : bool, optional
312
+ Whether to include interior polygons. By default, True.
313
+ convex_hull : bool, optional
314
+ Experimental. Whether to take the convex hull of each polygon. By default, False.
315
+ longitude : npt.NDArray[np.floating] | None, optional
316
+ If provided, the coordinates values corresponding to the longitude dimensions of ``arr``.
317
+ The contour coordinates will be converted to longitude-latitude values by indexing
318
+ into this array. Defaults to None.
319
+ latitude : npt.NDArray[np.floating] | None, optional
320
+ If provided, the coordinates values corresponding to the latitude dimensions of ``arr``.
321
+ precision : int | None, optional
322
+ If provided, the precision to use when rounding the coordinates. Defaults to None.
323
+
324
+ Returns
325
+ -------
326
+ shapely.MultiPolygon
327
+ A multipolygon of the contours.
328
+
329
+ Raises
330
+ ------
331
+ ValueError
332
+ If ``arr`` is not 2d.
333
+ """
334
+ if arr.ndim != 2:
335
+ raise ValueError("Array must be 2d")
336
+
337
+ assert (longitude is None) == (latitude is None)
338
+ if longitude is not None:
339
+ assert latitude is not None
340
+ assert arr.shape == (*longitude.shape, *latitude.shape)
341
+ buffer = determine_buffer(longitude, latitude)
342
+ else:
343
+ buffer = 0.5
344
+
345
+ arr_bin = np.empty(arr.shape, dtype=np.uint8)
346
+ if lower_bound:
347
+ np.greater_equal(arr, threshold, out=arr_bin)
348
+ else:
349
+ np.less_equal(arr, threshold, out=arr_bin)
350
+
351
+ mode = cv2.RETR_CCOMP if interiors else cv2.RETR_EXTERNAL
352
+ contours, hierarchy = cv2.findContours(arr_bin, mode, cv2.CHAIN_APPROX_SIMPLE)
353
+ if not contours:
354
+ return shapely.MultiPolygon()
355
+
356
+ assert len(hierarchy) == 1
357
+ hierarchy = hierarchy[0]
358
+
359
+ polygons = _contours_to_polygons(
360
+ contours, # type: ignore[arg-type]
361
+ hierarchy,
362
+ min_area,
363
+ convex_hull,
364
+ epsilon,
365
+ longitude,
366
+ latitude,
367
+ precision,
368
+ buffer,
369
+ )
370
+ return _make_valid_multipolygon(polygons, convex_hull)
371
+
372
+
373
+ def _take_convex_hull(polygon: shapely.Polygon) -> shapely.Polygon:
374
+ """Take the convex hull of a linear ring and preserve the orientation.
375
+
376
+ Parameters
377
+ ----------
378
+ polygon : shapely.Polygon
379
+ Linear ring to take the convex hull of.
380
+
381
+ Returns
382
+ -------
383
+ shapely.Polygon
384
+ Convex hull of the input.
385
+ """
386
+ convex_hull = polygon.convex_hull
387
+ if polygon.exterior.is_ccw == convex_hull.exterior.is_ccw:
388
+ return convex_hull
389
+ return shapely.Polygon(convex_hull.exterior.reverse())
390
+
391
+
392
+ def _buffer_simplify_iterate(polygon: shapely.Polygon, epsilon: float) -> shapely.Polygon:
393
+ """Simplify a linear ring by iterating over a larger buffer.
394
+
395
+ This function calls :func:`shapely.buffer` and :func:`shapely.simplify`
396
+ over a range of buffer distances. The buffer allows for the topology
397
+ of the contour to change, which is useful for simplifying contours.
398
+ Applying a buffer does introduce a slight bias towards the exterior
399
+ of the contour.
400
+
401
+ .. versionadded:: 0.38.0
402
+
403
+ Parameters
404
+ ----------
405
+ polygon : shapely.Polygon
406
+ Linear ring to simplify.
407
+ epsilon : float
408
+ Passed as ``tolerance`` parameter into :func:`shapely.simplify`.
409
+
410
+ Returns
411
+ -------
412
+ shapely.Polygon
413
+ Simplified linear ring as a :class:`shapely.Polygon`.
414
+ """
415
+ # Try to simplify without a buffer first
416
+ # This seems to be computationally faster
417
+ out = polygon.simplify(epsilon, preserve_topology=False)
418
+
419
+ # In some rare situations, calling simplify actually gives a MultiPolygon.
420
+ # In this case, take the polygon with the largest area
421
+ if isinstance(out, shapely.MultiPolygon):
422
+ out = max(out.geoms, key=lambda x: x.area)
423
+
424
+ if out.is_simple and out.is_valid:
425
+ return out
426
+
427
+ # Applying a naive linear_ring.buffer(0) can destroy the polygon completely
428
+ # https://stackoverflow.com/a/20873812
429
+
430
+ is_ccw = polygon.exterior.is_ccw
431
+
432
+ # Values here are somewhat ad hoc: These seem to allow the algorithm to
433
+ # terminate and are not too computationally expensive
434
+ for i in range(1, 11):
435
+ distance = epsilon * i / 10.0
436
+
437
+ # Taking the buffer can change the orientation of the contour
438
+ out = polygon.buffer(distance, quad_segs=1)
439
+ if out.exterior.is_ccw != is_ccw:
440
+ out = shapely.Polygon(out.exterior.coords[::-1])
441
+
442
+ out = out.simplify(epsilon, preserve_topology=False)
443
+ if out.is_simple and out.is_valid:
444
+ return out
445
+
446
+ warnings.warn(
447
+ f"Could not simplify contour with epsilon {epsilon}. Try passing a smaller epsilon."
448
+ )
449
+ return polygon.simplify(epsilon, preserve_topology=True)
450
+
451
+
452
+ def _make_valid_multipolygon(
453
+ polygons: list[shapely.Polygon], convex_hull: bool
454
+ ) -> shapely.MultiPolygon:
455
+ """Make a multipolygon valid.
456
+
457
+ This function attempts to make a multipolygon valid by iteratively
458
+ applying :func:`shapely.unary_union` to convert non-disjoint polygons
459
+ into disjoint polygons by merging them. If the multipolygon is still
460
+ invalid after 5 attempts, a warning is raised and the last
461
+ multipolygon is returned.
462
+
463
+ This function is needed because simplifying a contour can change the
464
+ geometry of it, which can cause non-disjoint polygons to be created.
465
+
466
+ .. versionadded:: 0.38.0
467
+
468
+ Parameters
469
+ ----------
470
+ polygons : list[shapely.Polygon]
471
+ List of polygons to combine into a multipolygon.
472
+ convex_hull : bool
473
+ If True, take the convex hull of merged polygons.
474
+
475
+ Returns
476
+ -------
477
+ shapely.MultiPolygon
478
+ Valid multipolygon.
479
+ """
480
+ mp = shapely.MultiPolygon(polygons)
481
+ if mp.is_empty:
482
+ return mp
483
+
484
+ n_attempts = 5
485
+ for _ in range(n_attempts):
486
+ if mp.is_valid:
487
+ return mp
488
+
489
+ mp = shapely.unary_union(mp)
490
+ if isinstance(mp, shapely.Polygon):
491
+ mp = shapely.MultiPolygon([mp])
492
+
493
+ # Fix the orientation of the polygons
494
+ mp = shapely.MultiPolygon([shapely.geometry.polygon.orient(p, sign=1) for p in mp.geoms])
495
+
496
+ if convex_hull:
497
+ mp = shapely.MultiPolygon([_take_convex_hull(p) for p in mp.geoms])
498
+
499
+ warnings.warn(
500
+ f"Could not make multipolygon valid after {n_attempts} attempts. "
501
+ "According to shapely, the multipolygon is invalid because: "
502
+ f"{shapely.validation.explain_validity(mp)}"
503
+ )
504
+ return mp
505
+
506
+
507
+ def multipolygon_to_geojson(
508
+ multipolygon: shapely.MultiPolygon,
509
+ altitude: float | None,
510
+ properties: dict[str, Any] | None = None,
511
+ ) -> dict[str, Any]:
512
+ """Convert a shapely multipolygon to a GeoJSON feature.
513
+
514
+ Parameters
515
+ ----------
516
+ multipolygon : shapely.MultiPolygon
517
+ Multipolygon to convert.
518
+ altitude : float | None
519
+ Altitude of the multipolygon. If provided, the multipolygon coordinates
520
+ will be given a z-coordinate.
521
+ properties : dict[str, Any] | None, optional
522
+ Properties to add to the GeoJSON feature.
523
+
524
+ Returns
525
+ -------
526
+ dict[str, Any]
527
+ GeoJSON feature with geometry type "MultiPolygon".
528
+ """
529
+ coordinates = []
530
+ for polygon in multipolygon.geoms:
531
+ poly_coords = []
532
+ rings = polygon.exterior, *polygon.interiors
533
+ for ring in rings:
534
+ if altitude is None:
535
+ coords = np.asarray(ring.coords)
536
+ else:
537
+ shape = len(ring.coords), 3
538
+ coords = np.empty(shape)
539
+ coords[:, :2] = np.asarray(ring.coords)
540
+ coords[:, 2] = altitude
541
+
542
+ poly_coords.append(coords.tolist())
543
+ coordinates.append(poly_coords)
544
+
545
+ return {
546
+ "type": "Feature",
547
+ "properties": properties or {},
548
+ "geometry": {"type": "MultiPolygon", "coordinates": coordinates},
549
+ }