rslearn 0.0.1__py3-none-any.whl → 0.0.21__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.
Files changed (166) hide show
  1. rslearn/arg_parser.py +31 -0
  2. rslearn/config/__init__.py +6 -12
  3. rslearn/config/dataset.py +520 -401
  4. rslearn/const.py +9 -15
  5. rslearn/data_sources/__init__.py +8 -23
  6. rslearn/data_sources/aws_landsat.py +242 -98
  7. rslearn/data_sources/aws_open_data.py +111 -151
  8. rslearn/data_sources/aws_sentinel1.py +131 -0
  9. rslearn/data_sources/climate_data_store.py +471 -0
  10. rslearn/data_sources/copernicus.py +884 -12
  11. rslearn/data_sources/data_source.py +43 -12
  12. rslearn/data_sources/earthdaily.py +484 -0
  13. rslearn/data_sources/earthdata_srtm.py +282 -0
  14. rslearn/data_sources/eurocrops.py +242 -0
  15. rslearn/data_sources/gcp_public_data.py +578 -222
  16. rslearn/data_sources/google_earth_engine.py +461 -135
  17. rslearn/data_sources/local_files.py +219 -150
  18. rslearn/data_sources/openstreetmap.py +51 -89
  19. rslearn/data_sources/planet.py +24 -60
  20. rslearn/data_sources/planet_basemap.py +275 -0
  21. rslearn/data_sources/planetary_computer.py +798 -0
  22. rslearn/data_sources/usda_cdl.py +195 -0
  23. rslearn/data_sources/usgs_landsat.py +115 -83
  24. rslearn/data_sources/utils.py +249 -61
  25. rslearn/data_sources/vector_source.py +1 -0
  26. rslearn/data_sources/worldcereal.py +449 -0
  27. rslearn/data_sources/worldcover.py +144 -0
  28. rslearn/data_sources/worldpop.py +153 -0
  29. rslearn/data_sources/xyz_tiles.py +150 -107
  30. rslearn/dataset/__init__.py +8 -2
  31. rslearn/dataset/add_windows.py +2 -2
  32. rslearn/dataset/dataset.py +40 -51
  33. rslearn/dataset/handler_summaries.py +131 -0
  34. rslearn/dataset/manage.py +313 -74
  35. rslearn/dataset/materialize.py +431 -107
  36. rslearn/dataset/remap.py +29 -4
  37. rslearn/dataset/storage/__init__.py +1 -0
  38. rslearn/dataset/storage/file.py +202 -0
  39. rslearn/dataset/storage/storage.py +140 -0
  40. rslearn/dataset/window.py +181 -44
  41. rslearn/lightning_cli.py +454 -0
  42. rslearn/log_utils.py +24 -0
  43. rslearn/main.py +384 -181
  44. rslearn/models/anysat.py +215 -0
  45. rslearn/models/attention_pooling.py +177 -0
  46. rslearn/models/clay/clay.py +231 -0
  47. rslearn/models/clay/configs/metadata.yaml +295 -0
  48. rslearn/models/clip.py +68 -0
  49. rslearn/models/component.py +111 -0
  50. rslearn/models/concatenate_features.py +103 -0
  51. rslearn/models/conv.py +63 -0
  52. rslearn/models/croma.py +306 -0
  53. rslearn/models/detr/__init__.py +5 -0
  54. rslearn/models/detr/box_ops.py +103 -0
  55. rslearn/models/detr/detr.py +504 -0
  56. rslearn/models/detr/matcher.py +107 -0
  57. rslearn/models/detr/position_encoding.py +114 -0
  58. rslearn/models/detr/transformer.py +429 -0
  59. rslearn/models/detr/util.py +24 -0
  60. rslearn/models/dinov3.py +177 -0
  61. rslearn/models/faster_rcnn.py +30 -28
  62. rslearn/models/feature_center_crop.py +53 -0
  63. rslearn/models/fpn.py +19 -8
  64. rslearn/models/galileo/__init__.py +5 -0
  65. rslearn/models/galileo/galileo.py +595 -0
  66. rslearn/models/galileo/single_file_galileo.py +1678 -0
  67. rslearn/models/module_wrapper.py +65 -0
  68. rslearn/models/molmo.py +69 -0
  69. rslearn/models/multitask.py +384 -28
  70. rslearn/models/olmoearth_pretrain/__init__.py +1 -0
  71. rslearn/models/olmoearth_pretrain/model.py +421 -0
  72. rslearn/models/olmoearth_pretrain/norm.py +86 -0
  73. rslearn/models/panopticon.py +170 -0
  74. rslearn/models/panopticon_data/sensors/drone.yaml +32 -0
  75. rslearn/models/panopticon_data/sensors/enmap.yaml +904 -0
  76. rslearn/models/panopticon_data/sensors/goes.yaml +9 -0
  77. rslearn/models/panopticon_data/sensors/himawari.yaml +9 -0
  78. rslearn/models/panopticon_data/sensors/intuition.yaml +606 -0
  79. rslearn/models/panopticon_data/sensors/landsat8.yaml +84 -0
  80. rslearn/models/panopticon_data/sensors/modis_terra.yaml +99 -0
  81. rslearn/models/panopticon_data/sensors/qb2_ge1.yaml +34 -0
  82. rslearn/models/panopticon_data/sensors/sentinel1.yaml +85 -0
  83. rslearn/models/panopticon_data/sensors/sentinel2.yaml +97 -0
  84. rslearn/models/panopticon_data/sensors/superdove.yaml +60 -0
  85. rslearn/models/panopticon_data/sensors/wv23.yaml +63 -0
  86. rslearn/models/pick_features.py +17 -10
  87. rslearn/models/pooling_decoder.py +60 -7
  88. rslearn/models/presto/__init__.py +5 -0
  89. rslearn/models/presto/presto.py +297 -0
  90. rslearn/models/presto/single_file_presto.py +926 -0
  91. rslearn/models/prithvi.py +1147 -0
  92. rslearn/models/resize_features.py +59 -0
  93. rslearn/models/sam2_enc.py +13 -9
  94. rslearn/models/satlaspretrain.py +38 -18
  95. rslearn/models/simple_time_series.py +188 -77
  96. rslearn/models/singletask.py +24 -13
  97. rslearn/models/ssl4eo_s12.py +40 -30
  98. rslearn/models/swin.py +44 -32
  99. rslearn/models/task_embedding.py +250 -0
  100. rslearn/models/terramind.py +256 -0
  101. rslearn/models/trunk.py +139 -0
  102. rslearn/models/unet.py +68 -22
  103. rslearn/models/upsample.py +48 -0
  104. rslearn/models/use_croma.py +508 -0
  105. rslearn/template_params.py +26 -0
  106. rslearn/tile_stores/__init__.py +41 -18
  107. rslearn/tile_stores/default.py +409 -0
  108. rslearn/tile_stores/tile_store.py +236 -132
  109. rslearn/train/all_patches_dataset.py +530 -0
  110. rslearn/train/callbacks/adapters.py +53 -0
  111. rslearn/train/callbacks/freeze_unfreeze.py +348 -17
  112. rslearn/train/callbacks/gradients.py +129 -0
  113. rslearn/train/callbacks/peft.py +116 -0
  114. rslearn/train/data_module.py +444 -20
  115. rslearn/train/dataset.py +588 -235
  116. rslearn/train/lightning_module.py +192 -62
  117. rslearn/train/model_context.py +88 -0
  118. rslearn/train/optimizer.py +31 -0
  119. rslearn/train/prediction_writer.py +319 -84
  120. rslearn/train/scheduler.py +92 -0
  121. rslearn/train/tasks/classification.py +55 -28
  122. rslearn/train/tasks/detection.py +132 -76
  123. rslearn/train/tasks/embedding.py +120 -0
  124. rslearn/train/tasks/multi_task.py +28 -14
  125. rslearn/train/tasks/per_pixel_regression.py +291 -0
  126. rslearn/train/tasks/regression.py +161 -44
  127. rslearn/train/tasks/segmentation.py +428 -53
  128. rslearn/train/tasks/task.py +6 -5
  129. rslearn/train/transforms/__init__.py +1 -1
  130. rslearn/train/transforms/concatenate.py +54 -10
  131. rslearn/train/transforms/crop.py +29 -11
  132. rslearn/train/transforms/flip.py +18 -6
  133. rslearn/train/transforms/mask.py +78 -0
  134. rslearn/train/transforms/normalize.py +101 -17
  135. rslearn/train/transforms/pad.py +19 -7
  136. rslearn/train/transforms/resize.py +83 -0
  137. rslearn/train/transforms/select_bands.py +76 -0
  138. rslearn/train/transforms/sentinel1.py +75 -0
  139. rslearn/train/transforms/transform.py +89 -70
  140. rslearn/utils/__init__.py +2 -6
  141. rslearn/utils/array.py +8 -6
  142. rslearn/utils/feature.py +2 -2
  143. rslearn/utils/fsspec.py +90 -1
  144. rslearn/utils/geometry.py +347 -7
  145. rslearn/utils/get_utm_ups_crs.py +2 -3
  146. rslearn/utils/grid_index.py +5 -5
  147. rslearn/utils/jsonargparse.py +178 -0
  148. rslearn/utils/mp.py +4 -3
  149. rslearn/utils/raster_format.py +268 -116
  150. rslearn/utils/rtree_index.py +64 -17
  151. rslearn/utils/sqlite_index.py +7 -1
  152. rslearn/utils/vector_format.py +252 -97
  153. {rslearn-0.0.1.dist-info → rslearn-0.0.21.dist-info}/METADATA +532 -283
  154. rslearn-0.0.21.dist-info/RECORD +167 -0
  155. {rslearn-0.0.1.dist-info → rslearn-0.0.21.dist-info}/WHEEL +1 -1
  156. rslearn-0.0.21.dist-info/licenses/NOTICE +115 -0
  157. rslearn/data_sources/raster_source.py +0 -309
  158. rslearn/models/registry.py +0 -5
  159. rslearn/tile_stores/file.py +0 -242
  160. rslearn/utils/mgrs.py +0 -24
  161. rslearn/utils/utils.py +0 -22
  162. rslearn-0.0.1.dist-info/RECORD +0 -88
  163. /rslearn/{data_sources/geotiff.py → py.typed} +0 -0
  164. {rslearn-0.0.1.dist-info → rslearn-0.0.21.dist-info}/entry_points.txt +0 -0
  165. {rslearn-0.0.1.dist-info → rslearn-0.0.21.dist-info/licenses}/LICENSE +0 -0
  166. {rslearn-0.0.1.dist-info → rslearn-0.0.21.dist-info}/top_level.txt +0 -0
@@ -1,9 +1,18 @@
1
1
  """Utilities shared by data sources."""
2
2
 
3
+ from dataclasses import dataclass
4
+ from datetime import UTC, datetime, timedelta
5
+ from typing import TypeVar
6
+
7
+ import shapely
8
+
3
9
  from rslearn.config import QueryConfig, SpaceMode, TimeMode
4
10
  from rslearn.data_sources import Item
11
+ from rslearn.log_utils import get_logger
5
12
  from rslearn.utils import STGeometry, shp_intersects
6
13
 
14
+ logger = get_logger(__name__)
15
+
7
16
  MOSAIC_MIN_ITEM_COVERAGE = 0.1
8
17
  """Minimum fraction of area that item should cover when adding it to a mosaic group."""
9
18
 
@@ -11,17 +20,203 @@ MOSAIC_REMAINDER_EPSILON = 0.01
11
20
  """Fraction of original geometry area below which mosaic is considered to contain the
12
21
  entire geometry."""
13
22
 
23
+ ItemType = TypeVar("ItemType", bound=Item)
24
+
25
+
26
+ @dataclass
27
+ class PendingMosaic:
28
+ """A mosaic being created by match_candidate_items_to_window.
29
+
30
+ Args:
31
+ items: the list of items in the mosaic.
32
+ remainder: the remainder of the geometry that is not covered by any of the
33
+ items.
34
+ completed: whether the mosaic is done (sufficient coverage of the geometry).
35
+ """
36
+
37
+ # Cannot use list[ItemType] here.
38
+ items: list
39
+ remainder: shapely.Polygon
40
+ completed: bool = False
41
+
42
+
43
+ def mosaic_matching(
44
+ window_geometry: STGeometry,
45
+ items: list[ItemType],
46
+ item_shps: list[shapely.Geometry],
47
+ max_matches: int,
48
+ ) -> list[list[ItemType]]:
49
+ """Spatial item matching for mosaic space mode.
50
+
51
+ This attempts to piece together items into mosaics that fully cover the window
52
+ geometry. If there are items leftover that only partially cover the window
53
+ geometry, they are returned as partial mosaics.
54
+
55
+ Args:
56
+ window_geometry: the geometry of the window.
57
+ items: list of items.
58
+ item_shps: the item shapes projected to the window's projection.
59
+ max_matches: the maximum number of matches (mosaics) to create.
60
+
61
+ Returns:
62
+ list of item groups, each one corresponding to a different mosaic.
63
+ """
64
+ # To create mosaics, we iterate over the items in order, and add each item to
65
+ # the first mosaic that the new item adds coverage to.
66
+
67
+ # max_matches could be very high if the user just wants us to create as many
68
+ # mosaics as possible, so we initialize the list here as empty and just add
69
+ # more pending mosaics when it is necessary.
70
+ pending_mosaics: list[PendingMosaic] = []
71
+
72
+ for item, item_shp in zip(items, item_shps):
73
+ # See if the item can match with any existing mosaic.
74
+ item_matched = False
75
+
76
+ for pending_mosaic in pending_mosaics:
77
+ if pending_mosaic.completed:
78
+ continue
79
+ if not shp_intersects(item_shp, pending_mosaic.remainder):
80
+ continue
81
+
82
+ # Check if the intersection area is too small.
83
+ # If it is a sizable part of the item or of the geometry, then proceed.
84
+ intersect_area = item_shp.intersection(pending_mosaic.remainder).area
85
+ if (
86
+ intersect_area / item_shp.area < MOSAIC_MIN_ITEM_COVERAGE
87
+ and intersect_area / pending_mosaic.remainder.area
88
+ < MOSAIC_MIN_ITEM_COVERAGE
89
+ ):
90
+ continue
91
+
92
+ pending_mosaic.remainder = pending_mosaic.remainder - item_shp
93
+ pending_mosaic.items.append(item)
94
+ item_matched = True
95
+
96
+ # Mark the mosaic completed if it has sufficient coverage of the
97
+ # geometry.
98
+ if (
99
+ pending_mosaic.remainder.area / window_geometry.shp.area
100
+ < MOSAIC_REMAINDER_EPSILON
101
+ ):
102
+ pending_mosaic.completed = True
103
+
104
+ break
105
+
106
+ if item_matched:
107
+ continue
108
+
109
+ # See if we can add a new mosaic based on this item. There must be room for
110
+ # more mosaics, but the item must also intersect the requested geometry.
111
+ if len(pending_mosaics) >= max_matches:
112
+ continue
113
+ intersect_area = item_shp.intersection(window_geometry.shp).area
114
+ if (
115
+ intersect_area / item_shp.area < MOSAIC_MIN_ITEM_COVERAGE
116
+ and intersect_area / window_geometry.shp.area < MOSAIC_MIN_ITEM_COVERAGE
117
+ ):
118
+ continue
119
+
120
+ pending_mosaics.append(
121
+ PendingMosaic(
122
+ items=[item],
123
+ remainder=window_geometry.shp - item_shp,
124
+ )
125
+ )
126
+
127
+ return [pending_mosaic.items for pending_mosaic in pending_mosaics]
128
+
129
+
130
+ def per_period_mosaic_matching(
131
+ window_geometry: STGeometry,
132
+ item_list: list[ItemType],
133
+ period_duration: timedelta,
134
+ max_matches: int,
135
+ ) -> list[list[ItemType]]:
136
+ """Match items to the geometry with one mosaic per period.
137
+
138
+ We divide the time range of the geometry into shorter periods. Within each period,
139
+ we use the items corresponding to that period to create a mosaic. The returned item
140
+ groups include one group per period, starting from the most recent periods, up to
141
+ the provided max_matches.
142
+
143
+ The periods are also bounded to the window's time range, and aligned with the end
144
+ of that time range, i.e. the most recent window is
145
+ (end_time - period_duration, end_time), the next is
146
+ (end_time - 2*period_duration, end_time - period_duration), and so on. Note that
147
+ this means that if the window duration is shorter than the period_duration, there
148
+ will be zero matches.
149
+
150
+ This is used e.g. when a model should process three mosaics, where each mosaic
151
+ should come from a different month. This gives more diversity of images, since
152
+ simply searching for the least cloudy images could result in selecting all of the
153
+ images from the same month.
154
+
155
+ max_matches may be smaller than the total number of periods in the given time
156
+ range. In this case, we prefer to use mosaics of the most recent periods. However,
157
+ sometimes there may be no items in a period; in that case, the older periods are
158
+ used as a fallback. This means that reducing the window duration down to match
159
+ max_matches*period_duration is not equivalent to a longer window duration.
160
+
161
+ Args:
162
+ window_geometry: the window geometry to match items to.
163
+ item_list: the list of items.
164
+ period_duration: the duration of one period.
165
+ max_matches: the number of per-period mosaics to create.
166
+
167
+ Returns:
168
+ the matched item groups, where each group contains items that yield a
169
+ per-period mosaic.
170
+ """
171
+ if window_geometry.time_range is None:
172
+ raise ValueError(
173
+ "all windows must have time range for per period mosaic matching"
174
+ )
175
+
176
+ # For each period, we create an STGeometry with modified time range matching that
177
+ # period, and use it with match_candidate_items_to_window to get a mosaic.
178
+ cur_groups: list[list[ItemType]] = []
179
+ period_start = window_geometry.time_range[1] - period_duration
180
+ while (
181
+ period_start >= window_geometry.time_range[0] and len(cur_groups) < max_matches
182
+ ):
183
+ period_time_range = (
184
+ period_start,
185
+ period_start + period_duration,
186
+ )
187
+ period_start -= period_duration
188
+ period_geom = STGeometry(
189
+ window_geometry.projection, window_geometry.shp, period_time_range
190
+ )
191
+
192
+ # We modify the QueryConfig here since caller should be asking for
193
+ # multiple mosaics, but we just want one mosaic per period.
194
+ period_groups = match_candidate_items_to_window(
195
+ period_geom,
196
+ item_list,
197
+ QueryConfig(space_mode=SpaceMode.MOSAIC, max_matches=1),
198
+ )
199
+
200
+ # There should be zero or one group depending on whether there were
201
+ # any items that matched. We keep the group if it is there.
202
+ if len(period_groups) == 0 or len(period_groups[0]) == 0:
203
+ # No matches for this period.
204
+ continue
205
+ cur_groups.append(period_groups[0])
206
+
207
+ return cur_groups
208
+
14
209
 
15
210
  def match_candidate_items_to_window(
16
- geometry: STGeometry, items: list[Item], query_config: QueryConfig
17
- ) -> list[list[Item]]:
211
+ geometry: STGeometry, items: list[ItemType], query_config: QueryConfig
212
+ ) -> list[list[ItemType]]:
18
213
  """Match candidate items to a window based on the query configuration.
19
214
 
20
215
  Candidate items should be collected that intersect with the window's spatial
21
216
  extent.
22
217
 
23
218
  Args:
24
- geometry: the window projected to the same projection as the items
219
+ geometry: the window's geometry
25
220
  items: all items from the data source that intersect spatially with the geometry
26
221
  query_config: the query configuration to use for matching
27
222
 
@@ -30,45 +225,45 @@ def match_candidate_items_to_window(
30
225
  """
31
226
  # Use time mode to filter and order the items.
32
227
  if geometry.time_range:
33
- if query_config.time_mode == TimeMode.WITHIN:
34
- items = [
35
- item
36
- for item in items
37
- if geometry.intersects_time_range(item.geometry.time_range)
38
- ]
39
- elif query_config.time_mode in [
40
- TimeMode.NEAREST,
41
- TimeMode.BEFORE,
42
- TimeMode.AFTER,
43
- ]:
44
- if query_config.time_mode == TimeMode.BEFORE:
45
- items = [
46
- item
47
- for item in items
48
- if not item.time_range or item.range[1] <= geometry.time_range[0]
49
- ]
50
- elif query_config.time_mode == TimeMode.AFTER:
51
- items = [
52
- item
53
- for item in items
54
- if not item.time_range
55
- or item.time_range[0] >= geometry.time_range[1]
56
- ]
228
+ items = [
229
+ item
230
+ for item in items
231
+ if geometry.intersects_time_range(item.geometry.time_range)
232
+ ]
233
+
234
+ placeholder_datetime = datetime.now(UTC)
235
+ if query_config.time_mode == TimeMode.BEFORE:
236
+ items.sort(
237
+ key=lambda item: item.geometry.time_range[0]
238
+ if item.geometry.time_range
239
+ else placeholder_datetime,
240
+ reverse=True,
241
+ )
242
+ elif query_config.time_mode == TimeMode.AFTER:
57
243
  items.sort(
58
- key=lambda item: geometry.distance_to_time_range(item.time_range)
244
+ key=lambda item: item.geometry.time_range[0]
245
+ if item.geometry.time_range
246
+ else placeholder_datetime,
247
+ reverse=False,
59
248
  )
60
249
 
61
250
  # Now apply space mode.
62
251
  item_shps = []
63
252
  for item in items:
64
253
  item_geom = item.geometry
254
+ # We need to re-project items to the geometry projection for the spatial checks
255
+ # below. Unless the item's geometry indicates global coverage, in which case we
256
+ # set it to match the geometry to show that it should cover the entire
257
+ # geometry.
65
258
  if item_geom.projection != geometry.projection:
66
- item_geom = item_geom.to_projection(geometry.projection)
259
+ if item_geom.is_global():
260
+ item_geom = geometry
261
+ else:
262
+ item_geom = item_geom.to_projection(geometry.projection)
67
263
  item_shps.append(item_geom.shp)
68
264
 
69
- groups = []
70
-
71
265
  if query_config.space_mode == SpaceMode.CONTAINS:
266
+ groups = []
72
267
  for item, item_shp in zip(items, item_shps):
73
268
  if not item_shp.contains(geometry.shp):
74
269
  continue
@@ -77,6 +272,7 @@ def match_candidate_items_to_window(
77
272
  break
78
273
 
79
274
  elif query_config.space_mode == SpaceMode.INTERSECTS:
275
+ groups = []
80
276
  for item, item_shp in zip(items, item_shps):
81
277
  if not shp_intersects(item_shp, geometry.shp):
82
278
  continue
@@ -85,40 +281,32 @@ def match_candidate_items_to_window(
85
281
  break
86
282
 
87
283
  elif query_config.space_mode == SpaceMode.MOSAIC:
88
- # To create mosaic groups, we repeatedly try to fill the geometry.
89
- # Each time the geometry is fully covered, we start another group.
90
- # We terminate when there are no more items or we have exceeded max_matches.
91
- cur_remainder = None
92
- cur_group = []
284
+ groups = mosaic_matching(geometry, items, item_shps, query_config.max_matches)
93
285
 
94
- for item, item_shp in zip(items, item_shps):
95
- if cur_remainder is None:
96
- cur_remainder = geometry.shp
286
+ elif query_config.space_mode == SpaceMode.PER_PERIOD_MOSAIC:
287
+ groups = per_period_mosaic_matching(
288
+ geometry, items, query_config.period_duration, query_config.max_matches
289
+ )
97
290
 
98
- if not shp_intersects(item_shp, cur_remainder):
99
- continue
100
-
101
- # Check if the intersection area is too small.
102
- # If it is a sizable part of the item or of the geometry, then continue.
103
- intersect_area = item_shp.intersection(cur_remainder).area
104
- if (
105
- intersect_area / item_shp.area < MOSAIC_MIN_ITEM_COVERAGE
106
- and intersect_area / cur_remainder.area < MOSAIC_MIN_ITEM_COVERAGE
107
- ):
291
+ elif query_config.space_mode == SpaceMode.COMPOSITE:
292
+ group = []
293
+ for item, item_shp in zip(items, item_shps):
294
+ if not shp_intersects(item_shp, geometry.shp):
108
295
  continue
296
+ group.append(item)
297
+ groups = [group]
109
298
 
110
- cur_remainder = cur_remainder - item_shp
111
- cur_group.append(item)
112
-
113
- if cur_remainder.area / geometry.shp.area < MOSAIC_REMAINDER_EPSILON:
114
- cur_remainder = None
115
- groups.append(cur_group)
116
- cur_group = []
117
-
118
- if len(groups) >= query_config.max_matches:
119
- break
299
+ else:
300
+ raise ValueError(f"invalid space mode {query_config.space_mode}")
120
301
 
121
- if len(cur_group) > 0:
122
- groups.append(cur_group)
302
+ # Enforce minimum matches if set.
303
+ if len(groups) < query_config.min_matches:
304
+ logger.warning(
305
+ "Window rejected: found %d matches (required: %d) for time range %s",
306
+ len(groups),
307
+ query_config.min_matches,
308
+ geometry.time_range if geometry.time_range else "unlimited",
309
+ )
310
+ return []
123
311
 
124
312
  return groups
@@ -0,0 +1 @@
1
+ """Placeholder for a vector data source."""