voxcity 1.0.2__py3-none-any.whl → 1.0.15__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 (41) hide show
  1. voxcity/downloader/ocean.py +559 -0
  2. voxcity/generator/api.py +6 -0
  3. voxcity/generator/grids.py +45 -32
  4. voxcity/generator/pipeline.py +327 -27
  5. voxcity/geoprocessor/draw.py +14 -8
  6. voxcity/geoprocessor/raster/__init__.py +2 -0
  7. voxcity/geoprocessor/raster/core.py +31 -0
  8. voxcity/geoprocessor/raster/landcover.py +173 -49
  9. voxcity/geoprocessor/raster/raster.py +1 -1
  10. voxcity/models.py +2 -0
  11. voxcity/simulator/solar/__init__.py +13 -0
  12. voxcity/simulator_gpu/__init__.py +90 -0
  13. voxcity/simulator_gpu/core.py +322 -0
  14. voxcity/simulator_gpu/domain.py +36 -0
  15. voxcity/simulator_gpu/init_taichi.py +154 -0
  16. voxcity/simulator_gpu/raytracing.py +776 -0
  17. voxcity/simulator_gpu/solar/__init__.py +222 -0
  18. voxcity/simulator_gpu/solar/core.py +66 -0
  19. voxcity/simulator_gpu/solar/csf.py +1249 -0
  20. voxcity/simulator_gpu/solar/domain.py +618 -0
  21. voxcity/simulator_gpu/solar/epw.py +421 -0
  22. voxcity/simulator_gpu/solar/integration.py +4322 -0
  23. voxcity/simulator_gpu/solar/mask.py +459 -0
  24. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  25. voxcity/simulator_gpu/solar/raytracing.py +182 -0
  26. voxcity/simulator_gpu/solar/reflection.py +533 -0
  27. voxcity/simulator_gpu/solar/sky.py +907 -0
  28. voxcity/simulator_gpu/solar/solar.py +337 -0
  29. voxcity/simulator_gpu/solar/svf.py +446 -0
  30. voxcity/simulator_gpu/solar/volumetric.py +2099 -0
  31. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  32. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  33. voxcity/simulator_gpu/visibility/integration.py +808 -0
  34. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  35. voxcity/simulator_gpu/visibility/view.py +944 -0
  36. voxcity/visualizer/renderer.py +2 -1
  37. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/METADATA +16 -53
  38. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/RECORD +41 -16
  39. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/WHEEL +0 -0
  40. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/licenses/AUTHORS.rst +0 -0
  41. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/licenses/LICENSE +0 -0
@@ -46,16 +46,19 @@ def get_last_effective_land_cover_source():
46
46
 
47
47
 
48
48
  def get_land_cover_grid(rectangle_vertices, meshsize, source, output_dir, print_class_info=True, **kwargs):
49
- print("Creating Land Use Land Cover grid\n ")
50
- print(f"Data source: {source}")
51
- if print_class_info:
52
- print(get_source_class_descriptions(source))
49
+ quiet = kwargs.get('quiet', False)
50
+ if not quiet:
51
+ print("Creating Land Use Land Cover grid\n ")
52
+ print(f"Data source: {source}")
53
+ if print_class_info:
54
+ print(get_source_class_descriptions(source))
53
55
 
54
56
  if source not in ["OpenStreetMap", "OpenEarthMapJapan"]:
55
57
  try:
56
58
  initialize_earth_engine()
57
59
  except Exception as e:
58
- print("Earth Engine unavailable (", str(e), ") — falling back to OpenStreetMap for land cover.")
60
+ if not quiet:
61
+ print("Earth Engine unavailable (", str(e), ") — falling back to OpenStreetMap for land cover.")
59
62
  source = 'OpenStreetMap'
60
63
 
61
64
  os.makedirs(output_dir, exist_ok=True)
@@ -70,11 +73,12 @@ def get_land_cover_grid(rectangle_vertices, meshsize, source, output_dir, print_
70
73
  try:
71
74
  image = get_ee_image_collection(collection_name, roi)
72
75
  # If collection is empty, image operations may fail; guard with try/except
73
- save_geotiff(image, geotiff_path)
76
+ save_geotiff(image, geotiff_path, scale=meshsize, region=roi, crs='EPSG:4326')
74
77
  if (not os.path.exists(geotiff_path)) or (os.path.getsize(geotiff_path) == 0):
75
78
  raise RuntimeError("Urbanwatch export produced no file")
76
79
  except Exception as e:
77
- print("Urbanwatch coverage not found for AOI; falling back to OpenStreetMap (reason:", str(e), ")")
80
+ if not quiet:
81
+ print("Urbanwatch coverage not found for AOI; falling back to OpenStreetMap (reason:", str(e), ")")
78
82
  effective_source = 'OpenStreetMap'
79
83
  land_cover_gdf = load_land_cover_gdf_from_osm(rectangle_vertices)
80
84
  elif source == 'ESA WorldCover':
@@ -114,7 +118,11 @@ def get_land_cover_grid(rectangle_vertices, meshsize, source, output_dir, print_
114
118
 
115
119
  if effective_source == 'OpenStreetMap':
116
120
  default_class = kwargs.get('default_land_cover_class', 'Developed space')
117
- land_cover_grid_str = create_land_cover_grid_from_gdf_polygon(land_cover_gdf, meshsize, effective_source, rectangle_vertices, default_class=default_class)
121
+ detect_ocean = kwargs.get('detect_ocean', True) # Default True for OSM
122
+ land_cover_grid_str = create_land_cover_grid_from_gdf_polygon(
123
+ land_cover_gdf, meshsize, effective_source, rectangle_vertices,
124
+ default_class=default_class, detect_ocean=detect_ocean
125
+ )
118
126
  else:
119
127
  land_cover_grid_str = create_land_cover_grid_from_geotiff_polygon(geotiff_path, meshsize, land_cover_classes, rectangle_vertices)
120
128
 
@@ -137,14 +145,17 @@ def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, b
137
145
  if source in ee_required_sources:
138
146
  initialize_earth_engine()
139
147
 
140
- print("Creating Building Height grid\n ")
141
- print(f"Base data source: {source}")
148
+ quiet = kwargs.get('quiet', False)
149
+ if not quiet:
150
+ print("Creating Building Height grid\n ")
151
+ print(f"Base data source: {source}")
142
152
 
143
153
  os.makedirs(output_dir, exist_ok=True)
144
154
 
145
155
  if building_gdf is not None:
146
156
  gdf = building_gdf
147
- print("Using provided GeoDataFrame for building data")
157
+ if not quiet:
158
+ print("Using provided GeoDataFrame for building data")
148
159
  else:
149
160
  floor_height = kwargs.get("floor_height", 3.0)
150
161
  if source == 'Microsoft Building Footprints':
@@ -171,7 +182,8 @@ def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, b
171
182
  building_complementary_source = kwargs.get("building_complementary_source")
172
183
  try:
173
184
  comp_label = building_complementary_source if building_complementary_source not in (None, "") else "None"
174
- print(f"Complementary data source: {comp_label}")
185
+ if not quiet:
186
+ print(f"Complementary data source: {comp_label}")
175
187
  except Exception:
176
188
  pass
177
189
  building_complement_height = kwargs.get("building_complement_height")
@@ -189,7 +201,8 @@ def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, b
189
201
  save_geotiff_open_buildings_temporal(roi, geotiff_path_comp)
190
202
  building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, geotiff_path_comp=geotiff_path_comp, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
191
203
  except Exception as e:
192
- print("Open Building 2.5D Temporal requires Earth Engine (", str(e), ") — proceeding without complementary raster.")
204
+ if not quiet:
205
+ print("Open Building 2.5D Temporal requires Earth Engine (", str(e), ") — proceeding without complementary raster.")
193
206
  building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
194
207
  elif building_complementary_source in ["England 1m DSM - DTM", "Netherlands 0.5m DSM - DTM"]:
195
208
  try:
@@ -199,7 +212,8 @@ def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, b
199
212
  save_geotiff_dsm_minus_dtm(roi, geotiff_path_comp, meshsize, building_complementary_source)
200
213
  building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, geotiff_path_comp=geotiff_path_comp, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
201
214
  except Exception as e:
202
- print("DSM-DTM complementary raster requires Earth Engine (", str(e), ") — proceeding without complementary raster.")
215
+ if not quiet:
216
+ print("DSM-DTM complementary raster requires Earth Engine (", str(e), ") — proceeding without complementary raster.")
203
217
  building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
204
218
  else:
205
219
  if building_complementary_source == 'Microsoft Building Footprints':
@@ -232,8 +246,10 @@ def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, b
232
246
 
233
247
 
234
248
  def get_canopy_height_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
235
- print("Creating Canopy Height grid\n ")
236
- print(f"Data source: {source}")
249
+ quiet = kwargs.get('quiet', False)
250
+ if not quiet:
251
+ print("Creating Canopy Height grid\n ")
252
+ print(f"Data source: {source}")
237
253
 
238
254
  os.makedirs(output_dir, exist_ok=True)
239
255
 
@@ -297,7 +313,8 @@ def get_canopy_height_grid(rectangle_vertices, meshsize, source, output_dir, **k
297
313
  try:
298
314
  initialize_earth_engine()
299
315
  except Exception as e:
300
- print("Earth Engine unavailable (", str(e), ") — falling back to Static canopy heights.")
316
+ if not quiet:
317
+ print("Earth Engine unavailable (", str(e), ") — falling back to Static canopy heights.")
301
318
  # Re-enter with explicit Static logic using land cover mask
302
319
  return get_canopy_height_grid(rectangle_vertices, meshsize, 'Static', output_dir, **kwargs)
303
320
 
@@ -313,7 +330,7 @@ def get_canopy_height_grid(rectangle_vertices, meshsize, source, output_dir, **k
313
330
  else:
314
331
  raise ValueError(f"Unsupported canopy source: {source}")
315
332
 
316
- save_geotiff(image, geotiff_path, resolution=meshsize)
333
+ save_geotiff(image, geotiff_path, scale=meshsize, region=roi, crs='EPSG:4326')
317
334
  canopy_height_grid = create_height_grid_from_geotiff_polygon(geotiff_path, meshsize, rectangle_vertices)
318
335
 
319
336
  trunk_height_ratio = kwargs.get("trunk_height_ratio")
@@ -330,8 +347,10 @@ def get_canopy_height_grid(rectangle_vertices, meshsize, source, output_dir, **k
330
347
 
331
348
 
332
349
  def get_dem_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
333
- print("Creating Digital Elevation Model (DEM) grid\n ")
334
- print(f"Data source: {source}")
350
+ quiet = kwargs.get('quiet', False)
351
+ if not quiet:
352
+ print("Creating Digital Elevation Model (DEM) grid\n ")
353
+ print(f"Data source: {source}")
335
354
 
336
355
  if source == "Local file":
337
356
  geotiff_path = kwargs["dem_path"]
@@ -339,18 +358,12 @@ def get_dem_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
339
358
  try:
340
359
  initialize_earth_engine()
341
360
  except Exception as e:
342
- print("Earth Engine unavailable (", str(e), ") — falling back to flat DEM.")
343
- dem_interpolation = kwargs.get("dem_interpolation")
344
- # Return flat DEM (zeros) with same shape as would be produced after rasterization
345
- # We defer to downstream to handle zeros appropriately.
346
- # To avoid shape inference here, we'll build after default path below.
347
- geotiff_path = None
348
- # Bypass EE path and produce zeros later
349
- dem_grid = np.zeros((1, 1), dtype=float)
350
- # Build shape using land cover grid shape if provided via kwargs for robustness
351
- lc_like = kwargs.get("land_cover_like")
352
- if lc_like is not None:
353
- dem_grid = np.zeros_like(lc_like)
361
+ if not quiet:
362
+ print("Earth Engine unavailable (", str(e), ") — falling back to flat DEM.")
363
+ # Compute grid shape directly from rectangle_vertices and meshsize
364
+ from ..geoprocessor.raster.core import compute_grid_shape
365
+ grid_shape = compute_grid_shape(rectangle_vertices, meshsize)
366
+ dem_grid = np.zeros(grid_shape, dtype=float)
354
367
  return dem_grid
355
368
 
356
369
  geotiff_path = os.path.join(output_dir, "dem.tif")
@@ -1,6 +1,7 @@
1
1
  import os
2
2
  from ..utils.logging import get_logger
3
3
  from typing import Optional
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
4
5
  import numpy as np
5
6
 
6
7
  from ..models import (
@@ -78,33 +79,70 @@ class VoxCityPipeline:
78
79
  canopy_strategy = CanopySourceFactory.create(cfg.canopy_height_source, cfg)
79
80
  dem_strategy = DemSourceFactory.create(cfg.dem_source)
80
81
 
81
- # Prefer structured options from cfg; allow legacy kwargs for back-compat
82
- land_cover_grid = land_strategy.build_grid(
83
- cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
84
- **{**cfg.land_cover_options, **kwargs}
85
- )
86
- # Detect effective land cover source (e.g., Urbanwatch -> OpenStreetMap fallback)
87
- try:
88
- from .grids import get_last_effective_land_cover_source
89
- lc_src_effective = get_last_effective_land_cover_source() or cfg.land_cover_source
90
- except Exception:
91
- lc_src_effective = cfg.land_cover_source
92
- bh, bmin, bid, building_gdf_out = build_strategy.build_grids(
93
- cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
94
- building_gdf=building_gdf,
95
- **{**cfg.building_options, **kwargs}
96
- )
97
- canopy_top, canopy_bottom = canopy_strategy.build_grids(
98
- cfg.rectangle_vertices, cfg.meshsize, land_cover_grid, cfg.output_dir,
99
- land_cover_source=lc_src_effective,
100
- **{**cfg.canopy_options, **kwargs}
101
- )
102
- dem = dem_strategy.build_grid(
103
- cfg.rectangle_vertices, cfg.meshsize, land_cover_grid, cfg.output_dir,
104
- terrain_gdf=terrain_gdf,
105
- land_cover_like=land_cover_grid,
106
- **{**cfg.dem_options, **kwargs}
107
- )
82
+ # Check if parallel download is enabled
83
+ parallel_download = getattr(cfg, 'parallel_download', False)
84
+
85
+ if parallel_download and cfg.canopy_height_source != "Static":
86
+ # All 4 downloads are independent - run in parallel
87
+ land_cover_grid, bh, bmin, bid, building_gdf_out, canopy_top, canopy_bottom, dem, lc_src_effective = \
88
+ self._run_parallel_downloads(
89
+ cfg, land_strategy, build_strategy, canopy_strategy, dem_strategy,
90
+ building_gdf, terrain_gdf, kwargs
91
+ )
92
+ # Run visualizations after parallel downloads complete (if gridvis enabled)
93
+ if kwargs.get('gridvis', cfg.gridvis):
94
+ self._visualize_grids_after_parallel(
95
+ land_cover_grid, bh, canopy_top, dem,
96
+ lc_src_effective, cfg.meshsize
97
+ )
98
+ elif parallel_download and cfg.canopy_height_source == "Static":
99
+ # Static canopy needs land_cover_grid for tree mask
100
+ # Run land_cover + building + dem in parallel, then canopy sequentially
101
+ land_cover_grid, bh, bmin, bid, building_gdf_out, dem, lc_src_effective = \
102
+ self._run_parallel_downloads_static_canopy(
103
+ cfg, land_strategy, build_strategy, dem_strategy,
104
+ building_gdf, terrain_gdf, kwargs
105
+ )
106
+ # Visualize land_cover, building, dem after parallel (if gridvis enabled)
107
+ if kwargs.get('gridvis', cfg.gridvis):
108
+ self._visualize_grids_after_parallel(
109
+ land_cover_grid, bh, None, dem,
110
+ lc_src_effective, cfg.meshsize
111
+ )
112
+ # Now run canopy with land_cover_grid available (this will visualize itself)
113
+ canopy_top, canopy_bottom = canopy_strategy.build_grids(
114
+ cfg.rectangle_vertices, cfg.meshsize, land_cover_grid, cfg.output_dir,
115
+ land_cover_source=lc_src_effective,
116
+ **{**cfg.canopy_options, **kwargs}
117
+ )
118
+ else:
119
+ # Sequential mode (original behavior)
120
+ land_cover_grid = land_strategy.build_grid(
121
+ cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
122
+ **{**cfg.land_cover_options, **kwargs}
123
+ )
124
+ # Detect effective land cover source (e.g., Urbanwatch -> OpenStreetMap fallback)
125
+ try:
126
+ from .grids import get_last_effective_land_cover_source
127
+ lc_src_effective = get_last_effective_land_cover_source() or cfg.land_cover_source
128
+ except Exception:
129
+ lc_src_effective = cfg.land_cover_source
130
+ bh, bmin, bid, building_gdf_out = build_strategy.build_grids(
131
+ cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
132
+ building_gdf=building_gdf,
133
+ **{**cfg.building_options, **kwargs}
134
+ )
135
+ canopy_top, canopy_bottom = canopy_strategy.build_grids(
136
+ cfg.rectangle_vertices, cfg.meshsize, land_cover_grid, cfg.output_dir,
137
+ land_cover_source=lc_src_effective,
138
+ **{**cfg.canopy_options, **kwargs}
139
+ )
140
+ dem = dem_strategy.build_grid(
141
+ cfg.rectangle_vertices, cfg.meshsize, land_cover_grid, cfg.output_dir,
142
+ terrain_gdf=terrain_gdf,
143
+ land_cover_like=land_cover_grid,
144
+ **{**cfg.dem_options, **kwargs}
145
+ )
108
146
 
109
147
  ro = cfg.remove_perimeter_object
110
148
  if (ro is not None) and (ro > 0):
@@ -152,6 +190,258 @@ class VoxCityPipeline:
152
190
  },
153
191
  )
154
192
 
193
+ def _visualize_grids_after_parallel(
194
+ self, land_cover_grid, building_height_grid, canopy_top, dem_grid,
195
+ land_cover_source, meshsize
196
+ ):
197
+ """
198
+ Run grid visualizations after parallel downloads complete.
199
+ This ensures matplotlib calls happen sequentially on the main thread.
200
+ """
201
+ from ..visualizer.grids import visualize_land_cover_grid, visualize_numerical_grid
202
+ from ..utils.lc import get_land_cover_classes
203
+
204
+ # Visualize land cover (convert int grid back to string for visualization)
205
+ try:
206
+ land_cover_classes = get_land_cover_classes(land_cover_source)
207
+ # Create reverse mapping: int -> string class name
208
+ int_to_class = {i: name for i, name in enumerate(land_cover_classes.values())}
209
+ # Convert integer grid to string grid for visualization
210
+ land_cover_grid_str = np.empty(land_cover_grid.shape, dtype=object)
211
+ for i, name in int_to_class.items():
212
+ land_cover_grid_str[land_cover_grid == i] = name
213
+ color_map = {cls: [r/255, g/255, b/255] for (r,g,b), cls in land_cover_classes.items()}
214
+ visualize_land_cover_grid(np.flipud(land_cover_grid_str), meshsize, color_map, land_cover_classes)
215
+ except Exception as e:
216
+ get_logger(__name__).warning("Land cover visualization failed: %s", e)
217
+
218
+ # Visualize building height
219
+ try:
220
+ building_height_grid_nan = building_height_grid.copy().astype(float)
221
+ building_height_grid_nan[building_height_grid_nan == 0] = np.nan
222
+ visualize_numerical_grid(np.flipud(building_height_grid_nan), meshsize, "building height (m)", cmap='viridis', label='Value')
223
+ except Exception as e:
224
+ get_logger(__name__).warning("Building height visualization failed: %s", e)
225
+
226
+ # Visualize canopy height (if provided)
227
+ if canopy_top is not None:
228
+ try:
229
+ canopy_height_grid_nan = canopy_top.copy()
230
+ canopy_height_grid_nan[canopy_height_grid_nan == 0] = np.nan
231
+ visualize_numerical_grid(np.flipud(canopy_height_grid_nan), meshsize, "Tree canopy height", cmap='Greens', label='Tree canopy height (m)')
232
+ except Exception as e:
233
+ get_logger(__name__).warning("Canopy height visualization failed: %s", e)
234
+
235
+ # Visualize DEM
236
+ try:
237
+ visualize_numerical_grid(np.flipud(dem_grid), meshsize, title='Digital Elevation Model', cmap='terrain', label='Elevation (m)')
238
+ except Exception as e:
239
+ get_logger(__name__).warning("DEM visualization failed: %s", e)
240
+
241
+ def _run_parallel_downloads(
242
+ self, cfg, land_strategy, build_strategy, canopy_strategy, dem_strategy,
243
+ building_gdf, terrain_gdf, kwargs
244
+ ):
245
+ """
246
+ Run all 4 downloads (land_cover, building, canopy, dem) in parallel.
247
+ Used when canopy source is NOT 'Static' (no land_cover dependency).
248
+ """
249
+ import logging
250
+ logger = get_logger(__name__)
251
+
252
+ # Print clean header for parallel mode
253
+ print("\n" + "="*60)
254
+ print("Downloading data in parallel mode (4 concurrent downloads)")
255
+ print("="*60)
256
+ print(f" • Land Cover: {cfg.land_cover_source}")
257
+ print(f" • Building: {cfg.building_source}")
258
+ print(f" • Canopy: {cfg.canopy_height_source}")
259
+ print(f" • DEM: {cfg.dem_source}")
260
+ print("-"*60)
261
+ print("Downloading... (this may take a moment)")
262
+
263
+ results = {}
264
+
265
+ # Disable gridvis and verbose prints in parallel mode
266
+ # Also suppress httpx INFO logs during parallel downloads
267
+ parallel_kwargs = {**kwargs, 'gridvis': False, 'print_class_info': False, 'quiet': True}
268
+ lc_opts = {**cfg.land_cover_options, 'gridvis': False, 'print_class_info': False, 'quiet': True}
269
+ bld_opts = {**cfg.building_options, 'gridvis': False, 'quiet': True}
270
+ canopy_opts = {**cfg.canopy_options, 'gridvis': False, 'quiet': True}
271
+ dem_opts = {**cfg.dem_options, 'gridvis': False, 'quiet': True}
272
+
273
+ # Suppress httpx verbose logging during parallel downloads
274
+ httpx_logger = logging.getLogger("httpx")
275
+ original_httpx_level = httpx_logger.level
276
+ httpx_logger.setLevel(logging.WARNING)
277
+
278
+ def download_land_cover():
279
+ grid = land_strategy.build_grid(
280
+ cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
281
+ **{**lc_opts, **parallel_kwargs}
282
+ )
283
+ # Get effective source after download
284
+ try:
285
+ from .grids import get_last_effective_land_cover_source
286
+ effective = get_last_effective_land_cover_source() or cfg.land_cover_source
287
+ except Exception:
288
+ effective = cfg.land_cover_source
289
+ return ('land_cover', (grid, effective))
290
+
291
+ def download_building():
292
+ bh, bmin, bid, gdf_out = build_strategy.build_grids(
293
+ cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
294
+ building_gdf=building_gdf,
295
+ **{**bld_opts, **parallel_kwargs}
296
+ )
297
+ return ('building', (bh, bmin, bid, gdf_out))
298
+
299
+ def download_canopy():
300
+ # For non-static canopy, we don't need land_cover_grid
301
+ # Pass None or empty array as placeholder - the strategy will download from GEE
302
+ placeholder_grid = np.zeros((1, 1), dtype=float)
303
+ top, bottom = canopy_strategy.build_grids(
304
+ cfg.rectangle_vertices, cfg.meshsize, placeholder_grid, cfg.output_dir,
305
+ land_cover_source=cfg.land_cover_source,
306
+ **{**canopy_opts, **parallel_kwargs}
307
+ )
308
+ return ('canopy', (top, bottom))
309
+
310
+ def download_dem():
311
+ # DEM no longer depends on land_cover_like for shape
312
+ dem = dem_strategy.build_grid(
313
+ cfg.rectangle_vertices, cfg.meshsize, None, cfg.output_dir,
314
+ terrain_gdf=terrain_gdf,
315
+ **{**dem_opts, **parallel_kwargs}
316
+ )
317
+ return ('dem', dem)
318
+
319
+ with ThreadPoolExecutor(max_workers=4) as executor:
320
+ futures = [
321
+ executor.submit(download_land_cover),
322
+ executor.submit(download_building),
323
+ executor.submit(download_canopy),
324
+ executor.submit(download_dem),
325
+ ]
326
+ completed_count = 0
327
+ for future in as_completed(futures):
328
+ try:
329
+ key, value = future.result()
330
+ results[key] = value
331
+ completed_count += 1
332
+ print(f" ✓ {key.replace('_', ' ').title()} complete ({completed_count}/4)")
333
+ except Exception as e:
334
+ logger.error("Parallel download failed: %s", e)
335
+ httpx_logger.setLevel(original_httpx_level) # Restore before raising
336
+ raise
337
+
338
+ # Restore httpx logging level
339
+ httpx_logger.setLevel(original_httpx_level)
340
+
341
+ print("-"*60)
342
+ print("All downloads complete!")
343
+ print("="*60 + "\n")
344
+
345
+ land_cover_grid, lc_src_effective = results['land_cover']
346
+ bh, bmin, bid, building_gdf_out = results['building']
347
+ canopy_top, canopy_bottom = results['canopy']
348
+ dem = results['dem']
349
+
350
+ return land_cover_grid, bh, bmin, bid, building_gdf_out, canopy_top, canopy_bottom, dem, lc_src_effective
351
+
352
+ def _run_parallel_downloads_static_canopy(
353
+ self, cfg, land_strategy, build_strategy, dem_strategy,
354
+ building_gdf, terrain_gdf, kwargs
355
+ ):
356
+ """
357
+ Run land_cover, building, and dem downloads in parallel.
358
+ Canopy (Static mode) will be run sequentially after, as it needs land_cover_grid.
359
+ """
360
+ import logging
361
+ logger = get_logger(__name__)
362
+
363
+ # Print clean header for parallel mode
364
+ print("\n" + "="*60)
365
+ print("Downloading data in parallel mode (3 concurrent + 1 deferred)")
366
+ print("="*60)
367
+ print(f" • Land Cover: {cfg.land_cover_source}")
368
+ print(f" • Building: {cfg.building_source}")
369
+ print(f" • DEM: {cfg.dem_source}")
370
+ print(f" • Canopy: {cfg.canopy_height_source} (deferred)")
371
+ print("-"*60)
372
+ print("Downloading... (this may take a moment)")
373
+
374
+ results = {}
375
+
376
+ # Disable gridvis and verbose prints in parallel mode
377
+ parallel_kwargs = {**kwargs, 'gridvis': False, 'print_class_info': False, 'quiet': True}
378
+ lc_opts = {**cfg.land_cover_options, 'gridvis': False, 'print_class_info': False, 'quiet': True}
379
+ bld_opts = {**cfg.building_options, 'gridvis': False, 'quiet': True}
380
+ dem_opts = {**cfg.dem_options, 'gridvis': False, 'quiet': True}
381
+
382
+ # Suppress httpx verbose logging during parallel downloads
383
+ httpx_logger = logging.getLogger("httpx")
384
+ original_httpx_level = httpx_logger.level
385
+ httpx_logger.setLevel(logging.WARNING)
386
+
387
+ def download_land_cover():
388
+ grid = land_strategy.build_grid(
389
+ cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
390
+ **{**lc_opts, **parallel_kwargs}
391
+ )
392
+ try:
393
+ from .grids import get_last_effective_land_cover_source
394
+ effective = get_last_effective_land_cover_source() or cfg.land_cover_source
395
+ except Exception:
396
+ effective = cfg.land_cover_source
397
+ return ('land_cover', (grid, effective))
398
+
399
+ def download_building():
400
+ bh, bmin, bid, gdf_out = build_strategy.build_grids(
401
+ cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
402
+ building_gdf=building_gdf,
403
+ **{**bld_opts, **parallel_kwargs}
404
+ )
405
+ return ('building', (bh, bmin, bid, gdf_out))
406
+
407
+ def download_dem():
408
+ dem = dem_strategy.build_grid(
409
+ cfg.rectangle_vertices, cfg.meshsize, None, cfg.output_dir,
410
+ terrain_gdf=terrain_gdf,
411
+ **{**dem_opts, **parallel_kwargs}
412
+ )
413
+ return ('dem', dem)
414
+
415
+ with ThreadPoolExecutor(max_workers=3) as executor:
416
+ futures = [
417
+ executor.submit(download_land_cover),
418
+ executor.submit(download_building),
419
+ executor.submit(download_dem),
420
+ ]
421
+ completed_count = 0
422
+ for future in as_completed(futures):
423
+ try:
424
+ key, value = future.result()
425
+ results[key] = value
426
+ completed_count += 1
427
+ print(f" ✓ {key.replace('_', ' ').title()} complete ({completed_count}/3)")
428
+ except Exception as e:
429
+ logger.error("Parallel download failed: %s", e)
430
+ httpx_logger.setLevel(original_httpx_level)
431
+ raise
432
+
433
+ # Restore httpx logging level
434
+ httpx_logger.setLevel(original_httpx_level)
435
+
436
+ print("-"*60)
437
+ print("Parallel downloads complete! Processing canopy...")
438
+
439
+ land_cover_grid, lc_src_effective = results['land_cover']
440
+ bh, bmin, bid, building_gdf_out = results['building']
441
+ dem = results['dem']
442
+
443
+ return land_cover_grid, bh, bmin, bid, building_gdf_out, dem, lc_src_effective
444
+
155
445
 
156
446
  class LandCoverSourceStrategy: # ABC simplified to avoid dependency in split
157
447
  def build_grid(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs): # pragma: no cover - interface
@@ -246,6 +536,11 @@ class DemSourceStrategy: # ABC simplified
246
536
 
247
537
  class FlatDemStrategy(DemSourceStrategy):
248
538
  def build_grid(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
539
+ # Compute shape from rectangle_vertices if land_cover_grid is None
540
+ if land_cover_grid is None:
541
+ from ..geoprocessor.raster.core import compute_grid_shape
542
+ grid_shape = compute_grid_shape(rectangle_vertices, meshsize)
543
+ return np.zeros(grid_shape, dtype=float)
249
544
  return np.zeros_like(land_cover_grid)
250
545
 
251
546
 
@@ -264,6 +559,11 @@ class SourceDemStrategy(DemSourceStrategy):
264
559
  # Fallback to flat DEM if source fails or unsupported
265
560
  logger = get_logger(__name__)
266
561
  logger.warning("DEM source '%s' failed (%s). Falling back to flat DEM.", self.source, e)
562
+ # Compute shape from rectangle_vertices if land_cover_grid is None
563
+ if land_cover_grid is None:
564
+ from ..geoprocessor.raster.core import compute_grid_shape
565
+ grid_shape = compute_grid_shape(rectangle_vertices, meshsize)
566
+ return np.zeros(grid_shape, dtype=float)
267
567
  return np.zeros_like(land_cover_grid)
268
568
 
269
569
 
@@ -337,23 +337,29 @@ def center_location_map_cityname(cityname, east_west_length, north_south_length,
337
337
  print(f"Point drawn at Longitude: {lon}, Latitude: {lat}")
338
338
 
339
339
  # Calculate corner points using geopy's distance calculator
340
- # Each point is calculated as a destination from center point using bearing
340
+ # First calculate north/south latitudes from center
341
341
  north = distance.distance(meters=north_south_length/2).destination((lat, lon), bearing=0)
342
342
  south = distance.distance(meters=north_south_length/2).destination((lat, lon), bearing=180)
343
- east = distance.distance(meters=east_west_length/2).destination((lat, lon), bearing=90)
344
- west = distance.distance(meters=east_west_length/2).destination((lat, lon), bearing=270)
343
+
344
+ # Calculate east/west at the SOUTH latitude to ensure correct grid dimensions
345
+ # The grid size calculation uses the SW corner (vertex_0) as reference,
346
+ # measuring E-W distance along the south edge. By calculating east/west
347
+ # at the south latitude, we ensure the E-W distance matches the requested length.
348
+ east_at_south = distance.distance(meters=east_west_length/2).destination((south.latitude, lon), bearing=90)
349
+ west_at_south = distance.distance(meters=east_west_length/2).destination((south.latitude, lon), bearing=270)
345
350
 
346
351
  # Create rectangle vertices in counter-clockwise order (lon,lat)
352
+ # Using the east/west longitudes calculated at south latitude for all corners
347
353
  rectangle_vertices.extend([
348
- (west.longitude, south.latitude),
349
- (west.longitude, north.latitude),
350
- (east.longitude, north.latitude),
351
- (east.longitude, south.latitude)
354
+ (west_at_south.longitude, south.latitude),
355
+ (west_at_south.longitude, north.latitude),
356
+ (east_at_south.longitude, north.latitude),
357
+ (east_at_south.longitude, south.latitude)
352
358
  ])
353
359
 
354
360
  # Create and add new rectangle to map (ipyleaflet expects lat,lon)
355
361
  rectangle = Rectangle(
356
- bounds=[(north.latitude, west.longitude), (south.latitude, east.longitude)],
362
+ bounds=[(north.latitude, west_at_south.longitude), (south.latitude, east_at_south.longitude)],
357
363
  color="red",
358
364
  fill_color="red",
359
365
  fill_opacity=0.2
@@ -19,6 +19,7 @@ from .core import (
19
19
  calculate_grid_size,
20
20
  create_coordinate_mesh,
21
21
  create_cell_polygon,
22
+ compute_grid_shape,
22
23
  )
23
24
 
24
25
  from .landcover import (
@@ -58,6 +59,7 @@ __all__ = [
58
59
  "calculate_grid_size",
59
60
  "create_coordinate_mesh",
60
61
  "create_cell_polygon",
62
+ "compute_grid_shape",
61
63
  # landcover
62
64
  "tree_height_grid_from_land_cover",
63
65
  "create_land_cover_grid_from_geotiff_polygon",
@@ -2,6 +2,8 @@ import numpy as np
2
2
  from typing import Tuple, Dict, Any
3
3
  from shapely.geometry import Polygon
4
4
 
5
+ from ..utils import initialize_geod, calculate_distance, normalize_to_one_meter
6
+
5
7
 
6
8
  def apply_operation(arr: np.ndarray, meshsize: float) -> np.ndarray:
7
9
  """
@@ -146,5 +148,34 @@ def create_cell_polygon(
146
148
  return Polygon([bottom_left, bottom_right, top_right, top_left])
147
149
 
148
150
 
151
+ def compute_grid_shape(rectangle_vertices, meshsize: float) -> Tuple[int, int]:
152
+ """
153
+ Compute the grid dimensions (rows, cols) for a given rectangle and mesh size.
154
+
155
+ This is useful when you need to know the output grid shape without
156
+ actually creating the grid (e.g., for pre-allocating arrays or fallback shapes).
157
+
158
+ Args:
159
+ rectangle_vertices: List of 4 vertices [(lon, lat), ...] defining the rectangle.
160
+ meshsize: Grid cell size in meters.
161
+
162
+ Returns:
163
+ Tuple of (grid_size_0, grid_size_1) representing grid dimensions.
164
+ """
165
+ geod = initialize_geod()
166
+ vertex_0, vertex_1, vertex_3 = rectangle_vertices[0], rectangle_vertices[1], rectangle_vertices[3]
167
+
168
+ dist_side_1 = calculate_distance(geod, vertex_0[0], vertex_0[1], vertex_1[0], vertex_1[1])
169
+ dist_side_2 = calculate_distance(geod, vertex_0[0], vertex_0[1], vertex_3[0], vertex_3[1])
170
+
171
+ side_1 = np.array(vertex_1) - np.array(vertex_0)
172
+ side_2 = np.array(vertex_3) - np.array(vertex_0)
173
+ u_vec = normalize_to_one_meter(side_1, dist_side_1)
174
+ v_vec = normalize_to_one_meter(side_2, dist_side_2)
175
+
176
+ grid_size, _ = calculate_grid_size(side_1, side_2, u_vec, v_vec, meshsize)
177
+ return grid_size
178
+
179
+
149
180
 
150
181