Coverage for gws-app/gws/lib/bounds/__init__.py: 96%

49 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Bounds utilities. 

2 

3A ``gws.Bounds`` object is an extent together with its CRS. This package 

4creates bounds from extents and OGC ``BBOX`` request parameters, transforms 

5them between CRS, and combines, compares and buffers them. Functions that 

6combine bounds in different CRS transform them to the CRS of the first 

7argument. Extents without a CRS are handled by ``gws.lib.extent``. 

8 

9When bounds are created from external input, the axis order of the CRS is 

10respected: for a CRS with the YX (lat/lon) axis order, the coordinates are 

11swapped, unless the input is known to be in the XY order. 

12 

13Example:: 

14 

15 b = gws.lib.bounds.from_request_bbox('7,50,8,51', gws.lib.crs.WGS84, always_xy=True) 

16 b = gws.lib.bounds.transform(b, gws.lib.crs.WEBMERCATOR) 

17 b = gws.lib.bounds.buffer(b, 100) 

18 wgs = gws.lib.bounds.wgs_extent(b) 

19""" 

20 

21from typing import Optional 

22 

23import gws 

24import gws.lib.crs 

25import gws.lib.extent 

26import gws.lib.gml 

27 

28_PAD = 1e-3 

29_MIN_PAD = 1e-6 

30 

31 

32def from_request_bbox(bbox: str, default_crs: gws.Crs = None, always_xy=False) -> Optional[gws.Bounds]: 

33 """Create bounds from a KVP ``BBOX`` parameter. 

34 

35 See OGC 06-121r9, 10.2.3 Bounding box KVP encoding. 

36 

37 Args: 

38 bbox: Four comma-separated coordinates, optionally followed by a CRS name. 

39 default_crs: CRS to use if the parameter has no CRS. 

40 always_xy: If ``True``, coordinates are assumed to be in the XY (lon/lat) order. 

41 

42 Returns: 

43 Bounds, or ``None`` if the parameter is empty or invalid, or there is no CRS. 

44 """ 

45 

46 if not bbox: 

47 return None 

48 

49 crs = default_crs 

50 

51 # x,y,x,y,crs 

52 ls = bbox.split(',') 

53 if len(ls) == 5: 

54 crs = gws.lib.crs.get(ls.pop()) 

55 

56 if not crs: 

57 return None 

58 

59 extent = gws.lib.extent.from_list(ls) 

60 if not extent: 

61 return None 

62 

63 return from_extent(extent, crs, always_xy) 

64 

65 

66def from_extent(extent: gws.Extent, crs: gws.Crs, always_xy=False) -> gws.Bounds: 

67 """Create bounds from an extent. 

68 

69 If the CRS has the YX axis order, the extent coordinates are swapped, unless ``always_xy`` is set. 

70 

71 Args: 

72 extent: Extent. 

73 crs: CRS of the extent. 

74 always_xy: If ``True``, coordinates are assumed to be in the XY (lon/lat) order. 

75 

76 Returns: 

77 Bounds. 

78 """ 

79 

80 if crs.isYX and not always_xy: 

81 extent = gws.lib.extent.swap_xy(extent) 

82 

83 return gws.Bounds(crs=crs, extent=extent) 

84 

85 

86def copy(b: gws.Bounds) -> gws.Bounds: 

87 """Copy bounds. 

88 

89 Args: 

90 b: Bounds. 

91 

92 Returns: 

93 New bounds with the same CRS and extent. 

94 """ 

95 return gws.Bounds(crs=b.crs, extent=b.extent) 

96 

97 

98def union(bs: list[gws.Bounds]) -> gws.Bounds: 

99 """Create the smallest bounds that contain all given bounds. 

100 

101 Args: 

102 bs: A non-empty list of bounds. 

103 

104 Returns: 

105 Bounds in the CRS of the first element of ``bs``. 

106 """ 

107 

108 crs = bs[0].crs 

109 exts = [gws.lib.extent.transform(b.extent, b.crs, crs) for b in bs] 

110 return gws.Bounds( 

111 crs=crs, 

112 extent=gws.lib.extent.union(*exts), 

113 ) 

114 

115 

116def intersect(b1: gws.Bounds, b2: gws.Bounds) -> bool: 

117 """Check if two bounds intersect. 

118 

119 Args: 

120 b1: First bounds. 

121 b2: Second bounds, transformed to the CRS of ``b1`` for the check. 

122 

123 Returns: 

124 ``True`` if the bounds intersect. 

125 """ 

126 e1 = b1.extent 

127 e2 = gws.lib.extent.transform(b2.extent, crs_from=b2.crs, crs_to=b1.crs) 

128 return gws.lib.extent.intersect(e1, e2) 

129 

130 

131def transform(b: gws.Bounds, crs_to: gws.Crs) -> gws.Bounds: 

132 """Transform bounds to a different CRS. 

133 

134 Args: 

135 b: Bounds. 

136 crs_to: Target CRS. 

137 

138 Returns: 

139 Bounds in the target CRS, or ``b`` itself if it is already in that CRS. 

140 """ 

141 if b.crs == crs_to: 

142 return b 

143 return gws.Bounds( 

144 crs=crs_to, 

145 extent=b.crs.transform_extent(b.extent, crs_to), 

146 ) 

147 

148 

149def wgs_extent(b: gws.Bounds, pad: bool = False) -> Optional[gws.Extent]: 

150 """Transform bounds to a WGS84 extent. 

151 

152 Args: 

153 b: Bounds. 

154 pad: Enlarge the extent slightly before the transformation, so that features on the edges 

155 of a data-derived extent survive the round trip through WGS84. 

156 

157 Returns: 

158 A WGS84 extent, or ``None`` if the result is invalid. 

159 """ 

160 

161 ext = b.extent 

162 if pad: 

163 buf = max(gws.lib.extent.w(ext) * _PAD, gws.lib.extent.h(ext) * _PAD, _MIN_PAD) 

164 ext = gws.lib.extent.buffer(ext, buf) 

165 ext = gws.lib.extent.transform(ext, b.crs, gws.lib.crs.WGS84) 

166 return ext if gws.lib.extent.is_valid(ext) else None 

167 

168 

169def buffer(b: gws.Bounds, buf_size: float) -> gws.Bounds: 

170 """Enlarge or shrink bounds by a buffer. 

171 

172 Args: 

173 b: Bounds. 

174 buf_size: Buffer size in CRS units. A positive buffer enlarges the bounds, a negative one shrinks them. 

175 

176 Returns: 

177 New bounds, or ``b`` itself if the buffer is 0. 

178 """ 

179 if buf_size == 0: 

180 return b 

181 return gws.Bounds(crs=b.crs, extent=gws.lib.extent.buffer(b.extent, buf_size))