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,634 +1,634 @@
1
- """
2
- CityLES export module for VoxCity
3
- Exports VoxCity grid data to CityLES input file format
4
- Updated 2025/08/05 with corrected land use and building material codes
5
- Integrated with VoxCity land cover utilities
6
-
7
- Notes:
8
- - This module expects raw land cover grids as produced per-source by VoxCity, not
9
- standardized/converted indices. Supported sources:
10
- 'OpenStreetMap', 'Urbanwatch', 'OpenEarthMapJapan', 'ESA WorldCover',
11
- 'ESRI 10m Annual Land Cover', 'Dynamic World V1'.
12
- """
13
-
14
- import os
15
- import numpy as np
16
- from pathlib import Path
17
- from ..models import VoxCity
18
-
19
-
20
- # VoxCity standard land cover classes after conversion
21
- # Based on convert_land_cover function output
22
- VOXCITY_STANDARD_CLASSES = {
23
- 0: 'Bareland',
24
- 1: 'Rangeland',
25
- 2: 'Shrub',
26
- 3: 'Agriculture land',
27
- 4: 'Tree',
28
- 5: 'Moss and lichen',
29
- 6: 'Wet land',
30
- 7: 'Mangrove',
31
- 8: 'Water',
32
- 9: 'Snow and ice',
33
- 10: 'Developed space',
34
- 11: 'Road',
35
- 12: 'Building',
36
- 13: 'No Data'
37
- }
38
-
39
- ## Source-specific class name to CityLES land use mappings
40
- # CityLES land use codes: 1=Water, 2=Rice Paddy, 3=Crops, 4=Grassland, 5=Deciduous Broadleaf Forest,
41
- # 9=Bare Land, 10=Building, 16=Asphalt (road), etc.
42
-
43
- # OpenStreetMap / Standard
44
- OSM_CLASS_TO_CITYLES = {
45
- 'Bareland': 9,
46
- 'Rangeland': 4,
47
- 'Shrub': 4,
48
- 'Moss and lichen': 4,
49
- 'Agriculture land': 3,
50
- 'Tree': 5,
51
- 'Wet land': 2,
52
- 'Mangroves': 5,
53
- 'Water': 1,
54
- 'Snow and ice': 9,
55
- 'Developed space': 10,
56
- 'Road': 16,
57
- 'Building': 10,
58
- 'No Data': 4
59
- }
60
-
61
- # Urbanwatch
62
- URBANWATCH_CLASS_TO_CITYLES = {
63
- 'Building': 10,
64
- 'Road': 16,
65
- 'Parking Lot': 16,
66
- 'Tree Canopy': 5,
67
- 'Grass/Shrub': 4,
68
- 'Agriculture': 3,
69
- 'Water': 1,
70
- 'Barren': 9,
71
- 'Unknown': 4,
72
- 'Sea': 1
73
- }
74
-
75
- # OpenEarthMapJapan
76
- OEMJ_CLASS_TO_CITYLES = {
77
- 'Bareland': 9,
78
- 'Rangeland': 4,
79
- 'Developed space': 10,
80
- 'Road': 16,
81
- 'Tree': 5,
82
- 'Water': 1,
83
- 'Agriculture land': 3,
84
- 'Building': 10
85
- }
86
-
87
- # ESA WorldCover
88
- ESA_CLASS_TO_CITYLES = {
89
- 'Trees': 5,
90
- 'Shrubland': 4,
91
- 'Grassland': 4,
92
- 'Cropland': 3,
93
- 'Built-up': 10,
94
- 'Barren / sparse vegetation': 9,
95
- 'Snow and ice': 9,
96
- 'Open water': 1,
97
- 'Herbaceous wetland': 2,
98
- 'Mangroves': 5,
99
- 'Moss and lichen': 9
100
- }
101
-
102
- # ESRI 10m Annual Land Cover
103
- ESRI_CLASS_TO_CITYLES = {
104
- 'No Data': 4,
105
- 'Water': 1,
106
- 'Trees': 5,
107
- 'Grass': 4,
108
- 'Flooded Vegetation': 2,
109
- 'Crops': 3,
110
- 'Scrub/Shrub': 4,
111
- 'Built Area': 10,
112
- 'Bare Ground': 9,
113
- 'Snow/Ice': 9,
114
- 'Clouds': 4
115
- }
116
-
117
- # Dynamic World V1
118
- DYNAMIC_WORLD_CLASS_TO_CITYLES = {
119
- 'Water': 1,
120
- 'Trees': 5,
121
- 'Grass': 4,
122
- 'Flooded Vegetation': 2,
123
- 'Crops': 3,
124
- 'Shrub and Scrub': 4,
125
- 'Built': 10,
126
- 'Bare': 9,
127
- 'Snow and Ice': 9
128
- }
129
-
130
- # Building material mapping based on corrected documentation
131
- BUILDING_MATERIAL_MAPPING = {
132
- 'building': 110, # Building (general)
133
- 'concrete': 110, # Building (concrete)
134
- 'residential': 111, # Old wooden house
135
- 'wooden': 111, # Old wooden house
136
- 'commercial': 110, # Building (commercial)
137
- 'industrial': 110, # Building (industrial)
138
- 'default': 110 # Default to general building
139
- }
140
-
141
- # Tree type mapping for vmap.txt
142
- TREE_TYPE_MAPPING = {
143
- 'deciduous': 101, # Leaf
144
- 'evergreen': 101, # Leaf (simplified)
145
- 'leaf': 101, # Leaf
146
- 'shade': 102, # Shade
147
- 'default': 101 # Default to leaf
148
- }
149
-
150
-
151
- def create_cityles_directories(output_directory):
152
- """Create necessary directories for CityLES output"""
153
- output_path = Path(output_directory)
154
- output_path.mkdir(parents=True, exist_ok=True)
155
- return output_path
156
-
157
-
158
- def _get_source_name_mapping(land_cover_source):
159
- """Return the class-name-to-CityLES mapping dictionary for the given source."""
160
- if land_cover_source == 'OpenStreetMap' or land_cover_source == 'Standard':
161
- return OSM_CLASS_TO_CITYLES
162
- if land_cover_source == 'Urbanwatch':
163
- return URBANWATCH_CLASS_TO_CITYLES
164
- if land_cover_source == 'OpenEarthMapJapan':
165
- return OEMJ_CLASS_TO_CITYLES
166
- if land_cover_source == 'ESA WorldCover':
167
- return ESA_CLASS_TO_CITYLES
168
- if land_cover_source == 'ESRI 10m Annual Land Cover':
169
- return ESRI_CLASS_TO_CITYLES
170
- if land_cover_source == 'Dynamic World V1':
171
- return DYNAMIC_WORLD_CLASS_TO_CITYLES
172
- # Default fallback
173
- return OSM_CLASS_TO_CITYLES
174
-
175
-
176
- def _build_index_to_cityles_map(land_cover_source):
177
- """Build mapping: raw per-source index -> CityLES code, using source class order."""
178
- try:
179
- from voxcity.utils.lc import get_land_cover_classes
180
- class_dict = get_land_cover_classes(land_cover_source)
181
- class_names = list(class_dict.values())
182
- except Exception:
183
- # Fallback: no class list; return empty so default is used
184
- class_names = []
185
-
186
- name_to_code = _get_source_name_mapping(land_cover_source)
187
- index_to_code = {}
188
- for idx, class_name in enumerate(class_names):
189
- index_to_code[idx] = name_to_code.get(class_name, 4)
190
- return index_to_code, class_names
191
-
192
-
193
- def _resolve_under_tree_code(under_tree_class_name, under_tree_cityles_code, land_cover_source):
194
- """Resolve the CityLES land-use code used under tree canopy.
195
-
196
- Priority:
197
- 1) Explicit numeric code if provided
198
- 2) Class name using the source-specific mapping
199
- 3) Class name using the standard (OSM) mapping
200
- 4) Default to 9 (Bare Land)
201
- """
202
- if under_tree_cityles_code is not None:
203
- try:
204
- return int(under_tree_cityles_code)
205
- except Exception:
206
- pass
207
- name_to_code = _get_source_name_mapping(land_cover_source)
208
- code = name_to_code.get(under_tree_class_name)
209
- if code is None:
210
- code = OSM_CLASS_TO_CITYLES.get(under_tree_class_name, 9)
211
- return code
212
-
213
-
214
- def export_topog(building_height_grid, building_id_grid, output_path,
215
- building_material='default', cityles_landuse_grid=None):
216
- """
217
- Export topog.txt file for CityLES
218
-
219
- Parameters:
220
- -----------
221
- building_height_grid : numpy.ndarray
222
- 2D array of building heights
223
- building_id_grid : numpy.ndarray
224
- 2D array of building IDs
225
- output_path : Path
226
- Output directory path
227
- building_material : str
228
- Building material type for mapping
229
- """
230
- filename = output_path / 'topog.txt'
231
-
232
- ny, nx = building_height_grid.shape
233
- material_code = BUILDING_MATERIAL_MAPPING.get(building_material,
234
- BUILDING_MATERIAL_MAPPING['default'])
235
-
236
- # Count only cells with building height > 0
237
- building_mask = building_height_grid > 0
238
- n_buildings = int(np.count_nonzero(building_mask))
239
-
240
- with open(filename, 'w') as f:
241
- # Write number of buildings
242
- f.write(f"{n_buildings}\n")
243
-
244
- # Write data for ALL grid points (buildings and non-buildings)
245
- for j in range(ny):
246
- for i in range(nx):
247
- # CityLES uses 1-based indexing
248
- i_1based = i + 1
249
- j_1based = j + 1
250
- height = float(building_height_grid[j, i])
251
- # Decide material code per cell
252
- if cityles_landuse_grid is not None:
253
- cell_lu = int(cityles_landuse_grid[j, i])
254
- material_code_cell = cell_lu + 100
255
- else:
256
- if height > 0:
257
- material_code_cell = material_code
258
- else:
259
- material_code_cell = 102
260
- # Format: i j height material_code depth1 depth2 changed_material
261
- f.write(f"{i_1based} {j_1based} {height:.1f} {material_code_cell} 0.0 0.0 102\n")
262
-
263
-
264
- def export_landuse(land_cover_grid, output_path, land_cover_source=None,
265
- canopy_height_grid=None, building_height_grid=None,
266
- under_tree_class_name='Bareland', under_tree_cityles_code=None):
267
- """
268
- Export landuse.txt file for CityLES
269
-
270
- Parameters:
271
- -----------
272
- land_cover_grid : numpy.ndarray
273
- 2D array of land cover values (may be raw or converted)
274
- output_path : Path
275
- Output directory path
276
- land_cover_source : str, optional
277
- Source of land cover data
278
- canopy_height_grid : numpy.ndarray, optional
279
- 2D array of canopy heights; if provided, cells with canopy (>0) will be
280
- assigned the ground class under the canopy instead of a tree class.
281
- building_height_grid : numpy.ndarray, optional
282
- 2D array of building heights; if provided, canopy overrides will not be
283
- applied where buildings exist (height > 0).
284
- under_tree_class_name : str, optional
285
- Name of ground land-cover class to use under tree canopy. Defaults to 'Bareland'.
286
- under_tree_cityles_code : int, optional
287
- Explicit CityLES land-use code to use under canopy; if provided it takes
288
- precedence over under_tree_class_name.
289
- """
290
- filename = output_path / 'landuse.txt'
291
-
292
- ny, nx = land_cover_grid.shape
293
-
294
- # Build per-source index mapping
295
- index_to_code, class_names = _build_index_to_cityles_map(land_cover_source)
296
-
297
- print(f"Land cover source: {land_cover_source} (raw indices)")
298
-
299
- # Resolve the CityLES code to use under tree canopy
300
- under_tree_code = _resolve_under_tree_code(
301
- under_tree_class_name, under_tree_cityles_code, land_cover_source
302
- )
303
-
304
- # Create mapping statistics: per raw index, count per resulting CityLES code
305
- mapping_stats = {}
306
- # Prepare grid to return
307
- cityles_landuse_grid = np.zeros((ny, nx), dtype=int)
308
-
309
- with open(filename, 'w') as f:
310
- # Write in row-major order (j varies first, then i)
311
- for j in range(ny):
312
- for i in range(nx):
313
- idx = int(land_cover_grid[j, i])
314
- cityles_code = index_to_code.get(idx, 4)
315
-
316
- # If a canopy grid is provided, override tree canopy cells to the
317
- # specified ground class, optionally skipping where buildings exist.
318
- if canopy_height_grid is not None:
319
- has_canopy = float(canopy_height_grid[j, i]) > 0.0
320
- has_building = False
321
- if building_height_grid is not None:
322
- has_building = float(building_height_grid[j, i]) > 0.0
323
- if has_canopy and not has_building:
324
- cityles_code = under_tree_code
325
- f.write(f"{cityles_code}\n")
326
-
327
- cityles_landuse_grid[j, i] = cityles_code
328
-
329
- # Track mapping statistics
330
- if idx not in mapping_stats:
331
- mapping_stats[idx] = {}
332
- mapping_stats[idx][cityles_code] = mapping_stats[idx].get(cityles_code, 0) + 1
333
-
334
- # Print mapping summary
335
- print("\nLand cover mapping summary (by source class):")
336
- total = ny * nx
337
- for idx in sorted(mapping_stats.keys()):
338
- class_name = class_names[idx] if 0 <= idx < len(class_names) else 'Unknown'
339
- for code, count in sorted(mapping_stats[idx].items()):
340
- percentage = (count / total) * 100
341
- print(f" {idx}: {class_name} -> CityLES {code}: {count} cells ({percentage:.1f}%)")
342
-
343
- return cityles_landuse_grid
344
-
345
-
346
- def export_dem(dem_grid, output_path):
347
- """
348
- Export dem.txt file for CityLES
349
-
350
- Parameters:
351
- -----------
352
- dem_grid : numpy.ndarray
353
- 2D array of elevation values
354
- output_path : Path
355
- Output directory path
356
- """
357
- filename = output_path / 'dem.txt'
358
-
359
- ny, nx = dem_grid.shape
360
-
361
- with open(filename, 'w') as f:
362
- for j in range(ny):
363
- for i in range(nx):
364
- # CityLES uses 1-based indexing
365
- i_1based = i + 1
366
- j_1based = j + 1
367
- elevation = float(dem_grid[j, i])
368
- # Clamp negative elevations to 0.0 meters
369
- if elevation < 0.0:
370
- elevation = 0.0
371
- f.write(f"{i_1based} {j_1based} {elevation:.1f}\n")
372
-
373
-
374
- def export_vmap(canopy_height_grid, output_path, trunk_height_ratio=0.3, tree_type='default', building_height_grid=None, canopy_bottom_height_grid=None):
375
- """
376
- Export vmap.txt file for CityLES
377
-
378
- Parameters:
379
- -----------
380
- canopy_height_grid : numpy.ndarray
381
- 2D array of canopy heights
382
- output_path : Path
383
- Output directory path
384
- trunk_height_ratio : float
385
- Ratio of tree base height to total canopy height
386
- tree_type : str
387
- Tree type for mapping
388
- """
389
- filename = output_path / 'vmap.txt'
390
-
391
- ny, nx = canopy_height_grid.shape
392
- tree_code = TREE_TYPE_MAPPING.get(tree_type, TREE_TYPE_MAPPING['default'])
393
-
394
- # If building heights are provided, remove trees where buildings exist
395
- if building_height_grid is not None:
396
- effective_canopy = np.where(building_height_grid > 0, 0.0, canopy_height_grid)
397
- else:
398
- effective_canopy = canopy_height_grid
399
-
400
- # Count only cells with canopy height > 0
401
- vegetation_mask = effective_canopy > 0
402
- n_trees = int(np.count_nonzero(vegetation_mask))
403
-
404
- with open(filename, 'w') as f:
405
- # Write number of trees
406
- f.write(f"{n_trees}\n")
407
-
408
- # Write data for ALL grid points (vegetation and non-vegetation)
409
- for j in range(ny):
410
- for i in range(nx):
411
- # CityLES uses 1-based indexing
412
- i_1based = i + 1
413
- j_1based = j + 1
414
- total_height = float(effective_canopy[j, i])
415
- if canopy_bottom_height_grid is not None:
416
- lower_height = float(np.clip(canopy_bottom_height_grid[j, i], 0.0, total_height))
417
- else:
418
- lower_height = total_height * trunk_height_ratio
419
- upper_height = total_height
420
- # Format: i j lower_height upper_height tree_type
421
- f.write(f"{i_1based} {j_1based} {lower_height:.1f} {upper_height:.1f} {tree_code}\n")
422
-
423
-
424
- def export_lonlat(rectangle_vertices, grid_shape, output_path):
425
- """
426
- Export lonlat.txt file for CityLES
427
-
428
- Parameters:
429
- -----------
430
- rectangle_vertices : list of tuples
431
- List of (lon, lat) vertices defining the area
432
- grid_shape : tuple
433
- Shape of the grid (ny, nx)
434
- output_path : Path
435
- Output directory path
436
- """
437
- filename = output_path / 'lonlat.txt'
438
-
439
- ny, nx = grid_shape
440
-
441
- # Extract bounds from vertices
442
- lons = [v[0] for v in rectangle_vertices]
443
- lats = [v[1] for v in rectangle_vertices]
444
- min_lon, max_lon = min(lons), max(lons)
445
- min_lat, max_lat = min(lats), max(lats)
446
-
447
- # Create coordinate grids
448
- lon_vals = np.linspace(min_lon, max_lon, nx)
449
- lat_vals = np.linspace(min_lat, max_lat, ny)
450
-
451
- with open(filename, 'w') as f:
452
- for j in range(ny):
453
- for i in range(nx):
454
- # CityLES uses 1-based indexing
455
- i_1based = i + 1
456
- j_1based = j + 1
457
- lon = lon_vals[i]
458
- lat = lat_vals[j]
459
-
460
- # Note: Format is i j longitude latitude (not latitude longitude)
461
- f.write(f"{i_1based} {j_1based} {lon:.7f} {lat:.8f}\n")
462
-
463
-
464
- def export_cityles(city: VoxCity,
465
- output_directory: str = "output/cityles",
466
- building_material: str = 'default',
467
- tree_type: str = 'default',
468
- trunk_height_ratio: float = 0.3,
469
- canopy_bottom_height_grid=None,
470
- under_tree_class_name: str = 'Bareland',
471
- under_tree_cityles_code=None,
472
- land_cover_source: str | None = None,
473
- **kwargs):
474
- """
475
- Export VoxCity data to CityLES format
476
-
477
- Parameters:
478
- -----------
479
- building_height_grid : numpy.ndarray
480
- 2D array of building heights
481
- building_id_grid : numpy.ndarray
482
- 2D array of building IDs
483
- canopy_height_grid : numpy.ndarray
484
- 2D array of canopy heights
485
- land_cover_grid : numpy.ndarray
486
- 2D array of land cover values (may be raw or VoxCity standard)
487
- dem_grid : numpy.ndarray
488
- 2D array of elevation values
489
- meshsize : float
490
- Grid cell size in meters
491
- land_cover_source : str
492
- Source of land cover data (e.g., 'ESRI 10m Annual Land Cover', 'ESA WorldCover')
493
- rectangle_vertices : list of tuples
494
- List of (lon, lat) vertices defining the area
495
- output_directory : str
496
- Output directory path
497
- building_material : str
498
- Building material type for mapping
499
- tree_type : str
500
- Tree type for mapping
501
- trunk_height_ratio : float
502
- Ratio of tree base height to total canopy height
503
- **kwargs : dict
504
- Additional parameters (for compatibility)
505
-
506
- Returns:
507
- --------
508
- str : Path to output directory
509
- """
510
- # Create output directory
511
- output_path = create_cityles_directories(output_directory)
512
-
513
- print(f"Exporting CityLES files to: {output_path}")
514
- # Resolve data from VoxCity
515
- building_height_grid = city.buildings.heights
516
- building_id_grid = city.buildings.ids if city.buildings.ids is not None else np.zeros_like(building_height_grid, dtype=int)
517
- canopy_height_grid = city.tree_canopy.top if city.tree_canopy is not None else np.zeros_like(city.land_cover.classes, dtype=float)
518
- land_cover_grid = city.land_cover.classes
519
- dem_grid = city.dem.elevation
520
- meshsize = float(city.voxels.meta.meshsize)
521
- rectangle_vertices = city.extras.get("rectangle_vertices") or [(0.0, 0.0)] * 4
522
- land_cover_source = land_cover_source or city.extras.get("land_cover_source", "Standard")
523
-
524
- print(f"Land cover source: {land_cover_source}")
525
-
526
- # Export individual files
527
- print("\nExporting landuse.txt...")
528
- cityles_landuse_grid = export_landuse(
529
- land_cover_grid,
530
- output_path,
531
- land_cover_source,
532
- canopy_height_grid=canopy_height_grid,
533
- building_height_grid=building_height_grid,
534
- under_tree_class_name=under_tree_class_name,
535
- under_tree_cityles_code=under_tree_cityles_code,
536
- )
537
-
538
- print("\nExporting topog.txt...")
539
- export_topog(
540
- building_height_grid,
541
- building_id_grid,
542
- output_path,
543
- building_material,
544
- cityles_landuse_grid=cityles_landuse_grid,
545
- )
546
-
547
- print("\nExporting dem.txt...")
548
- export_dem(dem_grid, output_path)
549
-
550
- print("\nExporting vmap.txt...")
551
- export_vmap(canopy_height_grid, output_path, trunk_height_ratio, tree_type, building_height_grid=building_height_grid, canopy_bottom_height_grid=canopy_bottom_height_grid)
552
-
553
- print("\nExporting lonlat.txt...")
554
- export_lonlat(rectangle_vertices, building_height_grid.shape, output_path)
555
-
556
- # Create metadata file for reference
557
- metadata_file = output_path / 'cityles_metadata.txt'
558
- with open(metadata_file, 'w') as f:
559
- f.write("CityLES Export Metadata\n")
560
- f.write("====================\n")
561
- f.write(f"Export date: 2025/08/05\n")
562
- f.write(f"Grid shape: {building_height_grid.shape}\n")
563
- f.write(f"Mesh size: {meshsize} m\n")
564
- f.write(f"Land cover source: {land_cover_source}\n")
565
- f.write(f"Building material: {building_material}\n")
566
- f.write(f"Tree type: {tree_type}\n")
567
- f.write(f"Bounds: {rectangle_vertices}\n")
568
- f.write(f"Buildings: {np.sum(building_height_grid > 0)}\n")
569
- # Trees count after removing overlaps with buildings
570
- trees_count = int(np.sum(np.where(building_height_grid > 0, 0.0, canopy_height_grid) > 0))
571
- f.write(f"Trees: {trees_count}\n")
572
- # Under-tree land-use selection
573
- under_tree_code = _resolve_under_tree_code(
574
- under_tree_class_name, under_tree_cityles_code, land_cover_source
575
- )
576
- f.write(f"Under-tree land use: {under_tree_class_name} (CityLES {under_tree_code})\n")
577
-
578
- # Add land use value ranges
579
- f.write(f"\nLand cover value range: {land_cover_grid.min()} - {land_cover_grid.max()}\n")
580
- unique_values = np.unique(land_cover_grid)
581
- f.write(f"Unique land cover values: {unique_values}\n")
582
-
583
- print(f"\nCityLES export completed successfully!")
584
- return str(output_path)
585
-
586
-
587
- class CityLesExporter:
588
- """Exporter adapter to write a VoxCity model to CityLES text files."""
589
-
590
- def export(self, obj, output_directory: str, base_filename: str, **kwargs):
591
- if not isinstance(obj, VoxCity):
592
- raise TypeError("CityLesExporter expects a VoxCity instance")
593
- city: VoxCity = obj
594
- # CityLES writes multiple files; use output_directory/base_filename as folder/name
595
- out_dir = os.path.join(output_directory, base_filename)
596
- os.makedirs(out_dir, exist_ok=True)
597
- export_cityles(
598
- city,
599
- output_directory=out_dir,
600
- **kwargs,
601
- )
602
- return out_dir
603
-
604
-
605
- # Helper function to apply VoxCity's convert_land_cover if needed
606
- def ensure_converted_land_cover(land_cover_grid, land_cover_source):
607
- """
608
- Ensure land cover grid uses VoxCity standard indices
609
-
610
- This function checks if the land cover data needs conversion and applies
611
- VoxCity's convert_land_cover function if necessary.
612
-
613
- Parameters:
614
- -----------
615
- land_cover_grid : numpy.ndarray
616
- 2D array of land cover values
617
- land_cover_source : str
618
- Source of land cover data
619
-
620
- Returns:
621
- --------
622
- numpy.ndarray : Land cover grid with VoxCity standard indices (0-13)
623
- """
624
- # Import VoxCity's convert function if available
625
- try:
626
- from voxcity.utils.lc import convert_land_cover
627
-
628
- # Apply conversion
629
- converted_grid = convert_land_cover(land_cover_grid, land_cover_source)
630
- print(f"Applied VoxCity land cover conversion for {land_cover_source}")
631
- return converted_grid
632
- except ImportError:
633
- print("Warning: Could not import VoxCity land cover utilities. Using direct mapping.")
1
+ """
2
+ CityLES export module for VoxCity
3
+ Exports VoxCity grid data to CityLES input file format
4
+ Updated 2025/08/05 with corrected land use and building material codes
5
+ Integrated with VoxCity land cover utilities
6
+
7
+ Notes:
8
+ - This module expects raw land cover grids as produced per-source by VoxCity, not
9
+ standardized/converted indices. Supported sources:
10
+ 'OpenStreetMap', 'Urbanwatch', 'OpenEarthMapJapan', 'ESA WorldCover',
11
+ 'ESRI 10m Annual Land Cover', 'Dynamic World V1'.
12
+ """
13
+
14
+ import os
15
+ import numpy as np
16
+ from pathlib import Path
17
+ from ..models import VoxCity
18
+
19
+
20
+ # VoxCity standard land cover classes after conversion
21
+ # Based on convert_land_cover function output (1-based indices)
22
+ VOXCITY_STANDARD_CLASSES = {
23
+ 1: 'Bareland',
24
+ 2: 'Rangeland',
25
+ 3: 'Shrub',
26
+ 4: 'Agriculture land',
27
+ 5: 'Tree',
28
+ 6: 'Moss and lichen',
29
+ 7: 'Wet land',
30
+ 8: 'Mangrove',
31
+ 9: 'Water',
32
+ 10: 'Snow and ice',
33
+ 11: 'Developed space',
34
+ 12: 'Road',
35
+ 13: 'Building',
36
+ 14: 'No Data'
37
+ }
38
+
39
+ ## Source-specific class name to CityLES land use mappings
40
+ # CityLES land use codes: 1=Water, 2=Rice Paddy, 3=Crops, 4=Grassland, 5=Deciduous Broadleaf Forest,
41
+ # 9=Bare Land, 10=Building, 16=Asphalt (road), etc.
42
+
43
+ # OpenStreetMap / Standard
44
+ OSM_CLASS_TO_CITYLES = {
45
+ 'Bareland': 9,
46
+ 'Rangeland': 4,
47
+ 'Shrub': 4,
48
+ 'Moss and lichen': 4,
49
+ 'Agriculture land': 3,
50
+ 'Tree': 5,
51
+ 'Wet land': 2,
52
+ 'Mangroves': 5,
53
+ 'Water': 1,
54
+ 'Snow and ice': 9,
55
+ 'Developed space': 10,
56
+ 'Road': 16,
57
+ 'Building': 10,
58
+ 'No Data': 4
59
+ }
60
+
61
+ # Urbanwatch
62
+ URBANWATCH_CLASS_TO_CITYLES = {
63
+ 'Building': 10,
64
+ 'Road': 16,
65
+ 'Parking Lot': 16,
66
+ 'Tree Canopy': 5,
67
+ 'Grass/Shrub': 4,
68
+ 'Agriculture': 3,
69
+ 'Water': 1,
70
+ 'Barren': 9,
71
+ 'Unknown': 4,
72
+ 'Sea': 1
73
+ }
74
+
75
+ # OpenEarthMapJapan
76
+ OEMJ_CLASS_TO_CITYLES = {
77
+ 'Bareland': 9,
78
+ 'Rangeland': 4,
79
+ 'Developed space': 10,
80
+ 'Road': 16,
81
+ 'Tree': 5,
82
+ 'Water': 1,
83
+ 'Agriculture land': 3,
84
+ 'Building': 10
85
+ }
86
+
87
+ # ESA WorldCover
88
+ ESA_CLASS_TO_CITYLES = {
89
+ 'Trees': 5,
90
+ 'Shrubland': 4,
91
+ 'Grassland': 4,
92
+ 'Cropland': 3,
93
+ 'Built-up': 10,
94
+ 'Barren / sparse vegetation': 9,
95
+ 'Snow and ice': 9,
96
+ 'Open water': 1,
97
+ 'Herbaceous wetland': 2,
98
+ 'Mangroves': 5,
99
+ 'Moss and lichen': 9
100
+ }
101
+
102
+ # ESRI 10m Annual Land Cover
103
+ ESRI_CLASS_TO_CITYLES = {
104
+ 'No Data': 4,
105
+ 'Water': 1,
106
+ 'Trees': 5,
107
+ 'Grass': 4,
108
+ 'Flooded Vegetation': 2,
109
+ 'Crops': 3,
110
+ 'Scrub/Shrub': 4,
111
+ 'Built Area': 10,
112
+ 'Bare Ground': 9,
113
+ 'Snow/Ice': 9,
114
+ 'Clouds': 4
115
+ }
116
+
117
+ # Dynamic World V1
118
+ DYNAMIC_WORLD_CLASS_TO_CITYLES = {
119
+ 'Water': 1,
120
+ 'Trees': 5,
121
+ 'Grass': 4,
122
+ 'Flooded Vegetation': 2,
123
+ 'Crops': 3,
124
+ 'Shrub and Scrub': 4,
125
+ 'Built': 10,
126
+ 'Bare': 9,
127
+ 'Snow and Ice': 9
128
+ }
129
+
130
+ # Building material mapping based on corrected documentation
131
+ BUILDING_MATERIAL_MAPPING = {
132
+ 'building': 110, # Building (general)
133
+ 'concrete': 110, # Building (concrete)
134
+ 'residential': 111, # Old wooden house
135
+ 'wooden': 111, # Old wooden house
136
+ 'commercial': 110, # Building (commercial)
137
+ 'industrial': 110, # Building (industrial)
138
+ 'default': 110 # Default to general building
139
+ }
140
+
141
+ # Tree type mapping for vmap.txt
142
+ TREE_TYPE_MAPPING = {
143
+ 'deciduous': 101, # Leaf
144
+ 'evergreen': 101, # Leaf (simplified)
145
+ 'leaf': 101, # Leaf
146
+ 'shade': 102, # Shade
147
+ 'default': 101 # Default to leaf
148
+ }
149
+
150
+
151
+ def create_cityles_directories(output_directory):
152
+ """Create necessary directories for CityLES output"""
153
+ output_path = Path(output_directory)
154
+ output_path.mkdir(parents=True, exist_ok=True)
155
+ return output_path
156
+
157
+
158
+ def _get_source_name_mapping(land_cover_source):
159
+ """Return the class-name-to-CityLES mapping dictionary for the given source."""
160
+ if land_cover_source == 'OpenStreetMap' or land_cover_source == 'Standard':
161
+ return OSM_CLASS_TO_CITYLES
162
+ if land_cover_source == 'Urbanwatch':
163
+ return URBANWATCH_CLASS_TO_CITYLES
164
+ if land_cover_source == 'OpenEarthMapJapan':
165
+ return OEMJ_CLASS_TO_CITYLES
166
+ if land_cover_source == 'ESA WorldCover':
167
+ return ESA_CLASS_TO_CITYLES
168
+ if land_cover_source == 'ESRI 10m Annual Land Cover':
169
+ return ESRI_CLASS_TO_CITYLES
170
+ if land_cover_source == 'Dynamic World V1':
171
+ return DYNAMIC_WORLD_CLASS_TO_CITYLES
172
+ # Default fallback
173
+ return OSM_CLASS_TO_CITYLES
174
+
175
+
176
+ def _build_index_to_cityles_map(land_cover_source):
177
+ """Build mapping: raw per-source index -> CityLES code, using source class order."""
178
+ try:
179
+ from voxcity.utils.lc import get_land_cover_classes
180
+ class_dict = get_land_cover_classes(land_cover_source)
181
+ class_names = list(class_dict.values())
182
+ except Exception:
183
+ # Fallback: no class list; return empty so default is used
184
+ class_names = []
185
+
186
+ name_to_code = _get_source_name_mapping(land_cover_source)
187
+ index_to_code = {}
188
+ for idx, class_name in enumerate(class_names):
189
+ index_to_code[idx] = name_to_code.get(class_name, 4)
190
+ return index_to_code, class_names
191
+
192
+
193
+ def _resolve_under_tree_code(under_tree_class_name, under_tree_cityles_code, land_cover_source):
194
+ """Resolve the CityLES land-use code used under tree canopy.
195
+
196
+ Priority:
197
+ 1) Explicit numeric code if provided
198
+ 2) Class name using the source-specific mapping
199
+ 3) Class name using the standard (OSM) mapping
200
+ 4) Default to 9 (Bare Land)
201
+ """
202
+ if under_tree_cityles_code is not None:
203
+ try:
204
+ return int(under_tree_cityles_code)
205
+ except Exception:
206
+ pass
207
+ name_to_code = _get_source_name_mapping(land_cover_source)
208
+ code = name_to_code.get(under_tree_class_name)
209
+ if code is None:
210
+ code = OSM_CLASS_TO_CITYLES.get(under_tree_class_name, 9)
211
+ return code
212
+
213
+
214
+ def export_topog(building_height_grid, building_id_grid, output_path,
215
+ building_material='default', cityles_landuse_grid=None):
216
+ """
217
+ Export topog.txt file for CityLES
218
+
219
+ Parameters:
220
+ -----------
221
+ building_height_grid : numpy.ndarray
222
+ 2D array of building heights
223
+ building_id_grid : numpy.ndarray
224
+ 2D array of building IDs
225
+ output_path : Path
226
+ Output directory path
227
+ building_material : str
228
+ Building material type for mapping
229
+ """
230
+ filename = output_path / 'topog.txt'
231
+
232
+ ny, nx = building_height_grid.shape
233
+ material_code = BUILDING_MATERIAL_MAPPING.get(building_material,
234
+ BUILDING_MATERIAL_MAPPING['default'])
235
+
236
+ # Count only cells with building height > 0
237
+ building_mask = building_height_grid > 0
238
+ n_buildings = int(np.count_nonzero(building_mask))
239
+
240
+ with open(filename, 'w') as f:
241
+ # Write number of buildings
242
+ f.write(f"{n_buildings}\n")
243
+
244
+ # Write data for ALL grid points (buildings and non-buildings)
245
+ for j in range(ny):
246
+ for i in range(nx):
247
+ # CityLES uses 1-based indexing
248
+ i_1based = i + 1
249
+ j_1based = j + 1
250
+ height = float(building_height_grid[j, i])
251
+ # Decide material code per cell
252
+ if cityles_landuse_grid is not None:
253
+ cell_lu = int(cityles_landuse_grid[j, i])
254
+ material_code_cell = cell_lu + 100
255
+ else:
256
+ if height > 0:
257
+ material_code_cell = material_code
258
+ else:
259
+ material_code_cell = 102
260
+ # Format: i j height material_code depth1 depth2 changed_material
261
+ f.write(f"{i_1based} {j_1based} {height:.1f} {material_code_cell} 0.0 0.0 102\n")
262
+
263
+
264
+ def export_landuse(land_cover_grid, output_path, land_cover_source=None,
265
+ canopy_height_grid=None, building_height_grid=None,
266
+ under_tree_class_name='Bareland', under_tree_cityles_code=None):
267
+ """
268
+ Export landuse.txt file for CityLES
269
+
270
+ Parameters:
271
+ -----------
272
+ land_cover_grid : numpy.ndarray
273
+ 2D array of land cover values (may be raw or converted)
274
+ output_path : Path
275
+ Output directory path
276
+ land_cover_source : str, optional
277
+ Source of land cover data
278
+ canopy_height_grid : numpy.ndarray, optional
279
+ 2D array of canopy heights; if provided, cells with canopy (>0) will be
280
+ assigned the ground class under the canopy instead of a tree class.
281
+ building_height_grid : numpy.ndarray, optional
282
+ 2D array of building heights; if provided, canopy overrides will not be
283
+ applied where buildings exist (height > 0).
284
+ under_tree_class_name : str, optional
285
+ Name of ground land-cover class to use under tree canopy. Defaults to 'Bareland'.
286
+ under_tree_cityles_code : int, optional
287
+ Explicit CityLES land-use code to use under canopy; if provided it takes
288
+ precedence over under_tree_class_name.
289
+ """
290
+ filename = output_path / 'landuse.txt'
291
+
292
+ ny, nx = land_cover_grid.shape
293
+
294
+ # Build per-source index mapping
295
+ index_to_code, class_names = _build_index_to_cityles_map(land_cover_source)
296
+
297
+ print(f"Land cover source: {land_cover_source} (raw indices)")
298
+
299
+ # Resolve the CityLES code to use under tree canopy
300
+ under_tree_code = _resolve_under_tree_code(
301
+ under_tree_class_name, under_tree_cityles_code, land_cover_source
302
+ )
303
+
304
+ # Create mapping statistics: per raw index, count per resulting CityLES code
305
+ mapping_stats = {}
306
+ # Prepare grid to return
307
+ cityles_landuse_grid = np.zeros((ny, nx), dtype=int)
308
+
309
+ with open(filename, 'w') as f:
310
+ # Write in row-major order (j varies first, then i)
311
+ for j in range(ny):
312
+ for i in range(nx):
313
+ idx = int(land_cover_grid[j, i])
314
+ cityles_code = index_to_code.get(idx, 4)
315
+
316
+ # If a canopy grid is provided, override tree canopy cells to the
317
+ # specified ground class, optionally skipping where buildings exist.
318
+ if canopy_height_grid is not None:
319
+ has_canopy = float(canopy_height_grid[j, i]) > 0.0
320
+ has_building = False
321
+ if building_height_grid is not None:
322
+ has_building = float(building_height_grid[j, i]) > 0.0
323
+ if has_canopy and not has_building:
324
+ cityles_code = under_tree_code
325
+ f.write(f"{cityles_code}\n")
326
+
327
+ cityles_landuse_grid[j, i] = cityles_code
328
+
329
+ # Track mapping statistics
330
+ if idx not in mapping_stats:
331
+ mapping_stats[idx] = {}
332
+ mapping_stats[idx][cityles_code] = mapping_stats[idx].get(cityles_code, 0) + 1
333
+
334
+ # Print mapping summary
335
+ print("\nLand cover mapping summary (by source class):")
336
+ total = ny * nx
337
+ for idx in sorted(mapping_stats.keys()):
338
+ class_name = class_names[idx] if 0 <= idx < len(class_names) else 'Unknown'
339
+ for code, count in sorted(mapping_stats[idx].items()):
340
+ percentage = (count / total) * 100
341
+ print(f" {idx}: {class_name} -> CityLES {code}: {count} cells ({percentage:.1f}%)")
342
+
343
+ return cityles_landuse_grid
344
+
345
+
346
+ def export_dem(dem_grid, output_path):
347
+ """
348
+ Export dem.txt file for CityLES
349
+
350
+ Parameters:
351
+ -----------
352
+ dem_grid : numpy.ndarray
353
+ 2D array of elevation values
354
+ output_path : Path
355
+ Output directory path
356
+ """
357
+ filename = output_path / 'dem.txt'
358
+
359
+ ny, nx = dem_grid.shape
360
+
361
+ with open(filename, 'w') as f:
362
+ for j in range(ny):
363
+ for i in range(nx):
364
+ # CityLES uses 1-based indexing
365
+ i_1based = i + 1
366
+ j_1based = j + 1
367
+ elevation = float(dem_grid[j, i])
368
+ # Clamp negative elevations to 0.0 meters
369
+ if elevation < 0.0:
370
+ elevation = 0.0
371
+ f.write(f"{i_1based} {j_1based} {elevation:.1f}\n")
372
+
373
+
374
+ def export_vmap(canopy_height_grid, output_path, trunk_height_ratio=0.3, tree_type='default', building_height_grid=None, canopy_bottom_height_grid=None):
375
+ """
376
+ Export vmap.txt file for CityLES
377
+
378
+ Parameters:
379
+ -----------
380
+ canopy_height_grid : numpy.ndarray
381
+ 2D array of canopy heights
382
+ output_path : Path
383
+ Output directory path
384
+ trunk_height_ratio : float
385
+ Ratio of tree base height to total canopy height
386
+ tree_type : str
387
+ Tree type for mapping
388
+ """
389
+ filename = output_path / 'vmap.txt'
390
+
391
+ ny, nx = canopy_height_grid.shape
392
+ tree_code = TREE_TYPE_MAPPING.get(tree_type, TREE_TYPE_MAPPING['default'])
393
+
394
+ # If building heights are provided, remove trees where buildings exist
395
+ if building_height_grid is not None:
396
+ effective_canopy = np.where(building_height_grid > 0, 0.0, canopy_height_grid)
397
+ else:
398
+ effective_canopy = canopy_height_grid
399
+
400
+ # Count only cells with canopy height > 0
401
+ vegetation_mask = effective_canopy > 0
402
+ n_trees = int(np.count_nonzero(vegetation_mask))
403
+
404
+ with open(filename, 'w') as f:
405
+ # Write number of trees
406
+ f.write(f"{n_trees}\n")
407
+
408
+ # Write data for ALL grid points (vegetation and non-vegetation)
409
+ for j in range(ny):
410
+ for i in range(nx):
411
+ # CityLES uses 1-based indexing
412
+ i_1based = i + 1
413
+ j_1based = j + 1
414
+ total_height = float(effective_canopy[j, i])
415
+ if canopy_bottom_height_grid is not None:
416
+ lower_height = float(np.clip(canopy_bottom_height_grid[j, i], 0.0, total_height))
417
+ else:
418
+ lower_height = total_height * trunk_height_ratio
419
+ upper_height = total_height
420
+ # Format: i j lower_height upper_height tree_type
421
+ f.write(f"{i_1based} {j_1based} {lower_height:.1f} {upper_height:.1f} {tree_code}\n")
422
+
423
+
424
+ def export_lonlat(rectangle_vertices, grid_shape, output_path):
425
+ """
426
+ Export lonlat.txt file for CityLES
427
+
428
+ Parameters:
429
+ -----------
430
+ rectangle_vertices : list of tuples
431
+ List of (lon, lat) vertices defining the area
432
+ grid_shape : tuple
433
+ Shape of the grid (ny, nx)
434
+ output_path : Path
435
+ Output directory path
436
+ """
437
+ filename = output_path / 'lonlat.txt'
438
+
439
+ ny, nx = grid_shape
440
+
441
+ # Extract bounds from vertices
442
+ lons = [v[0] for v in rectangle_vertices]
443
+ lats = [v[1] for v in rectangle_vertices]
444
+ min_lon, max_lon = min(lons), max(lons)
445
+ min_lat, max_lat = min(lats), max(lats)
446
+
447
+ # Create coordinate grids
448
+ lon_vals = np.linspace(min_lon, max_lon, nx)
449
+ lat_vals = np.linspace(min_lat, max_lat, ny)
450
+
451
+ with open(filename, 'w') as f:
452
+ for j in range(ny):
453
+ for i in range(nx):
454
+ # CityLES uses 1-based indexing
455
+ i_1based = i + 1
456
+ j_1based = j + 1
457
+ lon = lon_vals[i]
458
+ lat = lat_vals[j]
459
+
460
+ # Note: Format is i j longitude latitude (not latitude longitude)
461
+ f.write(f"{i_1based} {j_1based} {lon:.7f} {lat:.8f}\n")
462
+
463
+
464
+ def export_cityles(city: VoxCity,
465
+ output_directory: str = "output/cityles",
466
+ building_material: str = 'default',
467
+ tree_type: str = 'default',
468
+ trunk_height_ratio: float = 0.3,
469
+ canopy_bottom_height_grid=None,
470
+ under_tree_class_name: str = 'Bareland',
471
+ under_tree_cityles_code=None,
472
+ land_cover_source: str | None = None,
473
+ **kwargs):
474
+ """
475
+ Export VoxCity data to CityLES format
476
+
477
+ Parameters:
478
+ -----------
479
+ building_height_grid : numpy.ndarray
480
+ 2D array of building heights
481
+ building_id_grid : numpy.ndarray
482
+ 2D array of building IDs
483
+ canopy_height_grid : numpy.ndarray
484
+ 2D array of canopy heights
485
+ land_cover_grid : numpy.ndarray
486
+ 2D array of land cover values (may be raw or VoxCity standard)
487
+ dem_grid : numpy.ndarray
488
+ 2D array of elevation values
489
+ meshsize : float
490
+ Grid cell size in meters
491
+ land_cover_source : str
492
+ Source of land cover data (e.g., 'ESRI 10m Annual Land Cover', 'ESA WorldCover')
493
+ rectangle_vertices : list of tuples
494
+ List of (lon, lat) vertices defining the area
495
+ output_directory : str
496
+ Output directory path
497
+ building_material : str
498
+ Building material type for mapping
499
+ tree_type : str
500
+ Tree type for mapping
501
+ trunk_height_ratio : float
502
+ Ratio of tree base height to total canopy height
503
+ **kwargs : dict
504
+ Additional parameters (for compatibility)
505
+
506
+ Returns:
507
+ --------
508
+ str : Path to output directory
509
+ """
510
+ # Create output directory
511
+ output_path = create_cityles_directories(output_directory)
512
+
513
+ print(f"Exporting CityLES files to: {output_path}")
514
+ # Resolve data from VoxCity
515
+ building_height_grid = city.buildings.heights
516
+ building_id_grid = city.buildings.ids if city.buildings.ids is not None else np.zeros_like(building_height_grid, dtype=int)
517
+ canopy_height_grid = city.tree_canopy.top if city.tree_canopy is not None else np.zeros_like(city.land_cover.classes, dtype=float)
518
+ land_cover_grid = city.land_cover.classes
519
+ dem_grid = city.dem.elevation
520
+ meshsize = float(city.voxels.meta.meshsize)
521
+ rectangle_vertices = city.extras.get("rectangle_vertices") or [(0.0, 0.0)] * 4
522
+ land_cover_source = land_cover_source or city.extras.get("land_cover_source", "Standard")
523
+
524
+ print(f"Land cover source: {land_cover_source}")
525
+
526
+ # Export individual files
527
+ print("\nExporting landuse.txt...")
528
+ cityles_landuse_grid = export_landuse(
529
+ land_cover_grid,
530
+ output_path,
531
+ land_cover_source,
532
+ canopy_height_grid=canopy_height_grid,
533
+ building_height_grid=building_height_grid,
534
+ under_tree_class_name=under_tree_class_name,
535
+ under_tree_cityles_code=under_tree_cityles_code,
536
+ )
537
+
538
+ print("\nExporting topog.txt...")
539
+ export_topog(
540
+ building_height_grid,
541
+ building_id_grid,
542
+ output_path,
543
+ building_material,
544
+ cityles_landuse_grid=cityles_landuse_grid,
545
+ )
546
+
547
+ print("\nExporting dem.txt...")
548
+ export_dem(dem_grid, output_path)
549
+
550
+ print("\nExporting vmap.txt...")
551
+ export_vmap(canopy_height_grid, output_path, trunk_height_ratio, tree_type, building_height_grid=building_height_grid, canopy_bottom_height_grid=canopy_bottom_height_grid)
552
+
553
+ print("\nExporting lonlat.txt...")
554
+ export_lonlat(rectangle_vertices, building_height_grid.shape, output_path)
555
+
556
+ # Create metadata file for reference
557
+ metadata_file = output_path / 'cityles_metadata.txt'
558
+ with open(metadata_file, 'w') as f:
559
+ f.write("CityLES Export Metadata\n")
560
+ f.write("====================\n")
561
+ f.write(f"Export date: 2025/08/05\n")
562
+ f.write(f"Grid shape: {building_height_grid.shape}\n")
563
+ f.write(f"Mesh size: {meshsize} m\n")
564
+ f.write(f"Land cover source: {land_cover_source}\n")
565
+ f.write(f"Building material: {building_material}\n")
566
+ f.write(f"Tree type: {tree_type}\n")
567
+ f.write(f"Bounds: {rectangle_vertices}\n")
568
+ f.write(f"Buildings: {np.sum(building_height_grid > 0)}\n")
569
+ # Trees count after removing overlaps with buildings
570
+ trees_count = int(np.sum(np.where(building_height_grid > 0, 0.0, canopy_height_grid) > 0))
571
+ f.write(f"Trees: {trees_count}\n")
572
+ # Under-tree land-use selection
573
+ under_tree_code = _resolve_under_tree_code(
574
+ under_tree_class_name, under_tree_cityles_code, land_cover_source
575
+ )
576
+ f.write(f"Under-tree land use: {under_tree_class_name} (CityLES {under_tree_code})\n")
577
+
578
+ # Add land use value ranges
579
+ f.write(f"\nLand cover value range: {land_cover_grid.min()} - {land_cover_grid.max()}\n")
580
+ unique_values = np.unique(land_cover_grid)
581
+ f.write(f"Unique land cover values: {unique_values}\n")
582
+
583
+ print(f"\nCityLES export completed successfully!")
584
+ return str(output_path)
585
+
586
+
587
+ class CityLesExporter:
588
+ """Exporter adapter to write a VoxCity model to CityLES text files."""
589
+
590
+ def export(self, obj, output_directory: str, base_filename: str, **kwargs):
591
+ if not isinstance(obj, VoxCity):
592
+ raise TypeError("CityLesExporter expects a VoxCity instance")
593
+ city: VoxCity = obj
594
+ # CityLES writes multiple files; use output_directory/base_filename as folder/name
595
+ out_dir = os.path.join(output_directory, base_filename)
596
+ os.makedirs(out_dir, exist_ok=True)
597
+ export_cityles(
598
+ city,
599
+ output_directory=out_dir,
600
+ **kwargs,
601
+ )
602
+ return out_dir
603
+
604
+
605
+ # Helper function to apply VoxCity's convert_land_cover if needed
606
+ def ensure_converted_land_cover(land_cover_grid, land_cover_source):
607
+ """
608
+ Ensure land cover grid uses VoxCity standard indices
609
+
610
+ This function checks if the land cover data needs conversion and applies
611
+ VoxCity's convert_land_cover function if necessary.
612
+
613
+ Parameters:
614
+ -----------
615
+ land_cover_grid : numpy.ndarray
616
+ 2D array of land cover values
617
+ land_cover_source : str
618
+ Source of land cover data
619
+
620
+ Returns:
621
+ --------
622
+ numpy.ndarray : Land cover grid with VoxCity standard indices (0-13)
623
+ """
624
+ # Import VoxCity's convert function if available
625
+ try:
626
+ from voxcity.utils.lc import convert_land_cover
627
+
628
+ # Apply conversion
629
+ converted_grid = convert_land_cover(land_cover_grid, land_cover_source)
630
+ print(f"Applied VoxCity land cover conversion for {land_cover_source}")
631
+ return converted_grid
632
+ except ImportError:
633
+ print("Warning: Could not import VoxCity land cover utilities. Using direct mapping.")
634
634
  return land_cover_grid