grid_cells.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. from __future__ import annotations
  2. class LatLngGrid(object):
  3. """
  4. Represents a grid in latitude and longitude notation for some rectangular region of interest (ROI).
  5. The specs are a top-left and a bottom-right coordinate, as well as the number of cells in both directions.
  6. The class provides two ways of conceptualising cells which nicely cover the grid: square cells and hexagonal cells.
  7. For both, locations can be computed which represent the corners of said cells.
  8. Examples:
  9. - 4 cells in square: 9 unique locations in a 2x2 grid (4*4 locations, of which 7 are covered by another cell)
  10. - 4 cells in hex: 13 unique locations in a 2x2 grid (4*6 locations, of which 11 are already covered)
  11. - 10 cells in square: 18 unique locations in a 5x2 grid (10*4 locations, of which 11 are already covered)
  12. - 10 cells in hex: 34 unique locations in a 5x2 grid (10*6 locations, of which 26 are already covered)
  13. The top-right and bottom-left locations are always at the center of a cell, unless the grid has 1 row or 1 column.
  14. In those case, these locations are closer to one side of the cell.
  15. """
  16. top_left: tuple[float, float]
  17. bottom_right: tuple[float, float]
  18. num_cells_lat: int
  19. num_cells_lng: int
  20. def __init__(
  21. self,
  22. top_left: tuple[float, float],
  23. bottom_right: tuple[float, float],
  24. num_cells_lat: int,
  25. num_cells_lng: int,
  26. ):
  27. self.top_left = top_left
  28. self.bottom_right = bottom_right
  29. self.num_cells_lat = num_cells_lat
  30. self.num_cells_lng = num_cells_lng
  31. self.cell_size_lat = self.compute_cell_size_lat()
  32. self.cell_size_lng = self.compute_cell_size_lng()
  33. # correct top-left and bottom-right if there is only one cell
  34. if self.num_cells_lat == 1: # if only one row
  35. self.top_left = (
  36. self.top_left[0] + self.cell_size_lat / 4,
  37. self.top_left[1],
  38. )
  39. self.bottom_right = (
  40. self.bottom_right[0] - self.cell_size_lat / 4,
  41. self.bottom_right[1],
  42. )
  43. if self.num_cells_lng == 1:
  44. self.top_left = (
  45. self.top_left[0],
  46. self.top_left[1] + self.cell_size_lng / 4,
  47. )
  48. self.bottom_right = (
  49. self.bottom_right[0],
  50. self.bottom_right[1] - self.cell_size_lng / 4,
  51. )
  52. def __repr__(self) -> str:
  53. return (
  54. f"<LatLngGrid top-left:{self.top_left}, bot-right:{self.bottom_right},"
  55. + f"num_lat:{self.num_cells_lat}, num_lng:{self.num_cells_lng}>"
  56. )
  57. def get_locations(self, method: str) -> list[tuple[float, float]]:
  58. """Get locations by method ("square" or "hex")"""
  59. if method == "hex":
  60. locations = self.locations_hex()
  61. print(
  62. "[FLEXMEASURES] Number of locations in hex grid: "
  63. + str(len(self.locations_hex()))
  64. )
  65. return locations
  66. elif method == "square":
  67. locations = self.locations_square()
  68. print(
  69. "[FLEXMEASURES] Number of locations in square grid: "
  70. + str(len(self.locations_square()))
  71. )
  72. return locations
  73. else:
  74. raise Exception(
  75. "Method must either be 'square' or 'hex'! (is: %s)" % method
  76. )
  77. def compute_cell_size_lat(self) -> float:
  78. """Calculate the step size between latitudes"""
  79. if self.num_cells_lat != 1:
  80. return (self.bottom_right[0] - self.top_left[0]) / (self.num_cells_lat - 1)
  81. else:
  82. return (self.bottom_right[0] - self.top_left[0]) * 2
  83. def compute_cell_size_lng(self) -> float:
  84. """Calculate the step size between longitudes"""
  85. if self.num_cells_lng != 1:
  86. return (self.bottom_right[1] - self.top_left[1]) / (self.num_cells_lng - 1)
  87. else:
  88. return (self.bottom_right[1] - self.top_left[1]) * 2
  89. def locations_square(self) -> list[tuple[float, float]]:
  90. """square pattern"""
  91. locations = []
  92. # For each odd cell row, add all the coordinates of the row's cells
  93. for ilat in range(0, self.num_cells_lat, 2):
  94. lat = self.top_left[0] + ilat * self.cell_size_lat
  95. for ilng in range(self.num_cells_lng):
  96. lng = self.top_left[1] + ilng * self.cell_size_lng
  97. nw = (
  98. lat - self.cell_size_lat / 2,
  99. lng - self.cell_size_lng / 2,
  100. ) # North west coordinate of the cell
  101. locations.append(nw)
  102. sw = (
  103. lat + self.cell_size_lat / 2,
  104. lng - self.cell_size_lng / 2,
  105. ) # South west coordinate
  106. locations.append(sw)
  107. ne = (
  108. lat - self.cell_size_lat / 2,
  109. lng + self.cell_size_lng / 2,
  110. ) # North east coordinate
  111. locations.append(ne)
  112. se = (
  113. lat + self.cell_size_lat / 2,
  114. lng + self.cell_size_lng / 2,
  115. ) # South east coordinate
  116. locations.append(se)
  117. # In case of an even number of cell rows, add the southern coordinates of the southern most row
  118. if not self.num_cells_lat % 2:
  119. lat = self.top_left[0] + (self.num_cells_lat - 1) * self.cell_size_lat
  120. for ilng in range(self.num_cells_lng):
  121. lng = self.top_left[1] + ilng * self.cell_size_lng
  122. sw = (
  123. lat + self.cell_size_lat / 2,
  124. lng - self.cell_size_lng / 2,
  125. ) # South west coordinate
  126. locations.append(sw)
  127. se = (
  128. lat + self.cell_size_lat / 2,
  129. lng + self.cell_size_lng / 2,
  130. ) # South east coordinate
  131. locations.append(se)
  132. return locations
  133. def locations_hex(self) -> list[tuple[float, float]]:
  134. """The hexagonal pattern - actually leaves out one cell for every even row."""
  135. locations = []
  136. # For each odd cell row, add all the coordinates of the row's cells
  137. for ilat in range(0, self.num_cells_lat, 2):
  138. lat = self.top_left[0] + ilat * self.cell_size_lat
  139. for ilng in range(self.num_cells_lng):
  140. lng = self.top_left[1] + ilng * self.cell_size_lng
  141. n = (
  142. lat - self.cell_size_lat * 2 / 3,
  143. lng,
  144. ) # North coordinate of the cell
  145. locations.append(n)
  146. nw = (
  147. lat - self.cell_size_lat * 1 / 4,
  148. lng - self.cell_size_lng * 1 / 2,
  149. ) # North west coordinate
  150. locations.append(nw)
  151. s = (lat + self.cell_size_lat * 2 / 3, lng) # South coordinate
  152. locations.append(s)
  153. sw = (
  154. lat + self.cell_size_lat * 1 / 4,
  155. lng - self.cell_size_lng * 1 / 2,
  156. ) # South west coordinate
  157. locations.append(sw)
  158. ne = (
  159. lat - self.cell_size_lat * 1 / 4,
  160. lng + self.cell_size_lng * 1 / 2,
  161. ) # North east coordinate
  162. locations.append(ne)
  163. se = (
  164. lat + self.cell_size_lat * 1 / 4,
  165. lng + self.cell_size_lng * 1 / 2,
  166. ) # South east coordinate
  167. locations.append(se)
  168. # In case of an even number of cell rows, add the southern coordinates of the southern most row
  169. if not self.num_cells_lat % 2:
  170. lat = self.top_left[0] + (self.num_cells_lat - 1) * self.cell_size_lat
  171. for ilng in range(
  172. self.num_cells_lng - 1
  173. ): # One less cell in even rows of hexagonal locations
  174. # Cells are shifted half a cell to the right in even rows of hex locations
  175. lng = self.top_left[1] + (ilng + 1 / 2) * self.cell_size_lng
  176. s = (lat + self.cell_size_lat / 3 ** (1 / 2), lng) # South coordinate
  177. locations.append(s)
  178. sw = (
  179. lat + self.cell_size_lat / 2,
  180. lng - self.cell_size_lat / 3 ** (1 / 2) / 2,
  181. ) # South west coordinates
  182. locations.append(sw)
  183. se = (
  184. lat + self.cell_size_lat / 2,
  185. lng + self.cell_size_lng / 3 ** (1 / 2) / 2,
  186. ) # South east coordinates
  187. locations.append(se)
  188. return locations
  189. def get_cell_nums(
  190. tl: tuple[float, float], br: tuple[float, float], num_cells: int = 9
  191. ) -> tuple[int, int]:
  192. """
  193. Compute the number of cells in both directions, latitude and longitude.
  194. By default, a square grid with N=9 cells is computed, so 3 by 3.
  195. For N with non-integer square root, the function will determine a nice cell pattern.
  196. :param tl: top-left (lat, lng) tuple of ROI
  197. :param br: bottom-right (lat, lng) tuple of ROI
  198. :param num_cells: number of cells (9 by default, leading to a 3x3 grid)
  199. """
  200. def factors(n):
  201. """Factors of a number n"""
  202. return set(
  203. factor
  204. for i in range(1, int(n**0.5) + 1)
  205. if n % i == 0
  206. for factor in (i, n // i)
  207. )
  208. # Find closest integers n1 and n2 that, when multiplied, equal n
  209. n1 = min(factors(num_cells), key=lambda x: abs(x - num_cells ** (1 / 2)))
  210. n2 = num_cells // n1
  211. # Assign largest integer to lat or lng depending on which has the largest spread (earth curvature neglected)
  212. if br[0] - tl[0] > br[1] - tl[1]:
  213. return max(n1, n2), min(n1, n2)
  214. else:
  215. return min(n1, n2), max(n1, n2)