voxcity 0.7.0__py3-none-any.whl → 1.0.13__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 (81) hide show
  1. voxcity/__init__.py +14 -14
  2. voxcity/downloader/ocean.py +559 -0
  3. voxcity/exporter/__init__.py +12 -12
  4. voxcity/exporter/cityles.py +633 -633
  5. voxcity/exporter/envimet.py +733 -728
  6. voxcity/exporter/magicavoxel.py +333 -333
  7. voxcity/exporter/netcdf.py +238 -238
  8. voxcity/exporter/obj.py +1480 -1480
  9. voxcity/generator/__init__.py +47 -44
  10. voxcity/generator/api.py +727 -675
  11. voxcity/generator/grids.py +394 -379
  12. voxcity/generator/io.py +94 -94
  13. voxcity/generator/pipeline.py +582 -282
  14. voxcity/generator/update.py +429 -0
  15. voxcity/generator/voxelizer.py +18 -6
  16. voxcity/geoprocessor/__init__.py +75 -75
  17. voxcity/geoprocessor/draw.py +1494 -1219
  18. voxcity/geoprocessor/merge_utils.py +91 -91
  19. voxcity/geoprocessor/mesh.py +806 -806
  20. voxcity/geoprocessor/network.py +708 -708
  21. voxcity/geoprocessor/raster/__init__.py +2 -0
  22. voxcity/geoprocessor/raster/buildings.py +435 -428
  23. voxcity/geoprocessor/raster/core.py +31 -0
  24. voxcity/geoprocessor/raster/export.py +93 -93
  25. voxcity/geoprocessor/raster/landcover.py +178 -51
  26. voxcity/geoprocessor/raster/raster.py +1 -1
  27. voxcity/geoprocessor/utils.py +824 -824
  28. voxcity/models.py +115 -113
  29. voxcity/simulator/solar/__init__.py +66 -43
  30. voxcity/simulator/solar/integration.py +336 -336
  31. voxcity/simulator/solar/sky.py +668 -0
  32. voxcity/simulator/solar/temporal.py +792 -434
  33. voxcity/simulator_gpu/__init__.py +115 -0
  34. voxcity/simulator_gpu/common/__init__.py +9 -0
  35. voxcity/simulator_gpu/common/geometry.py +11 -0
  36. voxcity/simulator_gpu/core.py +322 -0
  37. voxcity/simulator_gpu/domain.py +262 -0
  38. voxcity/simulator_gpu/environment.yml +11 -0
  39. voxcity/simulator_gpu/init_taichi.py +154 -0
  40. voxcity/simulator_gpu/integration.py +15 -0
  41. voxcity/simulator_gpu/kernels.py +56 -0
  42. voxcity/simulator_gpu/radiation.py +28 -0
  43. voxcity/simulator_gpu/raytracing.py +623 -0
  44. voxcity/simulator_gpu/sky.py +9 -0
  45. voxcity/simulator_gpu/solar/__init__.py +178 -0
  46. voxcity/simulator_gpu/solar/core.py +66 -0
  47. voxcity/simulator_gpu/solar/csf.py +1249 -0
  48. voxcity/simulator_gpu/solar/domain.py +561 -0
  49. voxcity/simulator_gpu/solar/epw.py +421 -0
  50. voxcity/simulator_gpu/solar/integration.py +2953 -0
  51. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  52. voxcity/simulator_gpu/solar/raytracing.py +686 -0
  53. voxcity/simulator_gpu/solar/reflection.py +533 -0
  54. voxcity/simulator_gpu/solar/sky.py +907 -0
  55. voxcity/simulator_gpu/solar/solar.py +337 -0
  56. voxcity/simulator_gpu/solar/svf.py +446 -0
  57. voxcity/simulator_gpu/solar/volumetric.py +1151 -0
  58. voxcity/simulator_gpu/solar/voxcity.py +2953 -0
  59. voxcity/simulator_gpu/temporal.py +13 -0
  60. voxcity/simulator_gpu/utils.py +25 -0
  61. voxcity/simulator_gpu/view.py +32 -0
  62. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  63. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  64. voxcity/simulator_gpu/visibility/integration.py +808 -0
  65. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  66. voxcity/simulator_gpu/visibility/view.py +944 -0
  67. voxcity/utils/__init__.py +11 -0
  68. voxcity/utils/classes.py +194 -0
  69. voxcity/utils/lc.py +80 -39
  70. voxcity/utils/shape.py +230 -0
  71. voxcity/visualizer/__init__.py +24 -24
  72. voxcity/visualizer/builder.py +43 -43
  73. voxcity/visualizer/grids.py +141 -141
  74. voxcity/visualizer/maps.py +187 -187
  75. voxcity/visualizer/renderer.py +1146 -928
  76. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/METADATA +56 -52
  77. voxcity-1.0.13.dist-info/RECORD +116 -0
  78. voxcity-0.7.0.dist-info/RECORD +0 -77
  79. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
  80. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
@@ -1,282 +1,582 @@
1
- import os
2
- from ..utils.logging import get_logger
3
- from typing import Optional
4
- import numpy as np
5
-
6
- from ..models import (
7
- GridMetadata,
8
- BuildingGrid,
9
- LandCoverGrid,
10
- DemGrid,
11
- VoxelGrid,
12
- CanopyGrid,
13
- VoxCity,
14
- PipelineConfig,
15
- )
16
-
17
- from .grids import (
18
- get_land_cover_grid,
19
- get_building_height_grid,
20
- get_canopy_height_grid,
21
- get_dem_grid,
22
- )
23
- from .voxelizer import Voxelizer
24
-
25
-
26
- class VoxCityPipeline:
27
- def __init__(self, meshsize: float, rectangle_vertices, crs: str = "EPSG:4326") -> None:
28
- self.meshsize = float(meshsize)
29
- self.rectangle_vertices = rectangle_vertices
30
- self.crs = crs
31
-
32
- def _bounds(self):
33
- xs = [p[0] for p in self.rectangle_vertices]
34
- ys = [p[1] for p in self.rectangle_vertices]
35
- return (min(xs), min(ys), max(xs), max(ys))
36
-
37
- def _meta(self) -> GridMetadata:
38
- return GridMetadata(crs=self.crs, bounds=self._bounds(), meshsize=self.meshsize)
39
-
40
- def assemble_voxcity(
41
- self,
42
- voxcity_grid: np.ndarray,
43
- building_height_grid: np.ndarray,
44
- building_min_height_grid: np.ndarray,
45
- building_id_grid: np.ndarray,
46
- land_cover_grid: np.ndarray,
47
- dem_grid: np.ndarray,
48
- canopy_height_top: Optional[np.ndarray] = None,
49
- canopy_height_bottom: Optional[np.ndarray] = None,
50
- extras: Optional[dict] = None,
51
- ) -> VoxCity:
52
- meta = self._meta()
53
- buildings = BuildingGrid(
54
- heights=building_height_grid,
55
- min_heights=building_min_height_grid,
56
- ids=building_id_grid,
57
- meta=meta,
58
- )
59
- land = LandCoverGrid(classes=land_cover_grid, meta=meta)
60
- dem = DemGrid(elevation=dem_grid, meta=meta)
61
- voxels = VoxelGrid(classes=voxcity_grid, meta=meta)
62
- canopy = CanopyGrid(top=canopy_height_top if canopy_height_top is not None else np.zeros_like(land_cover_grid, dtype=float),
63
- bottom=canopy_height_bottom,
64
- meta=meta)
65
- _extras = {
66
- "rectangle_vertices": self.rectangle_vertices,
67
- "canopy_top": canopy.top,
68
- "canopy_bottom": canopy.bottom,
69
- }
70
- if extras:
71
- _extras.update(extras)
72
- return VoxCity(voxels=voxels, buildings=buildings, land_cover=land, dem=dem, tree_canopy=canopy, extras=_extras)
73
-
74
- def run(self, cfg: PipelineConfig, building_gdf=None, terrain_gdf=None, **kwargs) -> VoxCity:
75
- os.makedirs(cfg.output_dir, exist_ok=True)
76
- land_strategy = LandCoverSourceFactory.create(cfg.land_cover_source)
77
- build_strategy = BuildingSourceFactory.create(cfg.building_source)
78
- canopy_strategy = CanopySourceFactory.create(cfg.canopy_height_source, cfg)
79
- dem_strategy = DemSourceFactory.create(cfg.dem_source)
80
-
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
- )
108
-
109
- ro = cfg.remove_perimeter_object
110
- if (ro is not None) and (ro > 0):
111
- w_peri = int(ro * bh.shape[0] + 0.5)
112
- h_peri = int(ro * bh.shape[1] + 0.5)
113
- canopy_top[:w_peri, :] = canopy_top[-w_peri:, :] = canopy_top[:, :h_peri] = canopy_top[:, -h_peri:] = 0
114
- canopy_bottom[:w_peri, :] = canopy_bottom[-w_peri:, :] = canopy_bottom[:, :h_peri] = canopy_bottom[:, -h_peri:] = 0
115
- ids1 = np.unique(bid[:w_peri, :][bid[:w_peri, :] > 0]); ids2 = np.unique(bid[-w_peri:, :][bid[-w_peri:, :] > 0])
116
- ids3 = np.unique(bid[:, :h_peri][bid[:, :h_peri] > 0]); ids4 = np.unique(bid[:, -h_peri:][bid[:, -h_peri:] > 0])
117
- for rid in np.concatenate((ids1, ids2, ids3, ids4)):
118
- pos = np.where(bid == rid)
119
- bh[pos] = 0
120
- bmin[pos] = [[] for _ in range(len(bmin[pos]))]
121
-
122
- voxelizer = Voxelizer(
123
- voxel_size=cfg.meshsize,
124
- land_cover_source=lc_src_effective,
125
- trunk_height_ratio=cfg.trunk_height_ratio,
126
- voxel_dtype=kwargs.get("voxel_dtype", np.int8),
127
- max_voxel_ram_mb=kwargs.get("max_voxel_ram_mb"),
128
- )
129
- vox = voxelizer.generate_combined(
130
- building_height_grid_ori=bh,
131
- building_min_height_grid_ori=bmin,
132
- building_id_grid_ori=bid,
133
- land_cover_grid_ori=land_cover_grid,
134
- dem_grid_ori=dem,
135
- tree_grid_ori=canopy_top,
136
- canopy_bottom_height_grid_ori=canopy_bottom,
137
- )
138
- return self.assemble_voxcity(
139
- voxcity_grid=vox,
140
- building_height_grid=bh,
141
- building_min_height_grid=bmin,
142
- building_id_grid=bid,
143
- land_cover_grid=land_cover_grid,
144
- dem_grid=dem,
145
- canopy_height_top=canopy_top,
146
- canopy_height_bottom=canopy_bottom,
147
- extras={
148
- "building_gdf": building_gdf_out,
149
- "land_cover_source": lc_src_effective,
150
- "building_source": cfg.building_source,
151
- "dem_source": cfg.dem_source,
152
- },
153
- )
154
-
155
-
156
- class LandCoverSourceStrategy: # ABC simplified to avoid dependency in split
157
- def build_grid(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs): # pragma: no cover - interface
158
- raise NotImplementedError
159
-
160
-
161
- class DefaultLandCoverStrategy(LandCoverSourceStrategy):
162
- def __init__(self, source: str) -> None:
163
- self.source = source
164
-
165
- def build_grid(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs):
166
- return get_land_cover_grid(rectangle_vertices, meshsize, self.source, output_dir, **kwargs)
167
-
168
-
169
- class LandCoverSourceFactory:
170
- @staticmethod
171
- def create(source: str) -> LandCoverSourceStrategy:
172
- return DefaultLandCoverStrategy(source)
173
-
174
-
175
- class BuildingSourceStrategy: # ABC simplified
176
- def build_grids(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs): # pragma: no cover - interface
177
- raise NotImplementedError
178
-
179
-
180
- class DefaultBuildingSourceStrategy(BuildingSourceStrategy):
181
- def __init__(self, source: str) -> None:
182
- self.source = source
183
-
184
- def build_grids(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs):
185
- return get_building_height_grid(rectangle_vertices, meshsize, self.source, output_dir, **kwargs)
186
-
187
-
188
- class BuildingSourceFactory:
189
- @staticmethod
190
- def create(source: str) -> BuildingSourceStrategy:
191
- return DefaultBuildingSourceStrategy(source)
192
-
193
-
194
- class CanopySourceStrategy: # ABC simplified
195
- def build_grids(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs): # pragma: no cover
196
- raise NotImplementedError
197
-
198
-
199
- class StaticCanopyStrategy(CanopySourceStrategy):
200
- def __init__(self, cfg: PipelineConfig) -> None:
201
- self.cfg = cfg
202
-
203
- def build_grids(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
204
- canopy_top = np.zeros_like(land_cover_grid, dtype=float)
205
- static_h = self.cfg.static_tree_height if self.cfg.static_tree_height is not None else kwargs.get("static_tree_height", 10.0)
206
- from ..utils.lc import get_land_cover_classes
207
- _classes = get_land_cover_classes(self.cfg.land_cover_source)
208
- _class_to_int = {name: i for i, name in enumerate(_classes.values())}
209
- _tree_labels = ["Tree", "Trees", "Tree Canopy"]
210
- _tree_idx = [_class_to_int[label] for label in _tree_labels if label in _class_to_int]
211
- tree_mask = np.isin(land_cover_grid, _tree_idx) if _tree_idx else np.zeros_like(land_cover_grid, dtype=bool)
212
- canopy_top[tree_mask] = static_h
213
- tr = self.cfg.trunk_height_ratio if self.cfg.trunk_height_ratio is not None else (11.76 / 19.98)
214
- canopy_bottom = canopy_top * float(tr)
215
- return canopy_top, canopy_bottom
216
-
217
-
218
- class SourceCanopyStrategy(CanopySourceStrategy):
219
- def __init__(self, source: str) -> None:
220
- self.source = source
221
-
222
- def build_grids(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
223
- # Provide land_cover_like for graceful fallback sizing without EE
224
- return get_canopy_height_grid(
225
- rectangle_vertices,
226
- meshsize,
227
- self.source,
228
- output_dir,
229
- land_cover_like=land_cover_grid,
230
- **kwargs,
231
- )
232
-
233
-
234
- class CanopySourceFactory:
235
- @staticmethod
236
- def create(source: str, cfg: PipelineConfig) -> CanopySourceStrategy:
237
- if source == "Static":
238
- return StaticCanopyStrategy(cfg)
239
- return SourceCanopyStrategy(source)
240
-
241
-
242
- class DemSourceStrategy: # ABC simplified
243
- def build_grid(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs): # pragma: no cover
244
- raise NotImplementedError
245
-
246
-
247
- class FlatDemStrategy(DemSourceStrategy):
248
- def build_grid(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
249
- return np.zeros_like(land_cover_grid)
250
-
251
-
252
- class SourceDemStrategy(DemSourceStrategy):
253
- def __init__(self, source: str) -> None:
254
- self.source = source
255
-
256
- def build_grid(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
257
- terrain_gdf = kwargs.get("terrain_gdf")
258
- if terrain_gdf is not None:
259
- from ..geoprocessor.raster import create_dem_grid_from_gdf_polygon
260
- return create_dem_grid_from_gdf_polygon(terrain_gdf, meshsize, rectangle_vertices)
261
- try:
262
- return get_dem_grid(rectangle_vertices, meshsize, self.source, output_dir, **kwargs)
263
- except Exception as e:
264
- # Fallback to flat DEM if source fails or unsupported
265
- logger = get_logger(__name__)
266
- logger.warning("DEM source '%s' failed (%s). Falling back to flat DEM.", self.source, e)
267
- return np.zeros_like(land_cover_grid)
268
-
269
-
270
- class DemSourceFactory:
271
- @staticmethod
272
- def create(source: str) -> DemSourceStrategy:
273
- # Normalize and auto-fallback: None/"none" -> Flat
274
- try:
275
- src_norm = (source or "").strip().lower()
276
- except Exception:
277
- src_norm = ""
278
- if (not source) or (src_norm in {"flat", "none", "null"}):
279
- return FlatDemStrategy()
280
- return SourceDemStrategy(source)
281
-
282
-
1
+ import os
2
+ from ..utils.logging import get_logger
3
+ from typing import Optional
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ import numpy as np
6
+
7
+ from ..models import (
8
+ GridMetadata,
9
+ BuildingGrid,
10
+ LandCoverGrid,
11
+ DemGrid,
12
+ VoxelGrid,
13
+ CanopyGrid,
14
+ VoxCity,
15
+ PipelineConfig,
16
+ )
17
+
18
+ from .grids import (
19
+ get_land_cover_grid,
20
+ get_building_height_grid,
21
+ get_canopy_height_grid,
22
+ get_dem_grid,
23
+ )
24
+ from .voxelizer import Voxelizer
25
+
26
+
27
+ class VoxCityPipeline:
28
+ def __init__(self, meshsize: float, rectangle_vertices, crs: str = "EPSG:4326") -> None:
29
+ self.meshsize = float(meshsize)
30
+ self.rectangle_vertices = rectangle_vertices
31
+ self.crs = crs
32
+
33
+ def _bounds(self):
34
+ xs = [p[0] for p in self.rectangle_vertices]
35
+ ys = [p[1] for p in self.rectangle_vertices]
36
+ return (min(xs), min(ys), max(xs), max(ys))
37
+
38
+ def _meta(self) -> GridMetadata:
39
+ return GridMetadata(crs=self.crs, bounds=self._bounds(), meshsize=self.meshsize)
40
+
41
+ def assemble_voxcity(
42
+ self,
43
+ voxcity_grid: np.ndarray,
44
+ building_height_grid: np.ndarray,
45
+ building_min_height_grid: np.ndarray,
46
+ building_id_grid: np.ndarray,
47
+ land_cover_grid: np.ndarray,
48
+ dem_grid: np.ndarray,
49
+ canopy_height_top: Optional[np.ndarray] = None,
50
+ canopy_height_bottom: Optional[np.ndarray] = None,
51
+ extras: Optional[dict] = None,
52
+ ) -> VoxCity:
53
+ meta = self._meta()
54
+ buildings = BuildingGrid(
55
+ heights=building_height_grid,
56
+ min_heights=building_min_height_grid,
57
+ ids=building_id_grid,
58
+ meta=meta,
59
+ )
60
+ land = LandCoverGrid(classes=land_cover_grid, meta=meta)
61
+ dem = DemGrid(elevation=dem_grid, meta=meta)
62
+ voxels = VoxelGrid(classes=voxcity_grid, meta=meta)
63
+ canopy = CanopyGrid(top=canopy_height_top if canopy_height_top is not None else np.zeros_like(land_cover_grid, dtype=float),
64
+ bottom=canopy_height_bottom,
65
+ meta=meta)
66
+ _extras = {
67
+ "rectangle_vertices": self.rectangle_vertices,
68
+ "canopy_top": canopy.top,
69
+ "canopy_bottom": canopy.bottom,
70
+ }
71
+ if extras:
72
+ _extras.update(extras)
73
+ return VoxCity(voxels=voxels, buildings=buildings, land_cover=land, dem=dem, tree_canopy=canopy, extras=_extras)
74
+
75
+ def run(self, cfg: PipelineConfig, building_gdf=None, terrain_gdf=None, **kwargs) -> VoxCity:
76
+ os.makedirs(cfg.output_dir, exist_ok=True)
77
+ land_strategy = LandCoverSourceFactory.create(cfg.land_cover_source)
78
+ build_strategy = BuildingSourceFactory.create(cfg.building_source)
79
+ canopy_strategy = CanopySourceFactory.create(cfg.canopy_height_source, cfg)
80
+ dem_strategy = DemSourceFactory.create(cfg.dem_source)
81
+
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
+ )
146
+
147
+ ro = cfg.remove_perimeter_object
148
+ if (ro is not None) and (ro > 0):
149
+ w_peri = int(ro * bh.shape[0] + 0.5)
150
+ h_peri = int(ro * bh.shape[1] + 0.5)
151
+ canopy_top[:w_peri, :] = canopy_top[-w_peri:, :] = canopy_top[:, :h_peri] = canopy_top[:, -h_peri:] = 0
152
+ canopy_bottom[:w_peri, :] = canopy_bottom[-w_peri:, :] = canopy_bottom[:, :h_peri] = canopy_bottom[:, -h_peri:] = 0
153
+ ids1 = np.unique(bid[:w_peri, :][bid[:w_peri, :] > 0]); ids2 = np.unique(bid[-w_peri:, :][bid[-w_peri:, :] > 0])
154
+ ids3 = np.unique(bid[:, :h_peri][bid[:, :h_peri] > 0]); ids4 = np.unique(bid[:, -h_peri:][bid[:, -h_peri:] > 0])
155
+ for rid in np.concatenate((ids1, ids2, ids3, ids4)):
156
+ pos = np.where(bid == rid)
157
+ bh[pos] = 0
158
+ bmin[pos] = [[] for _ in range(len(bmin[pos]))]
159
+
160
+ voxelizer = Voxelizer(
161
+ voxel_size=cfg.meshsize,
162
+ land_cover_source=lc_src_effective,
163
+ trunk_height_ratio=cfg.trunk_height_ratio,
164
+ voxel_dtype=kwargs.get("voxel_dtype", np.int8),
165
+ max_voxel_ram_mb=kwargs.get("max_voxel_ram_mb"),
166
+ )
167
+ vox = voxelizer.generate_combined(
168
+ building_height_grid_ori=bh,
169
+ building_min_height_grid_ori=bmin,
170
+ building_id_grid_ori=bid,
171
+ land_cover_grid_ori=land_cover_grid,
172
+ dem_grid_ori=dem,
173
+ tree_grid_ori=canopy_top,
174
+ canopy_bottom_height_grid_ori=canopy_bottom,
175
+ )
176
+ return self.assemble_voxcity(
177
+ voxcity_grid=vox,
178
+ building_height_grid=bh,
179
+ building_min_height_grid=bmin,
180
+ building_id_grid=bid,
181
+ land_cover_grid=land_cover_grid,
182
+ dem_grid=dem,
183
+ canopy_height_top=canopy_top,
184
+ canopy_height_bottom=canopy_bottom,
185
+ extras={
186
+ "building_gdf": building_gdf_out,
187
+ "land_cover_source": lc_src_effective,
188
+ "building_source": cfg.building_source,
189
+ "dem_source": cfg.dem_source,
190
+ },
191
+ )
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
+
445
+
446
+ class LandCoverSourceStrategy: # ABC simplified to avoid dependency in split
447
+ def build_grid(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs): # pragma: no cover - interface
448
+ raise NotImplementedError
449
+
450
+
451
+ class DefaultLandCoverStrategy(LandCoverSourceStrategy):
452
+ def __init__(self, source: str) -> None:
453
+ self.source = source
454
+
455
+ def build_grid(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs):
456
+ return get_land_cover_grid(rectangle_vertices, meshsize, self.source, output_dir, **kwargs)
457
+
458
+
459
+ class LandCoverSourceFactory:
460
+ @staticmethod
461
+ def create(source: str) -> LandCoverSourceStrategy:
462
+ return DefaultLandCoverStrategy(source)
463
+
464
+
465
+ class BuildingSourceStrategy: # ABC simplified
466
+ def build_grids(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs): # pragma: no cover - interface
467
+ raise NotImplementedError
468
+
469
+
470
+ class DefaultBuildingSourceStrategy(BuildingSourceStrategy):
471
+ def __init__(self, source: str) -> None:
472
+ self.source = source
473
+
474
+ def build_grids(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs):
475
+ return get_building_height_grid(rectangle_vertices, meshsize, self.source, output_dir, **kwargs)
476
+
477
+
478
+ class BuildingSourceFactory:
479
+ @staticmethod
480
+ def create(source: str) -> BuildingSourceStrategy:
481
+ return DefaultBuildingSourceStrategy(source)
482
+
483
+
484
+ class CanopySourceStrategy: # ABC simplified
485
+ def build_grids(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs): # pragma: no cover
486
+ raise NotImplementedError
487
+
488
+
489
+ class StaticCanopyStrategy(CanopySourceStrategy):
490
+ def __init__(self, cfg: PipelineConfig) -> None:
491
+ self.cfg = cfg
492
+
493
+ def build_grids(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
494
+ canopy_top = np.zeros_like(land_cover_grid, dtype=float)
495
+ static_h = self.cfg.static_tree_height if self.cfg.static_tree_height is not None else kwargs.get("static_tree_height", 10.0)
496
+ from ..utils.lc import get_land_cover_classes
497
+ _classes = get_land_cover_classes(self.cfg.land_cover_source)
498
+ _class_to_int = {name: i for i, name in enumerate(_classes.values())}
499
+ _tree_labels = ["Tree", "Trees", "Tree Canopy"]
500
+ _tree_idx = [_class_to_int[label] for label in _tree_labels if label in _class_to_int]
501
+ tree_mask = np.isin(land_cover_grid, _tree_idx) if _tree_idx else np.zeros_like(land_cover_grid, dtype=bool)
502
+ canopy_top[tree_mask] = static_h
503
+ tr = self.cfg.trunk_height_ratio if self.cfg.trunk_height_ratio is not None else (11.76 / 19.98)
504
+ canopy_bottom = canopy_top * float(tr)
505
+ return canopy_top, canopy_bottom
506
+
507
+
508
+ class SourceCanopyStrategy(CanopySourceStrategy):
509
+ def __init__(self, source: str) -> None:
510
+ self.source = source
511
+
512
+ def build_grids(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
513
+ # Provide land_cover_like for graceful fallback sizing without EE
514
+ return get_canopy_height_grid(
515
+ rectangle_vertices,
516
+ meshsize,
517
+ self.source,
518
+ output_dir,
519
+ land_cover_like=land_cover_grid,
520
+ **kwargs,
521
+ )
522
+
523
+
524
+ class CanopySourceFactory:
525
+ @staticmethod
526
+ def create(source: str, cfg: PipelineConfig) -> CanopySourceStrategy:
527
+ if source == "Static":
528
+ return StaticCanopyStrategy(cfg)
529
+ return SourceCanopyStrategy(source)
530
+
531
+
532
+ class DemSourceStrategy: # ABC simplified
533
+ def build_grid(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs): # pragma: no cover
534
+ raise NotImplementedError
535
+
536
+
537
+ class FlatDemStrategy(DemSourceStrategy):
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)
544
+ return np.zeros_like(land_cover_grid)
545
+
546
+
547
+ class SourceDemStrategy(DemSourceStrategy):
548
+ def __init__(self, source: str) -> None:
549
+ self.source = source
550
+
551
+ def build_grid(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
552
+ terrain_gdf = kwargs.get("terrain_gdf")
553
+ if terrain_gdf is not None:
554
+ from ..geoprocessor.raster import create_dem_grid_from_gdf_polygon
555
+ return create_dem_grid_from_gdf_polygon(terrain_gdf, meshsize, rectangle_vertices)
556
+ try:
557
+ return get_dem_grid(rectangle_vertices, meshsize, self.source, output_dir, **kwargs)
558
+ except Exception as e:
559
+ # Fallback to flat DEM if source fails or unsupported
560
+ logger = get_logger(__name__)
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)
567
+ return np.zeros_like(land_cover_grid)
568
+
569
+
570
+ class DemSourceFactory:
571
+ @staticmethod
572
+ def create(source: str) -> DemSourceStrategy:
573
+ # Normalize and auto-fallback: None/"none" -> Flat
574
+ try:
575
+ src_norm = (source or "").strip().lower()
576
+ except Exception:
577
+ src_norm = ""
578
+ if (not source) or (src_norm in {"flat", "none", "null"}):
579
+ return FlatDemStrategy()
580
+ return SourceDemStrategy(source)
581
+
582
+