voxcity 0.6.15__py3-none-any.whl → 0.7.0__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 (78) hide show
  1. voxcity/__init__.py +14 -8
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/citygml.py +32 -18
  4. voxcity/downloader/gba.py +210 -0
  5. voxcity/downloader/gee.py +5 -1
  6. voxcity/downloader/mbfp.py +1 -1
  7. voxcity/downloader/oemj.py +80 -8
  8. voxcity/downloader/osm.py +23 -7
  9. voxcity/downloader/overture.py +26 -1
  10. voxcity/downloader/utils.py +73 -73
  11. voxcity/errors.py +30 -0
  12. voxcity/exporter/__init__.py +13 -4
  13. voxcity/exporter/cityles.py +633 -535
  14. voxcity/exporter/envimet.py +728 -708
  15. voxcity/exporter/magicavoxel.py +334 -297
  16. voxcity/exporter/netcdf.py +238 -0
  17. voxcity/exporter/obj.py +1481 -655
  18. voxcity/generator/__init__.py +44 -0
  19. voxcity/generator/api.py +675 -0
  20. voxcity/generator/grids.py +379 -0
  21. voxcity/generator/io.py +94 -0
  22. voxcity/generator/pipeline.py +282 -0
  23. voxcity/generator/voxelizer.py +380 -0
  24. voxcity/geoprocessor/__init__.py +75 -6
  25. voxcity/geoprocessor/conversion.py +153 -0
  26. voxcity/geoprocessor/draw.py +62 -12
  27. voxcity/geoprocessor/heights.py +199 -0
  28. voxcity/geoprocessor/io.py +101 -0
  29. voxcity/geoprocessor/merge_utils.py +91 -0
  30. voxcity/geoprocessor/mesh.py +806 -790
  31. voxcity/geoprocessor/network.py +708 -679
  32. voxcity/geoprocessor/overlap.py +84 -0
  33. voxcity/geoprocessor/raster/__init__.py +82 -0
  34. voxcity/geoprocessor/raster/buildings.py +428 -0
  35. voxcity/geoprocessor/raster/canopy.py +258 -0
  36. voxcity/geoprocessor/raster/core.py +150 -0
  37. voxcity/geoprocessor/raster/export.py +93 -0
  38. voxcity/geoprocessor/raster/landcover.py +156 -0
  39. voxcity/geoprocessor/raster/raster.py +110 -0
  40. voxcity/geoprocessor/selection.py +85 -0
  41. voxcity/geoprocessor/utils.py +18 -14
  42. voxcity/models.py +113 -0
  43. voxcity/simulator/common/__init__.py +22 -0
  44. voxcity/simulator/common/geometry.py +98 -0
  45. voxcity/simulator/common/raytracing.py +450 -0
  46. voxcity/simulator/solar/__init__.py +43 -0
  47. voxcity/simulator/solar/integration.py +336 -0
  48. voxcity/simulator/solar/kernels.py +62 -0
  49. voxcity/simulator/solar/radiation.py +648 -0
  50. voxcity/simulator/solar/temporal.py +434 -0
  51. voxcity/simulator/view.py +36 -2286
  52. voxcity/simulator/visibility/__init__.py +29 -0
  53. voxcity/simulator/visibility/landmark.py +392 -0
  54. voxcity/simulator/visibility/view.py +508 -0
  55. voxcity/utils/logging.py +61 -0
  56. voxcity/utils/orientation.py +51 -0
  57. voxcity/utils/weather/__init__.py +26 -0
  58. voxcity/utils/weather/epw.py +146 -0
  59. voxcity/utils/weather/files.py +36 -0
  60. voxcity/utils/weather/onebuilding.py +486 -0
  61. voxcity/visualizer/__init__.py +24 -0
  62. voxcity/visualizer/builder.py +43 -0
  63. voxcity/visualizer/grids.py +141 -0
  64. voxcity/visualizer/maps.py +187 -0
  65. voxcity/visualizer/palette.py +228 -0
  66. voxcity/visualizer/renderer.py +928 -0
  67. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/METADATA +113 -36
  68. voxcity-0.7.0.dist-info/RECORD +77 -0
  69. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/WHEEL +1 -1
  70. voxcity/generator.py +0 -1137
  71. voxcity/geoprocessor/grid.py +0 -1568
  72. voxcity/geoprocessor/polygon.py +0 -1344
  73. voxcity/simulator/solar.py +0 -2329
  74. voxcity/utils/visualization.py +0 -2660
  75. voxcity/utils/weather.py +0 -817
  76. voxcity-0.6.15.dist-info/RECORD +0 -37
  77. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/AUTHORS.rst +0 -0
  78. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/LICENSE +0 -0
@@ -1,536 +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
-
18
-
19
- # VoxCity standard land cover classes after conversion
20
- # Based on convert_land_cover function output
21
- VOXCITY_STANDARD_CLASSES = {
22
- 0: 'Bareland',
23
- 1: 'Rangeland',
24
- 2: 'Shrub',
25
- 3: 'Agriculture land',
26
- 4: 'Tree',
27
- 5: 'Moss and lichen',
28
- 6: 'Wet land',
29
- 7: 'Mangrove',
30
- 8: 'Water',
31
- 9: 'Snow and ice',
32
- 10: 'Developed space',
33
- 11: 'Road',
34
- 12: 'Building',
35
- 13: 'No Data'
36
- }
37
-
38
- ## Source-specific class name to CityLES land use mappings
39
- # CityLES land use codes: 1=Water, 2=Rice Paddy, 3=Crops, 4=Grassland, 5=Deciduous Broadleaf Forest,
40
- # 9=Bare Land, 10=Building, 16=Asphalt (road), etc.
41
-
42
- # OpenStreetMap / Standard
43
- OSM_CLASS_TO_CITYLES = {
44
- 'Bareland': 9,
45
- 'Rangeland': 4,
46
- 'Shrub': 4,
47
- 'Moss and lichen': 4,
48
- 'Agriculture land': 3,
49
- 'Tree': 5,
50
- 'Wet land': 2,
51
- 'Mangroves': 5,
52
- 'Water': 1,
53
- 'Snow and ice': 9,
54
- 'Developed space': 10,
55
- 'Road': 16,
56
- 'Building': 10,
57
- 'No Data': 4
58
- }
59
-
60
- # Urbanwatch
61
- URBANWATCH_CLASS_TO_CITYLES = {
62
- 'Building': 10,
63
- 'Road': 16,
64
- 'Parking Lot': 16,
65
- 'Tree Canopy': 5,
66
- 'Grass/Shrub': 4,
67
- 'Agriculture': 3,
68
- 'Water': 1,
69
- 'Barren': 9,
70
- 'Unknown': 4,
71
- 'Sea': 1
72
- }
73
-
74
- # OpenEarthMapJapan
75
- OEMJ_CLASS_TO_CITYLES = {
76
- 'Bareland': 9,
77
- 'Rangeland': 4,
78
- 'Developed space': 10,
79
- 'Road': 16,
80
- 'Tree': 5,
81
- 'Water': 1,
82
- 'Agriculture land': 3,
83
- 'Building': 10
84
- }
85
-
86
- # ESA WorldCover
87
- ESA_CLASS_TO_CITYLES = {
88
- 'Trees': 5,
89
- 'Shrubland': 4,
90
- 'Grassland': 4,
91
- 'Cropland': 3,
92
- 'Built-up': 10,
93
- 'Barren / sparse vegetation': 9,
94
- 'Snow and ice': 9,
95
- 'Open water': 1,
96
- 'Herbaceous wetland': 2,
97
- 'Mangroves': 5,
98
- 'Moss and lichen': 9
99
- }
100
-
101
- # ESRI 10m Annual Land Cover
102
- ESRI_CLASS_TO_CITYLES = {
103
- 'No Data': 4,
104
- 'Water': 1,
105
- 'Trees': 5,
106
- 'Grass': 4,
107
- 'Flooded Vegetation': 2,
108
- 'Crops': 3,
109
- 'Scrub/Shrub': 4,
110
- 'Built Area': 10,
111
- 'Bare Ground': 9,
112
- 'Snow/Ice': 9,
113
- 'Clouds': 4
114
- }
115
-
116
- # Dynamic World V1
117
- DYNAMIC_WORLD_CLASS_TO_CITYLES = {
118
- 'Water': 1,
119
- 'Trees': 5,
120
- 'Grass': 4,
121
- 'Flooded Vegetation': 2,
122
- 'Crops': 3,
123
- 'Shrub and Scrub': 4,
124
- 'Built': 10,
125
- 'Bare': 9,
126
- 'Snow and Ice': 9
127
- }
128
-
129
- # Building material mapping based on corrected documentation
130
- BUILDING_MATERIAL_MAPPING = {
131
- 'building': 110, # Building (general)
132
- 'concrete': 110, # Building (concrete)
133
- 'residential': 111, # Old wooden house
134
- 'wooden': 111, # Old wooden house
135
- 'commercial': 110, # Building (commercial)
136
- 'industrial': 110, # Building (industrial)
137
- 'default': 110 # Default to general building
138
- }
139
-
140
- # Tree type mapping for vmap.txt
141
- TREE_TYPE_MAPPING = {
142
- 'deciduous': 101, # Leaf
143
- 'evergreen': 101, # Leaf (simplified)
144
- 'leaf': 101, # Leaf
145
- 'shade': 102, # Shade
146
- 'default': 101 # Default to leaf
147
- }
148
-
149
-
150
- def create_cityles_directories(output_directory):
151
- """Create necessary directories for CityLES output"""
152
- output_path = Path(output_directory)
153
- output_path.mkdir(parents=True, exist_ok=True)
154
- return output_path
155
-
156
-
157
- def _get_source_name_mapping(land_cover_source):
158
- """Return the class-name-to-CityLES mapping dictionary for the given source."""
159
- if land_cover_source == 'OpenStreetMap' or land_cover_source == 'Standard':
160
- return OSM_CLASS_TO_CITYLES
161
- if land_cover_source == 'Urbanwatch':
162
- return URBANWATCH_CLASS_TO_CITYLES
163
- if land_cover_source == 'OpenEarthMapJapan':
164
- return OEMJ_CLASS_TO_CITYLES
165
- if land_cover_source == 'ESA WorldCover':
166
- return ESA_CLASS_TO_CITYLES
167
- if land_cover_source == 'ESRI 10m Annual Land Cover':
168
- return ESRI_CLASS_TO_CITYLES
169
- if land_cover_source == 'Dynamic World V1':
170
- return DYNAMIC_WORLD_CLASS_TO_CITYLES
171
- # Default fallback
172
- return OSM_CLASS_TO_CITYLES
173
-
174
-
175
- def _build_index_to_cityles_map(land_cover_source):
176
- """Build mapping: raw per-source index -> CityLES code, using source class order."""
177
- try:
178
- from voxcity.utils.lc import get_land_cover_classes
179
- class_dict = get_land_cover_classes(land_cover_source)
180
- class_names = list(class_dict.values())
181
- except Exception:
182
- # Fallback: no class list; return empty so default is used
183
- class_names = []
184
-
185
- name_to_code = _get_source_name_mapping(land_cover_source)
186
- index_to_code = {}
187
- for idx, class_name in enumerate(class_names):
188
- index_to_code[idx] = name_to_code.get(class_name, 4)
189
- return index_to_code, class_names
190
-
191
-
192
- def export_topog(building_height_grid, building_id_grid, output_path,
193
- building_material='default', cityles_landuse_grid=None):
194
- """
195
- Export topog.txt file for CityLES
196
-
197
- Parameters:
198
- -----------
199
- building_height_grid : numpy.ndarray
200
- 2D array of building heights
201
- building_id_grid : numpy.ndarray
202
- 2D array of building IDs
203
- output_path : Path
204
- Output directory path
205
- building_material : str
206
- Building material type for mapping
207
- """
208
- filename = output_path / 'topog.txt'
209
-
210
- ny, nx = building_height_grid.shape
211
- material_code = BUILDING_MATERIAL_MAPPING.get(building_material,
212
- BUILDING_MATERIAL_MAPPING['default'])
213
-
214
- # Count only cells with building height > 0
215
- building_mask = building_height_grid > 0
216
- n_buildings = int(np.count_nonzero(building_mask))
217
-
218
- with open(filename, 'w') as f:
219
- # Write number of buildings
220
- f.write(f"{n_buildings}\n")
221
-
222
- # Write data for ALL grid points (buildings and non-buildings)
223
- for j in range(ny):
224
- for i in range(nx):
225
- # CityLES uses 1-based indexing
226
- i_1based = i + 1
227
- j_1based = j + 1
228
- height = float(building_height_grid[j, i])
229
- # Decide material code per cell
230
- if cityles_landuse_grid is not None:
231
- cell_lu = int(cityles_landuse_grid[j, i])
232
- material_code_cell = cell_lu + 100
233
- else:
234
- if height > 0:
235
- material_code_cell = material_code
236
- else:
237
- material_code_cell = 102
238
- # Format: i j height material_code depth1 depth2 changed_material
239
- f.write(f"{i_1based} {j_1based} {height:.1f} {material_code_cell} 0.0 0.0 102\n")
240
-
241
-
242
- def export_landuse(land_cover_grid, output_path, land_cover_source=None):
243
- """
244
- Export landuse.txt file for CityLES
245
-
246
- Parameters:
247
- -----------
248
- land_cover_grid : numpy.ndarray
249
- 2D array of land cover values (may be raw or converted)
250
- output_path : Path
251
- Output directory path
252
- land_cover_source : str, optional
253
- Source of land cover data
254
- """
255
- filename = output_path / 'landuse.txt'
256
-
257
- ny, nx = land_cover_grid.shape
258
-
259
- # Build per-source index mapping
260
- index_to_code, class_names = _build_index_to_cityles_map(land_cover_source)
261
-
262
- print(f"Land cover source: {land_cover_source} (raw indices)")
263
-
264
- # Create mapping statistics
265
- mapping_stats = {}
266
- # Prepare grid to return
267
- cityles_landuse_grid = np.zeros((ny, nx), dtype=int)
268
-
269
- with open(filename, 'w') as f:
270
- # Write in row-major order (j varies first, then i)
271
- for j in range(ny):
272
- for i in range(nx):
273
- idx = int(land_cover_grid[j, i])
274
- cityles_code = index_to_code.get(idx, 4)
275
- f.write(f"{cityles_code}\n")
276
-
277
- cityles_landuse_grid[j, i] = cityles_code
278
-
279
- # Track mapping statistics
280
- if idx not in mapping_stats:
281
- mapping_stats[idx] = {'cityles_code': cityles_code, 'count': 0}
282
- mapping_stats[idx]['count'] += 1
283
-
284
- # Print mapping summary
285
- print("\nLand cover mapping summary (by source class):")
286
- total = ny * nx
287
- for idx in sorted(mapping_stats.keys()):
288
- stats = mapping_stats[idx]
289
- percentage = (stats['count'] / total) * 100
290
- class_name = class_names[idx] if 0 <= idx < len(class_names) else 'Unknown'
291
- print(f" {idx}: {class_name} -> CityLES {stats['cityles_code']}: "
292
- f"{stats['count']} cells ({percentage:.1f}%)")
293
-
294
- return cityles_landuse_grid
295
-
296
-
297
- def export_dem(dem_grid, output_path):
298
- """
299
- Export dem.txt file for CityLES
300
-
301
- Parameters:
302
- -----------
303
- dem_grid : numpy.ndarray
304
- 2D array of elevation values
305
- output_path : Path
306
- Output directory path
307
- """
308
- filename = output_path / 'dem.txt'
309
-
310
- ny, nx = dem_grid.shape
311
-
312
- with open(filename, 'w') as f:
313
- for j in range(ny):
314
- for i in range(nx):
315
- # CityLES uses 1-based indexing
316
- i_1based = i + 1
317
- j_1based = j + 1
318
- elevation = float(dem_grid[j, i])
319
- # Clamp negative elevations to 0.0 meters
320
- if elevation < 0.0:
321
- elevation = 0.0
322
- f.write(f"{i_1based} {j_1based} {elevation:.1f}\n")
323
-
324
-
325
- def export_vmap(canopy_height_grid, output_path, tree_base_ratio=0.3, tree_type='default', building_height_grid=None):
326
- """
327
- Export vmap.txt file for CityLES
328
-
329
- Parameters:
330
- -----------
331
- canopy_height_grid : numpy.ndarray
332
- 2D array of canopy heights
333
- output_path : Path
334
- Output directory path
335
- tree_base_ratio : float
336
- Ratio of tree base height to total canopy height
337
- tree_type : str
338
- Tree type for mapping
339
- """
340
- filename = output_path / 'vmap.txt'
341
-
342
- ny, nx = canopy_height_grid.shape
343
- tree_code = TREE_TYPE_MAPPING.get(tree_type, TREE_TYPE_MAPPING['default'])
344
-
345
- # If building heights are provided, remove trees where buildings exist
346
- if building_height_grid is not None:
347
- effective_canopy = np.where(building_height_grid > 0, 0.0, canopy_height_grid)
348
- else:
349
- effective_canopy = canopy_height_grid
350
-
351
- # Count only cells with canopy height > 0
352
- vegetation_mask = effective_canopy > 0
353
- n_trees = int(np.count_nonzero(vegetation_mask))
354
-
355
- with open(filename, 'w') as f:
356
- # Write number of trees
357
- f.write(f"{n_trees}\n")
358
-
359
- # Write data for ALL grid points (vegetation and non-vegetation)
360
- for j in range(ny):
361
- for i in range(nx):
362
- # CityLES uses 1-based indexing
363
- i_1based = i + 1
364
- j_1based = j + 1
365
- total_height = float(effective_canopy[j, i])
366
- lower_height = total_height * tree_base_ratio
367
- upper_height = total_height
368
- # Format: i j lower_height upper_height tree_type
369
- f.write(f"{i_1based} {j_1based} {lower_height:.1f} {upper_height:.1f} {tree_code}\n")
370
-
371
-
372
- def export_lonlat(rectangle_vertices, grid_shape, output_path):
373
- """
374
- Export lonlat.txt file for CityLES
375
-
376
- Parameters:
377
- -----------
378
- rectangle_vertices : list of tuples
379
- List of (lon, lat) vertices defining the area
380
- grid_shape : tuple
381
- Shape of the grid (ny, nx)
382
- output_path : Path
383
- Output directory path
384
- """
385
- filename = output_path / 'lonlat.txt'
386
-
387
- ny, nx = grid_shape
388
-
389
- # Extract bounds from vertices
390
- lons = [v[0] for v in rectangle_vertices]
391
- lats = [v[1] for v in rectangle_vertices]
392
- min_lon, max_lon = min(lons), max(lons)
393
- min_lat, max_lat = min(lats), max(lats)
394
-
395
- # Create coordinate grids
396
- lon_vals = np.linspace(min_lon, max_lon, nx)
397
- lat_vals = np.linspace(min_lat, max_lat, ny)
398
-
399
- with open(filename, 'w') as f:
400
- for j in range(ny):
401
- for i in range(nx):
402
- # CityLES uses 1-based indexing
403
- i_1based = i + 1
404
- j_1based = j + 1
405
- lon = lon_vals[i]
406
- lat = lat_vals[j]
407
-
408
- # Note: Format is i j longitude latitude (not latitude longitude)
409
- f.write(f"{i_1based} {j_1based} {lon:.7f} {lat:.8f}\n")
410
-
411
-
412
- def export_cityles(building_height_grid, building_id_grid, canopy_height_grid,
413
- land_cover_grid, dem_grid, meshsize, land_cover_source,
414
- rectangle_vertices, output_directory="output/cityles",
415
- building_material='default', tree_type='default',
416
- tree_base_ratio=0.3, **kwargs):
417
- """
418
- Export VoxCity data to CityLES format
419
-
420
- Parameters:
421
- -----------
422
- building_height_grid : numpy.ndarray
423
- 2D array of building heights
424
- building_id_grid : numpy.ndarray
425
- 2D array of building IDs
426
- canopy_height_grid : numpy.ndarray
427
- 2D array of canopy heights
428
- land_cover_grid : numpy.ndarray
429
- 2D array of land cover values (may be raw or VoxCity standard)
430
- dem_grid : numpy.ndarray
431
- 2D array of elevation values
432
- meshsize : float
433
- Grid cell size in meters
434
- land_cover_source : str
435
- Source of land cover data (e.g., 'ESRI 10m Annual Land Cover', 'ESA WorldCover')
436
- rectangle_vertices : list of tuples
437
- List of (lon, lat) vertices defining the area
438
- output_directory : str
439
- Output directory path
440
- building_material : str
441
- Building material type for mapping
442
- tree_type : str
443
- Tree type for mapping
444
- tree_base_ratio : float
445
- Ratio of tree base height to total canopy height
446
- **kwargs : dict
447
- Additional parameters (for compatibility)
448
-
449
- Returns:
450
- --------
451
- str : Path to output directory
452
- """
453
- # Create output directory
454
- output_path = create_cityles_directories(output_directory)
455
-
456
- print(f"Exporting CityLES files to: {output_path}")
457
- print(f"Land cover source: {land_cover_source}")
458
-
459
- # Export individual files
460
- print("\nExporting landuse.txt...")
461
- cityles_landuse_grid = export_landuse(land_cover_grid, output_path, land_cover_source)
462
-
463
- print("\nExporting topog.txt...")
464
- export_topog(
465
- building_height_grid,
466
- building_id_grid,
467
- output_path,
468
- building_material,
469
- cityles_landuse_grid=cityles_landuse_grid,
470
- )
471
-
472
- print("\nExporting dem.txt...")
473
- export_dem(dem_grid, output_path)
474
-
475
- print("\nExporting vmap.txt...")
476
- export_vmap(canopy_height_grid, output_path, tree_base_ratio, tree_type, building_height_grid=building_height_grid)
477
-
478
- print("\nExporting lonlat.txt...")
479
- export_lonlat(rectangle_vertices, building_height_grid.shape, output_path)
480
-
481
- # Create metadata file for reference
482
- metadata_file = output_path / 'cityles_metadata.txt'
483
- with open(metadata_file, 'w') as f:
484
- f.write("CityLES Export Metadata\n")
485
- f.write("====================\n")
486
- f.write(f"Export date: 2025/08/05\n")
487
- f.write(f"Grid shape: {building_height_grid.shape}\n")
488
- f.write(f"Mesh size: {meshsize} m\n")
489
- f.write(f"Land cover source: {land_cover_source}\n")
490
- f.write(f"Building material: {building_material}\n")
491
- f.write(f"Tree type: {tree_type}\n")
492
- f.write(f"Bounds: {rectangle_vertices}\n")
493
- f.write(f"Buildings: {np.sum(building_height_grid > 0)}\n")
494
- # Trees count after removing overlaps with buildings
495
- trees_count = int(np.sum(np.where(building_height_grid > 0, 0.0, canopy_height_grid) > 0))
496
- f.write(f"Trees: {trees_count}\n")
497
-
498
- # Add land use value ranges
499
- f.write(f"\nLand cover value range: {land_cover_grid.min()} - {land_cover_grid.max()}\n")
500
- unique_values = np.unique(land_cover_grid)
501
- f.write(f"Unique land cover values: {unique_values}\n")
502
-
503
- print(f"\nCityLES export completed successfully!")
504
- return str(output_path)
505
-
506
-
507
- # Helper function to apply VoxCity's convert_land_cover if needed
508
- def ensure_converted_land_cover(land_cover_grid, land_cover_source):
509
- """
510
- Ensure land cover grid uses VoxCity standard indices
511
-
512
- This function checks if the land cover data needs conversion and applies
513
- VoxCity's convert_land_cover function if necessary.
514
-
515
- Parameters:
516
- -----------
517
- land_cover_grid : numpy.ndarray
518
- 2D array of land cover values
519
- land_cover_source : str
520
- Source of land cover data
521
-
522
- Returns:
523
- --------
524
- numpy.ndarray : Land cover grid with VoxCity standard indices (0-13)
525
- """
526
- # Import VoxCity's convert function if available
527
- try:
528
- from voxcity.utils.lc import convert_land_cover
529
-
530
- # Apply conversion
531
- converted_grid = convert_land_cover(land_cover_grid, land_cover_source)
532
- print(f"Applied VoxCity land cover conversion for {land_cover_source}")
533
- return converted_grid
534
- except ImportError:
535
- 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
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.")
536
634
  return land_cover_grid